From 7763d679a56cd1f447a3898963ecf6983d4e62ee Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Wed, 24 Dec 2025 13:48:05 -0800 Subject: [PATCH] feat(nixgen): initial implementation of schemars-nixgen This crate converts JSON Schema (as generated by schemars) into strongly-typed Nix option definitions for the NixOS module system. The primary motivation was to generate NixOS module options for komorebi and komorebi-bar configurations, allowing users to write type-checked Nix configurations that map directly to the JSON config files expected by those applications. Some high level notes on the implementation: * Handles all common JSON Schema patterns including primitives, arrays, objects, enums, refs, oneOf/anyOf/allOf, and nullable types * Detects and correctly generates externally-tagged unions (Rust's default serde enum representation) as submodules with optional fields * Detects internally-tagged unions and mixed enum+object unions * Generates let bindings for frequently-used type definitions to avoid repetition in the output (e.g., large enums like Base16Value) * Uses topological sorting to generate type bindings in dependency order, ensuring nested refs are deduplicated correctly * Handles patternProperties for index-based configuration patterns --- .envrc | 1 + .gitignore | 3 + Cargo.lock | 676 +++++++++++++++++++ Cargo.toml | 21 + LICENSE.md | 108 +++ flake.lock | 179 +++++ flake.nix | 129 ++++ rust-toolchain.toml | 2 + src/format.rs | 131 ++++ src/generator.rs | 1555 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 103 +++ src/main.rs | 60 ++ src/prepare.rs | 503 ++++++++++++++ src/types.rs | 332 +++++++++ 14 files changed, 3803 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rust-toolchain.toml create mode 100644 src/format.rs create mode 100644 src/generator.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/prepare.rs create mode 100644 src/types.rs diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..052de265 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/result +/.direnv diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..c99caf58 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,676 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "ryu" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars-nixgen" +version = "0.1.0" +dependencies = [ + "clap", + "color-eyre", + "schemars", + "serde", + "serde_json_lenient", + "thiserror", +] + +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_lenient" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "zmij" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dccf46b25b205e4bebe1d5258a991df1cc17801017a845cb5b3fe0269781aa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..01f031cb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "schemars-nixgen" +version = "0.1.0" +edition = "2024" +license-file = "LICENSE.md" + +[lib] +name = "schemars_nixgen" +path = "src/lib.rs" + +[[bin]] +name = "schemars-nixgen" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive", "wrap_help"] } +color-eyre = "0.6" +schemars = "1.1" +serde = { version = "1", features = ["derive"] } +serde_json = { package = "serde_json_lenient", version = "0.2" } +thiserror = "2" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..c96f9a11 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,108 @@ +# Komorebi License + +Version 2.0.0 + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only distribute the source +code of the software according to the [Distribution License]( +#distribution-license), you may only make changes according +to the [Changes License](#changes-license), and you may not +otherwise distribute the software or new works based on the +software. + +## Distribution License + +The licensor grants you an additional copyright license to +distribute copies of the source code of the software. Your +license to distribute covers distributing the source code of +the software with changes permitted by the [Changes License]( +#changes-license). + +## Changes License + +The licensor grants you an additional copyright license to +make changes for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..6d875d54 --- /dev/null +++ b/flake.lock @@ -0,0 +1,179 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1766194365, + "narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=", + "owner": "ipetkov", + "repo": "crane", + "rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1765835352, + "narHash": "sha256-XswHlK/Qtjasvhd1nOa1e8MgZ8GS//jBoTqWtrS1Giw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "a34fae9c08a15ad73f295041fec82323541400a9", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "git-hooks-nix": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1765911976, + "narHash": "sha256-t3T/xm8zstHRLx+pIHxVpQTiySbKqcQbK+r+01XVKc0=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "b68b780b69702a090c8bb1b973bab13756cc7a27", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1766309749, + "narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1765674936, + "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-parts": "flake-parts", + "git-hooks-nix": "git-hooks-nix", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay", + "treefmt-nix": "treefmt-nix" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1766544144, + "narHash": "sha256-5ppfEyZqX6FMluZHty7Dvw4xN10vLq1yQosp7dGG/28=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "5359a4fdc3fa9baa2edcf49758d404dfeeca7743", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1766000401, + "narHash": "sha256-+cqN4PJz9y0JQXfAK5J1drd0U05D5fcAGhzhfVrDlsI=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "42d96e75aa56a3f70cab7e7dc4a32868db28e8fd", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..c97c4891 --- /dev/null +++ b/flake.nix @@ -0,0 +1,129 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + crane.url = "github:ipetkov/crane"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + git-hooks-nix.url = "github:cachix/git-hooks.nix"; + git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + inputs@{ + nixpkgs, + flake-parts, + crane, + rust-overlay, + ... + }: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + inputs.treefmt-nix.flakeModule + inputs.git-hooks-nix.flakeModule + ]; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + + perSystem = + { + config, + system, + ... + }: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + + rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + nightlyRustfmt = pkgs.rust-bin.nightly.latest.rustfmt; + rustToolchainWithNightlyRustfmt = pkgs.symlinkJoin { + name = "rust-toolchain-with-nightly-rustfmt"; + paths = [ + nightlyRustfmt + rustToolchain + ]; + }; + + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; + + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = craneLib.filterCargoSources; + }; + + commonArgs = { + inherit src; + strictDeps = true; + }; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + schemars-nixgen = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + } + ); + in + { + checks = { + clippy = craneLib.cargoClippy ( + commonArgs + // { + inherit cargoArtifacts; + } + ); + tests = craneLib.cargoTest ( + commonArgs + // { + inherit cargoArtifacts; + } + ); + }; + + packages = { + inherit schemars-nixgen; + default = schemars-nixgen; + }; + + devShells.default = pkgs.mkShell { + inputsFrom = [ schemars-nixgen ]; + packages = [ + rustToolchainWithNightlyRustfmt + pkgs.bacon + ]; + }; + + pre-commit = { + check.enable = true; + settings.hooks.treefmt = { + enable = true; + package = config.treefmt.build.wrapper; + pass_filenames = false; + }; + }; + + treefmt = { + projectRootFile = "flake.nix"; + programs = { + rustfmt = { + enable = true; + package = nightlyRustfmt; + }; + taplo.enable = true; + nixfmt.enable = true; + deadnix.enable = true; + }; + }; + }; + }; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..292fe499 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 00000000..b32b351c --- /dev/null +++ b/src/format.rs @@ -0,0 +1,131 @@ +//! Nix code formatting utilities + +/// Generate the Nix module header +pub fn module_header() -> &'static str { + "{ lib, ... }:" +} + +/// Generate the opening of an options block +pub fn options_block_open(option_name: &str) -> String { + format!( + r#"{{ + options.{} = lib.mkOption {{ + type = lib.types.submodule {{ + options = {{"#, + option_name + ) +} + +/// Generate the closing of an options block +pub fn options_block_close(description: &str) -> String { + format!( + r#" }}; + }}; + default = {{}}; + description = "{}"; + }}; +}}"#, + crate::types::escape_nix_string(description) + ) +} + +/// Indent a block of text +pub fn indent(text: &str, level: usize) -> String { + let prefix = " ".repeat(level); + text.lines() + .map(|line| { + if line.trim().is_empty() { + String::new() + } else { + format!("{}{}", prefix, line) + } + }) + .collect::>() + .join("\n") +} + +/// Convert a JSON value to a Nix literal +pub fn json_to_nix_literal(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + i.to_string() + } else if let Some(f) = n.as_f64() { + format!("{:.6}", f) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } else { + n.to_string() + } + } + serde_json::Value::String(s) => { + format!("\"{}\"", crate::types::escape_nix_string(s)) + } + serde_json::Value::Array(arr) => { + let items: Vec = arr.iter().map(json_to_nix_literal).collect(); + format!("[ {} ]", items.join(" ")) + } + serde_json::Value::Object(obj) => { + if obj.is_empty() { + "{}".to_string() + } else { + let attrs: Vec = obj + .iter() + .map(|(k, v)| { + format!( + "{} = {};", + crate::types::escape_nix_attr_name(k), + json_to_nix_literal(v) + ) + }) + .collect(); + format!("{{ {} }}", attrs.join(" ")) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_json_to_nix_literal_primitives() { + assert_eq!(json_to_nix_literal(&json!(null)), "null"); + assert_eq!(json_to_nix_literal(&json!(true)), "true"); + assert_eq!(json_to_nix_literal(&json!(false)), "false"); + assert_eq!(json_to_nix_literal(&json!(42)), "42"); + assert_eq!(json_to_nix_literal(&json!(2.5)), "2.5"); + assert_eq!(json_to_nix_literal(&json!("hello")), "\"hello\""); + } + + #[test] + fn test_json_to_nix_literal_array() { + assert_eq!(json_to_nix_literal(&json!([1, 2, 3])), "[ 1 2 3 ]"); + assert_eq!(json_to_nix_literal(&json!(["a", "b"])), "[ \"a\" \"b\" ]"); + } + + #[test] + fn test_json_to_nix_literal_object() { + assert_eq!(json_to_nix_literal(&json!({})), "{}"); + // Note: Object ordering may vary, so we just check it produces valid output + let result = json_to_nix_literal(&json!({"x": 1})); + assert!(result.contains("x = 1")); + } + + #[test] + fn test_indent() { + let text = "line1\nline2\nline3"; + let indented = indent(text, 2); + assert_eq!(indented, " line1\n line2\n line3"); + } + + #[test] + fn test_module_header() { + assert_eq!(module_header(), "{ lib, ... }:"); + } +} diff --git a/src/generator.rs b/src/generator.rs new file mode 100644 index 00000000..a3b18caa --- /dev/null +++ b/src/generator.rs @@ -0,0 +1,1555 @@ +use crate::format::json_to_nix_literal; +use crate::format::module_header; +use crate::format::options_block_close; +use crate::format::options_block_open; +use crate::prepare::collect_definitions; +use crate::prepare::detect_const_enum; +use crate::prepare::detect_externally_tagged_union; +use crate::prepare::detect_internally_tagged_union; +use crate::prepare::detect_mixed_enum_object_union; +use crate::prepare::detect_nullable_anyof; +use crate::prepare::detect_simple_enum; +use crate::prepare::get_additional_properties; +use crate::prepare::get_array_items; +use crate::prepare::get_default; +use crate::prepare::get_description; +use crate::prepare::get_pattern_properties; +use crate::prepare::get_prefix_items_type; +use crate::prepare::get_properties; +use crate::prepare::get_ref; +use crate::prepare::get_schema_type; +use crate::prepare::resolve_ref_name; +use crate::types::NixOption; +use crate::types::NixType; +use serde_json::Value; +use std::collections::HashMap; +use std::collections::HashSet; + +/// Configuration options for the generator +#[derive(Debug, Clone)] +pub struct GeneratorOptions { + /// The root option name (e.g., "komorebi" or "komorebi-bar") + pub root_name: String, + /// Description for the root option + pub root_description: String, +} + +impl Default for GeneratorOptions { + fn default() -> Self { + Self { + root_name: "config".to_string(), + root_description: "Configuration options".to_string(), + } + } +} + +/// Error type for generation failures +#[derive(Debug, thiserror::Error)] +pub enum GeneratorError { + #[error("Invalid schema: {0}")] + InvalidSchema(String), + #[error("Unsupported schema construct: {0}")] + UnsupportedConstruct(String), + #[error("Unresolved reference: {0}")] + UnresolvedRef(String), +} + +/// The main Nix code generator +pub struct Generator { + definitions: HashMap, + /// Cache for resolved type definitions to avoid infinite recursion + resolving: Vec, + /// Count of how many times each $def is referenced + ref_counts: HashMap, + /// Pre-generated types for frequently used definitions + type_bindings: HashMap, + /// Whether we're in the second pass (using type refs) + use_type_refs: bool, +} + +impl Generator { + /// Create a new generator from a JSON schema + pub fn new(schema: &Value) -> Self { + Self { + definitions: collect_definitions(schema), + resolving: Vec::new(), + ref_counts: HashMap::new(), + type_bindings: HashMap::new(), + use_type_refs: false, + } + } + + /// Convert a definition name to a valid Nix identifier + fn def_to_nix_ident(name: &str) -> String { + // Convert PascalCase to camelCase and handle special chars + let mut result = String::new(); + let mut chars = name.chars().peekable(); + let mut first = true; + + while let Some(c) = chars.next() { + if first { + result.push(c.to_ascii_lowercase()); + first = false; + } else if c == '-' || c == '_' { + // Skip separator and capitalize next char + if let Some(&next) = chars.peek() { + chars.next(); + result.push(next.to_ascii_uppercase()); + } + } else { + result.push(c); + } + } + + result + } + + /// First pass: count how many times each $ref is used + fn count_refs(&mut self, schema: &Value) { + if let Some(ref_str) = get_ref(schema) + && let Some(type_name) = resolve_ref_name(ref_str) + { + *self.ref_counts.entry(type_name.to_string()).or_insert(0) += 1; + } + + // Recurse into nested schemas + if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) { + for (_, prop_schema) in props { + self.count_refs(prop_schema); + } + } + if let Some(items) = schema.get("items") { + self.count_refs(items); + } + if let Some(additional) = schema.get("additionalProperties") + && additional.is_object() + { + self.count_refs(additional); + } + if let Some(pattern_props) = schema.get("patternProperties").and_then(|p| p.as_object()) { + for (_, prop_schema) in pattern_props { + self.count_refs(prop_schema); + } + } + if let Some(one_of) = schema.get("oneOf").and_then(|o| o.as_array()) { + for variant in one_of { + self.count_refs(variant); + } + } + if let Some(any_of) = schema.get("anyOf").and_then(|a| a.as_array()) { + for variant in any_of { + self.count_refs(variant); + } + } + if let Some(all_of) = schema.get("allOf").and_then(|a| a.as_array()) { + for variant in all_of { + self.count_refs(variant); + } + } + } + + /// Build a dependency graph of $defs + /// Returns HashMap> + fn build_dependency_graph(&self) -> HashMap> { + let mut graph: HashMap> = HashMap::new(); + + for (name, def) in &self.definitions { + let deps = Self::collect_refs_in_schema(def); + graph.insert(name.clone(), deps); + } + + graph + } + + /// Collect all $ref names referenced in a schema value + fn collect_refs_in_schema(schema: &Value) -> Vec { + let mut refs = Vec::new(); + Self::collect_refs_recursive(schema, &mut refs); + refs + } + + /// Recursively collect $ref names from a schema + fn collect_refs_recursive(schema: &Value, refs: &mut Vec) { + // Check for direct $ref + if let Some(ref_str) = get_ref(schema) + && let Some(type_name) = resolve_ref_name(ref_str) + { + refs.push(type_name.to_string()); + } + + // Recurse into properties + if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) { + for (_, prop_schema) in props { + Self::collect_refs_recursive(prop_schema, refs); + } + } + + // Recurse into items + if let Some(items) = schema.get("items") { + Self::collect_refs_recursive(items, refs); + } + + // Recurse into additionalProperties + if let Some(additional) = schema.get("additionalProperties") + && additional.is_object() + { + Self::collect_refs_recursive(additional, refs); + } + + // Recurse into patternProperties + if let Some(pattern_props) = schema.get("patternProperties").and_then(|p| p.as_object()) { + for (_, prop_schema) in pattern_props { + Self::collect_refs_recursive(prop_schema, refs); + } + } + + // Recurse into oneOf + if let Some(one_of) = schema.get("oneOf").and_then(|o| o.as_array()) { + for variant in one_of { + Self::collect_refs_recursive(variant, refs); + } + } + + // Recurse into anyOf + if let Some(any_of) = schema.get("anyOf").and_then(|a| a.as_array()) { + for variant in any_of { + Self::collect_refs_recursive(variant, refs); + } + } + + // Recurse into allOf + if let Some(all_of) = schema.get("allOf").and_then(|a| a.as_array()) { + for variant in all_of { + Self::collect_refs_recursive(variant, refs); + } + } + + // Recurse into prefixItems + if let Some(prefix_items) = schema.get("prefixItems").and_then(|p| p.as_array()) { + for item in prefix_items { + Self::collect_refs_recursive(item, refs); + } + } + } + + /// Topologically sort definitions so dependencies come before dependents + fn topological_sort(graph: &HashMap>) -> Vec { + let mut result = Vec::new(); + let mut visited = HashSet::new(); + let mut temp_visited = HashSet::new(); // For cycle detection + + for name in graph.keys() { + Self::topo_visit(name, graph, &mut visited, &mut temp_visited, &mut result); + } + + result + } + + /// DFS visit for topological sort + fn topo_visit( + name: &str, + graph: &HashMap>, + visited: &mut HashSet, + temp_visited: &mut HashSet, + result: &mut Vec, + ) { + if visited.contains(name) { + return; + } + if temp_visited.contains(name) { + // Cycle detected - skip (will use Anything for recursive types) + return; + } + + temp_visited.insert(name.to_string()); + + if let Some(deps) = graph.get(name) { + for dep in deps { + // Only visit if this dependency is in our graph (i.e., it's a $def) + if graph.contains_key(dep) { + Self::topo_visit(dep, graph, visited, temp_visited, result); + } + } + } + + temp_visited.remove(name); + visited.insert(name.to_string()); + result.push(name.to_string()); + } + + /// Generate type bindings for frequently used definitions + fn generate_type_bindings(&mut self) -> Result<(), GeneratorError> { + // Build dependency graph + let graph = self.build_dependency_graph(); + + // Topologically sort definitions (dependencies come first) + let sorted_defs = Self::topological_sort(&graph); + + // Only generate bindings for refs used more than once + let frequent_refs: HashSet = self + .ref_counts + .iter() + .filter(|&(_, &count)| count > 1) + .map(|(name, _)| name.clone()) + .collect(); + + // Generate bindings in topological order (leaf types first) + // This allows composite types to reference already-generated leaf types + self.use_type_refs = true; + + for ref_name in sorted_defs { + if !frequent_refs.contains(&ref_name) { + continue; + } + + if let Some(def) = self.definitions.get(&ref_name).cloned() { + let nix_type = self.schema_to_nix_type(&def)?; + self.type_bindings.insert(ref_name.clone(), nix_type); + } + } + + Ok(()) + } + + /// Generate the let bindings block + fn generate_let_block(&self) -> String { + if self.type_bindings.is_empty() { + return String::new(); + } + + let mut bindings: Vec<(String, String)> = self + .type_bindings + .iter() + .map(|(name, nix_type)| { + let ident = Self::def_to_nix_ident(name); + let expr = nix_type.to_nix_expr(); + (ident, expr) + }) + .collect(); + + // Sort for deterministic output + bindings.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut output = String::from("let\n"); + for (ident, expr) in bindings { + output.push_str(&format!(" {} = {};\n", ident, expr)); + } + output.push_str("in\n"); + output + } + + /// Generate Nix module code from the schema + pub fn generate( + &mut self, + schema: &Value, + options: &GeneratorOptions, + ) -> Result { + // First pass: count ref usage throughout the schema + self.count_refs(schema); + // Also count refs in definitions themselves (they can reference each other) + let defs: Vec = self.definitions.values().cloned().collect(); + for def in &defs { + self.count_refs(def); + } + + // Generate type bindings for frequently used definitions + self.generate_type_bindings()?; + + let mut output = String::new(); + + // Module header + output.push_str(module_header()); + output.push('\n'); + + // Generate let bindings if any + let let_block = self.generate_let_block(); + if !let_block.is_empty() { + output.push_str(&let_block); + } + + // Open options block + output.push_str(&options_block_open(&options.root_name)); + output.push('\n'); + + // Generate options from properties (second pass with type refs enabled) + if let Some(properties) = get_properties(schema) { + for (name, prop_schema) in properties { + let nix_option = self.schema_to_option(name, prop_schema)?; + output.push_str(&nix_option.to_nix_option_def(4)); + output.push('\n'); + } + } + + // Close options block + output.push_str(&options_block_close(&options.root_description)); + output.push('\n'); + + Ok(output) + } + + /// Convert a JSON schema to a NixOption + fn schema_to_option( + &mut self, + name: &str, + schema: &Value, + ) -> Result { + let nix_type = self.schema_to_nix_type(schema)?; + let mut option = NixOption::new(name, nix_type); + + if let Some(desc) = get_description(schema) { + option.description = Some(desc.to_string()); + } + + if let Some(default) = get_default(schema) { + option.default = Some(json_to_nix_literal(default)); + } + + Ok(option) + } + + /// Convert a JSON schema to a NixType + fn schema_to_nix_type(&mut self, schema: &Value) -> Result { + // Handle $ref first + if let Some(ref_str) = get_ref(schema) { + return self.resolve_ref(ref_str); + } + + // Check for nullable anyOf pattern: anyOf: [{$ref: T}, {type: null}] + if let Some(inner) = detect_nullable_anyof(schema) { + let inner_type = self.schema_to_nix_type(inner)?; + return Ok(inner_type.make_nullable()); + } + + // Check for nullable type array: ["array", "null"] or ["object", "null"] with extra schema info + // This needs to be handled BEFORE detect_nullable_type_array to preserve items/properties + if let Some(type_val) = schema.get("type").and_then(|v| v.as_array()) + && type_val.len() == 2 + { + let has_null = type_val.iter().any(|t| t.as_str() == Some("null")); + let other_type = type_val + .iter() + .find_map(|t| t.as_str().filter(|&s| s != "null")); + + if has_null && let Some(other) = other_type { + match other { + "array" => { + // Handle array with items - e.g., ["array", "null"] with items: {...} + let items_type = if let Some(items) = get_array_items(schema) { + self.schema_to_nix_type(items)? + } else { + NixType::Str // Default to string if no items specified + }; + return Ok(NixType::NullOr(Box::new(NixType::ListOf(Box::new( + items_type, + ))))); + } + "object" => { + // Handle object with properties/additionalProperties/patternProperties + let inner = self.object_schema_to_nix_type(schema)?; + return Ok(inner.make_nullable()); + } + _ => { + // Handle primitives like ["string", "null"], ["integer", "null"] + let inner_type = self.primitive_to_nix_type(other)?; + return Ok(inner_type.make_nullable()); + } + } + } + } + + // Check for const enum pattern in oneOf + if let Some(variants) = detect_const_enum(schema) { + return Ok(NixType::Enum(variants)); + } + + // Check for simple enum + if let Some(variants) = detect_simple_enum(schema) { + return Ok(NixType::Enum(variants)); + } + + // Check for internally tagged union (oneOf with shared discriminator field) + // Example: Grouping with { "kind": "None" }, { "kind": "Bar", ... } + // For these, we generate a submodule with an enum for the discriminator + // and optional fields for the variant-specific data + if let Some((discriminator, variants)) = detect_internally_tagged_union(schema) { + // Extract the enum values from the discriminator + let enum_values: Vec = variants.iter().map(|(val, _)| val.clone()).collect(); + + // Create a submodule with the discriminator as an enum and other shared fields + let mut options = vec![NixOption::new(&discriminator, NixType::Enum(enum_values))]; + + // Collect all other properties from all variants (excluding the discriminator) + // Track property types to merge them if they differ across variants + let mut prop_types: HashMap> = HashMap::new(); + + for (_, variant_schema) in &variants { + if let Some(props) = variant_schema.get("properties").and_then(|p| p.as_object()) { + for (prop_name, prop_schema) in props { + if prop_name != &discriminator { + let prop_type = self.schema_to_nix_type(prop_schema)?; + prop_types + .entry(prop_name.clone()) + .or_default() + .push(prop_type); + } + } + } + // Also handle $ref in variants + if let Some(ref_str) = variant_schema.get("$ref").and_then(|r| r.as_str()) + && let Ok(ref_type) = self.resolve_ref(ref_str) + { + // If the ref resolves to a submodule, merge its options + if let NixType::Submodule(ref_opts) = ref_type { + for opt in ref_opts { + prop_types + .entry(opt.name.clone()) + .or_default() + .push(opt.nix_type.clone()); + } + } + } + } + + // Build options from collected types, merging with OneOf if types differ + for (prop_name, types) in prop_types { + // Deduplicate types + let mut unique_types: Vec = Vec::new(); + for t in types { + if !unique_types.contains(&t) { + unique_types.push(t); + } + } + + let merged_type = if unique_types.len() == 1 { + unique_types.into_iter().next().unwrap() + } else { + NixType::OneOf(unique_types) + }; + + options.push(NixOption::new(&prop_name, merged_type.make_nullable())); + } + + return Ok(NixType::Submodule(options)); + } + + // Check for externally tagged union (oneOf with single-property object variants) + // This must be checked BEFORE generic oneOf handling + // Example: WidgetConfig with variants like { "Komorebi": {...} }, { "Battery": {...} } + if let Some(variants) = detect_externally_tagged_union(schema) { + let typed_variants: Result, GeneratorError> = variants + .into_iter() + .map(|(tag, inner_schema)| { + let inner_type = self.schema_to_nix_type(&inner_schema)?; + Ok((tag, inner_type)) + }) + .collect(); + return Ok(NixType::TaggedUnion(typed_variants?)); + } + + // Check for mixed enum + object union (oneOf with string consts AND tagged objects) + // Example: DateFormat with "Custom" enum value and { "Custom": "..." } object + if let Some((enum_variants, object_variants)) = detect_mixed_enum_object_union(schema) { + let mut types = Vec::new(); + + // Add enum type for string variants + if !enum_variants.is_empty() { + types.push(NixType::Enum(enum_variants)); + } + + // Add tagged union for object variants + if !object_variants.is_empty() { + let typed_variants: Result, GeneratorError> = + object_variants + .into_iter() + .map(|(tag, inner_schema)| { + let inner_type = self.schema_to_nix_type(&inner_schema)?; + Ok((tag, inner_type)) + }) + .collect(); + types.push(NixType::TaggedUnion(typed_variants?)); + } + + return if types.len() == 1 { + Ok(types.into_iter().next().unwrap()) + } else { + Ok(NixType::OneOf(types)) + }; + } + + // Check for oneOf with complex variants + if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) { + let types: Result, _> = one_of + .iter() + .map(|variant| self.schema_to_nix_type(variant)) + .collect(); + return Ok(NixType::OneOf(types?)); + } + + // Check for anyOf without null (union types) - should become oneOf + if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) { + // Only process if this wasn't already handled as nullable pattern + let has_null = any_of.iter().any(|v| { + v.get("type") + .and_then(|t| t.as_str()) + .is_some_and(|s| s == "null") + }); + if !has_null { + let types: Result, _> = any_of + .iter() + .map(|variant| self.schema_to_nix_type(variant)) + .collect(); + return Ok(NixType::OneOf(types?)); + } + } + + // Handle by primary type + if let Some(type_str) = get_schema_type(schema) { + return self.typed_schema_to_nix_type(type_str, schema); + } + + // If we have properties but no type, treat as object + if get_properties(schema).is_some() { + return self.typed_schema_to_nix_type("object", schema); + } + + // Fallback to anything + Ok(NixType::Anything) + } + + /// Convert a typed schema to NixType + fn typed_schema_to_nix_type( + &mut self, + type_str: &str, + schema: &Value, + ) -> Result { + match type_str { + "string" => Ok(NixType::Str), + "integer" => Ok(NixType::Int), + "boolean" => Ok(NixType::Bool), + "number" => Ok(NixType::Float), + "null" => Ok(NixType::NullOr(Box::new(NixType::Anything))), + "array" => { + let items_type = if let Some(items) = get_array_items(schema) { + self.schema_to_nix_type(items)? + } else if let Some(prefix_item) = get_prefix_items_type(schema) { + // Handle tuple types (prefixItems) - use the first item type + self.schema_to_nix_type(prefix_item)? + } else { + NixType::Str // Default to str if no items specified + }; + Ok(NixType::ListOf(Box::new(items_type))) + } + "object" => self.object_schema_to_nix_type(schema), + _ => Err(GeneratorError::UnsupportedConstruct(format!( + "unknown type: {}", + type_str + ))), + } + } + + /// Convert an object schema to NixType + fn object_schema_to_nix_type(&mut self, schema: &Value) -> Result { + // Check for additionalProperties (map type) + if let Some(ap_schema) = get_additional_properties(schema) { + let value_type = self.schema_to_nix_type(ap_schema)?; + return Ok(NixType::AttrsOf(Box::new(value_type))); + } + + // Check for patternProperties (also map type) + if let Some(pp_schema) = get_pattern_properties(schema) { + let value_type = self.schema_to_nix_type(pp_schema)?; + return Ok(NixType::AttrsOf(Box::new(value_type))); + } + + // Handle object with properties as submodule + if let Some(properties) = get_properties(schema) { + let mut options = Vec::new(); + for (name, prop_schema) in properties { + let option = self.schema_to_option(name, prop_schema)?; + options.push(option); + } + return Ok(NixType::Submodule(options)); + } + + // Empty object or object with no constraints + Ok(NixType::AttrsOf(Box::new(NixType::Anything))) + } + + /// Resolve a $ref to a NixType + fn resolve_ref(&mut self, ref_str: &str) -> Result { + let type_name = resolve_ref_name(ref_str).ok_or_else(|| { + GeneratorError::UnresolvedRef(format!("invalid ref format: {}", ref_str)) + })?; + + // If we're using type refs and this type has a binding, return a TypeRef + if self.use_type_refs && self.type_bindings.contains_key(type_name) { + return Ok(NixType::TypeRef(Self::def_to_nix_ident(type_name))); + } + + // Check for recursion + if self.resolving.contains(&type_name.to_string()) { + // For recursive types, return Anything to break the cycle + return Ok(NixType::Anything); + } + + // Get the definition + let def = self.definitions.get(type_name).cloned().ok_or_else(|| { + GeneratorError::UnresolvedRef(format!("definition not found: {}", type_name)) + })?; + + // Mark as resolving to detect cycles + self.resolving.push(type_name.to_string()); + let result = self.schema_to_nix_type(&def); + self.resolving.pop(); + + result + } + + /// Convert a primitive type name to NixType + fn primitive_to_nix_type(&self, type_name: &str) -> Result { + match type_name { + "string" => Ok(NixType::Str), + "integer" => Ok(NixType::Int), + "boolean" => Ok(NixType::Bool), + "number" => Ok(NixType::Float), + "null" => Ok(NixType::NullOr(Box::new(NixType::Anything))), + "array" => Ok(NixType::ListOf(Box::new(NixType::Anything))), + "object" => Ok(NixType::AttrsOf(Box::new(NixType::Anything))), + _ => Err(GeneratorError::UnsupportedConstruct(format!( + "unknown primitive type: {}", + type_name + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_primitive_types() { + let schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "count": {"type": "integer"}, + "enabled": {"type": "boolean"}, + "ratio": {"type": "number"} + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + + let output = generator.generate(&schema, &options).unwrap(); + assert!(output.contains("lib.types.str")); + assert!(output.contains("lib.types.int")); + assert!(output.contains("lib.types.bool")); + assert!(output.contains("lib.types.number")); + } + + #[test] + fn test_nullable_anyof() { + let schema = json!({ + "type": "object", + "properties": { + "color": { + "anyOf": [ + {"type": "string"}, + {"type": "null"} + ] + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["color"]) + .unwrap(); + assert!(matches!(nix_type, NixType::NullOr(_))); + } + + #[test] + fn test_enum() { + let schema = json!({ + "type": "object", + "properties": { + "direction": { + "enum": ["Left", "Right", "Up", "Down"] + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["direction"]) + .unwrap(); + assert!(matches!(nix_type, NixType::Enum(_))); + } + + #[test] + fn test_array() { + let schema = json!({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"} + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["tags"]) + .unwrap(); + assert!(matches!(nix_type, NixType::ListOf(_))); + } + + #[test] + fn test_ref_resolution() { + let schema = json!({ + "$defs": { + "Color": { + "enum": ["Red", "Green", "Blue"] + } + }, + "type": "object", + "properties": { + "bg": {"$ref": "#/$defs/Color"} + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["bg"]) + .unwrap(); + assert!(matches!(nix_type, NixType::Enum(_))); + } + + #[test] + fn test_object_submodule() { + let schema = json!({ + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "width": {"type": "integer"}, + "height": {"type": "integer"} + } + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["window"]) + .unwrap(); + assert!(matches!(nix_type, NixType::Submodule(_))); + } + + #[test] + fn test_attrs_of() { + let schema = json!({ + "type": "object", + "properties": { + "env": { + "type": "object", + "additionalProperties": {"type": "string"} + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["env"]) + .unwrap(); + assert!(matches!(nix_type, NixType::AttrsOf(_))); + } + + #[test] + fn test_nullable_array_with_items() { + // This is the pattern: "type": ["array", "null"], "items": {"type": "string"} + let schema = json!({ + "type": "object", + "properties": { + "tags": { + "type": ["array", "null"], + "items": {"type": "string"} + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["tags"]) + .unwrap(); + + // Should be nullOr (listOf str), not nullOr (listOf anything) + match nix_type { + NixType::NullOr(inner) => match *inner { + NixType::ListOf(item_type) => { + assert!( + matches!(*item_type, NixType::Str), + "Expected Str, got {:?}", + item_type + ); + } + _ => panic!("Expected ListOf, got {:?}", inner), + }, + _ => panic!("Expected NullOr, got {:?}", nix_type), + } + } + + #[test] + fn test_nullable_array_with_ref_items() { + // Pattern: "type": ["array", "null"], "items": {"$ref": "#/$defs/SomeType"} + let schema = json!({ + "$defs": { + "Color": {"enum": ["Red", "Green", "Blue"]} + }, + "type": "object", + "properties": { + "colors": { + "type": ["array", "null"], + "items": {"$ref": "#/$defs/Color"} + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["colors"]) + .unwrap(); + + // Should be nullOr (listOf (enum [...])), not nullOr (listOf anything) + match nix_type { + NixType::NullOr(inner) => match *inner { + NixType::ListOf(item_type) => { + assert!( + matches!(*item_type, NixType::Enum(_)), + "Expected Enum, got {:?}", + item_type + ); + } + _ => panic!("Expected ListOf, got {:?}", inner), + }, + _ => panic!("Expected NullOr, got {:?}", nix_type), + } + } + + #[test] + fn test_nullable_object_with_pattern_properties() { + // Pattern: "type": ["object", "null"], "patternProperties": {...} + let schema = json!({ + "type": "object", + "properties": { + "display_prefs": { + "type": ["object", "null"], + "patternProperties": { + "^\\d+$": {"type": "string"} + } + } + } + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator + .schema_to_nix_type(&schema["properties"]["display_prefs"]) + .unwrap(); + + // Should be nullOr (attrsOf str), not nullOr anything + match nix_type { + NixType::NullOr(inner) => match *inner { + NixType::AttrsOf(value_type) => { + assert!( + matches!(*value_type, NixType::Str), + "Expected Str, got {:?}", + value_type + ); + } + _ => panic!("Expected AttrsOf, got {:?}", inner), + }, + _ => panic!("Expected NullOr, got {:?}", nix_type), + } + } + + #[test] + fn test_externally_tagged_union() { + // Externally tagged union pattern like WidgetConfig + let schema = json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "Komorebi": { + "type": "object", + "properties": { + "enable": {"type": "boolean"} + } + } + }, + "required": ["Komorebi"] + }, + { + "type": "object", + "properties": { + "Battery": { + "type": "object", + "properties": { + "enable": {"type": "boolean"} + } + } + }, + "required": ["Battery"] + } + ] + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator.schema_to_nix_type(&schema).unwrap(); + + // Should be TaggedUnion, not OneOf + match nix_type { + NixType::TaggedUnion(variants) => { + assert_eq!(variants.len(), 2); + assert_eq!(variants[0].0, "Komorebi"); + assert_eq!(variants[1].0, "Battery"); + } + _ => panic!("Expected TaggedUnion, got {:?}", nix_type), + } + } + + #[test] + fn test_mixed_enum_object_union() { + // Mixed enum + object pattern like DateFormat + let schema = json!({ + "oneOf": [ + {"type": "string", "const": "DayMonthYear"}, + {"type": "string", "const": "YearMonthDay"}, + { + "type": "object", + "properties": { + "Custom": {"type": "string"} + }, + "required": ["Custom"] + } + ] + }); + + let mut generator = Generator::new(&schema); + let nix_type = generator.schema_to_nix_type(&schema).unwrap(); + + // Should be OneOf [Enum, TaggedUnion] + match nix_type { + NixType::OneOf(types) => { + assert_eq!(types.len(), 2); + assert!( + matches!(&types[0], NixType::Enum(v) if v.contains(&"DayMonthYear".to_string())) + ); + assert!(matches!(&types[1], NixType::TaggedUnion(v) if v[0].0 == "Custom")); + } + _ => panic!("Expected OneOf, got {:?}", nix_type), + } + } + + #[test] + fn test_tagged_union_generates_correct_nix() { + // Test that the generated Nix code has the correct structure + let schema = json!({ + "type": "object", + "properties": { + "widget": { + "oneOf": [ + { + "type": "object", + "properties": { + "Battery": {"type": "object", "properties": {"enable": {"type": "boolean"}}} + }, + "required": ["Battery"] + }, + { + "type": "object", + "properties": { + "Clock": {"type": "object", "properties": {"format": {"type": "string"}}} + }, + "required": ["Clock"] + } + ] + } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + + let output = generator.generate(&schema, &options).unwrap(); + + // Should contain submodule with optional fields, not oneOf + assert!( + output.contains("lib.types.submodule"), + "Should use submodule" + ); + assert!( + output.contains("Battery = lib.mkOption"), + "Should have Battery option" + ); + assert!( + output.contains("Clock = lib.mkOption"), + "Should have Clock option" + ); + assert!(output.contains("lib.types.nullOr"), "Should wrap in nullOr"); + assert!( + output.contains("default = null"), + "Should have default = null" + ); + // Should NOT contain oneOf for this pattern + assert!( + !output.contains("lib.types.oneOf"), + "Should NOT use oneOf for tagged unions" + ); + } + + #[test] + fn test_recursive_type_deduplication() { + // Test that nested $refs use TypeRef instead of inlining + let schema = json!({ + "$defs": { + "Base16Value": { + "enum": ["Base00", "Base01", "Base02"] + }, + "ThemeOptions": { + "type": "object", + "properties": { + "accent": { "$ref": "#/$defs/Base16Value" }, + "border": { "$ref": "#/$defs/Base16Value" } + } + } + }, + "type": "object", + "properties": { + "theme1": { "$ref": "#/$defs/ThemeOptions" }, + "theme2": { "$ref": "#/$defs/ThemeOptions" } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + let output = generator.generate(&schema, &options).unwrap(); + + // base16Value should appear in let bindings + assert!( + output.contains("base16Value = (lib.types.enum"), + "Should have base16Value in let bindings" + ); + + // themeOptions should reference base16Value, not inline the enum + // The themeOptions submodule should use "type = base16Value;" not inline the enum + assert!( + output.contains("type = base16Value;"), + "ThemeOptions should reference base16Value" + ); + + // The enum should appear exactly once (in the let binding definition) + let enum_count = output.matches("\"Base00\"").count(); + assert_eq!( + enum_count, 1, + "Base16Value enum should appear exactly once, found {} times", + enum_count + ); + } + + #[test] + fn test_topological_sort_handles_cycles() { + // Test that recursive types don't cause infinite loops + let schema = json!({ + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/Node" } + } + } + } + }, + "type": "object", + "properties": { + "root": { "$ref": "#/$defs/Node" }, + "root2": { "$ref": "#/$defs/Node" } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + + // Should not panic or infinite loop + let output = generator.generate(&schema, &options).unwrap(); + + // Cycle should be broken with Anything + assert!( + output.contains("lib.types.anything"), + "Recursive type cycle should be broken with anything" + ); + } + + #[test] + fn test_dependency_order() { + // Leaf types should be generated before composite types + let schema = json!({ + "$defs": { + "Color": { "enum": ["Red", "Green", "Blue"] }, + "Style": { + "type": "object", + "properties": { + "fg": { "$ref": "#/$defs/Color" }, + "bg": { "$ref": "#/$defs/Color" } + } + }, + "Theme": { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Style" }, + "secondary": { "$ref": "#/$defs/Style" } + } + } + }, + "type": "object", + "properties": { + "light": { "$ref": "#/$defs/Theme" }, + "dark": { "$ref": "#/$defs/Theme" } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + let output = generator.generate(&schema, &options).unwrap(); + + // Check order in let block: color before style before theme + let color_pos = output.find("color = ").expect("color should be in output"); + let style_pos = output.find("style = ").expect("style should be in output"); + let theme_pos = output.find("theme = ").expect("theme should be in output"); + + assert!( + color_pos < style_pos, + "color should be defined before style" + ); + assert!( + style_pos < theme_pos, + "style should be defined before theme" + ); + + // Style should reference color, not inline the enum + // Find the style definition and check it uses "type = color;" + let style_section = &output[style_pos..theme_pos]; + assert!( + style_section.contains("type = color;"), + "style should reference color type" + ); + + // Theme should reference style with "type = style;" + let theme_section = &output[theme_pos..]; + assert!( + theme_section.contains("type = style;"), + "theme should reference style type" + ); + } + + #[test] + fn test_internally_tagged_union_deduplicates_types() { + // Refs inside internally-tagged unions SHOULD create let bindings + // when used multiple times, to avoid repetition in output + let schema = json!({ + "$defs": { + "Config": { + "type": "object", + "properties": { + "value": { "type": "integer" } + } + }, + "Mode": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "Simple" } + }, + "required": ["kind"] + }, + { + "type": "object", + "allOf": [ + { "$ref": "#/$defs/Config" } + ], + "properties": { + "kind": { "type": "string", "const": "Advanced" } + }, + "required": ["kind"] + }, + { + "type": "object", + "allOf": [ + { "$ref": "#/$defs/Config" } + ], + "properties": { + "kind": { "type": "string", "const": "Expert" } + }, + "required": ["kind"] + } + ] + } + }, + "type": "object", + "properties": { + "mode": { "$ref": "#/$defs/Mode" } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + let output = generator.generate(&schema, &options).unwrap(); + + // Config is used twice (in Advanced and Expert variants), so it SHOULD get a let binding + assert!( + output.contains("config = "), + "Config should be in let bindings since it's used twice in internally-tagged union" + ); + } + + #[test] + fn test_internally_tagged_union_with_shared_ref_types() { + // Simulates KomorebiTheme pattern: internally-tagged union where each variant + // has multiple fields referencing the same enum type + let schema = json!({ + "$defs": { + "CatppuccinValue": { + "type": "string", + "enum": ["Rosewater", "Flamingo", "Pink", "Mauve", "Red", "Maroon"] + }, + "Base16Value": { + "type": "string", + "enum": ["Base00", "Base01", "Base02", "Base03", "Base04", "Base05"] + }, + "Theme": { + "oneOf": [ + { + "type": "object", + "properties": { + "palette": { "type": "string", "const": "Catppuccin" }, + "border1": { + "anyOf": [ + { "$ref": "#/$defs/CatppuccinValue" }, + { "type": "null" } + ] + }, + "border2": { + "anyOf": [ + { "$ref": "#/$defs/CatppuccinValue" }, + { "type": "null" } + ] + }, + "border3": { + "anyOf": [ + { "$ref": "#/$defs/CatppuccinValue" }, + { "type": "null" } + ] + } + }, + "required": ["palette"] + }, + { + "type": "object", + "properties": { + "palette": { "type": "string", "const": "Base16" }, + "border1": { + "anyOf": [ + { "$ref": "#/$defs/Base16Value" }, + { "type": "null" } + ] + }, + "border2": { + "anyOf": [ + { "$ref": "#/$defs/Base16Value" }, + { "type": "null" } + ] + }, + "border3": { + "anyOf": [ + { "$ref": "#/$defs/Base16Value" }, + { "type": "null" } + ] + } + }, + "required": ["palette"] + } + ] + } + }, + "type": "object", + "properties": { + "theme": { "$ref": "#/$defs/Theme" } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + let output = generator.generate(&schema, &options).unwrap(); + + // Both CatppuccinValue (used 3x) and Base16Value (used 3x) should get let bindings + assert!( + output.contains("catppuccinValue = (lib.types.enum"), + "CatppuccinValue should be in let bindings" + ); + assert!( + output.contains("base16Value = (lib.types.enum"), + "Base16Value should be in let bindings" + ); + + // Each enum should appear exactly once (in the let binding definition) + let catppuccin_count = output.matches("\"Rosewater\"").count(); + assert_eq!( + catppuccin_count, 1, + "CatppuccinValue enum should appear exactly once, found {} times", + catppuccin_count + ); + + let base16_count = output.matches("\"Base00\"").count(); + assert_eq!( + base16_count, 1, + "Base16Value enum should appear exactly once, found {} times", + base16_count + ); + } + + #[test] + fn test_internally_tagged_union_single_use_ref_not_deduplicated() { + // Refs used only once should NOT get let bindings (even inside unions) + let schema = json!({ + "$defs": { + "SingleUseType": { + "type": "string", + "enum": ["A", "B", "C"] + }, + "Mode": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "Simple" }, + "value": { "$ref": "#/$defs/SingleUseType" } + }, + "required": ["kind"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "Complex" }, + "other": { "type": "integer" } + }, + "required": ["kind"] + } + ] + } + }, + "type": "object", + "properties": { + "mode": { "$ref": "#/$defs/Mode" } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + let output = generator.generate(&schema, &options).unwrap(); + + // SingleUseType is used only once, so should NOT get a let binding + assert!( + !output.contains("singleUseType = "), + "SingleUseType should NOT be in let bindings - it's only used once" + ); + + // But it should still be inlined correctly + assert!( + output.contains("\"A\"") && output.contains("\"B\"") && output.contains("\"C\""), + "SingleUseType enum values should be inlined" + ); + } + + #[test] + fn test_pattern_properties_ref_counting() { + // Simulates the layout/layout_rules pattern from komorebi schema + // where layout_rules uses patternProperties to reference Layout + let schema = json!({ + "$defs": { + "Layout": { + "enum": ["BSP", "Columns", "Rows"] + } + }, + "type": "object", + "properties": { + "layout": { + "anyOf": [ + { "$ref": "#/$defs/Layout" }, + { "type": "null" } + ] + }, + "layout_rules": { + "type": ["object", "null"], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { "$ref": "#/$defs/Layout" } + } + } + } + }); + + let mut generator = Generator::new(&schema); + let options = GeneratorOptions { + root_name: "test".to_string(), + root_description: "Test config".to_string(), + }; + let output = generator.generate(&schema, &options).unwrap(); + + // Layout should be in let bindings (used twice: layout + layout_rules) + assert!( + output.contains("layout = (lib.types.enum"), + "Layout should be in let bindings" + ); + + // The enum should appear exactly once (in the let binding) + let enum_count = output.matches("\"BSP\"").count(); + assert_eq!( + enum_count, 1, + "Layout enum should appear exactly once, found {} times", + enum_count + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..65c5a1b6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,103 @@ +//! schemars-nixgen: Generate Nix option definitions from JSON Schema +//! +//! This library converts JSON Schema (as generated by schemars) into +//! strongly-typed Nix option definitions for the NixOS module system. + +pub mod format; +pub mod generator; +pub mod prepare; +pub mod types; + +pub use generator::Generator; +pub use generator::GeneratorError; +pub use generator::GeneratorOptions; +pub use types::NixOption; +pub use types::NixType; + +use schemars::JsonSchema; +use serde_json::Value; + +/// Extension trait for generating Nix options from types that implement JsonSchema +pub trait SchemaExt: JsonSchema { + /// Generate Nix module code for this type + fn to_nix_options(options: &GeneratorOptions) -> Result { + let schema = schemars::schema_for!(Self); + let schema_value: Value = serde_json::to_value(&schema) + .map_err(|e| GeneratorError::InvalidSchema(e.to_string()))?; + let mut generator = Generator::new(&schema_value); + generator.generate(&schema_value, options) + } +} + +// Implement SchemaExt for all types that implement JsonSchema +impl SchemaExt for T {} + +/// Generate Nix options from a JSON schema value +pub fn generate_from_schema( + schema: &Value, + options: &GeneratorOptions, +) -> Result { + let mut generator = Generator::new(schema); + generator.generate(schema, options) +} + +/// Generate Nix options from a JSON schema string +pub fn generate_from_str( + schema_str: &str, + options: &GeneratorOptions, +) -> Result { + let schema: Value = serde_json::from_str(schema_str) + .map_err(|e| GeneratorError::InvalidSchema(e.to_string()))?; + generate_from_schema(&schema, options) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_generate_from_schema() { + let schema = json!({ + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether to enable the feature" + }, + "count": { + "type": "integer", + "description": "Number of items" + } + } + }); + + let options = GeneratorOptions { + root_name: "myapp".to_string(), + root_description: "My application configuration".to_string(), + }; + + let output = generate_from_schema(&schema, &options).unwrap(); + + assert!(output.contains("{ lib, ... }:")); + assert!(output.contains("options.myapp")); + assert!(output.contains("lib.types.bool")); + assert!(output.contains("lib.types.int")); + assert!(output.contains("Whether to enable the feature")); + } + + #[test] + fn test_generate_from_str() { + let schema_str = r#"{ + "type": "object", + "properties": { + "name": {"type": "string"} + } + }"#; + + let options = GeneratorOptions::default(); + let output = generate_from_str(schema_str, &options).unwrap(); + + assert!(output.contains("lib.types.str")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..d4fda572 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,60 @@ +use clap::Parser; +use schemars_nixgen::GeneratorOptions; +use schemars_nixgen::generate_from_schema; +use std::fs; +use std::io::Write; +use std::io::{self}; +use std::path::PathBuf; + +/// Generate Nix option definitions from JSON Schema +#[derive(Parser, Debug)] +#[command(name = "schemars-nixgen")] +#[command(about = "Generate Nix option definitions from JSON Schema")] +struct Cli { + /// Input JSON Schema file + input: PathBuf, + + /// Output Nix file (default: stdout) + #[arg(short, long)] + output: Option, + + /// Root option name (e.g., "komorebi" or "komorebi-bar") + #[arg(short, long, default_value = "config")] + name: String, + + /// Description for the root option + #[arg(short, long, default_value = "Configuration options")] + description: String, +} + +fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + + let cli = Cli::parse(); + + // Read the input schema + let schema_str = fs::read_to_string(&cli.input)?; + let schema: serde_json::Value = serde_json::from_str(&schema_str)?; + + // Configure generator options + let options = GeneratorOptions { + root_name: cli.name, + root_description: cli.description, + }; + + // Generate Nix code + let nix_code = generate_from_schema(&schema, &options)?; + + // Write output + if let Some(output_path) = cli.output { + fs::write(&output_path, &nix_code)?; + eprintln!( + "Generated Nix options written to: {}", + output_path.display() + ); + } else { + io::stdout().write_all(nix_code.as_bytes())?; + } + + Ok(()) +} diff --git a/src/prepare.rs b/src/prepare.rs new file mode 100644 index 00000000..f83173e7 --- /dev/null +++ b/src/prepare.rs @@ -0,0 +1,503 @@ +//! Schema preparation utilities for normalizing JSON Schema before conversion + +use serde_json::Value; +use std::collections::HashMap; + +/// Result type for mixed enum + object union detection +/// Contains (enum_variants, object_variants) where object_variants are (tag_name, inner_schema) tuples +pub type MixedUnionResult = (Vec, Vec<(String, Value)>); + +/// Extract all definitions from the schema's $defs section +pub fn collect_definitions(schema: &Value) -> HashMap { + let mut defs = HashMap::new(); + + if let Some(definitions) = schema.get("$defs").and_then(|d| d.as_object()) { + for (name, def) in definitions { + defs.insert(name.clone(), def.clone()); + } + } + + // Also check "definitions" for older schemas + if let Some(definitions) = schema.get("definitions").and_then(|d| d.as_object()) { + for (name, def) in definitions { + defs.insert(name.clone(), def.clone()); + } + } + + defs +} + +/// Resolve a $ref to its definition name +pub fn resolve_ref_name(ref_str: &str) -> Option<&str> { + // Handle "#/$defs/TypeName" or "#/definitions/TypeName" + if let Some(name) = ref_str.strip_prefix("#/$defs/") { + Some(name) + } else if let Some(name) = ref_str.strip_prefix("#/definitions/") { + Some(name) + } else { + None + } +} + +/// Check if a schema represents a nullable type (anyOf with null) +/// Returns Some(inner_schema) if it's a nullable pattern, None otherwise +/// Handles cases with multiple null variants (e.g., from serde Option serialization) +pub fn detect_nullable_anyof(schema: &Value) -> Option<&Value> { + let any_of = schema.get("anyOf")?.as_array()?; + + // Collect null and non-null variants + let mut non_null_variants: Vec<&Value> = Vec::new(); + let mut has_null = false; + + for variant in any_of.iter() { + if is_null_schema(variant) { + has_null = true; + } else { + non_null_variants.push(variant); + } + } + + // If we have exactly one non-null variant and at least one null, it's a nullable pattern + if has_null && non_null_variants.len() == 1 { + Some(non_null_variants[0]) + } else { + None + } +} + +/// Check if a schema is a null type +pub fn is_null_schema(schema: &Value) -> bool { + // Check for {"type": "null"} + schema + .get("type") + .is_some_and(|type_val| type_val == "null") +} + +/// Check if a type array includes null: ["string", "null"] +/// Returns Some(non_null_type) if it's a nullable type array +pub fn detect_nullable_type_array(schema: &Value) -> Option { + let type_val = schema.get("type")?; + let type_array = type_val.as_array()?; + + if type_array.len() != 2 { + return None; + } + + let mut has_null = false; + let mut other_type = None; + + for t in type_array { + if let Some(t_str) = t.as_str() { + if t_str == "null" { + has_null = true; + } else { + other_type = Some(t_str.to_string()); + } + } + } + + if has_null { other_type } else { None } +} + +/// Detect enum pattern from oneOf with const variants +/// Returns Some(variants) if all variants are const strings +pub fn detect_const_enum(schema: &Value) -> Option> { + let one_of = schema.get("oneOf")?.as_array()?; + + let mut variants = Vec::new(); + + for variant in one_of { + // Check for {"const": "value"} + if let Some(const_val) = variant.get("const") + && let Some(s) = const_val.as_str() + { + variants.push(s.to_string()); + continue; + } + // If any variant is not a const string, this isn't a simple enum + return None; + } + + if variants.is_empty() { + None + } else { + Some(variants) + } +} + +/// Detect simple enum from "enum" array +pub fn detect_simple_enum(schema: &Value) -> Option> { + let enum_val = schema.get("enum")?.as_array()?; + + let variants: Vec = enum_val + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + if variants.len() == enum_val.len() && !variants.is_empty() { + Some(variants) + } else { + None + } +} + +/// Get the primary type from a schema +pub fn get_schema_type(schema: &Value) -> Option<&str> { + schema.get("type")?.as_str() +} + +/// Get the description from a schema +pub fn get_description(schema: &Value) -> Option<&str> { + schema.get("description")?.as_str() +} + +/// Get the default value from a schema +pub fn get_default(schema: &Value) -> Option<&Value> { + schema.get("default") +} + +/// Check if schema has a $ref +pub fn get_ref(schema: &Value) -> Option<&str> { + schema.get("$ref")?.as_str() +} + +/// Get properties from an object schema +pub fn get_properties(schema: &Value) -> Option<&serde_json::Map> { + schema.get("properties")?.as_object() +} + +/// Get the items schema for an array +pub fn get_array_items(schema: &Value) -> Option<&Value> { + schema.get("items") +} + +/// Get the prefixItems for a tuple array schema +/// Returns the first item type (for homogeneous tuples like [int, int]) +pub fn get_prefix_items_type(schema: &Value) -> Option<&Value> { + let prefix_items = schema.get("prefixItems")?.as_array()?; + // Return the first item's schema - for homogeneous tuples this represents all items + prefix_items.first() +} + +/// Get additionalProperties schema +pub fn get_additional_properties(schema: &Value) -> Option<&Value> { + let ap = schema.get("additionalProperties")?; + // additionalProperties can be a boolean or a schema + if ap.is_boolean() { None } else { Some(ap) } +} + +/// Get patternProperties schemas (returns first one for simplicity) +pub fn get_pattern_properties(schema: &Value) -> Option<&Value> { + let pp = schema.get("patternProperties")?.as_object()?; + // Return the first pattern's schema + pp.values().next() +} + +/// Detect externally tagged union pattern (oneOf with single-property object variants) +/// This is the Serde default representation for Rust enums with data. +/// Example: `oneOf: [{ "Komorebi": {...} }, { "Battery": {...} }]` +/// Returns Some(vec of (tag_name, inner_schema)) if detected +/// +/// IMPORTANT: Each variant must have a DIFFERENT tag name. If all variants have the same +/// property name (like "kind"), this is an internally-tagged union, not externally-tagged. +pub fn detect_externally_tagged_union(schema: &Value) -> Option> { + let one_of = schema.get("oneOf")?.as_array()?; + + // Need at least 2 variants for a tagged union + if one_of.len() < 2 { + return None; + } + + let mut variants = Vec::new(); + + for variant in one_of { + // Check if this variant is an object with exactly one required property + if let Some(props) = variant.get("properties").and_then(|p| p.as_object()) + && let Some(required) = variant.get("required").and_then(|r| r.as_array()) + { + // Must have exactly one property and one required field + if required.len() == 1 + && props.len() == 1 + && let Some(tag) = required[0].as_str() + && let Some(inner) = props.get(tag) + { + variants.push((tag.to_string(), inner.clone())); + continue; + } + } + // If any variant doesn't match the pattern, this isn't an externally tagged union + return None; + } + + if variants.is_empty() { + return None; + } + + // Check that all tag names are UNIQUE (externally tagged unions have different keys) + // If all variants have the same key, this is an internally-tagged union + let first_tag = &variants[0].0; + let all_same = variants.iter().all(|(tag, _)| tag == first_tag); + if all_same { + // All variants have the same property name - this is internally tagged, not externally + return None; + } + + Some(variants) +} + +/// Detect internally tagged union pattern (oneOf with shared discriminator field containing const) +/// Example: `oneOf: [{ "kind": "None" }, { "kind": "Bar", ...config }]` +/// Returns Some((discriminator_field, vec of (discriminator_value, variant_schema))) +pub fn detect_internally_tagged_union(schema: &Value) -> Option<(String, Vec<(String, Value)>)> { + let one_of = schema.get("oneOf")?.as_array()?; + + if one_of.len() < 2 { + return None; + } + + // Find common discriminator field with const values + let mut discriminator_field: Option = None; + let mut variants = Vec::new(); + + for variant in one_of { + let props = variant.get("properties")?.as_object()?; + + // Look for a property with a const value (the discriminator) + let mut found_discriminator = false; + for (key, prop_schema) in props { + if let Some(const_val) = prop_schema.get("const").and_then(|c| c.as_str()) { + if discriminator_field.is_none() { + discriminator_field = Some(key.clone()); + } + if discriminator_field.as_ref() == Some(key) { + variants.push((const_val.to_string(), variant.clone())); + found_discriminator = true; + break; + } + } + } + + if !found_discriminator { + return None; + } + } + + let disc = discriminator_field?; + if variants.len() == one_of.len() { + Some((disc, variants)) + } else { + None + } +} + +/// Detect mixed enum + object union (oneOf with string consts AND tagged object variants) +/// Example: DateFormat with "MonthDateYear" (string) OR { "Custom": "..." } (object) +/// Returns Some((enum_variants, object_variants)) if detected +pub fn detect_mixed_enum_object_union(schema: &Value) -> Option { + let one_of = schema.get("oneOf")?.as_array()?; + + let mut enum_variants = Vec::new(); + let mut object_variants = Vec::new(); + + for variant in one_of { + // Check for string const + if variant.get("type").and_then(|t| t.as_str()) == Some("string") { + if let Some(const_val) = variant.get("const").and_then(|c| c.as_str()) { + enum_variants.push(const_val.to_string()); + continue; + } + // Also handle enum arrays in string type + if let Some(enum_vals) = variant.get("enum").and_then(|e| e.as_array()) { + for v in enum_vals { + if let Some(s) = v.as_str() { + enum_variants.push(s.to_string()); + } + } + continue; + } + } + + // Check for object with single required property (externally tagged variant) + if let Some(props) = variant.get("properties").and_then(|p| p.as_object()) + && let Some(required) = variant.get("required").and_then(|r| r.as_array()) + && required.len() == 1 + && props.len() == 1 + && let Some(tag) = required[0].as_str() + && let Some(inner) = props.get(tag) + { + object_variants.push((tag.to_string(), inner.clone())); + continue; + } + + // Variant doesn't match either pattern - could still be valid if we have some of each + } + + // Only return if we have BOTH enum variants AND object variants + if !enum_variants.is_empty() && !object_variants.is_empty() { + Some((enum_variants, object_variants)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_resolve_ref_name() { + assert_eq!(resolve_ref_name("#/$defs/MyType"), Some("MyType")); + assert_eq!(resolve_ref_name("#/definitions/OldType"), Some("OldType")); + assert_eq!(resolve_ref_name("invalid"), None); + } + + #[test] + fn test_detect_nullable_anyof() { + let schema = json!({ + "anyOf": [ + {"$ref": "#/$defs/Color"}, + {"type": "null"} + ] + }); + let inner = detect_nullable_anyof(&schema).unwrap(); + assert_eq!(inner.get("$ref").unwrap(), "#/$defs/Color"); + } + + #[test] + fn test_detect_nullable_type_array() { + let schema = json!({ + "type": ["string", "null"] + }); + assert_eq!( + detect_nullable_type_array(&schema), + Some("string".to_string()) + ); + } + + #[test] + fn test_detect_const_enum() { + let schema = json!({ + "oneOf": [ + {"const": "Left"}, + {"const": "Right"}, + {"const": "Center"} + ] + }); + let variants = detect_const_enum(&schema).unwrap(); + assert_eq!(variants, vec!["Left", "Right", "Center"]); + } + + #[test] + fn test_detect_simple_enum() { + let schema = json!({ + "enum": ["Red", "Green", "Blue"] + }); + let variants = detect_simple_enum(&schema).unwrap(); + assert_eq!(variants, vec!["Red", "Green", "Blue"]); + } + + #[test] + fn test_collect_definitions() { + let schema = json!({ + "$defs": { + "Color": {"type": "string"}, + "Size": {"type": "integer"} + } + }); + let defs = collect_definitions(&schema); + assert_eq!(defs.len(), 2); + assert!(defs.contains_key("Color")); + assert!(defs.contains_key("Size")); + } + + #[test] + fn test_detect_externally_tagged_union() { + // Pattern like WidgetConfig: { "Komorebi": {...} } | { "Battery": {...} } + let schema = json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "Komorebi": {"$ref": "#/$defs/KomorebiConfig"} + }, + "required": ["Komorebi"] + }, + { + "type": "object", + "properties": { + "Battery": {"$ref": "#/$defs/BatteryConfig"} + }, + "required": ["Battery"] + } + ] + }); + let variants = detect_externally_tagged_union(&schema).unwrap(); + assert_eq!(variants.len(), 2); + assert_eq!(variants[0].0, "Komorebi"); + assert_eq!(variants[1].0, "Battery"); + } + + #[test] + fn test_detect_externally_tagged_union_not_matching() { + // Not a tagged union - just const enums + let schema = json!({ + "oneOf": [ + {"type": "string", "const": "Left"}, + {"type": "string", "const": "Right"} + ] + }); + assert!(detect_externally_tagged_union(&schema).is_none()); + } + + #[test] + fn test_detect_internally_tagged_union() { + // Pattern like Grouping: { "kind": "None" } | { "kind": "Bar", ...config } + let schema = json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "kind": {"type": "string", "const": "None"} + }, + "required": ["kind"] + }, + { + "type": "object", + "properties": { + "kind": {"type": "string", "const": "Bar"}, + "transparency": {"type": "number"} + }, + "required": ["kind"] + } + ] + }); + let (disc, variants) = detect_internally_tagged_union(&schema).unwrap(); + assert_eq!(disc, "kind"); + assert_eq!(variants.len(), 2); + assert_eq!(variants[0].0, "None"); + assert_eq!(variants[1].0, "Bar"); + } + + #[test] + fn test_detect_mixed_enum_object_union() { + // Pattern like DateFormat: "MonthDateYear" | { "Custom": "..." } + let schema = json!({ + "oneOf": [ + {"type": "string", "const": "MonthDateYear"}, + {"type": "string", "const": "YearMonthDate"}, + { + "type": "object", + "properties": { + "Custom": {"type": "string"} + }, + "required": ["Custom"] + } + ] + }); + let (enums, objects) = detect_mixed_enum_object_union(&schema).unwrap(); + assert_eq!(enums, vec!["MonthDateYear", "YearMonthDate"]); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].0, "Custom"); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 00000000..bb8c5ec1 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,332 @@ +/// Represents a Nix type expression +#[derive(Debug, Clone, PartialEq)] +pub enum NixType { + /// lib.types.str + Str, + /// lib.types.int + Int, + /// lib.types.bool + Bool, + /// lib.types.float (actually lib.types.number in Nix) + Float, + /// lib.types.path + Path, + /// lib.types.nullOr + NullOr(Box), + /// lib.types.listOf + ListOf(Box), + /// lib.types.attrsOf + AttrsOf(Box), + /// lib.types.enum [ "A" "B" ... ] + Enum(Vec), + /// lib.types.oneOf [ ... ] + OneOf(Vec), + /// lib.types.submodule { options = { ... }; } + Submodule(Vec), + /// Tagged union: submodule where each variant is an optional field + /// Generated from externally tagged unions like WidgetConfig + /// Each tuple is (tag_name, inner_type) + TaggedUnion(Vec<(String, NixType)>), + /// Reference to a named type defined in a let binding + TypeRef(String), + /// lib.types.anything - fallback for complex types + Anything, + /// Raw Nix expression for edge cases + Raw(String), +} + +/// Represents a Nix option definition +#[derive(Debug, Clone, PartialEq)] +pub struct NixOption { + pub name: String, + pub nix_type: NixType, + pub description: Option, + pub default: Option, + pub example: Option, +} + +impl NixType { + /// Convert this type to a Nix expression string + pub fn to_nix_expr(&self) -> String { + match self { + NixType::Str => "lib.types.str".to_string(), + NixType::Int => "lib.types.int".to_string(), + NixType::Bool => "lib.types.bool".to_string(), + NixType::Float => "lib.types.number".to_string(), + NixType::Path => "lib.types.path".to_string(), + NixType::NullOr(inner) => { + format!("(lib.types.nullOr {})", inner.to_nix_expr()) + } + NixType::ListOf(inner) => { + format!("(lib.types.listOf {})", inner.to_nix_expr()) + } + NixType::AttrsOf(inner) => { + format!("(lib.types.attrsOf {})", inner.to_nix_expr()) + } + NixType::Enum(variants) => { + let variants_str: Vec = variants + .iter() + .map(|v| format!("\"{}\"", escape_nix_string(v))) + .collect(); + format!("(lib.types.enum [ {} ])", variants_str.join(" ")) + } + NixType::OneOf(types) => { + let types_str: Vec = types.iter().map(|t| t.to_nix_expr()).collect(); + format!("(lib.types.oneOf [ {} ])", types_str.join(" ")) + } + NixType::Submodule(options) => { + if options.is_empty() { + "(lib.types.attrsOf lib.types.anything)".to_string() + } else { + let opts: Vec = + options.iter().map(|opt| opt.to_nix_option_def(2)).collect(); + format!( + "(lib.types.submodule {{\n options = {{\n{}\n }};\n }})", + opts.join("\n") + ) + } + } + NixType::TaggedUnion(variants) => { + // Generate submodule with each variant as nullOr option with default = null + // This transforms externally-tagged unions into a single submodule where + // users specify exactly one variant (the others remain null) + if variants.is_empty() { + "(lib.types.attrsOf lib.types.anything)".to_string() + } else { + let opts: Vec = variants + .iter() + .map(|(tag, inner_type)| { + // Each variant becomes: Tag = lib.mkOption { type = lib.types.nullOr ; default = null; }; + let inner_expr = inner_type.to_nix_expr(); + let tag_escaped = escape_nix_attr_name(tag); + format!( + " {} = lib.mkOption {{ type = (lib.types.nullOr {}); default = null; }};", + tag_escaped, + inner_expr + ) + }) + .collect(); + format!( + "(lib.types.submodule {{\n options = {{\n{}\n }};\n }})", + opts.join("\n") + ) + } + } + NixType::TypeRef(name) => name.clone(), + NixType::Anything => "lib.types.anything".to_string(), + NixType::Raw(expr) => expr.clone(), + } + } + + /// Check if this type is nullable (NullOr) + pub fn is_nullable(&self) -> bool { + matches!(self, NixType::NullOr(_)) + } + + /// Wrap this type in NullOr if not already nullable + pub fn make_nullable(self) -> NixType { + if self.is_nullable() { + self + } else { + NixType::NullOr(Box::new(self)) + } + } +} + +impl NixOption { + /// Create a new NixOption + pub fn new(name: impl Into, nix_type: NixType) -> Self { + Self { + name: name.into(), + nix_type, + description: None, + default: None, + example: None, + } + } + + /// Set the description + pub fn with_description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Set the default value + pub fn with_default(mut self, default: impl Into) -> Self { + self.default = Some(default.into()); + self + } + + /// Generate the Nix option definition code + pub fn to_nix_option_def(&self, indent_level: usize) -> String { + let indent = " ".repeat(indent_level); + let inner_indent = " ".repeat(indent_level + 1); + + let mut parts = vec![format!( + "{}type = {};", + inner_indent, + self.nix_type.to_nix_expr() + )]; + + // Add default if present, otherwise use null for nullable types + if let Some(ref default) = self.default { + parts.push(format!("{}default = {};", inner_indent, default)); + } else if self.nix_type.is_nullable() { + parts.push(format!("{}default = null;", inner_indent)); + } + + // Add description if present + if let Some(ref desc) = self.description { + parts.push(format!( + "{}description = \"{}\";", + inner_indent, + escape_nix_string(desc) + )); + } + + format!( + "{}{} = lib.mkOption {{\n{}\n{}}};", + indent, + escape_nix_attr_name(&self.name), + parts.join("\n"), + indent + ) + } +} + +/// Escape a string for use in Nix string literals +pub fn escape_nix_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\t', "\\t") + .replace("${", "\\${") +} + +/// Escape an attribute name if it contains special characters +pub fn escape_nix_attr_name(name: &str) -> String { + // Nix attribute names that contain hyphens or start with numbers need quoting + if name.chars().all(|c| c.is_alphanumeric() || c == '_') + && name.chars().next().is_some_and(|c| !c.is_ascii_digit()) + { + name.to_string() + } else { + format!("\"{}\"", escape_nix_string(name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_primitive_types() { + assert_eq!(NixType::Str.to_nix_expr(), "lib.types.str"); + assert_eq!(NixType::Int.to_nix_expr(), "lib.types.int"); + assert_eq!(NixType::Bool.to_nix_expr(), "lib.types.bool"); + assert_eq!(NixType::Float.to_nix_expr(), "lib.types.number"); + } + + #[test] + fn test_nullable_type() { + let nullable_str = NixType::NullOr(Box::new(NixType::Str)); + assert_eq!( + nullable_str.to_nix_expr(), + "(lib.types.nullOr lib.types.str)" + ); + } + + #[test] + fn test_list_type() { + let list_of_int = NixType::ListOf(Box::new(NixType::Int)); + assert_eq!( + list_of_int.to_nix_expr(), + "(lib.types.listOf lib.types.int)" + ); + } + + #[test] + fn test_attrs_type() { + let attrs_of_str = NixType::AttrsOf(Box::new(NixType::Str)); + assert_eq!( + attrs_of_str.to_nix_expr(), + "(lib.types.attrsOf lib.types.str)" + ); + } + + #[test] + fn test_enum_type() { + let enum_type = NixType::Enum(vec!["Left".to_string(), "Right".to_string()]); + assert_eq!( + enum_type.to_nix_expr(), + "(lib.types.enum [ \"Left\" \"Right\" ])" + ); + } + + #[test] + fn test_escape_nix_string() { + assert_eq!(escape_nix_string("hello"), "hello"); + assert_eq!(escape_nix_string("line1\nline2"), "line1\\nline2"); + assert_eq!(escape_nix_string("say \"hi\""), "say \\\"hi\\\""); + assert_eq!(escape_nix_string("${var}"), "\\${var}"); + } + + #[test] + fn test_escape_attr_name() { + assert_eq!(escape_nix_attr_name("foo"), "foo"); + assert_eq!(escape_nix_attr_name("foo_bar"), "foo_bar"); + assert_eq!(escape_nix_attr_name("foo-bar"), "\"foo-bar\""); + assert_eq!(escape_nix_attr_name("123start"), "\"123start\""); + } + + #[test] + fn test_make_nullable() { + let str_type = NixType::Str; + let nullable = str_type.make_nullable(); + assert!(nullable.is_nullable()); + assert_eq!(nullable.to_nix_expr(), "(lib.types.nullOr lib.types.str)"); + + // Already nullable should not double-wrap + let already_nullable = NixType::NullOr(Box::new(NixType::Int)); + let still_nullable = already_nullable.clone().make_nullable(); + assert_eq!(still_nullable, already_nullable); + } + + #[test] + fn test_tagged_union_type() { + let tagged = NixType::TaggedUnion(vec![ + ("Komorebi".to_string(), NixType::Str), + ("Battery".to_string(), NixType::Int), + ]); + let expr = tagged.to_nix_expr(); + + // Should produce a submodule with optional fields + assert!(expr.contains("lib.types.submodule")); + assert!(expr.contains("Komorebi = lib.mkOption")); + assert!(expr.contains("Battery = lib.mkOption")); + assert!(expr.contains("lib.types.nullOr lib.types.str")); + assert!(expr.contains("lib.types.nullOr lib.types.int")); + assert!(expr.contains("default = null")); + } + + #[test] + fn test_tagged_union_empty() { + let tagged = NixType::TaggedUnion(vec![]); + assert_eq!( + tagged.to_nix_expr(), + "(lib.types.attrsOf lib.types.anything)" + ); + } + + #[test] + fn test_tagged_union_with_submodule() { + // Test that tagged union can contain submodule types + let inner_submodule = NixType::Submodule(vec![NixOption::new("enable", NixType::Bool)]); + let tagged = NixType::TaggedUnion(vec![("Widget".to_string(), inner_submodule)]); + let expr = tagged.to_nix_expr(); + + assert!(expr.contains("Widget = lib.mkOption")); + assert!(expr.contains("lib.types.nullOr")); + assert!(expr.contains("lib.types.submodule")); + } +}