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
This commit is contained in:
LGUG2Z committed 2025-12-24 17:20:16 -08:00
commit 7763d679a5
14 files changed
+3803

No files matched your search

+1
View File
@@ -0,0 +1 @@
use flake
+3
View File
@@ -0,0 +1,3 @@
/target
/result
/.direnv
Generated
+676
View File
@@ -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"
+21
View File
@@ -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"
+108
View File
@@ -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.
Generated
+179
View File
@@ -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
}
+129
View File
@@ -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;
};
};
};
};
}
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "stable"
+131
View File
@@ -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::<Vec<_>>()
.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<String> = 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<String> = 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, ... }:");
}
}
+1555
View File
File diff suppressed because it is too large. Load diff
+103
View File
@@ -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<String, GeneratorError> {
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<T: JsonSchema> SchemaExt for T {}
/// Generate Nix options from a JSON schema value
pub fn generate_from_schema(
schema: &Value,
options: &GeneratorOptions,
) -> Result<String, GeneratorError> {
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<String, GeneratorError> {
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"));
}
}
+60
View File
@@ -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<PathBuf>,
/// 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(())
}
+503
View File
@@ -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<String>, Vec<(String, Value)>);
/// Extract all definitions from the schema's $defs section
pub fn collect_definitions(schema: &Value) -> HashMap<String, Value> {
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<String> {
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<Vec<String>> {
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<Vec<String>> {
let enum_val = schema.get("enum")?.as_array()?;
let variants: Vec<String> = 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<String, Value>> {
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<Vec<(String, Value)>> {
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<String> = 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<MixedUnionResult> {
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");
}
}
+332
View File
@@ -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 <inner>
NullOr(Box<NixType>),
/// lib.types.listOf <inner>
ListOf(Box<NixType>),
/// lib.types.attrsOf <inner>
AttrsOf(Box<NixType>),
/// lib.types.enum [ "A" "B" ... ]
Enum(Vec<String>),
/// lib.types.oneOf [ ... ]
OneOf(Vec<NixType>),
/// lib.types.submodule { options = { ... }; }
Submodule(Vec<NixOption>),
/// 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<String>,
pub default: Option<String>,
pub example: Option<String>,
}
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<String> = variants
.iter()
.map(|v| format!("\"{}\"", escape_nix_string(v)))
.collect();
format!("(lib.types.enum [ {} ])", variants_str.join(" "))
}
NixType::OneOf(types) => {
let types_str: Vec<String> = 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<String> =
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<String> = variants
.iter()
.map(|(tag, inner_type)| {
// Each variant becomes: Tag = lib.mkOption { type = lib.types.nullOr <inner>; 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<String>, 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<String>) -> Self {
self.description = Some(desc.into());
self
}
/// Set the default value
pub fn with_default(mut self, default: impl Into<String>) -> 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"));
}
}