diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ba63093e..0bd58b9b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -111,7 +111,6 @@ jobs: run: | cargo clippy -p usage-lib --no-default-features -- -D warnings cargo clippy -p usage-rs --no-default-features -- -D warnings - cargo clippy -p usage-config-build -- -D warnings cargo clippy -p usage-config --no-default-features -- -D warnings - run: mise r render # Same reasoning as `render`: the shadow is checked in, so a change to the derive's @@ -154,7 +153,7 @@ jobs: - version: "1.91" crates: usage-argv usage-derive usage-config usage-validation usage-rs usage-test - version: "1.95" - crates: usage-lib usage-config-build clap_usage usage-cli + crates: usage-lib clap_usage usage-cli steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/Cargo.lock b/Cargo.lock index 3847a7ecf..a30dc91bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2284,14 +2284,6 @@ dependencies = [ "yaml_serde", ] -[[package]] -name = "usage-config-build" -version = "5.1.0" -dependencies = [ - "usage-config", - "usage-lib", -] - [[package]] name = "usage-conformance" version = "0.0.0" @@ -2354,6 +2346,7 @@ version = "5.1.0" dependencies = [ "serde", "usage-argv", + "usage-config", "usage-derive", "usage-lib", "usage-test", diff --git a/Cargo.toml b/Cargo.toml index 63b1d3c4e..3eef60b39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ resolver = "2" members = [ "argv", "config", - "config-build", "derive", "test", "usage-rs", @@ -48,7 +47,7 @@ usage-config = { path = "./config", version = "5.1.0" } usage-derive = { path = "./derive", version = "5.1.0" } # No features, and defaults off. A feature named here is inherited by every member that writes # `workspace = true` and inlines into their published manifests — so `clap` and `validation` -# reached `usage-config-build`, which runs in an adopter's build script and uses neither. Defaults +# reached members that use neither, one of them an adopter's build script. Defaults # are off rather than absent because cargo *ignores* a member's `default-features = false` unless # the workspace declaration sets it too, and warns that it may become a hard error. Members name # what they need, `docs` included. diff --git a/PLAN.md b/PLAN.md index f54c8602a..3fac11270 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,7 +13,7 @@ already written, which is the failure mode a status file has to avoid. into the spec. The first is underway. The second is **underway too**, which this file said it was -not: `usage-config` and `usage-config-build` are 8,220 lines, the spec's `config` +not: `usage-config` and the `usage::Config` derive are the settings model, the spec's `config` block has a model behind it, and the derive lowers `#[usage(setting = …)]` into `SETTINGS_BINDINGS` with a `Registry::drift` check over it. What has _not_ happened is adoption — none of mise, hk, pitchfork or fnox depends on the crate — @@ -1791,7 +1791,9 @@ including telling you to delete the label afterwards. ## Config -Not started. Design first, implementation after the parser gate. +Implemented — the crates, the spec block, the derive, and the CLI binding all exist below. +What has not happened is adoption, which is tracked per-CLI by the fleet effort rather than +here. ### What the four CLIs already do @@ -1828,17 +1830,24 @@ Where they differ is instructive, because it is mostly _drift_: ### The shape -- [ ] **Declare props in code**, `#[derive(usage::Config)]`, lowered into the +- [x] **Declare props in code**, `#[derive(usage::Config)]`, lowered into the spec's `config { prop ... }` block so settings documentation flows through the same pipeline as command documentation. Same canonicality rule as the - parser: code authors, the spec defines. **Decided (2026-08-21): the derive goes - on a registry-only declaration**, whose generated output feeds the CLI's existing - settings type rather than replacing it. Putting it on the final typed `Settings` - would be one source of truth, but it pushes layer bindings and merge policy onto - application fields and makes every adopter convert its whole registry in one step. - A registry-only declaration lets mise, hk, pitchfork and fnox adopt a layer at a - time, which is the migration path this section already judges likeliest to happen. - The cost is accepted and bounded: two declarations coexist during a migration. + parser: code authors, the spec defines. **Decided (2026-08-21, superseding the + registry-only decision of the same day): the derive goes on the real typed + `Settings` struct** — the same shape as `#[derive(usage::Cli)]`, which is the + argument that won. The author writes the struct the CLI already holds its settings + in; the derive generates `SETTINGS_PROPS`/`SETTINGS_REGISTRY`, `read(&Resolved)`, + and `spec_kdl()`, and a root's `#[usage(config = Settings)]` puts the block in its + emitted spec. One source of truth, no generated structs. The registry-only shape + was chosen for layer-at-a-time migration; the fleet's first adopters (pitchfork, + fnox, tak) are converting wholesale, so the incremental path's cost — two + coexisting declarations — was being paid for a benefit nobody scheduled. Nested + groups compose through `usage_config::Props` with compile-time `concat_props` + (duplicate keys refuse the build); `derive/src/config.rs`, + `conformance/tests/derive_config.rs`. The struct is the only declaration: the + build-time KDL-to-registry generator (`usage-config-build`) is gone, because a + second backend was a third description of every setting. - [x] **A prop vocabulary that is the union of the four registries** — `type` (bool, int, string, path, duration, list, map, plus a Rust-type escape hatch), `default`, `env` and `deprecated_env`, `docs`, `deprecated` with @@ -1885,13 +1894,16 @@ Where they differ is instructive, because it is mostly _drift_: pipeline — `config/` and `SpecConfigProp` are here today, so this is also the status quo. A split would buy an independent stability and MSRV policy at the price of cross-repo coordination on every spec vocabulary change. -- [ ] Whether the four CLIs migrate incrementally (one layer at a time, keeping +- [x] Whether the four CLIs migrate incrementally (one layer at a time, keeping their generated `Settings`) or by regenerating from a converted registry. - Incremental looks far more likely to actually happen. **Owned by the fleet - adoption effort (2026-08-21), not decided here** — it is a question about what - each CLI does, and the answer sets whether the first public APIs must wrap - existing layers and settings types without owning them. The registry-only - derive above deliberately keeps the incremental path open either way. + **Answered by the fleet adoption effort (2026-08-21): wholesale, on the real + struct.** The first adopters — pitchfork, fnox, tak — convert their registries + into `#[derive(usage::Config)]` structs in one PR each, stacked on their clap-swap + PRs; hk and aube are deferred (git/pkl layers, `env_only` bootstrap, per-item + provenance; aube's managed-policy ratchet and two-axis sources). There is no + second path held open for a later incremental adopter: `usage-config-build` was + removed with this decision, since keeping a KDL-first backend alive meant keeping + two generators emitting one registry shape. - [x] fnox's model, where config files are not a settings source at all, is the one real behavior change rather than a consolidation. Worth confirming that is a fix and not a deliberate choice. **Decided (2026-08-21): preserve fnox's diff --git a/argv/src/help.rs b/argv/src/help.rs index 31927c84a..03c225079 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -490,7 +490,10 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { let mut buf = [0u8; 4]; (*short as char).encode_utf8(&mut buf) == flag.name } - _ => false, + // A flag whose only spelling is its negation — clap's `SetFalse`, tak's + // `--no-credit` — is named after that spelling, so the prefix would repeat it: + // `no-credit: --no-credit`. + _ => show.negate && flag.negate == Some(flag.name), }; if !implied_matches { let _ = write!(out, "{}:", flag.name); @@ -1238,6 +1241,10 @@ pub(crate) fn flag_spelling(meta: &FlagMeta<'_>) -> String { fn display_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { let usage = flag_usage_masked(meta, show); match meta.flag.negate.filter(|_| show.negate) { + // A flag whose only spelling is its negation has nothing before it: the name prefix + // would repeat the spelling, so `flag_usage_masked` writes nothing and the negation + // is the whole entry. Joining with a space put one at the front of the column. + Some(negate) if usage.is_empty() => format!("--{negate}"), Some(negate) if show.long.is_none() && show.short.is_none() => { format!("{usage} --{negate}") } @@ -2785,6 +2792,30 @@ mod style_tests { assert_eq!(display_usage_masked(&meta, &shown), "color: --no-color"); } + #[test] + fn a_flag_spelled_only_as_its_negation_writes_that_spelling_and_nothing_before_it() { + // clap's `SetFalse`, tak's `--no-credit`: the flag is *named* after its negation, so + // the `name:` prefix would repeat the spelling and there is no positive form to join + // it to. Both halves wrote nothing, and the join put a space at the front of the + // column: `" --no-credit"`. + let flag = Flag { + name: "no-credit", + negate: Some("no-credit"), + ..Flag::BOOL + }; + let meta = FlagMeta { + flag: &flag, + ..FlagMeta::EMPTY + }; + let shown = Shown { + long: None, + short: None, + negate: true, + }; + + assert_eq!(display_usage_masked(&meta, &shown), "--no-credit"); + } + #[test] fn flattened_next_line_deprecation_follows_help_without_a_blank_row() { let flag = Flag { diff --git a/config-build/Cargo.toml b/config-build/Cargo.toml deleted file mode 100644 index d7ca7e2f4..000000000 --- a/config-build/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "usage-config-build" -description = "Generates a usage-config settings registry from a usage spec, at build time" -version = "5.1.0" -edition = "2021" -rust-version = "1.95" -homepage = { workspace = true } -documentation = { workspace = true } -repository = { workspace = true } -authors = { workspace = true } -license = { workspace = true } - -# Runs in a build script, so its dependencies cost an adopter's *build* and never ship: the KDL -# parser lives here and `usage-config` carries none of it. -[dependencies] -# No features: all this needs is `Spec::parse_file` and the `config` sub-model. `docs` would add -# tera and roff to every adopter's build script to render markdown nobody asked for. -usage-lib = { workspace = true } -# For the *rules*, not for the generated code: a default has to be held to the same choices, and read -# by the same coercion, that the runtime will apply — and a second implementation of those rules here -# is exactly the drift this crate removes elsewhere. It carries no dependencies of its own. -usage-config = { workspace = true } - -# The generated code is compiled and run in the tests, which is the only way to know it is code -# rather than plausible text. -[dev-dependencies] -usage-config = { workspace = true, features = ["toml"] } - -[package.metadata.release] -shared-version = true -release = true diff --git a/config-build/NOTICE.md b/config-build/NOTICE.md deleted file mode 120000 index dbac6d24b..000000000 --- a/config-build/NOTICE.md +++ /dev/null @@ -1 +0,0 @@ -../NOTICE.md \ No newline at end of file diff --git a/config-build/examples/gen.rs b/config-build/examples/gen.rs deleted file mode 100644 index f3355b28a..000000000 --- a/config-build/examples/gen.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Regenerates the checked-in registry the tests compile. -//! -//! ```sh -//! cargo run -p usage-config-build --example gen > config-build/tests/golden/settings.rs -//! ``` -//! -//! An example rather than a build script, because the point of checking the file in is that a -//! human reads the diff: generated code that nobody looks at is where a generator's mistakes live. - -const SPEC: &str = "config-build/tests/fixtures/hk.usage.kdl"; - -fn main() { - match usage_config_build::source(SPEC) { - Ok(source) => print!("{source}"), - Err(err) => { - eprintln!("{SPEC}: {err}"); - std::process::exit(1); - } - } -} diff --git a/config-build/src/emit.rs b/config-build/src/emit.rs deleted file mode 100644 index 8cc22eda6..000000000 --- a/config-build/src/emit.rs +++ /dev/null @@ -1,680 +0,0 @@ -//! The spec's settings written out as `const` Rust. -//! -//! Two rules run through all of it. Nothing is guessed: a declaration this crate cannot represent -//! faithfully is refused rather than approximated, because an approximation compiles and then -//! misbehaves at run time in the author's users' hands. And every problem is collected, because an -//! author fixing a registry wants the list rather than one round trip per mistake. - -use std::collections::BTreeMap; -use std::fmt::Write as _; - -use usage::spec::config::{ - SpecConfig, SpecConfigMerge, SpecConfigProp, SpecConfigScope, SpecConfigValue, -}; -use usage::spec::config_type::{Base, SpecConfigType}; - -/// The generated registry, or everything wrong with it. -pub(crate) fn registry(config: &SpecConfig, name: &str) -> Result> { - let mut problems = Vec::new(); - let props: Vec<(&String, &SpecConfigProp)> = config.props.iter().collect(); - - check_keys(&props, &mut problems); - check_aliases(&props, &mut problems); - check_renames(&props, &mut problems); - let idents = identifiers(&props, &mut problems); - - let mut body = String::new(); - for (key, prop) in &props { - let _ = writeln!(body, "{}", prop_meta(key, prop, &mut problems)); - } - - let settings = crate::settings::settings(&props, &idents, &mut problems); - - if !problems.is_empty() { - return Err(problems); - } - - let mut out = String::new(); - // The name is a file name, or whatever a caller passed to `source_of_spec`, and it goes into a - // *line* comment: a newline in it ends that comment and everything after it reads as code. The - // same rule the keys in this file follow, and the same reason. - let name = one_line(name); - let _ = writeln!( - out, - "// @generated by usage-config-build from `{name}`. Do not edit.\n\ - //\n\ - // Every setting this CLI has, as consts: there is no second declaration of a setting to\n\ - // keep in step with this one, which is the drift this file exists to remove." - ); - let _ = writeln!( - out, - "\npub static SETTINGS_PROPS: &[::usage_config::PropMeta] = &[\n{body}];" - ); - let _ = writeln!( - out, - "\npub const SETTINGS_REGISTRY: ::usage_config::Registry =\n \ - ::usage_config::Registry::new(SETTINGS_PROPS);" - ); - - // Ids as consts, because a `PropId` *is* the index into the table above — so generated code - // reads a setting without looking its name up, and a typo in a key is a compile error rather - // than a `None` at run time. - let _ = writeln!( - out, - "\n/// Each setting's id, which is its position in [`SETTINGS_PROPS`].\n\ - pub mod prop {{\n \ - use ::usage_config::PropId;\n" - ); - for (index, ((key, prop), ident)) in props.iter().zip(&idents).enumerate() { - // Both through `one_line`: a doc comment is a line, and a key can hold a newline as easily - // as help can — after which the rest of the key is code. - let key = one_line(key); - if let Some(help) = prop.help.as_deref() { - let _ = writeln!(out, " /// `{key}` — {}", one_line(help)); - } else { - let _ = writeln!(out, " /// `{key}`"); - } - let _ = writeln!(out, " pub const {ident}: PropId = PropId({index});"); - } - let _ = writeln!(out, "}}"); - out.push_str(&settings); - Ok(out) -} - -/// One `PropMeta`, as the struct-update form: a field this crate does not set is whatever -/// `PropMeta::new` says it is, so a field added to the runtime does not break generated code. -fn prop_meta(key: &str, prop: &SpecConfigProp, problems: &mut Vec) -> String { - let mut fields = String::new(); - if let Some(default) = default_const(key, prop, problems) { - let _ = writeln!(fields, " default: Some({default}),"); - } - match prop.merge { - SpecConfigMerge::Replace => {} - SpecConfigMerge::Union => { - let _ = writeln!(fields, " merge: ::usage_config::Merge::Union,"); - } - SpecConfigMerge::Deep => { - let _ = writeln!(fields, " merge: ::usage_config::Merge::Deep,"); - } - } - match prop.scope { - SpecConfigScope::Any => {} - SpecConfigScope::Global => { - let _ = writeln!(fields, " scope: ::usage_config::Scope::Global,"); - } - SpecConfigScope::Env => { - let _ = writeln!(fields, " scope: ::usage_config::Scope::Env,"); - } - } - if let Some(parse) = prop.parse.as_deref() { - match parser(parse) { - Some(variant) => { - let _ = writeln!( - fields, - " parse: Some(::usage_config::Parser::{variant})," - ); - } - // Not carried as an extension and not dropped: a spec that names a parser nothing - // implements would resolve values by a rule that does not exist, and every layer - // handing over text would produce one item where the author wrote several. - None => problems.push(format!( - "`{key}` names the parser `{parse}`, which is not one of: {}", - PARSERS - .iter() - .map(|(name, _)| *name) - .collect::>() - .join(", ") - )), - } - } - let envs = environment(prop); - if !envs.is_empty() { - let list: Vec = envs.iter().map(|env| rust_str(env)).collect(); - let _ = writeln!(fields, " envs: &[{}],", list.join(", ")); - } - if !prop.deprecated_envs.is_empty() { - let list: Vec = prop - .deprecated_envs - .iter() - .map(|env| rust_str(env)) - .collect(); - let _ = writeln!(fields, " deprecated_envs: &[{}],", list.join(", ")); - } - if !prop.cli.is_empty() { - let flags: Vec = prop.cli.iter().map(|flag| rust_str(flag)).collect(); - let _ = writeln!(fields, " cli: &[{}],", flags.join(", ")); - } - if !prop.bindings.is_empty() { - let mut pairs = Vec::new(); - for (kind, keys) in &prop.bindings { - for bound in keys { - pairs.push(format!("({}, {})", rust_str(kind), rust_str(bound))); - } - } - let _ = writeln!(fields, " bindings: &[{}],", pairs.join(", ")); - } - if !prop.choices.is_empty() { - let values: Vec = prop.choices.iter().map(|c| value_const(&c.value)).collect(); - let _ = writeln!(fields, " choices: &[{}],", values.join(", ")); - } - if prop.hide { - let _ = writeln!(fields, " hide: true,"); - } - if let Some(why) = prop.deprecated.as_deref() { - let _ = writeln!(fields, " deprecated: Some({}),", rust_str(why)); - } - if let Some(to) = prop.renamed_to.as_deref() { - let _ = writeln!(fields, " renamed_to: Some({}),", rust_str(to)); - } - if !prop.aliases.is_empty() { - let aliases: Vec = prop.aliases.iter().map(|alias| rust_str(alias)).collect(); - let _ = writeln!(fields, " aliases: &[{}],", aliases.join(", ")); - } - if let Some(optional) = prop.optional { - let _ = writeln!(fields, " optional: Some({optional}),"); - } - if let Some(help) = prop.help.as_deref() { - let _ = writeln!(fields, " help: Some({}),", rust_str(help)); - } - let new = format!( - "::usage_config::PropMeta::new({}, {})", - rust_str(key), - ty(key, prop.value_type.as_ref(), problems), - ); - // A setting with nothing but a key and a type is written as the call itself: the update form - // with no fields in it is the same value spelled twice, and clippy says so. - if fields.is_empty() { - return format!(" {new},"); - } - format!(" ::usage_config::PropMeta {{\n{fields} ..{new}\n }},") -} - -/// The runtime `Ty` expression for a declared type. -/// -/// Borrowed inners are fine in a `const`: `Ty::List(&Ty::String)` promotes, which is why the -/// runtime's type is shaped this way. -fn ty(key: &str, declared: Option<&SpecConfigType>, problems: &mut Vec) -> String { - let Some(declared) = declared else { - // No `type=` at all. The spec's own default is a string, and saying so beats inventing - // something cleverer for a property whose author did not say. - return "::usage_config::Ty::String".to_string(); - }; - match declared { - SpecConfigType::Base(base) => base_ty(base).to_string(), - SpecConfigType::List(inner) => format!( - "::usage_config::Ty::List(&{})", - ty(key, Some(inner), problems) - ), - SpecConfigType::Set(inner) => format!( - "::usage_config::Ty::Set(&{})", - ty(key, Some(inner), problems) - ), - SpecConfigType::Option(inner) => { - format!( - "::usage_config::Ty::Option(&{})", - ty(key, Some(inner), problems) - ) - } - SpecConfigType::Map(k, value) => { - // A table's keys are text in every format a settings file is written in, so a map - // keyed by anything else is a promise the runtime cannot keep — and one it would keep - // *quietly*, handing the CLI string keys for a `map`. - if !matches!(k, Base::String | Base::Custom(_)) { - problems.push(format!( - "`{key}` is a `map<{k}, …>`, and a settings table's keys are text: \ - write `map`" - )); - } - format!( - "::usage_config::Ty::Map(&{})", - ty(key, Some(value), problems) - ) - } - // A union, or a name this usage does not know: the spec has said usage cannot decide what - // belongs here, and `Any` is the runtime's word for exactly that — nothing coerced, nothing - // refused, the field's own type the only judge. - SpecConfigType::Union(_) => "::usage_config::Ty::Any".to_string(), - } -} - -fn base_ty(base: &Base) -> &'static str { - match base { - Base::Bool => "::usage_config::Ty::Bool", - Base::String => "::usage_config::Ty::String", - Base::Int => "::usage_config::Ty::Int", - Base::Uint => "::usage_config::Ty::Uint", - Base::Float => "::usage_config::Ty::Float", - Base::Path => "::usage_config::Ty::Path", - Base::Url => "::usage_config::Ty::Url", - Base::Duration => "::usage_config::Ty::Duration", - Base::Object => "::usage_config::Ty::Object", - Base::Custom(_) => "::usage_config::Ty::Any", - } -} - -/// The declared default as a `Const`, or nothing when there is none. -fn default_const(key: &str, prop: &SpecConfigProp, problems: &mut Vec) -> Option { - let has_default = prop.default.is_some() || !prop.default_list.is_empty(); - // Asked before either form of default is returned, because a *list* default returned early and - // skipped this — the one shape of the mistake that was generated instead of refused. - // - // An old name that carries a default its replacement does not have would seed a value under a - // key nothing reads: the merge folds a rename to its target, whose own default is what shows - // up. Left to run time it is a warning on every run of a shipped binary for a mistake only the - // author can fix, so it is refused here. - if let (Some(to), true) = (prop.renamed_to.as_deref(), has_default) { - problems.push(format!( - "`{key}` is renamed to `{to}` and declares a default of its own: put the default on \ - the setting that replaced it" - )); - } - // Both forms at once is something the spec accepts and nothing can mean: returning the list and - // ignoring the scalar dropped a value the author wrote, and I had claimed here that the parser - // refused it — it does not. I checked. - if prop.default.is_some() && !prop.default_list.is_empty() { - problems.push(format!( - "`{key}` declares a default twice, as `default=` and as a `default` node: keep one" - )); - } - // Anything declared here that the declared type cannot read is a value nothing can ever hold: a - // `choice` nobody can pick — `choice 1` under `type="bool"`, which reads booleans and their - // spellings and not numbers — or a default that is seeded straight into the resolution and then - // read by nothing, since a seeded default passes through no coercion at all. - let scalar = scalar_ty(prop.value_type.as_ref()); - let mut unreadable = Vec::new(); - for (what, value) in prop - .default - .iter() - .chain(prop.default_list.iter()) - .map(|value| ("defaults to", value)) - .chain(prop.choices.iter().map(|c| ("has a choice", &c.value))) - { - if scalar.coerce(runtime_value(value)).is_err() { - unreadable.push(what); - problems.push(format!( - "`{key}` {what} `{}`, which is not {}", - display(value), - scalar.describe() - )); - } - } - - // A declared default the declared choices do not allow. At run time it is seeded as the bottom - // layer and read by whatever holds it, so this is the only place it can be caught at all. - // - // Compared only when every choice is one a value could be: against a choice nothing can read, - // "not one of the values it allows" is a second message for the same mistake, and it blames the - // wrong end of it. - if !prop.choices.is_empty() && unreadable.is_empty() { - // Read the way the run time will read them — through the same `Ty::coerce` — because that is - // the only comparison that means anything: a spec writes `default="1"` beside `choice 1`, or - // `choice "yes"` under `type="bool"`, and neither is a mismatch once both are the declared - // type. Comparing spec literals refused those; comparing only same-shaped literals let - // `default="3"` beside `choice 1` through, and a *seeded* default never passes the check a - // supplied value does, so it would have become the effective value with nothing said. - let allowed = |value: &SpecConfigValue| { - let target = scalar.coerce(runtime_value(value)); - prop.choices.iter().any(|choice| { - match (&target, scalar.coerce(runtime_value(&choice.value))) { - (Ok(value), Ok(coerced)) if *value == coerced => true, - // Coercion did not settle it: compare what the two are written as, exactly as the - // run time does. `any` is where that matters — it coerces nothing, so a union's - // `choice 1` and a `default="1"` stay an integer and a string, and stopping at - // the arm above would refuse a spec the run time accepts. - _ => display(value) == display(&choice.value), - } - }) - }; - // A list default under a type that declares no items is one value, not several: `any` takes - // a list perfectly well, and a scalar choice is not one however many items it has. Checked - // item by item, `default 1 2` beside `choice 1` and `choice 2` passed — and then the whole - // list is what a seeded default *is*, refused by its own choices and never checked again. - let declares_items = declares_items(prop.value_type.as_ref()); - if !declares_items && !prop.default_list.is_empty() { - let written = prop - .default_list - .iter() - .map(display) - .collect::>() - .join(","); - problems.push(format!( - "`{key}` defaults to `{written}`, which is not one of the values it allows: {}", - prop.choices - .iter() - .map(|c| display(&c.value)) - .collect::>() - .join(", ") - )); - } - let items = match declares_items { - true => prop.default_list.as_slice(), - false => &[], - }; - for value in prop.default.iter().chain(items.iter()) { - if !allowed(value) { - problems.push(format!( - "`{key}` defaults to `{}`, which is not one of the values it allows: {}", - display(value), - prop.choices - .iter() - .map(|c| display(&c.value)) - .collect::>() - .join(", ") - )); - } - } - } - // A list default is a child node rather than a value, and either may be the one that is there. - if !prop.default_list.is_empty() { - let items: Vec = prop - .default_list - .iter() - .map(|value| coerced_const(scalar, value)) - .collect(); - return Some(format!( - "::usage_config::Const::List(&[{}])", - items.join(", ") - )); - } - prop.default - .as_ref() - .map(|value| coerced_const(scalar, value)) -} - -/// A declared default as the value its type says it is. -/// -/// Emitted *coerced*, because a default is seeded straight into the resolution and passes through no -/// coercion on the way: `default "yes"` under `list` is a boolean by the time anything reads -/// it, and emitted as the string it was written as, the read fails on a registry this crate had just -/// accepted. A value the type cannot read is refused above, so this only ever converts what the run -/// time would have converted itself. -/// -/// A *scalar* default arrives already converted — the spec's own parser reads it as the declared -/// type — so for that one this is belt and braces, and only the list form is observably fixed by it. -/// Applied to both because which of them the parser happens to cover is not something this should -/// depend on. -/// -/// Choices are *not* put through this: they are documentation as much as values — the message says -/// "one of yes, no" because that is what a user may write — and the comparison coerces them at the -/// moment it needs them. -fn coerced_const(ty: usage_config::Ty, value: &SpecConfigValue) -> String { - match ty.coerce(runtime_value(value)) { - Ok(usage_config::Value::Bool(b)) => format!("::usage_config::Const::Bool({b})"), - Ok(usage_config::Value::Int(i)) => format!("::usage_config::Const::Int({i})"), - Ok(usage_config::Value::Float(f)) => format!("::usage_config::Const::Float({f:?})"), - Ok(usage_config::Value::String(s)) => { - format!("::usage_config::Const::Str({})", rust_str(&s)) - } - // A collection cannot come out of coercing a scalar, and a value the type cannot read is - // already a refusal — either way the generation does not survive to be emitted. - _ => value_const(value), - } -} - -/// Whether the type this emits has items of its own, which is what makes an item-wise check right. -/// -/// Asked of the type that is *emitted*, not of the one the spec wrote: `simplified` collapses a union -/// to its first member, so a `list|string` looked like a list — while what is emitted for any -/// union is `Ty::Any`, which compares a value whole and would refuse the very default this had just -/// accepted. `option` is looked through for the same reason the runtime looks through it. -fn declares_items(declared: Option<&SpecConfigType>) -> bool { - match declared { - Some(SpecConfigType::List(_) | SpecConfigType::Set(_) | SpecConfigType::Map(..)) => true, - Some(SpecConfigType::Option(inner)) => declares_items(Some(inner)), - _ => false, - } -} - -/// The type a *scalar* of this setting is read as. -/// -/// Defaults and choices are scalars, so a collection is asked about what is in it. Every base type is -/// `const`-constructible without a reference, which is what makes this possible at build time at all: -/// the composite `Ty`s borrow, and nothing here can hand out a `&'static Ty`. -fn scalar_ty(declared: Option<&SpecConfigType>) -> usage_config::Ty { - fn of(ty: &SpecConfigType) -> usage_config::Ty { - match ty { - SpecConfigType::Base(base) => match base { - Base::Bool => usage_config::Ty::Bool, - Base::String => usage_config::Ty::String, - Base::Int => usage_config::Ty::Int, - Base::Uint => usage_config::Ty::Uint, - Base::Float => usage_config::Ty::Float, - Base::Path => usage_config::Ty::Path, - Base::Url => usage_config::Ty::Url, - Base::Duration => usage_config::Ty::Duration, - Base::Object => usage_config::Ty::Object, - Base::Custom(_) => usage_config::Ty::Any, - }, - SpecConfigType::List(inner) | SpecConfigType::Set(inner) => of(inner), - SpecConfigType::Map(_, value) => of(value), - SpecConfigType::Option(inner) => of(inner), - // A union coerces nothing, and neither does this. - SpecConfigType::Union(_) => usage_config::Ty::Any, - } - } - declared.map_or(usage_config::Ty::String, of) -} - -/// A spec value as the runtime holds one, for the coercion above. -fn runtime_value(value: &SpecConfigValue) -> usage_config::Value { - match value { - SpecConfigValue::Bool(b) => usage_config::Value::Bool(*b), - SpecConfigValue::Int(i) => usage_config::Value::Int(*i), - SpecConfigValue::Float(f) => usage_config::Value::Float(*f), - SpecConfigValue::String(s) => usage_config::Value::String(s.clone()), - } -} - -/// A spec value as a user would have written it, for a message and for the comparison that falls -/// back to one. -/// -/// Through the *runtime's* renderer, which is the only way the two can agree about anything: written -/// here as well, they drifted the moment one of them learned that a whole-number float is `1.0` and -/// not `1` — and this side would then have accepted a default the generated registry refuses. -fn display(value: &SpecConfigValue) -> String { - runtime_value(value).display() -} - -fn value_const(value: &SpecConfigValue) -> String { - match value { - SpecConfigValue::Bool(b) => format!("::usage_config::Const::Bool({b})"), - SpecConfigValue::Int(i) => format!("::usage_config::Const::Int({i})"), - // `{:?}` rather than `{}`: `1.0` written as `1` is an integer literal, and a `f64` field - // will not take one. - SpecConfigValue::Float(f) => format!("::usage_config::Const::Float({f:?})"), - SpecConfigValue::String(s) => format!("::usage_config::Const::Str({})", rust_str(s)), - } -} - -/// Both spellings of the environment, in precedence order. -/// -/// `env=` is the singular form and `env` the child node; the spec keeps them in step, and the -/// higher-precedence one comes first. -fn environment(prop: &SpecConfigProp) -> Vec { - let mut envs: Vec = Vec::new(); - if let Some(env) = prop.env.as_deref() { - envs.push(env.to_string()); - } - for env in &prop.envs { - if !envs.iter().any(|seen| seen == env) { - envs.push(env.clone()); - } - } - envs -} - -const PARSERS: [(&str, &str); 4] = [ - ("list_by_comma", "ListByComma"), - ("list_by_colon", "ListByColon"), - ("list_by_os_path_separator", "ListByOsPathSeparator"), - ("set_by_comma", "SetByComma"), -]; - -fn parser(name: &str) -> Option<&'static str> { - PARSERS - .iter() - .find(|(spec, _)| *spec == name) - .map(|(_, variant)| *variant) -} - -/// The const name for each key, and a problem for any two that would collide. -fn identifiers(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) -> Vec { - let mut idents = Vec::with_capacity(props.len()); - let mut seen: BTreeMap = BTreeMap::new(); - for (key, _) in props { - let ident = ident_of(key); - if let Some(other) = seen.get(&ident) { - // `task.output` and `task_output` are two settings and one const. Generating both - // would not compile, which is a good outcome reported badly: the message names the - // pair rather than leaving the author to read generated code. - problems.push(format!( - "`{key}` and `{other}` both generate `prop::{ident}`: rename one of them" - )); - } else { - seen.insert(ident.clone(), (*key).clone()); - } - idents.push(ident); - } - idents -} - -fn ident_of(key: &str) -> String { - let mut out = String::with_capacity(key.len()); - for c in key.chars() { - match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' => out.extend(c.to_uppercase()), - // A dot, a dash, anything else: one underscore, so `task.output` and `task-output` - // read the same way a Rust const is written. - _ => out.push('_'), - } - } - // A const cannot start with a digit, and a key may. - if out.starts_with(|c: char| c.is_ascii_digit()) { - out.insert(0, '_'); - } - out -} - -/// Refuse a key that cannot become a name. -/// -/// An empty key generated `pub const : PropId = …`, and an empty part of one (`task..output`) -/// generated a field with no name: both are code the *adopter's* crate fails to compile, in a file -/// they did not write and cannot fix. -fn check_keys(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) { - for (key, _) in props { - if key.is_empty() { - problems.push("a setting with no name cannot be generated".to_string()); - } else if !nameable(key) { - problems.push(format!( - "`{key}` has a part with no name in it: every piece of a dotted key needs an \ - ASCII letter or digit" - )); - } - } -} - -/// Refuse aliases whose owner is ambiguous. -fn check_aliases(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) { - let mut owners: BTreeMap<&str, &str> = props - .iter() - .map(|(key, _)| (key.as_str(), key.as_str())) - .collect(); - for (key, prop) in props { - for alias in &prop.aliases { - if alias.is_empty() { - problems.push(format!("`{key}` has an empty alias")); - continue; - } - if let Some(owner) = owners.insert(alias, key) { - problems.push(format!( - "`{alias}` names both `{owner}` and `{key}`; every setting key and alias must be unique" - )); - } - } - } -} - -/// Whether every part of `key` can be an identifier. -/// -/// A part needs an ASCII letter or digit in it, which is the alphabet the names are actually built -/// from: `ident_of` keeps those and turns everything else into an underscore. Asking the wider -/// question — is any character of it alphanumeric — accepted `é` and then generated `_` for it, so -/// the two disagreed about the same key. Rust would take `É` as an identifier, but what an arbitrary -/// letter uppercases to is not something to bet a generated name on. -/// -/// Empty (`task..output`) a part generated a field with no name; made only of characters outside -/// that alphabet (`-`) it generated an *anonymous* const — legal Rust, and unreferenceable, so the -/// reader that names it does not compile — and a field called `_`, which is not legal at all. -pub(crate) fn nameable(key: &str) -> bool { - !key.is_empty() - && key - .split('.') - .all(|part| part.chars().any(|c| c.is_ascii_alphanumeric())) -} - -/// Refuse a `renamed_to` that cannot be followed. -fn check_renames(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) { - let by_key: BTreeMap<&str, &SpecConfigProp> = props - .iter() - .map(|(key, prop)| (key.as_str(), *prop)) - .collect(); - for (key, prop) in props { - let Some(to) = prop.renamed_to.as_deref() else { - continue; - }; - if !by_key.contains_key(to) { - problems.push(format!( - "`{key}` is renamed to `{to}`, which is not a setting" - )); - continue; - } - // Walking the chain from here, bounded by how many settings there are. A cycle makes - // every lookup of these keys answer `None` at run time — the setting becomes unreachable - // rather than wrong, which is the hardest kind of bug to see. - let mut seen = vec![key.as_str()]; - let mut current = to; - for _ in 0..props.len() { - if seen.contains(¤t) { - problems.push(format!( - "the renames of `{}` form a cycle: {} → {current}", - seen[0], - seen.join(" → ") - )); - break; - } - seen.push(current); - match by_key.get(current).and_then(|p| p.renamed_to.as_deref()) { - Some(next) => current = next, - None => break, - } - } - } -} - -/// `text` as a Rust string literal. -pub(crate) fn rust_str(text: &str) -> String { - let mut out = String::with_capacity(text.len() + 2); - out.push('"'); - for c in text.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - // A control character written raw is a literal a compiler accepts and a reader cannot - // see. `\u{…}` is the same string, spelled. - c if c.is_control() => { - let _ = write!(out, "\\u{{{:x}}}", c as u32); - } - c => out.push(c), - } - } - out.push('"'); - out -} - -/// Help text as one line, for a doc comment on a generated const. -pub(crate) fn one_line(text: &str) -> String { - text.replace(['\n', '\r'], " ") -} diff --git a/config-build/src/lib.rs b/config-build/src/lib.rs deleted file mode 100644 index 44d0216b7..000000000 --- a/config-build/src/lib.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! A spec's `config` block, as the registry `usage-config` resolves against. -//! -//! Every CLI in the jdx fleet declares its settings in one file and resolves them in another, and -//! the two are kept in step by hand. They drift every time: hk declares eighteen `sources.cli` -//! bindings and reads five, pitchfork generates five settings that its own `settings get` cannot -//! reach, fnox's docs describe a layer that does not exist. This crate is the join. The spec is -//! read here, at build time, and what comes out is `const` — so a setting that is declared is a -//! setting that resolves, and there is no second place to forget to update. -//! -//! ```no_run -//! // build.rs -//! usage_config_build::generate("mycli.usage.kdl").expect("settings"); -//! ``` -//! -//! ```ignore -//! // src/settings.rs -//! include!(concat!(env!("OUT_DIR"), "/settings.rs")); -//! ``` -//! -//! Nothing generated here allocates or parses at run time: `PropMeta` is `const`-constructible -//! down to its defaults, so a binary that reads no settings pays nothing for having them, and one -//! that reads all of them pays a `static`. -//! -//! # What it refuses -//! -//! A build script is the right place to be strict, because the alternative is a warning on every -//! run of a shipped binary for a mistake only the author can fix. So a registry is refused when it -//! cannot mean what it says: a `renamed_to` naming a setting that is not there, renames that form a -//! cycle, an old name carrying a default its replacement lacks, a `parse` nobody implements, a -//! `map` keyed by something a config file cannot spell, or two keys whose generated names collide. -//! All of them at once, rather than the first — an author fixing a registry wants the list. - -use std::fmt; -use std::path::{Path, PathBuf}; -use std::str::FromStr as _; - -mod emit; -mod settings; - -/// Read `spec` and write the registry to `$OUT_DIR/settings.rs`. -/// -/// Returns the path written, and tells cargo to re-run when the spec changes. -pub fn generate(spec: impl AsRef) -> Result { - let out_dir = std::env::var("OUT_DIR").map_err(|_| Error::NoOutDir)?; - let out = PathBuf::from(out_dir).join("settings.rs"); - generate_to(spec, &out)?; - Ok(out) -} - -/// Read `spec` and write the registry to `out`, returning the files it told cargo to watch. -/// -/// For a caller that keeps generated code in the repository rather than in `OUT_DIR` — which is -/// how this crate tests itself, and how a CLI that wants its registry reviewable does it. The -/// returned list is what was printed, rather than a second list assembled the same way: printing is -/// not something a test in this process can see, and a watch list nothing checks is one that can -/// quietly lose a file. -pub fn generate_to(spec: impl AsRef, out: impl AsRef) -> Result, Error> { - let spec = spec.as_ref(); - // Before anything can fail, so a spec that does not parse is still watched and the next build - // is not a stale success. - println!("cargo::rerun-if-changed={}", spec.display()); - let parsed = parse(spec)?; - // Every file the spec *included*, which is where a CLI with many settings keeps them — so - // watching only the file the build script names left editing the settings rebuilding nothing. - // Printed before the registry is built, so a spec whose settings are *refused* still watches the - // file its author is about to go and edit. - for included in parsed.sources.iter().skip(1) { - println!("cargo::rerun-if-changed={}", included.display()); - } - let watching = parsed.sources.clone(); - let source = source_of(&parsed.config, &name_of(spec))?; - let out = out.as_ref(); - if let Some(parent) = out.parent() { - std::fs::create_dir_all(parent).map_err(|err| Error::Io { - path: parent.to_path_buf(), - why: err.to_string(), - })?; - } - // Only when it differs, so a checked-in registry keeps its mtime and nothing downstream - // rebuilds for a generator that produced the same bytes. - if std::fs::read_to_string(out).is_ok_and(|existing| existing == source) { - return Ok(watching); - } - std::fs::write(out, source).map_err(|err| Error::Io { - path: out.to_path_buf(), - why: err.to_string(), - })?; - Ok(watching) -} - -/// Every file a build should watch: the spec, then each `include`, recursively. -/// -/// [`generate_to`] prints these for cargo. A build script doing something more elaborate — writing -/// the registry somewhere of its own, or generating other things from the same spec — wants the list -/// rather than the printing. -pub fn watched(spec: impl AsRef) -> Result, Error> { - Ok(parse(spec.as_ref())?.sources) -} - -/// The Rust source a spec's `config` block becomes. -pub fn source(spec: impl AsRef) -> Result { - let spec = spec.as_ref(); - source_of(&parse(spec)?.config, &name_of(spec)) -} - -/// The spec, read once. -/// -/// [`generate_to`] wants two things from it — the files to watch and the registry to write — and -/// asking for them one at a time parsed the whole spec twice on every build. -fn parse(spec: &Path) -> Result { - usage::Spec::parse_file(spec).map_err(|err| Error::Spec(err.to_string())) -} - -/// What the generated header calls the spec it came from. -fn name_of(spec: &Path) -> String { - spec.file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| spec.display().to_string()) -} - -/// The same, from a spec that is already in memory. -/// -/// A relative `include` resolves against the file a spec was read from, so a spec whose settings -/// live in their own file has to go through [`source`]; this is for a caller holding one text. -pub fn source_of_spec(spec: &str, name: &str) -> Result { - let parsed = usage::Spec::from_str(spec).map_err(|err| Error::Spec(err.to_string()))?; - source_of(&parsed.config, name) -} - -fn source_of(config: &usage::spec::config::SpecConfig, name: &str) -> Result { - if config.props.is_empty() { - return Err(Error::NoSettings); - } - emit::registry(config, name).map_err(Error::Registry) -} - -/// Why a registry could not be generated. -#[derive(Debug)] -pub enum Error { - /// The spec itself did not parse. - Spec(String), - /// The spec parsed and declares no settings, which is not something to generate an empty - /// registry for: a build script asking for one has been pointed at the wrong file. - NoSettings, - /// Everything wrong with the settings, rather than the first thing. - Registry(Vec), - /// [`generate`] was called outside a build script. - NoOutDir, - Io { - path: PathBuf, - why: String, - }, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Spec(why) => write!(f, "{why}"), - Self::NoSettings => f.write_str("this spec declares no `config` settings"), - Self::Registry(problems) => { - for (i, problem) in problems.iter().enumerate() { - if i > 0 { - f.write_str("\n")?; - } - f.write_str(problem)?; - } - Ok(()) - } - Self::NoOutDir => f.write_str("OUT_DIR is not set; call this from a build script"), - Self::Io { path, why } => write!(f, "{}: {why}", path.display()), - } - } -} - -impl std::error::Error for Error {} diff --git a/config-build/src/settings.rs b/config-build/src/settings.rs deleted file mode 100644 index 358de347a..000000000 --- a/config-build/src/settings.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! The settings struct a CLI actually reads. -//! -//! The registry is what resolution needs; this is what the code needs. Between them they are the -//! whole reason the fleet's hand-written key↔field match arms exist — pitchfork writes about three -//! hundred and thirty lines of them, twice, and five settings still cannot be reached from its own -//! `settings get`. -//! -//! Dotted keys become nested structs, because `settings.task.output` is how the setting reads in -//! the file and there is no reason for the code to spell it differently. - -use std::collections::BTreeMap; -use std::fmt::Write as _; - -use usage::spec::config::SpecConfigProp; -use usage::spec::config_type::{Base, SpecConfigType}; - -/// A group of settings under a common dotted prefix, which becomes one struct. -#[derive(Default)] -struct Group<'a> { - /// Settings whose key ends here, by their last segment. - leaves: BTreeMap<&'a str, Leaf<'a>>, - /// Groups below this one, by their segment. - groups: BTreeMap<&'a str, Group<'a>>, -} - -struct Leaf<'a> { - /// The whole dotted key. - key: &'a str, - /// The const in `prop`, and the local this is read into. - ident: &'a str, - prop: &'a SpecConfigProp, -} - -/// The `Settings` struct, its nested structs, and the one function that reads them. -pub(crate) fn settings( - props: &[(&String, &SpecConfigProp)], - idents: &[String], - problems: &mut Vec, -) -> String { - let mut root = Group::default(); - for ((key, prop), ident) in props.iter().zip(idents) { - // An old name is not a field: every read of it folds to the setting that replaced it, so a - // field for it would be a second name for one value — and `config set old.key` writing to a - // field nothing reads is exactly the pitchfork bug. - if prop.renamed_to.is_some() { - continue; - } - // A key with a piece that cannot be a name is already refused by name, and building a tree - // from it invents a group called nothing that then collides with its own parent — two - // messages for one mistake, one of them about something the author never wrote. - if !crate::emit::nameable(key) { - continue; - } - let segments: Vec<&str> = key.split('.').collect(); - let (last, path) = segments.split_last().expect("a key has a segment"); - let mut group = &mut root; - for segment in path { - group = group.groups.entry(segment).or_default(); - } - group.leaves.insert(last, Leaf { key, ident, prop }); - } - - let mut structs: BTreeMap = BTreeMap::new(); - check_names(&root, "", "Settings", &mut structs, problems); - - let mut structs = String::new(); - let mut reads = String::new(); - let root_body = emit(&root, "Settings", &mut structs, &mut reads); - - format!( - "\n/// Every setting, as the types a CLI holds them in.\n\ - ///\n\ - /// Read with [`Settings::read`] from a resolution, which is the only way to build one:\n\ - /// the values, and every reason one could not be read, come from the merge.\n\ - #[derive(Debug, Clone, PartialEq)]\n\ - pub struct Settings {{\n{root_body}}}\n\ - {structs}\n\ - impl Settings {{\n \ - /// This resolution's values, as their types.\n \ - ///\n \ - /// Every setting is read before anything is returned, so the error is the whole list of\n \ - /// what is wrong rather than the first thing found.\n \ - pub fn read(\n \ - resolved: &::usage_config::Resolved,\n \ - ) -> ::std::result::Result {{\n \ - let mut fold = resolved.fold();\n{reads} \ - fold.finish()?;\n \ - ::std::result::Result::Ok(Self {{\n{} }})\n }}\n}}\n", - construct(&root, "Settings", 3), - ) -} - -/// One group's fields, its descendants' structs, and the reads that fill them. -fn emit(group: &Group<'_>, name: &str, structs: &mut String, reads: &mut String) -> String { - let mut fields = String::new(); - for (segment, sub) in &group.groups { - let sub_name = format!("{name}{}", camel(segment)); - let body = emit(sub, &sub_name, structs, reads); - let _ = write!( - structs, - "\n/// The `{}` settings.\n\ - #[derive(Debug, Clone, PartialEq)]\n\ - pub struct {sub_name} {{\n{body}}}\n", - crate::emit::one_line(segment) - ); - let _ = writeln!(fields, " pub {}: {sub_name},", field(segment)); - } - for (segment, leaf) in &group.leaves { - let ty = rust_ty(leaf); - if let Some(help) = leaf.prop.help.as_deref() { - let _ = writeln!(fields, " /// {}", one_line(help)); - } - // The key as well as the help, because the field name is a translation of it and a reader - // going from code to a config file needs the name the file uses. - let _ = writeln!(fields, " /// (`{}`)", crate::emit::one_line(leaf.key)); - if optional(leaf) { - let _ = writeln!(fields, " pub {}: Option<{ty}>,", field(segment)); - let _ = writeln!( - reads, - " let {}: Option<{ty}> = fold.optional(prop::{});", - local(leaf.ident), - leaf.ident - ); - } else { - let _ = writeln!(fields, " pub {}: {ty},", field(segment)); - let _ = writeln!( - reads, - " let {}: Option<{ty}> = fold.required(prop::{});", - local(leaf.ident), - leaf.ident - ); - } - } - fields -} - -/// The expression that builds one group, once the fold has proved every value read. -fn construct(group: &Group<'_>, name: &str, depth: usize) -> String { - let pad = " ".repeat(depth); - let mut out = String::new(); - for (segment, sub) in &group.groups { - // Named, not inferred: a nested group is its own struct, and the name is the same one its - // declaration got. - let sub_name = format!("{name}{}", camel(segment)); - let _ = writeln!(out, "{pad}{}: {sub_name} {{", field(segment)); - let _ = write!(out, "{}", construct(sub, &sub_name, depth + 1)); - let _ = writeln!(out, "{pad}}},"); - } - for (segment, leaf) in &group.leaves { - if optional(leaf) { - let name = field(segment); - let read_into = local(leaf.ident); - // `ci` rather than `ci: ci` where the two agree, which they do for every setting whose - // key has one segment: clippy will not have the long form, and nor should it. - if name == read_into { - let _ = writeln!(out, "{pad}{name},"); - } else { - let _ = writeln!(out, "{pad}{name}: {read_into},"); - } - } else { - // The unwrap the fold's own contract allows: `required` returns `None` only when it has - // recorded an error, and `finish` has already turned any error into a return. - // The message is a literal in generated code, so the key goes through the same escaper - // `PropMeta` uses: a key holding a quote or a backslash — `a"b` is a nameable key — - // otherwise ended the literal early and the rest of it read as code. - let message = crate::emit::rust_str(&format!( - "`{}` has a declared default, so the fold has already reported any absence", - leaf.key - )); - let _ = writeln!( - out, - "{pad}{}: {}.expect({message}),", - field(segment), - local(leaf.ident), - ); - } - } - out -} - -/// Whether a field holds an `Option`. -/// -/// Two ways to be optional and they mean the same thing to the code: the spec said `option`, or -/// nothing declared a default, in which case the resolution can perfectly well come back with no -/// value. Writing the field as `T` would mean a failure at run time for a shape the spec allows. -fn optional(leaf: &Leaf<'_>) -> bool { - if let Some(optional) = leaf.prop.optional { - return optional; - } - leaf.prop.default.is_none() && leaf.prop.default_list.is_empty() - || leaf - .prop - .value_type - .as_ref() - .is_some_and(SpecConfigType::is_optional) -} - -/// The Rust type for one setting, without the `Option` around it. -fn rust_ty(leaf: &Leaf<'_>) -> String { - fn of(ty: &SpecConfigType) -> String { - match ty { - SpecConfigType::Base(base) => base_ty(base).to_string(), - // A `set` is a `Vec` too: the merge has already dropped duplicates, and a list keeps - // the order the files were read in — which for a `PATH`-like setting is the meaning. - SpecConfigType::List(inner) | SpecConfigType::Set(inner) => { - format!("Vec<{}>", of(inner)) - } - SpecConfigType::Map(_, value) => { - format!("::std::collections::BTreeMap", of(value)) - } - SpecConfigType::Option(inner) => of(inner), - // A union is not a Rust type. The value arrives as it was written and the CLI decides, - // which is what declaring a union asked for. - SpecConfigType::Union(_) => "::usage_config::Value".to_string(), - } - } - let Some(declared) = leaf.prop.value_type.as_ref() else { - return "String".to_string(); - }; - // Every `Value` this can produce is one the spec asked for: an `object` says its keys are not - // described, a union says usage cannot decide, and a name usage does not know says the same. So - // there is nothing to refuse here — the type is as narrow as the declaration was. - of(declared) -} - -fn base_ty(base: &Base) -> &'static str { - match base { - Base::Bool => "bool", - Base::Int => "i64", - Base::Uint => "u64", - Base::Float => "f64", - Base::String => "String", - Base::Path => "::std::path::PathBuf", - // Read as text, both of them. What makes a string a URL is what the CLI does with it, and - // the crate that owns the duration type owns its spelling — inventing one here would put a - // dependency in every adopter's binary for a value they may only ever print. - Base::Url | Base::Duration => "String", - // A table whose keys the spec does not describe, and a name usage does not know: the value - // as it was written. - Base::Object | Base::Custom(_) => "::usage_config::Value", - } -} - -/// `task` → `Task`, for a nested struct's name. -/// -/// ASCII only, like every other name here: a Rust identifier is not "any alphanumeric character" — -/// `½` is one and is not allowed in an identifier, and a Unicode digit cannot start one — and the -/// consts in `prop` are built from ASCII already, so keeping more here made one key produce names -/// from two alphabets, one of which the adopter could not compile. -fn camel(segment: &str) -> String { - let mut out = String::with_capacity(segment.len()); - let mut upper = true; - for c in segment.chars() { - if !c.is_ascii_alphanumeric() { - upper = true; - continue; - } - if upper { - out.extend(c.to_uppercase()); - upper = false; - } else { - out.push(c); - } - } - out -} - -/// A key segment as a field name. -fn field(segment: &str) -> String { - let mut name: String = segment - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - // A field cannot start with a digit any more than a const can, and `2fa` is a real name for a - // real setting. Prefixed the same way, so the two spellings of one key agree. - if name.starts_with(|c: char| c.is_ascii_digit()) { - name.insert(0, '_'); - } - raw(&name) -} - -/// The local a setting is read into. -/// -/// Prefixed, and that is the whole point: unprefixed, a setting called `fold` generated -/// `let fold = fold.optional(…)` and shadowed the reader's own fold, so every read after it — and -/// `fold.finish()` — stopped compiling. A setting called `SELF` generated `let self`. The prefix -/// goes on every local, so no name a spec can choose reaches either state, and since the consts it -/// is built from are unique, the locals are too. -fn local(ident: &str) -> String { - format!("read_{}", ident.to_lowercase()) -} - -/// `name`, spelled so it can be an identifier. -/// -/// A `let` binding needs this as much as a field does: `match` is an unremarkable name for a -/// setting, and `let match: Option` is not Rust. -fn raw(name: &str) -> String { - if RAW_ALLOWED.contains(&name) { - return format!("r#{name}"); - } - name.to_string() -} - -/// Keywords a field can still be named, spelled `r#`. -/// -/// The four that cannot — `self`, `crate`, `super` and `Self` — are refused by name in -/// [`check_names`], because there is no way to write them. -const RAW_ALLOWED: [&str; 49] = [ - "as", - "break", - "const", - "continue", - "else", - "enum", - "extern", - "false", - "fn", - "for", - "if", - "impl", - "in", - "let", - "loop", - "match", - "mod", - "move", - "mut", - "pub", - "ref", - "return", - "static", - "struct", - "trait", - "true", - "type", - "unsafe", - "use", - "where", - "while", - "async", - "await", - "dyn", - "abstract", - "become", - "box", - "do", - "final", - "macro", - "override", - "priv", - "typeof", - "unsized", - "virtual", - "yield", - "try", - "gen", - "macro_rules", -]; - -/// Keywords no identifier can be, raw or otherwise. -const NEVER_ALLOWED: [&str; 4] = ["self", "crate", "super", "Self"]; - -/// Refuse the names that cannot become fields, and the ones that would collide. -/// -/// Checked on the names that are actually *emitted*, not on the key segments they come from: two -/// segments can differ and still translate to one field (`foo-bar` beside `foo_bar`), and two groups -/// can differ and still translate to one struct (`a` beside `A`, since a type name loses the case -/// of its first letter). Comparing the segments, as this did, refused neither — and the collision -/// surfaced as duplicate items in a file the adopter did not write. The `prop::` consts do not catch -/// these either: `foo-bar.x` and `foo_bar.y` make two distinct consts and one field. -fn check_names( - group: &Group<'_>, - path: &str, - name: &str, - structs: &mut BTreeMap, - problems: &mut Vec, -) { - // Fields are per level: each group is its own struct, so two groups may both have a `timeout`. - // Every entry remembers whether it came from a setting or from a group, because a setting and a - // group wanting one name is a mistake worth its own words. - let mut fields: BTreeMap = BTreeMap::new(); - - for (segment, leaf) in &group.leaves { - check_segment(segment, leaf.key, problems); - if let Some((other, _)) = fields.insert(field(segment), (leaf.key.to_string(), true)) { - collide(&other, leaf.key, &field(segment), problems); - } - } - for (segment, sub) in &group.groups { - let key = format!("{path}{segment}"); - check_segment(segment, &key, problems); - match fields.insert(field(segment), (key.clone(), false)) { - // `python` as a setting and `python.compile` as another: one field name with two things - // to be, a value and a table. The spec can say it and no struct can hold it. - Some((setting, true)) => problems.push(format!( - "`{setting}` is a setting and a group of settings: it cannot be both a value and \ - a table" - )), - Some((other, false)) => collide(&other, &key, &field(segment), problems), - None => {} - } - // Struct names are *not* per level. Every one of them is declared in the same module and - // built by concatenation, so `http.client.x` and `http_client.y` both arrive at - // `SettingsHttpClient` from different depths — compared among siblings, as this was, neither - // was refused and the adopter's crate got two structs with one name. - let sub_name = format!("{name}{}", camel(segment)); - if let Some(other) = structs.insert(sub_name.clone(), key.clone()) { - problems.push(format!( - "`{other}` and `{key}` are both groups named `{sub_name}`: rename one of them" - )); - } - check_names(sub, &format!("{key}."), &sub_name, structs, problems); - } -} - -/// Whether one key segment can be a field at all. -fn check_segment(segment: &str, of: &str, problems: &mut Vec) { - if NEVER_ALLOWED.contains(&segment) { - problems.push(format!( - "`{of}` cannot be a field: `{segment}` is a keyword Rust has no spelling for" - )); - } -} - -fn collide(one: &str, other: &str, name: &str, problems: &mut Vec) { - problems.push(format!( - "`{one}` and `{other}` both generate the field `{name}`: rename one of them" - )); -} - -/// Help text as one line, for a doc comment. -fn one_line(text: &str) -> String { - text.replace(['\n', '\r'], " ") -} diff --git a/config-build/tests/fixtures/hk.usage.kdl b/config-build/tests/fixtures/hk.usage.kdl deleted file mode 100644 index ab6b2bc04..000000000 --- a/config-build/tests/fixtures/hk.usage.kdl +++ /dev/null @@ -1,81 +0,0 @@ -name "hk" -bin "hk" - -// A settings block with one of everything this generator has to carry, modelled on hk's own -// registry — the fleet's most featureful, and the first CLI that will adopt this. -config { - source "git" name="git config" doc_hint="git config `{key}`" - source "pkl" name="hk.pkl" - - file "/etc/hk/config.toml" scope="system" - file "~/.config/hk/config.toml" scope="global" - file "hk.toml" findup=#true - - prop "jobs" type="uint" default=4 default_note="0 = one per core" \ - help="How many jobs to run at once" { - env "HK_JOBS" "HK_JOB" - deprecated_env "HK_JOBS_OLD" - source "git" "hk.jobs" - cli "--jobs" "-j" - } - - prop "exclude" type="list" merge="union" parse="list_by_comma" \ - help="Paths to leave alone" { - env "HK_EXCLUDE" - source "pkl" "exclude" "defaults.exclude" - } - - prop "path" type="list" parse="list_by_os_path_separator" { - env "HK_PATH" - } - - prop "stash" type="string" default="git" help="How to stash before a run" { - choices { - choice "git" help="git stash" - choice "patch-file" help="A patch on disk" - choice "none" - } - } - - // Choices written as numbers under a `string` type, which is as ordinary a thing to write as - // quoting them — and which the registry has to accept once the value is coerced to text. - // The default written as a number under a `string` type, which is the shape a default has to be - // *coerced* for: it is seeded straight into the resolution and read by whatever holds it. - prop "log_format" type="string" default=1 help="Which log format to use" { - choices { - choice 1 help="The old one" - choice 2 help="The one with colour" - } - } - - prop "url_replacements" type="map" merge="deep" \ - help="Rewrite these URL prefixes" - - prop "trusted" type="bool" scope="global" default=#false \ - help="Whether this checkout may run its own hooks" - - prop "ci" type="bool" scope="env" hide=#true { - env "CI" - } - - prop "task.output" type="string" default="prefix" help="How task output is interleaved" - - // A keyword as a key, which is ordinary in a config file and needs `r#` in Rust. - prop "match" type="string" default="all" help="Which files to match" - - prop "either" type="bool|string" help="A \"union\" only hk understands" - - prop "timeout" type="option" help="How long to wait, if at all" - - prop "log_level" type="string" default="info" { - env "HK_LOG_LEVEL" - x "hk.parse_env" "lowercase" - } - - prop "concurrency" type="uint" deprecated="Use jobs instead." renamed_to="jobs" \ - deprecated_warn_at="2026.12.0" - - prop "ports" type="list" { - default 80 443 - } -} diff --git a/config-build/tests/fixtures/split-settings.usage.kdl b/config-build/tests/fixtures/split-settings.usage.kdl deleted file mode 100644 index 2b8db29a1..000000000 --- a/config-build/tests/fixtures/split-settings.usage.kdl +++ /dev/null @@ -1,5 +0,0 @@ -config { - prop "jobs" type="uint" default=2 help="How many jobs to run at once" { - env "SPLIT_JOBS" - } -} diff --git a/config-build/tests/fixtures/split.usage.kdl b/config-build/tests/fixtures/split.usage.kdl deleted file mode 100644 index dc47f1bf6..000000000 --- a/config-build/tests/fixtures/split.usage.kdl +++ /dev/null @@ -1,5 +0,0 @@ -name "split" -bin "split" - -// The shape a CLI with many settings uses: the settings live in a file of their own. -include file="./split-settings.usage.kdl" diff --git a/config-build/tests/generated.rs b/config-build/tests/generated.rs deleted file mode 100644 index 0383ab6bf..000000000 --- a/config-build/tests/generated.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! The generated registry, compiled and resolved against. -//! -//! A generator can be tested by comparing strings, and a string that looks like Rust is not Rust. -//! So the output of `usage-config-build` for [the fixture spec](fixtures/hk.usage.kdl) is checked -//! in beside this file and included here: it is compiled by `cargo test`, and every assertion -//! below is against the registry that compilation produced. - -use std::collections::BTreeMap; - -use usage_config::{ - resolve, FileLayer, FileScope, Layers, Merge, Parser, Scope, SourceKind, Ty, Value, -}; - -include!("golden/settings.rs"); - -#[test] -fn every_declared_default_resolves_as_its_type() { - // No layers at all: what a CLI gets from its registry alone, which is also the only thing a - // `const` default can be wrong about. - let resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - let mut fold = resolved.fold(); - - let jobs: Option = fold.required(prop::JOBS); - let stash: Option = fold.required(prop::STASH); - let trusted: Option = fold.required(prop::TRUSTED); - let output: Option = fold.required(prop::TASK_OUTPUT); - let ports: Option> = fold.required(prop::PORTS); - let timeout: Option = fold.optional(prop::TIMEOUT); - let format: Option = fold.required(prop::LOG_FORMAT); - fold.finish().expect("every default fits its type"); - - assert_eq!(jobs, Some(4)); - assert_eq!(stash, Some("git".to_string())); - // Written `default=1` under a `string` type, and seeded with no coercion on the way — so it has - // to have been *emitted* as the string it is, or this read fails on a registry the generator - // accepted. - assert_eq!(format, Some("1".to_string())); - assert_eq!(trusted, Some(false)); - assert_eq!(output, Some("prefix".to_string())); - // A list default is a child node in the spec and stays typed all the way here: three numbers, - // not three strings. - assert_eq!(ports, Some(vec![80, 443])); - // No default and an `option`: absent is a state, not a failure. - assert_eq!(timeout, None); -} - -#[test] -fn a_generated_id_is_the_setting_it_names() { - // The one thing emitting ids as indices can get wrong, and it would be silent: every read - // would answer about the wrong setting. So each const is checked against the name it claims. - for id in SETTINGS_REGISTRY.ids() { - let key = SETTINGS_REGISTRY.get(id).key; - // `lookup_exact`, not `lookup`: the folding one answers about the setting that *replaced* - // an old name, which is right for reading a value and wrong for asking where a name lives. - assert_eq!( - SETTINGS_REGISTRY.lookup_exact(key), - Some(id), - "`{key}` is not where the registry says it is" - ); - } - assert_eq!(SETTINGS_REGISTRY.get(prop::JOBS).key, "jobs"); - assert_eq!(SETTINGS_REGISTRY.get(prop::TASK_OUTPUT).key, "task.output"); - assert_eq!( - SETTINGS_REGISTRY.get(prop::URL_REPLACEMENTS).key, - "url_replacements" - ); -} - -#[test] -fn what_the_spec_said_about_each_setting_is_what_the_registry_holds() { - let meta = |id| SETTINGS_REGISTRY.get(id); - - // Types, including the two that are not a name: a union is `Any`, because the spec has said - // usage cannot decide what belongs there. - assert_eq!(meta(prop::JOBS).ty, Ty::Uint); - assert_eq!(meta(prop::EXCLUDE).ty, Ty::List(&Ty::String)); - assert_eq!(meta(prop::PATH).ty, Ty::List(&Ty::Path)); - assert_eq!(meta(prop::URL_REPLACEMENTS).ty, Ty::Map(&Ty::String)); - assert_eq!(meta(prop::TIMEOUT).ty, Ty::Option(&Ty::Duration)); - assert_eq!(meta(prop::EITHER).ty, Ty::Any); - - // Merge policy, scope, and the named parser: each of these is a `prop` attribute that a - // hand-written registry has to remember to copy, and every one of them is a real setting in - // hk's own file. - assert_eq!(meta(prop::EXCLUDE).merge, Merge::Union); - assert_eq!(meta(prop::URL_REPLACEMENTS).merge, Merge::Deep); - assert_eq!(meta(prop::JOBS).merge, Merge::Replace); - assert_eq!(meta(prop::TRUSTED).scope, Scope::Global); - assert_eq!(meta(prop::CI).scope, Scope::Env); - assert_eq!(meta(prop::JOBS).scope, Scope::Any); - assert_eq!(meta(prop::EXCLUDE).parse, Some(Parser::ListByComma)); - assert_eq!(meta(prop::PATH).parse, Some(Parser::ListByOsPathSeparator)); - assert_eq!(meta(prop::JOBS).parse, None); - assert!(meta(prop::CI).hide); - - // What the spec says a setting will take, which until now reached the docs and the schema and - // nothing that resolved a value. - assert_eq!( - meta(prop::STASH).choices, - &[ - usage_config::Const::Str("git"), - usage_config::Const::Str("patch-file"), - usage_config::Const::Str("none") - ] - ); - assert!( - meta(prop::JOBS).choices.is_empty(), - "most settings say nothing" - ); - assert!(!meta(prop::JOBS).hide); - - // The flags that set it, which the spec declares beside the environment variables and which the - // generator used to read and drop — leaving the registry unable to say a setting is settable from - // the command line at all. - assert_eq!(meta(prop::JOBS).cli, &["--jobs", "-j"]); - assert_eq!(meta(prop::JOBS).deprecated_envs, &["HK_JOBS_OLD"]); - assert!( - meta(prop::EXCLUDE).cli.is_empty(), - "most settings have no flag" - ); - - // The environment in precedence order, and help as the spec wrote it — quotes and all. - assert_eq!(meta(prop::JOBS).envs, &["HK_JOBS", "HK_JOB"]); - assert_eq!(meta(prop::CI).envs, &["CI"]); - assert_eq!(meta(prop::JOBS).help, Some("How many jobs to run at once")); - assert_eq!( - meta(prop::EITHER).help, - Some("A \"union\" only hk understands") - ); - - // Bindings, which is the whole mechanism behind hk's git and pkl layers: a custom layer asks - // the registry for its own kind and iterates what it finds. - assert_eq!( - SETTINGS_REGISTRY - .bindings(SourceKind::new("git")) - .collect::>(), - vec![(prop::JOBS, "hk.jobs")] - ); - assert_eq!( - SETTINGS_REGISTRY - .bindings(SourceKind::new("pkl")) - .collect::>(), - vec![ - (prop::EXCLUDE, "exclude"), - (prop::EXCLUDE, "defaults.exclude") - ] - ); -} - -#[test] -fn a_value_the_spec_does_not_allow_is_refused_through_the_generated_registry() { - // End to end for the choices: declared as `choice` nodes in the spec, carried into the registry - // by the generator, and enforced by the merge — the three places that used to disagree. - let dir = - std::env::temp_dir().join(format!("usage_config_build_choice_{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("dir"); - let path = dir.join("hk.toml"); - std::fs::write(&path, "stash = \"svn\"\n").expect("write"); - - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - // The declared default stands, because a refused value costs its own key and nothing else. - assert_eq!(resolved.get_key("stash"), Some(&Value::from("git"))); - let warnings = usage_config::explain::warnings(&resolved); - assert!( - warnings - .iter() - .any(|w| w.starts_with("stash expected one of git, patch-file, none but has `svn`")), - "{warnings:?}" - ); - - // And a choice written as a number under a `string` type is the value it names once the value - // has been coerced: comparing the shapes refused a value the spec plainly allows. - let path = dir.join("formats.toml"); - std::fs::write(&path, "log_format = 2\n").expect("write"); - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - assert_eq!(resolved.get_key("log_format"), Some(&Value::from("2"))); - assert!( - usage_config::explain::warnings(&resolved).is_empty(), - "{:?}", - usage_config::explain::warnings(&resolved) - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn a_renamed_setting_folds_into_the_one_that_replaced_it() { - let resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - // The old name still answers, about the setting that replaced it. - assert_eq!(resolved.get_key("concurrency"), Some(&Value::Int(4))); - assert_eq!( - SETTINGS_REGISTRY.get(prop::CONCURRENCY).renamed_to, - Some("jobs") - ); - let explained = usage_config::explain(&resolved, "concurrency").expect("declared"); - assert!( - explained.starts_with("concurrency is now jobs\n"), - "{explained}" - ); - assert!( - explained.contains("deprecated: Use jobs instead."), - "{explained}" - ); -} - -#[test] -fn a_file_read_against_the_generated_registry_means_what_the_spec_said() { - // End to end, which is the point of the whole crate: a spec declared these settings, a build - // generated the registry, and a config file is read through it with nothing hand-written in - // between. - let dir = std::env::temp_dir().join(format!("usage_config_build_{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("dir"); - let path = dir.join("hk.toml"); - std::fs::write( - &path, - "jobs = 8\nexclude = \"target,dist\"\nconcurrency = 2\n\ - [url_replacements]\n\"git@github.com:\" = \"https://github.com/\"\n", - ) - .expect("write"); - - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - let mut fold = resolved.fold(); - let jobs: Option = fold.required(prop::JOBS); - let exclude: Option> = fold.required(prop::EXCLUDE); - let replacements: Option> = fold.required(prop::URL_REPLACEMENTS); - fold.finish().expect("reads"); - - // The file's value beats the declared default… - assert_eq!(jobs, Some(8)); - // …the named parser splits what the file wrote as one string… - assert_eq!( - exclude, - Some(vec!["target".to_string(), "dist".to_string()]) - ); - // …a `map` arrives as a table… - assert_eq!( - replacements.as_ref().and_then(|m| m.get("git@github.com:")), - Some(&"https://github.com/".to_string()) - ); - // …and the deprecated key in the same file is folded, with something said about it. - let warnings = usage_config::explain::warnings(&resolved); - assert!( - warnings - .iter() - .any(|w| w.contains("concurrency is deprecated")), - "{warnings:?}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn the_settings_struct_holds_what_each_type_says() { - // The struct is generated, so this is the compiler agreeing as much as it is a test: every - // field below has to exist, with that type, or this file does not build. - let resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - let settings = Settings::read(&resolved).expect("every default fits its field"); - - // A declared default means the field is the value itself — there is nothing to unwrap for a - // setting that always has one. - assert_eq!(settings.jobs, 4u64); - assert_eq!(settings.stash, "git"); - assert!(!settings.trusted); - assert_eq!(settings.ports, vec![80u64, 443]); - // A dotted key is a nested struct, spelled the way the config file spells it. - assert_eq!(settings.task.output, "prefix"); - // No default, or `option<…>`: absent is a state the type can hold, so nothing fails at run - // time for a shape the spec allows. - assert_eq!(settings.exclude, None); - assert_eq!(settings.timeout, None); - assert_eq!(settings.ci, None); - // A keyword is an unremarkable name for a setting, and `r#` is how Rust spells it — for the - // field and for the local the generated reader binds it to, which is what `let match: …` taught - // me the hard way. - assert_eq!(settings.r#match, "all"); - // A union has no Rust type. The value arrives as it was written, which is what declaring one - // asked for. - assert_eq!(settings.either, None); - // And an old name is not a field at all: `concurrency` folds into `jobs`, so a field for it - // would be a second name for one value. - assert_eq!( - SETTINGS_REGISTRY.get(prop::CONCURRENCY).renamed_to, - Some("jobs") - ); -} - -#[test] -fn a_file_fills_the_struct_and_a_bad_value_names_itself() { - let dir = - std::env::temp_dir().join(format!("usage_config_build_struct_{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("dir"); - - let path = dir.join("hk.toml"); - std::fs::write( - &path, - "jobs = 8\nexclude = \"target,dist\"\npath = \"/bin\"\n[task]\noutput = \"interleave\"\n", - ) - .expect("write"); - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - let settings = Settings::read(&resolved).expect("reads"); - assert_eq!(settings.jobs, 8); - assert_eq!( - settings.exclude, - Some(vec!["target".to_string(), "dist".to_string()]) - ); - assert_eq!( - settings.path, - Some(vec![std::path::PathBuf::from("/bin")]), - "a `list` arrives as paths" - ); - assert_eq!(settings.task.output, "interleave"); - - // And the failure a struct read can still have: a hook writing past the declared type, which - // is the one place the merge did not check. The error names the setting and the hook. - let mut resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - resolved.coerced(prop::JOBS, Value::Int(-1), "one job when raw"); - let err = Settings::read(&resolved).expect_err("a `uint` field cannot hold -1"); - assert_eq!( - err.to_string(), - "jobs expected a non-negative integer but has `-1` (set by one job when raw)" - ); - - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/config-build/tests/golden/settings.rs b/config-build/tests/golden/settings.rs deleted file mode 100644 index e966c89c9..000000000 --- a/config-build/tests/golden/settings.rs +++ /dev/null @@ -1,226 +0,0 @@ -// @generated by usage-config-build from `hk.usage.kdl`. Do not edit. -// -// Every setting this CLI has, as consts: there is no second declaration of a setting to -// keep in step with this one, which is the drift this file exists to remove. - -pub static SETTINGS_PROPS: &[::usage_config::PropMeta] = &[ - ::usage_config::PropMeta { - scope: ::usage_config::Scope::Env, - envs: &["CI"], - hide: true, - ..::usage_config::PropMeta::new("ci", ::usage_config::Ty::Bool) - }, - ::usage_config::PropMeta { - deprecated: Some("Use jobs instead."), - renamed_to: Some("jobs"), - ..::usage_config::PropMeta::new("concurrency", ::usage_config::Ty::Uint) - }, - ::usage_config::PropMeta { - help: Some("A \"union\" only hk understands"), - ..::usage_config::PropMeta::new("either", ::usage_config::Ty::Any) - }, - ::usage_config::PropMeta { - merge: ::usage_config::Merge::Union, - parse: Some(::usage_config::Parser::ListByComma), - envs: &["HK_EXCLUDE"], - bindings: &[("pkl", "exclude"), ("pkl", "defaults.exclude")], - help: Some("Paths to leave alone"), - ..::usage_config::PropMeta::new("exclude", ::usage_config::Ty::List(&::usage_config::Ty::String)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Int(4)), - envs: &["HK_JOBS", "HK_JOB"], - deprecated_envs: &["HK_JOBS_OLD"], - cli: &["--jobs", "-j"], - bindings: &[("git", "hk.jobs")], - help: Some("How many jobs to run at once"), - ..::usage_config::PropMeta::new("jobs", ::usage_config::Ty::Uint) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("1")), - choices: &[::usage_config::Const::Int(1), ::usage_config::Const::Int(2)], - help: Some("Which log format to use"), - ..::usage_config::PropMeta::new("log_format", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("info")), - envs: &["HK_LOG_LEVEL"], - ..::usage_config::PropMeta::new("log_level", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("all")), - help: Some("Which files to match"), - ..::usage_config::PropMeta::new("match", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - parse: Some(::usage_config::Parser::ListByOsPathSeparator), - envs: &["HK_PATH"], - ..::usage_config::PropMeta::new("path", ::usage_config::Ty::List(&::usage_config::Ty::Path)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::List(&[::usage_config::Const::Int(80), ::usage_config::Const::Int(443)])), - ..::usage_config::PropMeta::new("ports", ::usage_config::Ty::List(&::usage_config::Ty::Uint)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("git")), - choices: &[::usage_config::Const::Str("git"), ::usage_config::Const::Str("patch-file"), ::usage_config::Const::Str("none")], - help: Some("How to stash before a run"), - ..::usage_config::PropMeta::new("stash", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("prefix")), - help: Some("How task output is interleaved"), - ..::usage_config::PropMeta::new("task.output", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - help: Some("How long to wait, if at all"), - ..::usage_config::PropMeta::new("timeout", ::usage_config::Ty::Option(&::usage_config::Ty::Duration)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Bool(false)), - scope: ::usage_config::Scope::Global, - help: Some("Whether this checkout may run its own hooks"), - ..::usage_config::PropMeta::new("trusted", ::usage_config::Ty::Bool) - }, - ::usage_config::PropMeta { - merge: ::usage_config::Merge::Deep, - help: Some("Rewrite these URL prefixes"), - ..::usage_config::PropMeta::new("url_replacements", ::usage_config::Ty::Map(&::usage_config::Ty::String)) - }, -]; - -pub const SETTINGS_REGISTRY: ::usage_config::Registry = - ::usage_config::Registry::new(SETTINGS_PROPS); - -/// Each setting's id, which is its position in [`SETTINGS_PROPS`]. -pub mod prop { - use ::usage_config::PropId; - - /// `ci` - pub const CI: PropId = PropId(0); - /// `concurrency` - pub const CONCURRENCY: PropId = PropId(1); - /// `either` — A "union" only hk understands - pub const EITHER: PropId = PropId(2); - /// `exclude` — Paths to leave alone - pub const EXCLUDE: PropId = PropId(3); - /// `jobs` — How many jobs to run at once - pub const JOBS: PropId = PropId(4); - /// `log_format` — Which log format to use - pub const LOG_FORMAT: PropId = PropId(5); - /// `log_level` - pub const LOG_LEVEL: PropId = PropId(6); - /// `match` — Which files to match - pub const MATCH: PropId = PropId(7); - /// `path` - pub const PATH: PropId = PropId(8); - /// `ports` - pub const PORTS: PropId = PropId(9); - /// `stash` — How to stash before a run - pub const STASH: PropId = PropId(10); - /// `task.output` — How task output is interleaved - pub const TASK_OUTPUT: PropId = PropId(11); - /// `timeout` — How long to wait, if at all - pub const TIMEOUT: PropId = PropId(12); - /// `trusted` — Whether this checkout may run its own hooks - pub const TRUSTED: PropId = PropId(13); - /// `url_replacements` — Rewrite these URL prefixes - pub const URL_REPLACEMENTS: PropId = PropId(14); -} - -/// Every setting, as the types a CLI holds them in. -/// -/// Read with [`Settings::read`] from a resolution, which is the only way to build one: -/// the values, and every reason one could not be read, come from the merge. -#[derive(Debug, Clone, PartialEq)] -pub struct Settings { - pub task: SettingsTask, - /// (`ci`) - pub ci: Option, - /// A "union" only hk understands - /// (`either`) - pub either: Option<::usage_config::Value>, - /// Paths to leave alone - /// (`exclude`) - pub exclude: Option>, - /// How many jobs to run at once - /// (`jobs`) - pub jobs: u64, - /// Which log format to use - /// (`log_format`) - pub log_format: String, - /// (`log_level`) - pub log_level: String, - /// Which files to match - /// (`match`) - pub r#match: String, - /// (`path`) - pub path: Option>, - /// (`ports`) - pub ports: Vec, - /// How to stash before a run - /// (`stash`) - pub stash: String, - /// How long to wait, if at all - /// (`timeout`) - pub timeout: Option, - /// Whether this checkout may run its own hooks - /// (`trusted`) - pub trusted: bool, - /// Rewrite these URL prefixes - /// (`url_replacements`) - pub url_replacements: Option<::std::collections::BTreeMap>, -} - -/// The `task` settings. -#[derive(Debug, Clone, PartialEq)] -pub struct SettingsTask { - /// How task output is interleaved - /// (`task.output`) - pub output: String, -} - -impl Settings { - /// This resolution's values, as their types. - /// - /// Every setting is read before anything is returned, so the error is the whole list of - /// what is wrong rather than the first thing found. - pub fn read( - resolved: &::usage_config::Resolved, - ) -> ::std::result::Result { - let mut fold = resolved.fold(); - let read_task_output: Option = fold.required(prop::TASK_OUTPUT); - let read_ci: Option = fold.optional(prop::CI); - let read_either: Option<::usage_config::Value> = fold.optional(prop::EITHER); - let read_exclude: Option> = fold.optional(prop::EXCLUDE); - let read_jobs: Option = fold.required(prop::JOBS); - let read_log_format: Option = fold.required(prop::LOG_FORMAT); - let read_log_level: Option = fold.required(prop::LOG_LEVEL); - let read_match: Option = fold.required(prop::MATCH); - let read_path: Option> = fold.optional(prop::PATH); - let read_ports: Option> = fold.required(prop::PORTS); - let read_stash: Option = fold.required(prop::STASH); - let read_timeout: Option = fold.optional(prop::TIMEOUT); - let read_trusted: Option = fold.required(prop::TRUSTED); - let read_url_replacements: Option<::std::collections::BTreeMap> = fold.optional(prop::URL_REPLACEMENTS); - fold.finish()?; - ::std::result::Result::Ok(Self { - task: SettingsTask { - output: read_task_output.expect("`task.output` has a declared default, so the fold has already reported any absence"), - }, - ci: read_ci, - either: read_either, - exclude: read_exclude, - jobs: read_jobs.expect("`jobs` has a declared default, so the fold has already reported any absence"), - log_format: read_log_format.expect("`log_format` has a declared default, so the fold has already reported any absence"), - log_level: read_log_level.expect("`log_level` has a declared default, so the fold has already reported any absence"), - r#match: read_match.expect("`match` has a declared default, so the fold has already reported any absence"), - path: read_path, - ports: read_ports.expect("`ports` has a declared default, so the fold has already reported any absence"), - stash: read_stash.expect("`stash` has a declared default, so the fold has already reported any absence"), - timeout: read_timeout, - trusted: read_trusted.expect("`trusted` has a declared default, so the fold has already reported any absence"), - url_replacements: read_url_replacements, - }) - } -} diff --git a/config-build/tests/optional_aliases.rs b/config-build/tests/optional_aliases.rs deleted file mode 100644 index 9a9683b32..000000000 --- a/config-build/tests/optional_aliases.rs +++ /dev/null @@ -1,70 +0,0 @@ -use usage_config_build::source_of_spec; - -fn generated(settings: &str) -> String { - source_of_spec( - &format!("name \"ex\"\nbin \"ex\"\nconfig {{\n{settings}\n}}\n"), - "ex.usage.kdl", - ) - .expect("registry") -} - -#[test] -fn aliases_reach_the_runtime_registry() { - let source = generated( - r#"prop "jobs" type="uint" { - alias "parallelism" "threads" -}"#, - ); - assert!( - source.contains(r#"aliases: &["parallelism", "threads"]"#), - "{source}" - ); -} - -#[test] -fn explicit_optionality_overrides_default_inference() { - let source = generated( - r#"prop "required_without_default" type="string" optional=#false -prop "optional_with_default" type="string" default="value" optional=#true"#, - ); - assert!( - source.contains("pub required_without_default: String"), - "{source}" - ); - assert!( - source.contains("fold.required(prop::REQUIRED_WITHOUT_DEFAULT)"), - "{source}" - ); - assert!( - source.contains("pub optional_with_default: Option"), - "{source}" - ); - assert!( - source.contains("fold.optional(prop::OPTIONAL_WITH_DEFAULT)"), - "{source}" - ); -} - -#[test] -fn ambiguous_aliases_are_refused_together() { - let error = source_of_spec( - r#"name "ex" -bin "ex" -config { - prop "jobs" { alias "shared" } - prop "threads" { alias "shared" "jobs" } -} -"#, - "ex.usage.kdl", - ) - .expect_err("ambiguous names"); - let text = error.to_string(); - assert!( - text.contains("`shared` names both `jobs` and `threads`"), - "{text}" - ); - assert!( - text.contains("`jobs` names both `jobs` and `threads`"), - "{text}" - ); -} diff --git a/config-build/tests/refusals.rs b/config-build/tests/refusals.rs deleted file mode 100644 index 84f2e17e7..000000000 --- a/config-build/tests/refusals.rs +++ /dev/null @@ -1,701 +0,0 @@ -//! What a registry has to be refused for, and the one thing it must stay identical to. -//! -//! A build script is where strictness belongs: the alternative to refusing a declaration that -//! cannot mean what it says is a warning on every run of a shipped binary, for a mistake only the -//! author of the spec can fix. - -use usage_config_build::{source, source_of_spec, Error}; - -/// A spec with `config { … }` around the settings under test. -fn spec(settings: &str) -> String { - format!("name \"mycli\"\nbin \"mycli\"\nconfig {{\n{settings}\n}}\n") -} - -/// Every problem a spec's settings are refused for. -fn problems(settings: &str) -> Vec { - match source_of_spec(&spec(settings), "mycli.usage.kdl") { - Ok(generated) => panic!("should have been refused, generated:\n{generated}"), - Err(Error::Registry(problems)) => problems, - Err(other) => panic!("refused for the wrong reason: {other}"), - } -} - -#[test] -fn the_checked_in_registry_is_what_the_generator_produces() { - // The generated file is checked in so that `cargo test` compiles it — which is the only way to - // know the generator emits code rather than plausible text. That only works while the two - // agree, so this is the test that fails when a change to the emitter is not regenerated: - // - // cargo run -p usage-config-build --example gen > config-build/tests/golden/settings.rs - let generated = source("tests/fixtures/hk.usage.kdl").expect("the fixture is valid"); - let checked_in = std::fs::read_to_string("tests/golden/settings.rs").expect("golden"); - assert_eq!( - generated, checked_in, - "tests/golden/settings.rs is stale; regenerate it with the command in this test" - ); -} - -#[test] -fn the_name_in_the_header_stays_on_its_own_comment() { - // The header says which spec the file came from, in a *line* comment — and the name is a file - // name, or whatever a caller hands to `source_of_spec`. A newline in it ended that comment and - // everything after it read as code, in a file the adopter did not write. - let generated = source_of_spec( - &spec(" prop \"jobs\" type=\"uint\""), - "mycli.usage.kdl\nstruct Oops;", - ) - .expect("should generate"); - let first = generated.lines().next().unwrap_or_default(); - assert!(first.starts_with("// @generated"), "{generated}"); - assert!( - first.contains("mycli.usage.kdl struct Oops;"), - "{generated}" - ); - // Nothing escaped onto a line of its own. - assert!( - !generated - .lines() - .any(|line| line.starts_with("struct Oops;")), - "the name ended its own comment:\n{generated}" - ); -} - -#[test] -fn an_included_spec_is_watched_and_read() { - // `include` is how a CLI with many settings keeps them in a file of their own, which makes that - // file the one most likely to be edited — and watching only the file the build script names left - // editing the settings rebuilding nothing, so the generated registry went stale in silence. - let watched = usage_config_build::watched("tests/fixtures/split.usage.kdl").expect("parses"); - let names: Vec = watched - .iter() - .map(|p| { - p.file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned() - }) - .collect(); - assert_eq!( - names, - vec!["split.usage.kdl", "split-settings.usage.kdl"], - "the spec and what it included" - ); - - // And the settings in the included file are the ones generated, which is the other half of it. - let generated = source("tests/fixtures/split.usage.kdl").expect("generates"); - assert!(generated.contains("PropMeta::new(\"jobs\""), "{generated}"); - assert!(generated.contains("SPLIT_JOBS"), "{generated}"); - - // The list `generate_to` *prints* is the list it returns, which is the only way a test in this - // process can see it: `println!` to cargo goes nowhere it can read. - let out = std::env::temp_dir() - .join(format!("usage_config_build_watch_{}", std::process::id())) - .join("settings.rs"); - let printed = - usage_config_build::generate_to("tests/fixtures/split.usage.kdl", &out).expect("generates"); - assert_eq!( - printed, watched, - "what it watched is what it said it watched" - ); - let _ = std::fs::remove_dir_all(out.parent().unwrap_or(&out)); -} - -#[test] -fn a_rename_to_a_setting_that_is_not_there_is_refused() { - // The runtime folds an old name into its replacement by looking the replacement up. Naming one - // that does not exist leaves every value written under the old key silently unread. - let problems = problems(" prop \"old\" renamed_to=\"new\"\n prop \"jobs\" type=\"uint\""); - assert_eq!( - problems, - vec!["`old` is renamed to `new`, which is not a setting"] - ); -} - -#[test] -fn renames_that_go_round_in_a_circle_are_refused() { - // A cycle makes `lookup` give up and answer `None`, so both settings become unreachable rather - // than wrong — the hardest kind of bug to notice, and one only the spec's author can fix. - let problems = problems( - " prop \"a\" renamed_to=\"b\"\n \ - prop \"b\" renamed_to=\"c\"\n \ - prop \"c\" renamed_to=\"a\"", - ); - assert!( - problems.iter().any(|p| p.contains("form a cycle")), - "{problems:?}" - ); - // Named from each end, because the author has to find the loop and any of its names will do. - assert_eq!(problems.len(), 3, "{problems:?}"); -} - -#[test] -fn an_old_name_with_a_default_of_its_own_is_refused() { - // The merge folds a rename to its target, whose default is the one that shows up — so a - // default on the old name is seeded where nothing reads it. Left to run time this is a - // warning on every run of a shipped binary. - let problems = problems( - " prop \"concurrency\" type=\"uint\" default=8 renamed_to=\"jobs\"\n \ - prop \"jobs\" type=\"uint\" default=4", - ); - assert_eq!( - problems, - vec![ - "`concurrency` is renamed to `jobs` and declares a default of its own: put the \ - default on the setting that replaced it" - ] - ); -} - -#[test] -fn an_old_name_with_a_list_default_is_refused_too() { - // The same mistake in the other spelling. A list default is a child node rather than a value, - // and returning it early skipped the check above — so this was the one shape of an alias - // carrying a default that was generated instead of refused. - let problems = problems( - " prop \"old_ports\" type=\"list\" renamed_to=\"ports\" {\n \ - default 80 443\n }\n \ - prop \"ports\" type=\"list\"", - ); - assert_eq!( - problems, - vec![ - "`old_ports` is renamed to `ports` and declares a default of its own: put the default \ - on the setting that replaced it" - ] - ); -} - -#[test] -fn a_default_the_choices_do_not_allow_is_refused() { - // At run time this is seeded as the bottom layer and then refused by the same check every other - // value goes through — a warning on every run of a shipped binary, for a mistake only the author - // of the spec can fix. So it is refused where the author will see it. - assert_eq!( - problems( - " prop \"stash\" type=\"string\" default=\"svn\" {\n \ - choices {\n choice \"git\"\n choice \"none\"\n }\n }" - ), - vec!["`stash` defaults to `svn`, which is not one of the values it allows: git, none"] - ); - - // And a list default is held to them item by item, the way the values themselves are. - assert_eq!( - problems( - " prop \"skip\" type=\"list\" {\n default \"lint\" \"deploy\"\n \ - choices {\n choice \"lint\"\n choice \"test\"\n }\n }" - ), - vec!["`skip` defaults to `deploy`, which is not one of the values it allows: lint, test"] - ); -} - -#[test] -fn a_value_the_declared_type_cannot_read_is_refused_wherever_it_is_declared() { - // `choice 1` under `type="bool"`, which reads booleans and their spellings and not numbers: a - // choice nobody can ever pick, so *every* value the setting is given would be refused at run - // time. It was caught only by accident, through the default not matching those choices — a - // message that blames the wrong end of the mistake. - assert_eq!( - problems( - " prop \"colour\" type=\"bool\" default=#true {\n \ - choices {\n choice 1\n choice 0\n }\n }" - ), - vec![ - "`colour` has a choice `1`, which is not a boolean", - "`colour` has a choice `0`, which is not a boolean" - ] - ); - - // And a *default* the type cannot read, which nothing else catches at all: it is seeded straight - // into the resolution, passing through no coercion, and then read by whatever holds it. - assert_eq!( - problems(" prop \"colour\" type=\"bool\" default=1"), - vec!["`colour` defaults to `1`, which is not a boolean"] - ); - - // A choice that is merely unreachable is the same mistake in a quieter form: it appears in the - // docs as a value one may set, and no value can ever be it. - assert_eq!( - problems( - " prop \"jobs\" type=\"uint\" default=1 {\n \ - choices {\n choice 1\n choice \"lots\"\n }\n }" - ), - vec!["`jobs` has a choice `lots`, which is not a non-negative integer"] - ); -} - -#[test] -fn the_spec_and_the_runtime_write_a_value_the_same_way() { - // Three crates render a value and all three have to agree, because a default is coerced by the - // *spec's* parser and its choices are read by the *runtime's* — so one character of difference - // refused a `default=1.0` beside a `choice 1.0`, written identically. usage-lib cannot depend on - // usage-config, so the rule exists in both; this is the one place that can see them at once, and - // it is here so that a change to either is a failure rather than a surprise. - for value in [ - 0.0_f64, - 1.0, - -1.0, - 0.5, - 1.25, - 1e3, - 1e300, - f64::MIN, - f64::MAX, - ] { - assert_eq!( - usage::spec::config::SpecConfigValue::Float(value).display(), - usage_config::Value::Float(value).display(), - "the two crates write {value} differently" - ); - } -} - -#[test] -fn a_list_default_under_a_type_with_no_items_is_one_value() { - // `any` takes a list perfectly well, and a scalar choice is not one however many items it has. - // Checked item by item — which is right only where the type declares items — `default 1 2` beside - // `choice 1` and `choice 2` passed, and the whole list is what a seeded default *is*: refused by - // its own choices at run time, and never checked again. - assert_eq!( - problems( - " prop \"level\" type=\"int|string\" {\n default 1 2\n \ - choices {\n choice 1\n choice 2\n }\n }" - ), - vec!["`level` defaults to `1,2`, which is not one of the values it allows: 1, 2"] - ); - - // A union whose *first arm* is a list is still a union: what is emitted for it is `Ty::Any`, - // which compares a value whole — so asking the spec's `simplified` type, which collapses a union - // to that first arm, accepted a default the generated registry would refuse. - assert_eq!( - problems( - " prop \"level\" type=\"list|uint\" {\n default 1 2\n \ - choices {\n choice 1\n choice 2\n }\n }" - ), - vec!["`level` defaults to `1,2`, which is not one of the values it allows: 1, 2"] - ); - - // Where the type *does* declare items, each of them is one of the choices, as before — including - // through an `option`, which the runtime looks through as well. - for ty in ["list", "set", "option>"] { - source_of_spec( - &spec(&format!( - " prop \"ports\" type=\"{ty}\" {{\n default 1 2\n \ - choices {{\n choice 1\n choice 2\n }}\n }}" - )), - "mycli.usage.kdl", - ) - .unwrap_or_else(|err| panic!("each item is one of them, for `{ty}`: {err}")); - } -} - -#[test] -fn a_value_reads_here_the_way_it_reads_at_run_time() { - // The generator and the runtime have to agree about what a value *is*, or this crate accepts a - // registry the registry itself refuses. They drifted the moment one of them learned that a - // whole-number float is `1.0` and not `1`: `default="1"` beside `choice 1.0` was accepted here - // and refused there — and a seeded default is never checked again, so it would simply have been - // the effective value. - assert_eq!( - problems( - " prop \"scale\" type=\"string\" default=\"1\" {\n \ - choices {\n choice 1.0\n }\n }" - ), - vec!["`scale` defaults to `1`, which is not one of the values it allows: 1.0"] - ); - // And the spelling the spec wrote is the one that is one of them. - source_of_spec( - &spec( - " prop \"scale\" type=\"string\" default=\"1.0\" {\n \ - choices {\n choice 1.0\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("`1.0` is `1.0`"); -} - -#[test] -fn a_default_no_choice_can_be_is_refused_however_it_is_written() { - // `default="3"` beside `choice 1`: written differently, and still not one of them once both are - // read as the declared type. Skipping the pair because their literals differ let this through on - // the assumption that resolution would refuse it later — and a *seeded* default never passes the - // check a supplied value does, so it would have become the effective value with nothing said. - assert_eq!( - problems( - " prop \"level\" type=\"int\" default=\"3\" {\n \ - choices {\n choice 1\n choice 2\n }\n }" - ), - vec!["`level` defaults to `3`, which is not one of the values it allows: 1, 2"] - ); -} - -#[test] -fn a_default_written_unlike_its_choices_is_still_one_of_them() { - // `choice "yes"` under `type="bool"`: read as the declared type — through the same `Ty::coerce` - // the run time uses — `#true` *is* one of them, and refusing a spec that resolves perfectly well - // would be the worse mistake of the two. - let generated = source_of_spec( - &spec( - " prop \"colour\" type=\"bool\" default=#true {\n \ - choices {\n choice \"yes\"\n choice \"no\"\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!(generated.contains("Const::Bool(true)"), "{generated}"); - assert!(generated.contains("Const::Str(\"yes\")"), "{generated}"); - - // A union coerces nothing, so its choice and its default stay an integer and a string — and only - // what they are written as can say whether they are the same. Refusing here would refuse a spec - // the run time accepts, which is the worse way round to be wrong. - source_of_spec( - &spec( - " prop \"level\" type=\"int|string\" default=\"1\" {\n \ - choices {\n choice 1\n choice 2\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("a union takes what it is given"); - - // And inside a list it is the *item's* type that reads them: read as the list, `yes` is a - // one-item list and matches nothing. - let generated = source_of_spec( - &spec( - " prop \"flags\" type=\"list\" {\n default \"yes\"\n \ - choices {\n choice #true\n choice #false\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("`yes` is `true` once an item is read as a bool"); - // And it is emitted as the boolean it *is*: a default is seeded straight into the resolution - // with no coercion, so emitting the string it was written as would fail the first read of a - // registry this crate had just accepted. - assert!( - generated.contains("Const::List(&[::usage_config::Const::Bool(true)])"), - "{generated}" - ); - // The choices keep the spelling the spec gave them: they are documentation as much as values, - // and the comparison reads them when it needs to. - assert!( - generated.contains("choices: &[::usage_config::Const::Bool(true)"), - "{generated}" - ); -} - -#[test] -fn a_default_declared_twice_is_refused() { - // `default=1` beside a `default 80 443` node. The spec takes both — I checked, rather than - // trusting the comment I had written saying it did not — and there is no reading of a property - // that has two defaults. Generated, the scalar was dropped and nothing said so. - assert_eq!( - problems( - " prop \"ports\" type=\"list\" default=1 {\n default 80 443\n }" - ), - vec!["`ports` declares a default twice, as `default=` and as a `default` node: keep one"] - ); -} - -#[test] -fn a_key_that_cannot_be_a_name_is_refused() { - // KDL takes `prop ""` perfectly happily, and it generated `pub const : PropId = …` — code the - // *adopter's* crate fails to compile, in a file they did not write. - assert_eq!( - problems(" prop \"\" type=\"string\""), - vec!["a setting with no name cannot be generated"] - ); - // And a piece of a dotted key with no name in it: empty, - assert_eq!( - problems(" prop \"task..output\" type=\"string\""), - vec![ - "`task..output` has a part with no name in it: every piece of a dotted key needs an \ - ASCII letter or digit" - ] - ); - // or nothing but separators, where every character becomes an underscore. That generates an - // *anonymous* const — legal Rust, and unreferenceable, so the code naming it does not compile. - assert_eq!( - problems(" prop \"-\" type=\"string\""), - vec![ - "`-` has a part with no name in it: every piece of a dotted key needs an ASCII letter \ - or digit" - ] - ); -} - -#[test] -fn a_key_that_holds_a_newline_stays_on_its_doc_comment() { - // A doc comment is a line. A key can hold a newline as easily as help text can, and the rest of - // it then reads as code — in a file the adopter did not write. - let generated = source_of_spec( - &spec(" prop \"a\\nb\" type=\"string\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - // A doc comment needs no escapes, only a single line: the newline becomes a space. - assert!(generated.contains("/// `a b`"), "{generated}"); - for line in generated.lines() { - let trimmed = line.trim_start(); - assert!( - trimmed.starts_with("///") || trimmed.starts_with("//") || !trimmed.starts_with("b`"), - "the key ended its own comment:\n{generated}" - ); - } -} - -#[test] -fn a_key_with_no_ascii_in_it_is_refused() { - // `é` is a letter, and `ident_of` builds names from ASCII — so it became `_`, and the promised - // `prop::` const was an anonymous one nothing can refer to. Rust would take `É` as an identifier; - // what an arbitrary letter uppercases to is not something to bet a generated name on. - assert_eq!( - problems(" prop \"é\" type=\"string\""), - vec![ - "`é` has a part with no name in it: every piece of a dotted key needs an ASCII letter \ - or digit" - ] - ); -} - -#[test] -fn a_parser_nothing_implements_is_refused() { - // Not dropped and not carried: a spec naming a parser that does not exist would have its - // values split by a rule nobody wrote, so a layer handing over text produces one item where - // the author meant several. - let problems = problems(" prop \"paths\" type=\"list\" parse=\"split_on_vibes\""); - assert_eq!(problems.len(), 1, "{problems:?}"); - assert!( - problems[0].starts_with("`paths` names the parser `split_on_vibes`, which is not one of: "), - "{problems:?}" - ); - // The message lists what there is, because the author's next move is to pick one. - assert!(problems[0].contains("list_by_comma"), "{problems:?}"); -} - -#[test] -fn a_table_keyed_by_something_a_file_cannot_spell_is_refused() { - // Keys in TOML and JSON are text. A `map` would be honoured by handing the CLI string - // keys — quietly, which is the worst way to not support something. - let problems = problems(" prop \"ports\" type=\"map\""); - assert_eq!( - problems, - vec![ - "`ports` is a `map`, and a settings table's keys are text: \ - write `map`" - ] - ); -} - -#[test] -fn two_keys_that_generate_one_const_are_refused() { - // `task.output` and `task_output` are two settings and one `prop::TASK_OUTPUT`. Generating - // both would not compile — a good outcome reported badly, in a file the author did not write. - let problems = problems( - " prop \"task.output\" type=\"string\"\n prop \"task_output\" type=\"string\"", - ); - assert_eq!( - problems, - vec![ - "`task_output` and `task.output` both generate `prop::TASK_OUTPUT`: rename one of them" - ] - ); -} - -#[test] -fn everything_wrong_is_reported_at_once() { - // Three mistakes, one build. Reporting the first would make fixing a registry a sequence of - // builds, which is how the fleet's own generators behave and why nobody enjoys editing them. - let problems = problems( - " prop \"a\" parse=\"nope\"\n \ - prop \"b\" renamed_to=\"nowhere\"\n \ - prop \"c\" type=\"map\"", - ); - assert_eq!(problems.len(), 3, "{problems:?}"); - let all = problems.join("\n"); - assert!(all.contains("`a` names the parser `nope`"), "{all}"); - assert!(all.contains("`b` is renamed to `nowhere`"), "{all}"); - assert!(all.contains("`c` is a `map`"), "{all}"); -} - -#[test] -fn a_spec_with_no_settings_is_not_an_empty_registry() { - // A build script that generates an empty registry has been pointed at the wrong file, and the - // failure it would otherwise produce is "no such setting" for every read in the CLI. - let err = source_of_spec("name \"mycli\"\nbin \"mycli\"\n", "mycli.usage.kdl") - .expect_err("should be refused"); - assert!(matches!(err, Error::NoSettings), "{err}"); - assert_eq!(err.to_string(), "this spec declares no `config` settings"); -} - -#[test] -fn a_spec_that_does_not_parse_says_so_as_the_parser_put_it() { - // Nothing here re-words a KDL error: the spec's own parser has the line and column. - let err = source_of_spec("config {\n prop\n", "mycli.usage.kdl").expect_err("refused"); - assert!(matches!(err, Error::Spec(_)), "{err}"); -} - -#[test] -fn a_setting_that_is_also_a_group_of_settings_is_refused() { - // `python` as a setting and `python.compile` as another: one field name with two things to be, - // a value and a table. The spec can say it; no struct can hold it. - let problems = - problems(" prop \"python\" type=\"string\"\n prop \"python.compile\" type=\"bool\""); - assert_eq!( - problems, - vec![ - "`python` is a setting and a group of settings: it cannot be both a value and a table" - ] - ); -} - -#[test] -fn two_keys_that_generate_one_field_are_refused() { - // The `prop::` consts do not catch these: `foo-bar.x` and `foo_bar.y` are two distinct consts - // and one field, because a dash and an underscore are the same character in an identifier. The - // check has to be on the names that are emitted, not on the segments they came from. - assert_eq!( - problems(" prop \"foo-bar.x\" type=\"string\"\n prop \"foo_bar.y\" type=\"string\""), - vec![ - "`foo-bar` and `foo_bar` both generate the field `foo_bar`: rename one of them", - "`foo-bar` and `foo_bar` are both groups named `SettingsFooBar`: rename one of them" - ] - ); - - // And two groups whose *type* names collide, which a field name does not: a struct name loses - // the case of its first letter. - assert_eq!( - problems(" prop \"a.x\" type=\"string\"\n prop \"A.y\" type=\"string\""), - vec!["`A` and `a` are both groups named `SettingsA`: rename one of them"] - ); -} - -#[test] -fn two_groups_that_generate_one_struct_are_refused_however_deep_they_are() { - // Struct names are built by concatenation and all declared in one module, so `http.client` and - // `http_client` arrive at `SettingsHttpClient` from different depths. Compared among siblings - // they are not siblings at all, and the adopter's crate got two structs with one name. - assert_eq!( - problems( - " prop \"http.client.timeout\" type=\"string\"\n \ - prop \"http_client.retries\" type=\"uint\"" - ), - vec![ - "`http.client` and `http_client` are both groups named `SettingsHttpClient`: \ - rename one of them" - ] - ); -} - -#[test] -fn a_key_that_needs_escaping_is_escaped_where_it_becomes_a_literal() { - // `a"b` is a nameable key — it has letters in it — and the generated reader quotes the key into - // an `expect` message. Interpolated raw, the quote ended that literal early and the rest of the - // message read as code, in a file the adopter did not write. - let generated = source_of_spec( - &spec(" prop \"a\\\"b\" type=\"uint\" default=1"), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("`a\\\"b` has a declared default"), - "{generated}" - ); - // The same key in `PropMeta`, which was escaped all along, and in a doc comment, where it needs - // no escape and only one line. - assert!( - generated.contains("PropMeta::new(\"a\\\"b\""), - "{generated}" - ); - assert!(generated.contains("/// (`a\"b`)"), "{generated}"); -} - -#[test] -fn a_setting_named_after_the_readers_own_bindings_is_still_a_setting() { - // `fold` is what the generated reader calls its fold, and an unprefixed local shadowed it: every - // read after it, and `fold.finish()`, stopped compiling. Nothing about `fold` is special to a - // config file, so the fix is on this side — every local is prefixed. - let generated = source_of_spec( - &spec(" prop \"fold\" type=\"string\"\n prop \"resolved\" type=\"bool\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("let read_fold: Option ="), - "{generated}" - ); - assert!( - generated.contains("let read_resolved: Option ="), - "{generated}" - ); - // And the fields keep the names the config file uses. - assert!( - generated.contains("pub fold: Option,"), - "{generated}" - ); -} - -#[test] -fn a_name_is_built_from_ascii_wherever_it_is_built() { - // A Rust identifier is not "any alphanumeric character": `½` is one and cannot appear in an - // identifier, and a Unicode digit cannot start one. The `prop::` consts were ASCII already, so - // keeping more in the fields made one key produce names from two alphabets — a const the adopter - // could compile beside a field they could not. - let generated = source_of_spec( - &spec(" prop \"caf\u{e9}.si\u{bd}e\" type=\"string\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("pub const CAF__SI_E: PropId"), - "{generated}" - ); - assert!(generated.contains("pub caf_: SettingsCaf,"), "{generated}"); - assert!( - generated.contains("pub si_e: Option,"), - "{generated}" - ); - // Every line that *declares* a name is ASCII. The key itself is not — it appears as a string - // literal in `PropMeta`, and as data it is exactly what the spec said. - for line in generated.lines() { - let declares = line.trim_start(); - let declares = declares.starts_with("pub const ") - || declares.starts_with("pub struct ") - || declares.starts_with("pub fn ") - || declares.starts_with("let ") - || (declares.starts_with("pub ") && declares.contains(':')); - assert!( - !declares || line.is_ascii(), - "a generated name kept a character no identifier can hold: {line}" - ); - } -} - -#[test] -fn a_key_that_starts_with_a_digit_is_a_field_all_the_same() { - // `2fa` is a real name for a real setting, and neither a const nor a field can start with a - // digit — so both get the same prefix rather than one of them being invalid Rust. - let generated = source_of_spec( - &spec(" prop \"2fa.token\" type=\"string\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("pub const _2FA_TOKEN: PropId"), - "{generated}" - ); - assert!(generated.contains("pub _2fa: Settings2fa,"), "{generated}"); -} - -#[test] -fn a_key_rust_has_no_spelling_for_is_refused() { - // Most keywords are fine as fields — `type` is written `r#type`, and settings called that are - // perfectly ordinary. Four are not: there is no `r#self`, so the field cannot be written at all. - assert_eq!( - problems(" prop \"self\" type=\"string\""), - vec!["`self` cannot be a field: `self` is a keyword Rust has no spelling for"] - ); - - // And the same for a group, whose name becomes a field too. - assert_eq!( - problems(" prop \"crate.name\" type=\"string\""), - vec!["`crate` cannot be a field: `crate` is a keyword Rust has no spelling for"] - ); -} diff --git a/config/Cargo.toml b/config/Cargo.toml index bd0e03bc5..76730fd04 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -10,9 +10,9 @@ repository = { workspace = true } authors = { workspace = true } license = { workspace = true } -# Nothing here parses KDL: a spec is read at *build* time by usage-config-build, which emits -# the registry as consts. This crate is what runs in the CLI, so it carries no spec parser -# and no format reader it was not asked for. +# Nothing here parses KDL: `#[derive(usage::Config)]` emits the registry as consts at compile +# time. This crate is what runs in the CLI, so it carries no spec parser and no format reader +# it was not asked for. [dependencies] toml = { version = "1.0", optional = true } serde_json = { version = "1", optional = true } diff --git a/config/src/lib.rs b/config/src/lib.rs index add4271c4..f4a2f6a3f 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -8,9 +8,9 @@ //! declaration of a setting and the code that resolves it are two separate things that have //! to be kept in step by hand. //! -//! Here they are one thing. `usage-config-build` reads the spec's `config` block at build -//! time and emits a [`Registry`] of consts; this crate resolves values against it. Nothing -//! here parses KDL, so a CLI carries a resolver rather than a spec parser. +//! Here they are one thing. `#[derive(usage::Config)]` reads the settings struct and emits a +//! [`Registry`] of consts beside it; this crate resolves values against it. Nothing here +//! parses KDL, so a CLI carries a resolver rather than a spec parser. //! //! # What it guarantees //! @@ -33,7 +33,7 @@ //! ``` //! use usage_config::{resolve, Const, EnvLayer, Layers, PropMeta, Registry, Ty, Value}; //! -//! // Normally generated from the spec by usage-config-build. +//! // Normally generated from the settings struct by `#[derive(usage::Config)]`. //! static PROPS: &[PropMeta] = &[PropMeta { //! envs: &["MYCLI_JOBS"], //! default: Some(Const::Int(4)), @@ -61,10 +61,12 @@ pub mod explain; #[cfg(any(feature = "toml", feature = "json", feature = "yaml"))] pub mod files; pub mod layer; +pub mod props; pub mod read; pub mod registry; pub mod resolve; pub mod source; +pub mod spec; pub mod ty; pub mod value; @@ -74,9 +76,11 @@ pub use explain::explain; #[cfg(any(feature = "toml", feature = "json", feature = "yaml"))] pub use files::{FileLayer, Format}; pub use layer::{Entry, Layer, LayerCtx, LayerError, LayerOutput, Warning, WarningKind}; +pub use props::{concat_props, Props}; pub use read::{Fold, FromValue, ReadError, ReadErrorKind, ReadErrors}; pub use registry::{Lookup, Merge, PropId, PropMeta, Registry, Scope}; pub use resolve::{resolve, Layers, Resolved}; pub use source::{FileScope, Origin, SourceKind, Trust}; +pub use spec::spec_kdl; pub use ty::{Parser, Ty, TypeError}; pub use value::{Const, Value}; diff --git a/config/src/props.rs b/config/src/props.rs new file mode 100644 index 000000000..6ae2d8ead --- /dev/null +++ b/config/src/props.rs @@ -0,0 +1,219 @@ +//! What `#[derive(usage::Config)]` generates, and how flattened groups compose. +//! +//! A derive expansion sees one struct. A settings struct that flattens another — pitchfork +//! keeps eight groups in eight structs — therefore joins tables through this trait's +//! associated const, the same way `usage::Cli` joins a flattened group's flags: the child +//! declares its own slice, and the parent concatenates at compile time. Nothing is assembled +//! at run time, and a prop's id is its position in the joined slice. + +use crate::read::Fold; +use crate::registry::PropMeta; + +/// A group of settings declared in code. +/// +/// Implemented by `#[derive(usage::Config)]`, not by hand: the derive is what keeps +/// [`Props::PROPS`] and [`Props::read_at`] describing the same fields in the same order, +/// which is the invariant everything here leans on. +pub trait Props: Sized { + /// This group's settings, in declaration order. + /// + /// A flattened child's props follow the parent's own, so an id is a position in the + /// parent's joined slice — which is why reading takes a `base`. + const PROPS: &'static [PropMeta]; + + /// Read this group's fields from a fold, its props starting at `base`. + /// + /// `None` means a field could not be read and the fold has recorded why. Every field is + /// still visited first — the errors are a list, not the first thing found — so a caller + /// checks [`Fold::finish`] before treating `None` as anything but "already reported". + #[doc(hidden)] + fn read_at(fold: &mut Fold<'_>, base: u16) -> Option; +} + +/// Join groups of prop metadata into one slice, at compile time. +/// +/// The settings counterpart of `usage_argv::spec::concat_flag_metas`, for the same reason: a +/// flattened struct's props belong in the parent's registry, and the parent's macro expansion +/// has only a type to reach them through. +/// +/// `N` must be the summed length of `groups`. Two groups claiming the same *name* are refused +/// here, at compile time — the parent and the struct it flattens each declared it, a collision +/// neither expansion can see. A name is a key or an alias, because [`Registry::lookup`] checks +/// both and takes the first match: an alias colliding with another group's key makes one of +/// the two unreachable by that name, which is the same bug as a duplicate key and quieter. +/// +/// [`Registry::lookup`]: crate::Registry::lookup +pub const fn concat_props(groups: &[&[PropMeta]]) -> [PropMeta; N] { + let mut out = [PropMeta::new("", crate::ty::Ty::Any); N]; + let mut at = 0; + let mut g = 0; + while g < groups.len() { + let group = groups[g]; + let mut i = 0; + while i < group.len() { + out[at] = group[i]; + at += 1; + i += 1; + } + g += 1; + } + assert!( + at == N, + "`N` must be the summed length of the groups, or the registry would describe a \ + setting that does not exist" + ); + let mut a = 0; + while a < N { + let mut b = a + 1; + while b < N { + assert!( + !str_eq(out[a].key, out[b].key), + "two flattened groups declare the same setting key, so one of them could \ + never be reached: give one of them another key or prefix" + ); + // Each one's key against the other's aliases, and then alias against alias. A + // lookup does not care which kind of name it matched, so neither can this. + assert!( + !names_any(out[a].key, out[b].aliases), + "one flattened group's setting key is another's alias, so a lookup for that \ + name could only ever reach one of them: rename one of the two" + ); + assert!( + !names_any(out[b].key, out[a].aliases), + "one flattened group's setting key is another's alias, so a lookup for that \ + name could only ever reach one of them: rename one of the two" + ); + let mut i = 0; + while i < out[a].aliases.len() { + assert!( + !names_any(out[a].aliases[i], out[b].aliases), + "two flattened groups declare the same alias, so a lookup for it could \ + only ever reach one of them: rename one of the two" + ); + i += 1; + } + b += 1; + } + a += 1; + } + out +} + +/// Whether `name` is one of `names`, in a const context. +const fn names_any(name: &str, names: &[&str]) -> bool { + let mut i = 0; + while i < names.len() { + if str_eq(name, names[i]) { + return true; + } + i += 1; + } + false +} + +/// Whether two strings are equal, in a const context. +const fn str_eq(a: &str, b: &str) -> bool { + let a = a.as_bytes(); + let b = b.as_bytes(); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ty::Ty; + + /// The refusals, exercised at run time. + /// + /// `concat_props` is a `const fn`, so in its real use — a `static` initializer — these + /// assertions are a compile error, which a test cannot observe. Called at run time the + /// same assertion panics, which one can. + mod one_name_reaches_one_setting { + use super::*; + + #[test] + #[should_panic(expected = "same setting key")] + fn two_groups_cannot_declare_one_key() { + static A: &[PropMeta] = &[PropMeta::new("jobs", Ty::Uint)]; + static B: &[PropMeta] = &[PropMeta::new("jobs", Ty::Uint)]; + let _ = concat_props::<2>(&[A, B]); + } + + #[test] + #[should_panic(expected = "is another's alias")] + fn an_alias_cannot_shadow_another_groups_key() { + static A: &[PropMeta] = &[PropMeta { + aliases: &["threads"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + static B: &[PropMeta] = &[PropMeta::new("threads", Ty::Uint)]; + let _ = concat_props::<2>(&[A, B]); + } + + /// The same collision found from the other side, which is a separate comparison. + #[test] + #[should_panic(expected = "is another's alias")] + fn a_key_cannot_be_shadowed_by_a_later_groups_alias() { + static A: &[PropMeta] = &[PropMeta::new("threads", Ty::Uint)]; + static B: &[PropMeta] = &[PropMeta { + aliases: &["threads"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + let _ = concat_props::<2>(&[A, B]); + } + + #[test] + #[should_panic(expected = "same alias")] + fn two_groups_cannot_declare_one_alias() { + static A: &[PropMeta] = &[PropMeta { + aliases: &["shared"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + static B: &[PropMeta] = &[PropMeta { + aliases: &["shared"], + ..PropMeta::new("threads", Ty::Uint) + }]; + let _ = concat_props::<2>(&[A, B]); + } + + /// And distinct names join, which is the case that has to keep working. + #[test] + fn distinct_names_join() { + static A: &[PropMeta] = &[PropMeta { + aliases: &["concurrency"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + static B: &[PropMeta] = &[PropMeta { + aliases: &["task.concurrency"], + ..PropMeta::new("task.jobs", Ty::Uint) + }]; + let joined = concat_props::<2>(&[A, B]); + assert_eq!(joined[0].key, "jobs"); + assert_eq!(joined[1].key, "task.jobs"); + } + } + + #[test] + fn groups_join_in_order_and_ids_are_positions() { + static OWN: &[PropMeta] = &[PropMeta::new("jobs", Ty::Uint)]; + static CHILD: &[PropMeta] = &[ + PropMeta::new("task.output", Ty::String), + PropMeta::new("task.jobs", Ty::Uint), + ]; + const N: usize = OWN.len() + CHILD.len(); + static JOINED: [PropMeta; N] = concat_props(&[OWN, CHILD]); + assert_eq!(JOINED[0].key, "jobs"); + assert_eq!(JOINED[1].key, "task.output"); + assert_eq!(JOINED[2].key, "task.jobs"); + } +} diff --git a/config/src/read.rs b/config/src/read.rs index 688f6f3bf..f4b022efb 100644 --- a/config/src/read.rs +++ b/config/src/read.rs @@ -250,6 +250,49 @@ impl FromValue for f64 { } } +impl FromValue for f32 { + fn from_value(value: &Value) -> Result { + let wide = f64::from_value(value)?; + let narrow = wide as Self; + // The rule the narrower integers below follow, held here too: a value that does not + // fit is reported rather than wrapped. Rounding a finite value is ordinary precision + // loss; turning `1e300` into `inf` is a value the declaration never named. + if narrow.is_infinite() && wide.is_finite() { + return Err(mismatch("a number that fits 32 bits", value)); + } + Ok(narrow) + } +} + +/// The narrower integers a struct actually holds — a fleet CLI keeps `jobs` in a `usize` and a +/// verbosity in a `u8` — read through the same rule as [`u64`]: a value that does not fit is +/// reported rather than wrapped, because wrapping is the shape of this bug everywhere it exists. +macro_rules! narrower_int { + ($($ty:ty => $expected:literal,)*) => {$( + impl FromValue for $ty { + fn from_value(value: &Value) -> Result { + match value { + Value::Int(i) => { + Self::try_from(*i).map_err(|_| mismatch($expected, value)) + } + other => Err(mismatch($expected, other)), + } + } + } + )*}; +} + +narrower_int! { + u8 => "a non-negative integer that fits 8 bits", + u16 => "a non-negative integer that fits 16 bits", + u32 => "a non-negative integer that fits 32 bits", + usize => "a non-negative integer", + i8 => "an integer that fits 8 bits", + i16 => "an integer that fits 16 bits", + i32 => "an integer that fits 32 bits", + isize => "an integer", +} + impl FromValue for String { fn from_value(value: &Value) -> Result { match value { @@ -302,6 +345,31 @@ mod tests { use crate::ty::{Parser, Ty}; use crate::value::Const; + /// A value that does not fit the field it reads into is reported rather than wrapped. + /// + /// The narrower integers say so; `f32` reached `f64` and then cast with `as`, which turns + /// `1e300` into `inf` and calls it a successful read — a setting whose effective value + /// nothing declared. + #[test] + fn a_value_too_wide_for_the_field_is_reported_rather_than_wrapped() { + u8::from_value(&Value::Int(256)).expect_err("256 does not fit 8 bits"); + i8::from_value(&Value::Int(-129)).expect_err("-129 does not fit 8 bits"); + usize::from_value(&Value::Int(-1)).expect_err("-1 is not non-negative"); + assert_eq!(u8::from_value(&Value::Int(255)).expect("255 fits"), 255); + + f32::from_value(&Value::Float(1e300)).expect_err("1e300 is not an f32"); + f32::from_value(&Value::Float(-1e300)).expect_err("-1e300 is not an f32"); + // Rounding a value that does fit is ordinary precision loss, which is allowed. + assert_eq!( + f32::from_value(&Value::Float(0.1)).expect("0.1 fits"), + 0.1_f32 + ); + // An infinity a layer actually supplied is the value it supplied, not an overflow. + assert!(f32::from_value(&Value::Float(f64::INFINITY)) + .expect("an infinity reads as one") + .is_infinite()); + } + static PROPS: &[PropMeta] = &[ PropMeta { default: Some(Const::Int(4)), diff --git a/config/src/registry.rs b/config/src/registry.rs index 070296ce1..b553a733c 100644 --- a/config/src/registry.rs +++ b/config/src/registry.rs @@ -1,6 +1,6 @@ //! The settings a CLI has, as a generated table. //! -//! `usage-config-build` reads the spec's `config` block and emits a `static` of these, so at +//! `#[derive(usage::Config)]` reads the settings struct and emits a `static` of these, so at //! runtime a registry is a slice — no parsing, no map to build, and a [`PropId`] that indexes //! it directly. A merge over a hundred settings therefore never hashes a key, which is what //! makes resolving the whole struct at once cheap enough to do eagerly. @@ -103,6 +103,17 @@ pub struct PropMeta { /// An explicit optionality contract, when the declaration does not use inference. pub optional: Option, pub help: Option<&'static str>, + pub long_help: Option<&'static str>, + /// Prose beside the default, for a default the registry cannot hold — one computed at + /// runtime, or one whose literal alone would mislead ("0 = one per core"). + pub default_note: Option<&'static str>, + /// The release that introduced this setting, when the declaration says. + pub since: Option<&'static str>, + /// The release that starts warning about a deprecated setting, and the one that removes it. + pub deprecated_warn_at: Option<&'static str>, + pub deprecated_remove_at: Option<&'static str>, + /// Values worth showing a reader, verbatim. + pub examples: &'static [&'static str], } impl PropMeta { @@ -126,6 +137,12 @@ impl PropMeta { aliases: &[], optional: None, help: None, + long_help: None, + default_note: None, + since: None, + deprecated_warn_at: None, + deprecated_remove_at: None, + examples: &[], } } } @@ -306,7 +323,7 @@ impl Registry { /// Bounded by the number of settings there are, so a registry whose renames form a cycle stops /// rather than following them forever — the same guard [`Registry::lookup`] uses, and for the /// same reason: this is an authoring mistake, and hanging is a worse way to report one than - /// nothing at all. `usage-config-build` refuses such a registry outright. + /// nothing at all. The derive refuses such a declaration outright. pub fn deprecation(&self, key: &str) -> Option<&'static str> { let mut current = self .props diff --git a/config/src/spec.rs b/config/src/spec.rs new file mode 100644 index 000000000..9a5b7fbe3 --- /dev/null +++ b/config/src/spec.rs @@ -0,0 +1,349 @@ +//! A registry written back out as the spec's `config` block. +//! +//! A CLI declares its settings in code with `#[derive(usage::Config)]`, and this is the way +//! back out: the registry it derived, rendered as the `config { prop … }` block the spec +//! grammar defines, so docs, JSON schema, completions and every other spec consumer read +//! declarations made in Rust exactly as they read ones made in KDL. +//! +//! Written by hand rather than through a KDL library for the same reason the argv crate's +//! spec writer is: this crate has no dependencies, and the grammar being emitted is the small +//! fixed one the spec parser defines. + +use crate::registry::{Merge, PropMeta, Scope}; +use crate::value::Const; +use std::fmt::Write; + +/// The spec `config` block for these settings, as KDL. +/// +/// Ends with a newline, so it can be appended to an emitted spec as-is. Props are written in +/// registry order, which for a derived registry is declaration order. +pub fn spec_kdl(props: &[PropMeta]) -> String { + let mut out = String::from("config {\n"); + for meta in props { + let _ = write_prop(&mut out, meta); + } + out.push_str("}\n"); + out +} + +fn write_prop(out: &mut String, meta: &PropMeta) -> std::fmt::Result { + write!( + out, + " prop {} type={}", + quoted(meta.key), + quoted(&meta.ty.name()) + )?; + if let Some(default) = scalar_default(meta.default) { + write!(out, " default={default}")?; + } + if let Some(note) = meta.default_note { + write!(out, " default_note={}", quoted(note))?; + } + if let Some(optional) = meta.optional { + write!(out, " optional=#{optional}")?; + } + match meta.merge { + Merge::Replace => {} + Merge::Union => out.push_str(" merge=\"union\""), + Merge::Deep => out.push_str(" merge=\"deep\""), + } + if let Some(parse) = meta.parse { + write!(out, " parse={}", quoted(parse.name()))?; + } + match meta.scope { + Scope::Any => {} + Scope::Global => out.push_str(" scope=\"global\""), + Scope::Env => out.push_str(" scope=\"env\""), + } + if meta.hide { + out.push_str(" hide=#true"); + } + if let Some(deprecated) = meta.deprecated { + write!(out, " deprecated={}", quoted(deprecated))?; + } + if let Some(at) = meta.deprecated_warn_at { + write!(out, " deprecated_warn_at={}", quoted(at))?; + } + if let Some(at) = meta.deprecated_remove_at { + write!(out, " deprecated_remove_at={}", quoted(at))?; + } + if let Some(renamed_to) = meta.renamed_to { + write!(out, " renamed_to={}", quoted(renamed_to))?; + } + if let Some(since) = meta.since { + write!(out, " since={}", quoted(since))?; + } + if let Some(help) = meta.help { + write!(out, " help={}", quoted(help))?; + } + if let Some(long_help) = meta.long_help { + write!(out, " long_help={}", quoted(long_help))?; + } + + let mut children = Vec::new(); + if let Some(Const::List(items)) = meta.default { + // A list default is a child node — `default 80 443` — because several values do not + // fit one `default=` entry. + let rendered: Vec = items.iter().map(|item| const_kdl(*item)).collect(); + children.push(format!("default {}", rendered.join(" "))); + } + if !meta.envs.is_empty() { + children.push(word_list("env", meta.envs)); + } + if !meta.deprecated_envs.is_empty() { + children.push(word_list("deprecated_env", meta.deprecated_envs)); + } + if !meta.aliases.is_empty() { + children.push(word_list("alias", meta.aliases)); + } + if !meta.cli.is_empty() { + children.push(word_list("cli", meta.cli)); + } + for example in meta.examples { + children.push(format!("example {}", quoted(example))); + } + // One `source` node per kind, holding every key bound in it, in declaration order — + // `source "pkl" "exclude" "defaults.exclude"`. + let mut kinds: Vec<&str> = Vec::new(); + for (kind, _) in meta.bindings { + if !kinds.contains(kind) { + kinds.push(kind); + } + } + for kind in kinds { + let keys: Vec = meta + .bindings + .iter() + .filter(|(k, _)| *k == kind) + .map(|(_, key)| quoted(key)) + .collect(); + children.push(format!("source {} {}", quoted(kind), keys.join(" "))); + } + // One `choice` node carries one value, so only a scalar belongs here. A registry written + // by hand can hold a list or a table; rendering one produced `choice 1 2` or a bare + // `choice`, neither of which the prop grammar can read back. + let choices: Vec = meta + .choices + .iter() + .filter(|choice| !matches!(choice, Const::List(_) | Const::Map(_))) + .map(|choice| const_kdl(*choice)) + .collect(); + if !choices.is_empty() { + let mut block = String::from("choices {\n"); + for choice in choices { + let _ = writeln!(block, " choice {choice}"); + } + block.push_str(" }"); + children.push(block); + } + + if children.is_empty() { + out.push('\n'); + } else { + out.push_str(" {\n"); + for child in children { + let _ = writeln!(out, " {child}"); + } + out.push_str(" }\n"); + } + Ok(()) +} + +/// A scalar default as a KDL entry value, or `None` for a list (a child node) or a table +/// (which the prop grammar cannot hold, and a generator refuses before it gets here). +fn scalar_default(default: Option) -> Option { + match default? { + Const::List(_) | Const::Map(_) => None, + scalar => Some(const_kdl(scalar)), + } +} + +/// One constant as KDL writes it: `#true`, `4`, `1.5`, `"text"`. +fn const_kdl(value: Const) -> String { + match value { + Const::Bool(b) => format!("#{b}"), + Const::Int(i) => i.to_string(), + // `{:?}` keeps the decimal point, which KDL requires of a float — `1.0`, not `1`. + Const::Float(f) => format!("{f:?}"), + Const::Str(s) => quoted(s), + Const::List(items) => items + .iter() + .map(|item| const_kdl(*item)) + .collect::>() + .join(" "), + // Unreachable from a derived registry — the derive refuses table defaults — and not + // something the prop grammar can spell. + Const::Map(_) => String::new(), + } +} + +fn word_list(name: &str, words: &[&str]) -> String { + let quoted: Vec = words.iter().map(|word| quoted(word)).collect(); + format!("{name} {}", quoted.join(" ")) +} + +/// `text` as a quoted KDL string. +fn quoted(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + // KDL forbids a raw control character in a document at all, so the rest go through + // the escape it does spell. A `help` string that carried an ANSI escape wrote a + // block no parser would read back. + c if c.is_control() => { + let _ = write!(out, "\\u{{{:x}}}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ty::{Parser, Ty}; + + #[test] + fn a_registry_renders_as_the_config_block_the_spec_grammar_defines() { + static PROPS: &[PropMeta] = &[ + PropMeta { + default: Some(Const::Int(4)), + default_note: Some("0 = one per core"), + envs: &["HK_JOBS", "HK_JOB"], + deprecated_envs: &["HK_JOBS_OLD"], + cli: &["--jobs", "-j"], + bindings: &[("git", "hk.jobs")], + help: Some("How many jobs to run at once"), + ..PropMeta::new("jobs", Ty::Uint) + }, + PropMeta { + merge: Merge::Union, + parse: Some(Parser::ListByComma), + envs: &["HK_EXCLUDE"], + bindings: &[("pkl", "exclude"), ("pkl", "defaults.exclude")], + ..PropMeta::new("exclude", Ty::List(&Ty::String)) + }, + PropMeta { + default: Some(Const::Str("git")), + choices: &[ + Const::Str("git"), + Const::Str("patch-file"), + Const::Str("none"), + ], + help: Some("How to \"stash\" first"), + ..PropMeta::new("stash", Ty::String) + }, + PropMeta { + default: Some(Const::List(&[Const::Int(80), Const::Int(443)])), + ..PropMeta::new("ports", Ty::List(&Ty::Uint)) + }, + PropMeta { + scope: Scope::Env, + hide: true, + envs: &["CI"], + ..PropMeta::new("ci", Ty::Bool) + }, + // The rest of the vocabulary, in one prop: where each of these lands — an entry on + // the `prop` node, or a child of it — is exactly what a golden string is for. + PropMeta { + optional: Some(true), + aliases: &["fail-fast.legacy", "failfast"], + examples: &["true", "false"], + deprecated: Some("use `stop-on-error`"), + deprecated_warn_at: Some("6.0.0"), + deprecated_remove_at: Some("7.0.0"), + since: Some("5.2.0"), + help: Some("Stop at the first failure"), + long_help: Some("Whether a failing job stops the rest."), + ..PropMeta::new("fail_fast", Ty::Option(&Ty::Bool)) + }, + // A `choice` node carries one value, and a registry written by hand can hold a + // list where one belongs. The scalar survives; the list is not something the prop + // grammar can spell, so it is left out rather than written as two arguments. + PropMeta { + choices: &[ + Const::Str("plain"), + Const::List(&[Const::Int(1), Const::Int(2)]), + ], + ..PropMeta::new("level", Ty::Any) + }, + ]; + let kdl = spec_kdl(PROPS); + assert_eq!( + kdl, + r#"config { + prop "jobs" type="uint" default=4 default_note="0 = one per core" help="How many jobs to run at once" { + env "HK_JOBS" "HK_JOB" + deprecated_env "HK_JOBS_OLD" + cli "--jobs" "-j" + source "git" "hk.jobs" + } + prop "exclude" type="list" merge="union" parse="list_by_comma" { + env "HK_EXCLUDE" + source "pkl" "exclude" "defaults.exclude" + } + prop "stash" type="string" default="git" help="How to \"stash\" first" { + choices { + choice "git" + choice "patch-file" + choice "none" + } + } + prop "ports" type="list" { + default 80 443 + } + prop "ci" type="bool" scope="env" hide=#true { + env "CI" + } + prop "fail_fast" type="option" optional=#true deprecated="use `stop-on-error`" deprecated_warn_at="6.0.0" deprecated_remove_at="7.0.0" since="5.2.0" help="Stop at the first failure" long_help="Whether a failing job stops the rest." { + alias "fail-fast.legacy" "failfast" + example "true" + example "false" + } + prop "level" type="any" { + choices { + choice "plain" + } + } +} +"# + ); + } + + /// KDL forbids a raw control character anywhere in a document, so a value carrying one has + /// to go out as the escape KDL does spell — or the block this renders is one no parser, + /// including usage's own, will read back. + #[test] + fn a_control_character_in_a_value_is_escaped_rather_than_written() { + static PROPS: &[PropMeta] = &[PropMeta { + help: Some("plain\u{1b}[0m and \u{0}"), + ..PropMeta::new("color", Ty::Bool) + }]; + assert_eq!( + spec_kdl(PROPS), + "config {\n prop \"color\" type=\"bool\" help=\"plain\\u{1b}[0m and \\u{0}\"\n}\n" + ); + } + + /// A prop whose only choices are shapes the grammar cannot spell gets no `choices` block at + /// all, rather than one holding a node with no argument. + #[test] + fn choices_no_single_value_can_hold_leave_no_block_behind() { + static PROPS: &[PropMeta] = &[PropMeta { + choices: &[Const::Map(&[("a", Const::Int(1))])], + ..PropMeta::new("shape", Ty::Any) + }]; + assert_eq!( + spec_kdl(PROPS), + "config {\n prop \"shape\" type=\"any\"\n}\n" + ); + } +} diff --git a/config/src/ty.rs b/config/src/ty.rs index 7103c6b24..033c6f885 100644 --- a/config/src/ty.rs +++ b/config/src/ty.rs @@ -1,8 +1,8 @@ //! The type a setting was declared with, and reading a raw string as it. //! //! A trimmed-down runtime form of the spec's type grammar: enough to coerce and validate, -//! with none of the parsing. `usage-config-build` turns `list` into -//! `Ty::List(&Ty::String)` at build time, so the shape a value must take costs a match +//! with none of the parsing. The derive turns a `Vec` field into +//! `Ty::List(&Ty::String)` at compile time, so the shape a value must take costs a match //! rather than a parse. //! //! Every layer that reads text — the environment, an `.npmrc`, a git config — hands over a diff --git a/conformance/src/config.rs b/conformance/src/config.rs index c634c08b2..aae700ac0 100644 --- a/conformance/src/config.rs +++ b/conformance/src/config.rs @@ -9,8 +9,9 @@ //! An argv vector carries a KDL spec, because parsing argv is a question about a spec. A resolution //! is not: it is a question about a *registry* — keys, types, defaults, merge policies — which a //! CLI's build step produces from the spec long before anything is resolved. So a vector describes -//! the registry directly in KDL, usage's canonical interchange format. How a spec becomes a -//! registry is `usage-config-build`'s question, and its own golden test answers it. +//! the registry directly in KDL, usage's canonical interchange format. How a *declaration* +//! becomes a registry is the `usage::Config` derive's question, and `derive_config.rs` answers +//! it. //! //! Nothing here touches the filesystem, the process environment, or a subprocess. A file layer is a //! description of what a file said, which is all the merge ever sees of one. @@ -662,9 +663,13 @@ fn registry_of(settings: &[Setting]) -> Result { let mut props = Vec::with_capacity(settings.len()); for setting in settings { let ty = ty_of(&setting.r#type)?; + // Only what a vector states; everything else is `PropMeta::new`'s default, so a field + // added to the registry's metadata does not have to be restated here to say "unset". + // + // No flags in particular: a vector describes what reaches a *resolution*, and which flag + // declares a setting does not change one. That is documentation, and the drift test that + // holds it against a CLI's own bindings is that CLI's test rather than this corpus's. props.push(PropMeta { - key: leak(&setting.key), - ty, default: setting.default.as_ref().map(const_of).transpose()?, merge: match setting.merge { MergePolicy::Replace => Merge::Replace, @@ -683,13 +688,6 @@ fn registry_of(settings: &[Setting]) -> Result { ), None => None, }, - envs: &[], - deprecated_envs: &[], - // No flags: a corpus vector describes what reaches a *resolution*, and which flag a - // setting declares does not change one. It is documentation, and the drift test that - // holds it against a CLI's own binding is that CLI's test rather than this corpus's. - cli: &[], - bindings: &[], choices: Box::leak( setting .choices @@ -698,12 +696,9 @@ fn registry_of(settings: &[Setting]) -> Result { .collect::, _>>()? .into_boxed_slice(), ), - hide: false, deprecated: setting.deprecated.as_deref().map(leak), renamed_to: setting.renamed_to.as_deref().map(leak), - aliases: &[], - optional: None, - help: None, + ..PropMeta::new(leak(&setting.key), ty) }); } Ok(Registry::new(Box::leak(props.into_boxed_slice()))) diff --git a/conformance/tests/config_value_display.rs b/conformance/tests/config_value_display.rs new file mode 100644 index 000000000..10274efe0 --- /dev/null +++ b/conformance/tests/config_value_display.rs @@ -0,0 +1,29 @@ +//! One value, written the same way by the spec parser and by the resolver. +//! +//! A `config` block's default is coerced by the *spec's* parser and its choices are read by the +//! *runtime's*, so one character of difference refused a `default=1.0` beside a `choice 1.0` +//! written identically. `usage-lib` cannot depend on `usage-config` — a CLI carries a resolver, +//! not a spec parser — so the rule exists in both. This crate depends on both, which makes it +//! the one place that can see them at once, so a change to either is a failure rather than a +//! surprise found later in a fleet CLI. + +#[test] +fn the_spec_and_the_runtime_write_a_value_the_same_way() { + for value in [ + 0.0_f64, + 1.0, + -1.0, + 0.5, + 1.25, + 1e3, + 1e300, + f64::MIN, + f64::MAX, + ] { + assert_eq!( + usage::spec::config::SpecConfigValue::Float(value).display(), + usage_config::Value::Float(value).display(), + "the two crates write {value} differently" + ); + } +} diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs new file mode 100644 index 000000000..274386ec4 --- /dev/null +++ b/conformance/tests/derive_config.rs @@ -0,0 +1,545 @@ +//! A settings struct as its own declaration. +//! +//! The claim under test: `#[derive(usage::Config)]` on the struct a CLI already holds its +//! settings in generates the registry the resolver reads, a reader that fills the struct from +//! a resolution, and a `config` block the spec parser reads back — so the registry, the reader, and the documentation cannot drift from +//! the struct or from each other. That drift is the fleet's `settings.toml` + `build.rs` +//! pattern's whole failure mode: three descriptions of every setting, kept in step by hand. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::PathBuf; + +use usage_config::{resolve, Const, EnvLayer, Layers, Ty, Value}; +use usage_derive::{Cli, Config}; + +fn default_cache_dir() -> PathBuf { + PathBuf::from("/tmp/ex-cache") +} + +/// The `task.*` settings, as their own group. +#[derive(Config, Debug, PartialEq)] +#[usage(prefix = "task")] +struct TaskSettings { + /// How task output is interleaved + #[usage(default = "prefix", choices("prefix", "interleave"), alias("task.out"))] + output: String, + + /// Jobs for tasks alone + #[usage(env = "EX_TASK_JOBS")] + jobs: Option, +} + +/// Every setting ex has — one of everything the derive carries. +#[derive(Config, Debug, PartialEq)] +struct Settings { + /// How many jobs to run at once + #[usage( + env("EX_JOBS", "EX_JOB"), + deprecated_env = "EX_JOBS_OLD", + default = 4, + cli("--jobs", "-j"), + source("git", "ex.jobs") + )] + jobs: u64, + + /// Paths to leave alone + #[usage(env = "EX_EXCLUDE", merge = "union", parse = "list_by_comma")] + exclude: Option>, + + /// Where the cache lives + #[usage( + env = "EX_CACHE_DIR", + default_fn = default_cache_dir, + default_note = "under the user cache directory" + )] + cache_dir: PathBuf, + + /// Which files to match + // A keyword as a field, which is ordinary for a setting key and needs `r#` in Rust. + #[usage(default = "all")] + r#match: String, + + /// How long to wait, if at all + // The registry declares a duration; the field holds its text, the same way a generated + // struct does — the crate that owns the duration type owns its spelling. + #[usage(env = "EX_TIMEOUT", ty = "duration")] + timeout: Option, + + /// Whether this checkout may run its own hooks + #[usage(scope = "global", default = false)] + trusted: bool, + + /// Ports to listen on + #[usage(default(80, 443))] + ports: Vec, + + /// Only from the environment + #[usage(env = "CI", scope = "env", hide)] + ci: Option, + + /// Rewrite these URL prefixes + #[usage(merge = "deep")] + url_replacements: Option>, + + #[usage(flatten)] + task: TaskSettings, +} + +#[test] +fn the_registry_is_the_struct_in_declaration_order() { + let keys: Vec<&str> = Settings::SETTINGS_PROPS + .iter() + .map(|meta| meta.key) + .collect(); + assert_eq!( + keys, + vec![ + "jobs", + "exclude", + "cache_dir", + "match", + "timeout", + "trusted", + "ports", + "ci", + "url_replacements", + "task.output", + "task.jobs", + ], + "a flattened group's props follow its field's position, under its prefix" + ); + + let jobs = &Settings::SETTINGS_PROPS[0]; + assert_eq!(jobs.ty, Ty::Uint); + assert_eq!(jobs.default, Some(Const::Int(4))); + assert_eq!(jobs.envs, &["EX_JOBS", "EX_JOB"]); + assert_eq!(jobs.deprecated_envs, &["EX_JOBS_OLD"]); + assert_eq!(jobs.cli, &["--jobs", "-j"]); + assert_eq!(jobs.bindings, &[("git", "ex.jobs")]); + assert_eq!(jobs.help, Some("How many jobs to run at once")); + + let cache_dir = &Settings::SETTINGS_PROPS[2]; + assert_eq!(cache_dir.ty, Ty::Path); + assert_eq!(cache_dir.default, None, "a computed default is not a const"); + assert_eq!(cache_dir.optional, Some(true)); + assert_eq!( + cache_dir.default_note, + Some("under the user cache directory") + ); + + let timeout = &Settings::SETTINGS_PROPS[4]; + assert_eq!( + timeout.ty, + Ty::Option(&Ty::Duration), + "an `Option` field is an optional setting, and `ty` renames what the spec calls it" + ); + + let ports = &Settings::SETTINGS_PROPS[6]; + assert_eq!( + ports.default, + Some(Const::List(&[Const::Int(80), Const::Int(443)])) + ); + + // The joined registry resolves dotted keys like any other. + let found = Settings::SETTINGS_REGISTRY + .lookup("task.output") + .expect("declared"); + assert_eq!( + Settings::SETTINGS_REGISTRY.get(found.id).help, + Some("How task output is interleaved") + ); +} + +#[test] +fn nothing_supplied_reads_as_the_declared_defaults() { + let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new()).expect("resolves"); + let settings = Settings::read(&resolved).expect("every field reads"); + assert_eq!( + settings, + Settings { + jobs: 4, + exclude: None, + cache_dir: PathBuf::from("/tmp/ex-cache"), + r#match: "all".to_string(), + timeout: None, + trusted: false, + ports: vec![80, 443], + ci: None, + url_replacements: None, + task: TaskSettings { + output: "prefix".to_string(), + jobs: None, + }, + } + ); +} + +#[test] +fn a_layer_fills_the_struct_through_the_declared_types() { + let env = EnvLayer::new([ + ("EX_JOB".to_string(), "9".to_string()), + ("EX_EXCLUDE".to_string(), "target, dist".to_string()), + ("EX_CACHE_DIR".to_string(), "/var/cache/ex".to_string()), + ("EX_TASK_JOBS".to_string(), "2".to_string()), + ("CI".to_string(), "1".to_string()), + ]); + let resolved = + resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).expect("resolves"); + let settings = Settings::read(&resolved).expect("every value fits its field"); + assert_eq!( + settings.jobs, 9, + "the second declared variable still sets it" + ); + assert_eq!( + settings.exclude.as_deref(), + Some(&["target".to_string(), "dist".to_string()][..]), + "the named parser splits one variable into the list" + ); + assert_eq!( + settings.cache_dir, + PathBuf::from("/var/cache/ex"), + "a supplied value wins over `default_fn`" + ); + assert_eq!(settings.ci, Some(true)); + assert_eq!( + settings.task.jobs, + Some(2), + "a flattened field reads through its prefix" + ); +} + +#[test] +fn the_emitted_config_block_is_the_spec_grammar() { + // Parsed by the reference implementation, not merely inspected: the block the derive + // renders has to be a declaration usage-lib reads, or docs, JSON schema and completions + // see a different CLI than the one that runs. + let kdl = format!("name \"ex\"\nbin \"ex\"\n{}", Settings::spec_kdl()); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the emitted block"); + + let jobs = spec.config.props.get("jobs").expect("declared"); + assert_eq!(jobs.envs, vec!["EX_JOBS".to_string(), "EX_JOB".to_string()]); + assert_eq!(jobs.deprecated_envs, vec!["EX_JOBS_OLD".to_string()]); + assert_eq!(jobs.cli, vec!["--jobs".to_string(), "-j".to_string()]); + assert!( + matches!( + jobs.default, + Some(usage::spec::config::SpecConfigValue::Int(4)) + ), + "the declared default survives the round-trip: {:?}", + jobs.default + ); + assert_eq!(jobs.help.as_deref(), Some("How many jobs to run at once")); + + let output = spec.config.props.get("task.output").expect("declared"); + assert_eq!(output.choices.len(), 2); + + let cache_dir = spec.config.props.get("cache_dir").expect("declared"); + assert_eq!( + cache_dir.default_note.as_deref(), + Some("under the user cache directory") + ); + assert_eq!(cache_dir.optional, Some(true)); +} + +#[test] +fn a_groups_prefix_reaches_its_keys_and_not_its_aliases() { + // `prefix` is applied to `key` alone: an alias is written out in full, prefix and all. + // + // Deliberate, because an alias is usually a *legacy* name and a setting that moved into a + // group often wants the spelling it had before the move — `task.jobs` keeping plain + // `jobs`. Prefixing aliases would make that unsayable, while writing them in full costs + // one repeated word and can say either. The collision checks in `Config::from_input` and + // `concat_props` are what keep a full-form alias from quietly taking a name off another + // group, which is the risk this reading carries. + let output = Settings::SETTINGS_PROPS + .iter() + .find(|meta| meta.key == "task.output") + .expect("declared under its group's prefix"); + assert_eq!(output.aliases, &["task.out"]); +} + +/// Every metadata attribute that only reaches the registry, declared at once. +#[derive(Config, Debug, PartialEq)] +struct Documented { + /// Stop at the first failure + /// + /// Whether a failing job stops the rest of them. + #[usage( + alias("fail-fast.legacy", "failfast"), + example("true", "false"), + since = "5.2.0", + deprecated = "use `stop-on-error`", + deprecated_warn_at = "6.0.0", + deprecated_remove_at = "7.0.0" + )] + fail_fast: Option, +} + +#[test] +fn the_metadata_only_docs_read_survives_the_round_trip() { + // These fields never reach `read` — they exist for docs, the JSON schema and the + // completers, all of which go through the *spec*. So rendering them is only half a claim: + // the other half is that usage-lib reads back what the derive wrote. A field the renderer + // emits and the parser ignores would be metadata that silently does not exist. + let props = Documented::SETTINGS_PROPS; + assert_eq!(props.len(), 1); + let declared = &props[0]; + + let kdl = format!("name \"ex\"\nbin \"ex\"\n{}", Documented::spec_kdl()); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the emitted block"); + let parsed = spec.config.props.get("fail_fast").expect("declared"); + + assert_eq!(parsed.aliases, declared.aliases.to_vec()); + assert_eq!(parsed.examples, declared.examples.to_vec()); + assert_eq!(parsed.since.as_deref(), declared.since); + assert_eq!(parsed.deprecated.as_deref(), declared.deprecated); + assert_eq!( + parsed.deprecated_warn_at.as_deref(), + declared.deprecated_warn_at + ); + assert_eq!( + parsed.deprecated_remove_at.as_deref(), + declared.deprecated_remove_at + ); + assert_eq!(parsed.optional, declared.optional); + + // A doc comment becomes both: the first paragraph is the short help, and the *whole* + // comment is the long one — the same `doc_comment` helper the `Cli` derive uses, so a + // setting's prose and a flag's are split the same way. + assert_eq!(parsed.help.as_deref(), Some("Stop at the first failure")); + assert_eq!( + parsed.long_help.as_deref(), + Some("Stop at the first failure\n\nWhether a failing job stops the rest of them.") + ); + assert_eq!(parsed.long_help.as_deref(), declared.long_help); +} + +/// A CLI whose settings-bound flag is deprecated. +#[derive(Cli, Debug)] +#[usage(bin = "dep", version = "2.0.0", config = Settings)] +struct Deprecated { + /// How many jobs to run at once + #[usage(long, setting = "jobs", deprecated = "use --parallel")] + jobs: Option, +} + +#[test] +fn the_settings_entry_reports_the_deprecations_a_parse_used() { + // `parse` collects deprecations and prints them; `parse_with_settings` is the same kind of + // thing — an entry point that *is* the process — so it has to say the same. It read the + // partial and threw the warnings away, so a CLI that adopted settings went quiet about + // every deprecation it had, which is the sort of difference nobody notices until a + // release removes the flag. + let argv = ["dep", "--jobs", "4"].map(OsStr::new); + let mut warnings = Vec::new(); + let (parsed, layer) = + Deprecated::parse_from_argv_with_settings_and_warnings(&argv, &mut warnings) + .expect("parses"); + assert_eq!(parsed.jobs, Some(4)); + assert_eq!( + warnings.len(), + 1, + "the deprecated flag this parse used should be reported: {warnings:?}" + ); + + // The layer is still the layer: collecting warnings does not change what argv bound. + let resolved = + resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); + assert_eq!(Settings::read(&resolved).expect("reads").jobs, 4); + + // And a caller that does not ask walks no tree and gets no warnings, which is why the + // collecting form is a separate entry rather than the only one. + Deprecated::parse_from_argv_with_settings(&argv).expect("parses"); +} + +/// A setting nothing declares a value for: not an `Option`, and no default. +#[derive(Config, Debug, PartialEq)] +struct Required { + /// The token every request needs + #[usage(env = "EX_TOKEN")] + token: String, +} + +#[test] +fn a_setting_nothing_defaults_says_it_is_required() { + // Leaving `optional` unset invited the reader's own inference — "no default means + // optional" — while `read` reports the key as missing. Docs, the JSON schema and the + // `config_keys` completer all read the registry, so the registry has to state what + // `read` will do rather than let each consumer guess. + assert_eq!(Required::SETTINGS_PROPS[0].optional, Some(false)); + assert_eq!( + Settings::SETTINGS_PROPS[0].optional, + None, + "a setting with a declared default always has one, and inference agrees" + ); + + // Through the spec too, because that is the copy a docs page reads. + let kdl = format!("name \"ex\"\nbin \"ex\"\n{}", Required::spec_kdl()); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the emitted block"); + assert_eq!( + spec.config.props.get("token").expect("declared").optional, + Some(false) + ); + + // And the reader says the same thing: nothing supplied it, so it is missing. + let resolved = resolve(Required::SETTINGS_REGISTRY, Layers::new()).expect("resolves"); + Required::read(&resolved).expect_err("a required setting nothing supplied"); + + let env = EnvLayer::new([("EX_TOKEN".to_string(), "abc".to_string())]); + let resolved = + resolve(Required::SETTINGS_REGISTRY, Layers::new().then(&env)).expect("resolves"); + assert_eq!( + Required::read(&resolved).expect("supplied"), + Required { + token: "abc".to_string() + } + ); +} + +/// A tool with settings, declaring which type holds them. +#[derive(Cli, Debug)] +#[usage(bin = "ex", config = Settings)] +struct Ex { + /// How many jobs to run at once + #[usage(long, short = 'j', setting = "jobs")] + jobs: Option, +} + +#[test] +fn the_flags_this_cli_reads_are_the_flags_its_settings_declare() { + // The one-line adopter test, now with both sides generated: the bindings from the `Cli` + // derive, the registry from the `Config` derive. + assert_eq!( + Settings::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS), + Vec::::new() + ); +} + +#[test] +fn the_clis_spec_carries_its_settings() { + let kdl = Ex::to_kdl(); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the whole spec"); + assert_eq!(spec.bin, "ex"); + assert!( + spec.config.props.contains_key("task.output"), + "the config block rides in the emitted spec: {kdl}" + ); +} + +#[test] +fn the_spec_endpoint_answers_with_the_settings_too() { + // `to_kdl` is where the config block is appended and also what the `__usage_spec__` + // endpoint answers with, so the point of the endpoint — that a tool can ask *any* usage + // binary what it is — has to include what it can be configured with. Worth its own test + // because `to_kdl` now has two independent appenders, this one and `spec_extra`, and they + // write to the same string. + let request = [OsStr::new(usage_argv::SPEC_REQUEST)]; + let answered = Ex::spec_request(&request).expect("the endpoint answers"); + let spec: usage::Spec = answered.parse().expect("usage-lib reads what it answered"); + assert!( + spec.config.props.contains_key("task.output"), + "the endpoint's answer carries the settings: {answered}" + ); + assert_eq!( + answered, + Ex::to_kdl(), + "the endpoint answers the whole spec" + ); + + // And an ordinary command line is not a request, so the endpoint does not shadow a parse. + assert!(Ex::spec_request(&[OsStr::new("--jobs"), OsStr::new("4")]).is_none()); +} + +#[test] +fn the_command_line_outranks_every_other_layer() { + let argv = [OsStr::new("-j"), OsStr::new("12")]; + let (_, cli) = Ex::parse_from_with_settings(&argv).expect("parses"); + let env = EnvLayer::new([("EX_JOBS".to_string(), "9".to_string())]); + let resolved = resolve( + Settings::SETTINGS_REGISTRY, + Layers::new().then(&cli).then(&env), + ) + .expect("resolves"); + let settings = Settings::read(&resolved).expect("reads"); + assert_eq!(settings.jobs, 12); + assert_eq!( + resolved.get_key("jobs"), + Some(&Value::Int(12)), + "and the registry agrees with the struct" + ); +} + +#[test] +fn the_argv_entry_strips_argv0_and_returns_the_layer() { + // What a fleet `main` calls: the process-shaped argv with the settings beside the + // struct. `parse_with_settings` wraps this with the same help/version/exit behaviour + // as `parse`, which a test cannot hold without leaving the process. + let argv = [OsStr::new("ex"), OsStr::new("-j"), OsStr::new("7")]; + let (cli, layer) = Ex::parse_from_argv_with_settings(&argv).expect("parses"); + assert_eq!(cli.jobs, Some(7)); + let resolved = + resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); + assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(7))); +} + +/// A tool whose only spelling for a bound flag is the negation. +/// +/// tak's `--no-credit`: the setting defaults to true and the command line can only turn it +/// off. The regression here was in view-aware codegen — a global flag's selector list was +/// built from longs and shorts alone, so a negate-only flag produced an empty `matches!` +/// that did not parse. +#[derive(Cli, Debug)] +#[usage(bin = "negonly")] +struct NegOnly { + /// Leave the credit line off + // clap's SetFalse spelling: the single long becomes the negative spelling, so this + // flag's *only* selector is the negation. + #[arg(long = "no-credit", action = clap::ArgAction::SetFalse, global = true)] + #[usage(default = "true", setting = "credit")] + credit: bool, + + #[usage(subcommand)] + command: Option, +} + +#[derive(usage_derive::Subcommands, Debug)] +enum NegOnlyCommands { + /// Run it + Run, +} + +#[test] +fn a_negate_only_global_flag_still_binds_its_setting() { + static PROPS: &[usage_config::PropMeta] = &[usage_config::PropMeta { + default: Some(Const::Bool(true)), + cli: &["--no-credit"], + ..usage_config::PropMeta::new("credit", Ty::Bool) + }]; + const REGISTRY: usage_config::Registry = usage_config::Registry::new(PROPS); + assert_eq!( + REGISTRY.drift(NegOnly::SETTINGS_BINDINGS), + Vec::::new() + ); + + let argv = [OsStr::new("--no-credit")]; + let (cli, layer) = NegOnly::parse_from_with_settings(&argv).expect("parses"); + assert!(!cli.credit); + let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves"); + assert_eq!(resolved.get_key("credit"), Some(&Value::Bool(false))); + + // And left off, the default stands: absence is not a value. + let (cli, layer) = NegOnly::parse_from_with_settings(&[]).expect("parses"); + assert!(cli.credit); + assert!(layer.is_empty()); +} + +#[test] +fn a_negate_only_flag_is_not_named_twice_in_help() { + // `no-credit: --no-credit` — the declared name of a `SetFalse` flag is its negation, so + // the `name:` prefix help adds for a name the forms do not imply only repeats it. + let page = + usage_argv::help::render(NegOnly::spec(), NegOnly::spec().root.cmd, false).expect("page"); + assert!(page.contains("--no-credit"), "{page}"); + assert!(!page.contains("no-credit: --no-credit"), "{page}"); +} diff --git a/corpus/config/README.md b/corpus/config/README.md index 07f3682ba..0ca95aa4e 100644 --- a/corpus/config/README.md +++ b/corpus/config/README.md @@ -19,8 +19,8 @@ about a spec. A resolution is not: it is a question about a **registry** — key types, defaults, merge policies — which a CLI's build step produces from its spec long before anything is resolved. -So a vector describes the registry directly in KDL. How a spec becomes a registry -is `usage-config-build`'s question, and its own tests answer it. +So a vector describes the registry directly in KDL. How a _declaration_ becomes a +registry is the `usage::Config` derive's question, and its own tests answer it. ## Format diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index bc1cbf902..85e768ea7 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -262,6 +262,94 @@ pub fn emit(cli: &Cli) -> TokenStream { // see another struct's fields. A root that does neither gets the compile-time guard instead of // the layer, so a group's binding cannot go quietly uncollected. let resolves = cli.fields.iter().any(|f| f.setting.is_some()) || cli.settings; + let config = crate::config::config_path(); + // A root that names its settings type gets a spec carrying the `config` block, appended + // after the command tree: KDL nodes are read by name, so position is presentation. Written + // as an append rather than as the whole body so that it composes with `spec_extra`, which + // appends to the same string — a root can have both. + let config_extra = match &cli.config { + Some(ty) => quote! { + __usage_kdl.push_str(&#config::spec_kdl(<#ty>::SETTINGS_PROPS)); + }, + None => TokenStream::new(), + }; + // The argv collection every process entry shares: the full refs for parsing, the + // view- or multicall-rewritten words for help routing, and the selected view. + // Before the preamble, which interpolates the intercept. + let (spec_endpoint, spec_endpoint_intercept) = spec_endpoint_fns(cli); + let parse_preamble = quote! { + let __usage_all: ::std::vec::Vec<::std::ffi::OsString> = + ::std::env::args_os().collect(); + let __usage_all_refs: ::std::vec::Vec<&::std::ffi::OsStr> = + __usage_all.iter().map(|a| a.as_os_str()).collect(); + // In the shared preamble rather than in `parse` alone: the point of the + // endpoint is that *any* usage binary answers `__usage_spec__`, so a CLI + // that resolves settings has to answer it too. + #spec_endpoint_intercept + let __usage_raw: ::std::vec::Vec<::std::ffi::OsString> = + if let ::std::option::Option::Some((__usage_argv0, __usage_words)) = + __usage_all_refs.split_first() + { + if let ::std::option::Option::Some(__usage_view) = + usage_argv::spec::view_for_program(&SPEC, __usage_argv0) + { + if __usage_words.first().is_some_and(|word| { + usage_argv::is_version_arg(SPEC.root.cmd, word) + }) + { + __usage_words + .iter() + .map(|word| (*word).to_os_string()) + .collect() + } else { + __usage_view + .root + .split_ascii_whitespace() + .map(::std::ffi::OsString::from) + .chain( + __usage_words + .iter() + .map(|word| (*word).to_os_string()), + ) + .collect() + } + } else if SPEC.multicall { + let mut __usage_words: ::std::vec::Vec<::std::ffi::OsString> = + __usage_words + .iter() + .map(|word| (*word).to_os_string()) + .collect(); + if let ::std::option::Option::Some(__usage_word) = + __usage_argv0.to_str().and_then(|s| { + usage_argv::multicall_applet(s, SPEC.name, SPEC.bin) + }) + { + __usage_words.insert( + 0, + ::std::ffi::OsString::from(__usage_word), + ); + } + __usage_words + } else { + __usage_words + .iter() + .map(|word| (*word).to_os_string()) + .collect() + } + } else { + ::std::vec::Vec::new() + }; + let __usage_argv: ::std::vec::Vec<&::std::ffi::OsStr> = + __usage_raw.iter().map(|a| a.as_os_str()).collect(); + let __usage_selected_view = __usage_all + .first() + .and_then(|argv0| usage_argv::spec::view_for_program(&SPEC, argv0)); + // This is the entry point that *is* the process — it already exits for a help + // request — so it answers a failure the way a command-line program does: + // the message on stderr, and a non-zero status. `parse_from` hands the error + // back instead, for a library embedding this that wants to decide. + }; + let parts = settings(cli); // Only the layer calls it, so a root that has children and no settings of its own emits // neither: the guard below is what speaks for that case. @@ -270,7 +358,7 @@ pub fn emit(cli: &Cli) -> TokenStream { .as_ref() .filter(|_| resolves) .map(|s| s.bindings.clone()); - let settings_layer = resolves.then(settings_layer); + let settings_layer = resolves.then(|| settings_layer(&config)); let settings_guard = (!resolves).then(|| settings_guard(cli)).flatten(); // The name an adopter uses, forwarding to the one inside the const block, which is where // the table that reads it lives. @@ -305,7 +393,37 @@ pub fn emit(cli: &Cli) -> TokenStream { pub fn parse_from_with_settings<'v>( argv: &[&'v ::std::ffi::OsStr], ) -> ::std::result::Result< - (Self, ::usage_config::CliLayer), + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_with_settings(argv, ::std::option::Option::None) + } + + /// [`Self::parse_from_with_settings`], collecting the deprecations it used. + /// + /// A settings adopter renders these through its own logging rather than to raw + /// stderr, which is the whole reason the collecting form exists. + pub fn parse_from_with_settings_and_warnings<'v>( + argv: &[&'v ::std::ffi::OsStr], + warnings: &mut ::std::vec::Vec>, + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_with_settings( + argv, + ::std::option::Option::Some(warnings), + ) + } + + #[doc(hidden)] + fn __usage_parse_from_with_settings<'v>( + argv: &[&'v ::std::ffi::OsStr], + __usage_warnings: ::std::option::Option< + &mut ::std::vec::Vec>, + >, + ) -> ::std::result::Result< + (Self, #config::CliLayer), usage_argv::Error<'static, 'v>, > { // The layer from what argv left, and only then the rest: `check` fills a field @@ -317,6 +435,9 @@ pub fn emit(cli: &Cli) -> TokenStream { read_argv_into(Self::command(), argv, &mut partial)?; let __usage_settings = settings_layer(&partial); check(&mut partial)?; + if let ::std::option::Option::Some(__usage_out) = __usage_warnings { + Self::__usage_deprecations(&partial, __usage_out); + } let __usage_built = #built; ::std::result::Result::Ok((__usage_built, __usage_settings)) } @@ -326,7 +447,6 @@ pub fn emit(cli: &Cli) -> TokenStream { let post = post_binding(cli); let (completion, completion_intercept) = completion_fns(cli); let spec_extra = spec_extra_append(cli); - let (spec_endpoint, spec_endpoint_intercept) = spec_endpoint_fns(cli); // `field: local` rather than the shorthand, because the locals are prefixed: // a field called `text` or `parser` would otherwise collide with something the // generated code needs. @@ -366,6 +486,144 @@ pub fn emit(cli: &Cli) -> TokenStream { built_value(cli, view_sub_build, &view_field_finals) }; + // The process entry with a settings layer beside it, for a CLI that binds settings and + // resolves them at startup — the fleet's `main` shape. Same help, version, and error + // behaviour as `parse`, through the same shared renderer. + let settings_parse_entry = settings_bindings.as_ref().map(|_| { + quote! { + /// Parse a full argv, including the program name, and the settings it + /// gave values for. + /// + /// The `parse_from_argv` counterpart of [`Self::parse_from_with_settings`]: + /// argv0 is stripped, views and multicall applets are honoured, and the + /// layer is built from what the parser saw. + pub fn parse_from_argv_with_settings<'v>( + argv: &[&'v ::std::ffi::OsStr], + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_argv_with_settings( + argv, + ::std::option::Option::None, + ) + } + + /// [`Self::parse_from_argv_with_settings`], collecting the deprecations it used. + pub fn parse_from_argv_with_settings_and_warnings<'v>( + argv: &[&'v ::std::ffi::OsStr], + warnings: &mut ::std::vec::Vec>, + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_argv_with_settings( + argv, + ::std::option::Option::Some(warnings), + ) + } + + #[doc(hidden)] + fn __usage_parse_from_argv_with_settings<'v>( + argv: &[&'v ::std::ffi::OsStr], + __usage_warnings: ::std::option::Option< + &mut ::std::vec::Vec>, + >, + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + let ::std::option::Option::Some((__usage_argv0, __usage_words)) = + argv.split_first() + else { + return Self::__usage_parse_from_with_settings(&[], __usage_warnings); + }; + if let ::std::option::Option::Some(__usage_view) = + usage_argv::spec::view_for_program(&SPEC, __usage_argv0) + { + let mut __usage_rewritten = ::std::vec::Vec::with_capacity( + __usage_words.len() + + __usage_view.root.split_ascii_whitespace().count(), + ); + __usage_rewritten.extend( + __usage_view.root + .split_ascii_whitespace() + .map(::std::ffi::OsStr::new), + ); + __usage_rewritten.extend_from_slice(__usage_words); + #defaults + read_argv_into_view( + Self::command(), + &__usage_rewritten, + &mut partial, + ::std::option::Option::Some(__usage_view), + )?; + // Before `check`, for the same reason `parse_from_with_settings` is: + // the layer holds what argv gave, not what the environment filled. + let __usage_settings = settings_layer(&partial); + check_with_view( + &mut partial, + ::std::option::Option::Some(__usage_view), + )?; + if let ::std::option::Option::Some(__usage_out) = __usage_warnings { + Self::__usage_deprecations(&partial, __usage_out); + } + return ::std::result::Result::Ok((#built_for_view, __usage_settings)); + } + if SPEC.multicall { + if let ::std::option::Option::Some(__usage_word) = + __usage_argv0.to_str().and_then(|s| { + usage_argv::multicall_applet(s, SPEC.name, SPEC.bin) + }) + { + let mut __usage_rewritten = + ::std::vec::Vec::with_capacity(argv.len()); + __usage_rewritten.push(::std::ffi::OsStr::new(__usage_word)); + __usage_rewritten.extend_from_slice(__usage_words); + return Self::__usage_parse_from_with_settings( + &__usage_rewritten, + __usage_warnings, + ); + } + } + Self::__usage_parse_from_with_settings(__usage_words, __usage_warnings) + } + + /// Parse the process's own arguments, and the settings they gave values for. + /// + /// [`Self::parse`] with the layer beside the struct: it prints a help page or + /// a version and leaves, and renders a failure to stderr with exit 2. + pub fn parse_with_settings() -> (Self, #config::CliLayer) { + #completion_intercept + #parse_preamble + // The same as `parse`: this is an entry point that *is* the process, so it is + // one of the two that may write to stderr, and a failure prints nothing about + // deprecations because what the user typed did not run. + let mut __usage_warnings = ::std::vec::Vec::new(); + match Self::__usage_parse_from_argv_with_settings( + &__usage_all_refs, + ::std::option::Option::Some(&mut __usage_warnings), + ) { + ::std::result::Result::Ok(__usage_parsed) => { + if !__usage_warnings.is_empty() { + ::std::eprint!( + "{}", + usage_argv::render_warnings(&__usage_warnings), + ); + } + __usage_parsed + } + ::std::result::Result::Err(e) => Self::__usage_exit_on_error( + e, + &__usage_all_refs, + &__usage_argv, + __usage_selected_view, + ), + } + } + } + }); + let min_usage_version = option_str(cli.min_usage_version.as_deref()); let views: Vec<_> = cli .views @@ -852,6 +1110,7 @@ pub fn emit(cli: &Cli) -> TokenStream { pub fn to_kdl() -> ::std::string::String { #[allow(unused_mut)] let mut __usage_kdl = SPEC.to_kdl(); + #config_extra #spec_extra __usage_kdl } @@ -1045,73 +1304,7 @@ pub fn emit(cli: &Cli) -> TokenStream { pub fn parse() -> Self { #completion_intercept - let __usage_all: ::std::vec::Vec<::std::ffi::OsString> = - ::std::env::args_os().collect(); - let __usage_all_refs: ::std::vec::Vec<&::std::ffi::OsStr> = - __usage_all.iter().map(|a| a.as_os_str()).collect(); - #spec_endpoint_intercept - let __usage_raw: ::std::vec::Vec<::std::ffi::OsString> = - if let ::std::option::Option::Some((__usage_argv0, __usage_words)) = - __usage_all_refs.split_first() - { - if let ::std::option::Option::Some(__usage_view) = - usage_argv::spec::view_for_program(&SPEC, __usage_argv0) - { - if __usage_words.first().is_some_and(|word| { - usage_argv::is_version_arg(SPEC.root.cmd, word) - }) - { - __usage_words - .iter() - .map(|word| (*word).to_os_string()) - .collect() - } else { - __usage_view - .root - .split_ascii_whitespace() - .map(::std::ffi::OsString::from) - .chain( - __usage_words - .iter() - .map(|word| (*word).to_os_string()), - ) - .collect() - } - } else if SPEC.multicall { - let mut __usage_words: ::std::vec::Vec<::std::ffi::OsString> = - __usage_words - .iter() - .map(|word| (*word).to_os_string()) - .collect(); - if let ::std::option::Option::Some(__usage_word) = - __usage_argv0.to_str().and_then(|s| { - usage_argv::multicall_applet(s, SPEC.name, SPEC.bin) - }) - { - __usage_words.insert( - 0, - ::std::ffi::OsString::from(__usage_word), - ); - } - __usage_words - } else { - __usage_words - .iter() - .map(|word| (*word).to_os_string()) - .collect() - } - } else { - ::std::vec::Vec::new() - }; - let __usage_argv: ::std::vec::Vec<&::std::ffi::OsStr> = - __usage_raw.iter().map(|a| a.as_os_str()).collect(); - let __usage_selected_view = __usage_all - .first() - .and_then(|argv0| usage_argv::spec::view_for_program(&SPEC, argv0)); - // This is the entry point that *is* the process — it already exits for a help - // request — so it answers a failure the way a command-line program does: - // the message on stderr, and a non-zero status. `parse_from` hands the error - // back instead, for a library embedding this that wants to decide. + #parse_preamble // Collected here rather than printed where they are found: `parse` is the // entry point that *is* the process, so it is the one that may write to // stderr. A failure prints nothing about deprecations — the error is what @@ -1130,8 +1323,36 @@ pub fn emit(cli: &Cli) -> TokenStream { } parsed } + ::std::result::Result::Err(e) => Self::__usage_exit_on_error( + e, + &__usage_all_refs, + &__usage_argv, + __usage_selected_view, + ), + } + } + + #settings_parse_entry + + /// Render a parse failure the way the process entry does, and leave. + /// + /// This is reached only from an entry point that *is* the process — it + /// already exits for a help request — so it answers a failure the way a + /// command-line program does: the message on stderr, and a non-zero status. + /// `parse_from` hands the error back instead, for a library embedding this + /// that wants to decide. One copy, shared by `parse` and by + /// `parse_with_settings` when settings are bound. + fn __usage_exit_on_error<'v>( + __usage_error: usage_argv::Error<'static, 'v>, + __usage_all_refs: &[&'v ::std::ffi::OsStr], + __usage_argv: &[&'v ::std::ffi::OsStr], + __usage_selected_view: ::std::option::Option< + &usage_argv::spec::ViewMeta<'static>, + >, + ) -> ! { + match __usage_error { // Not failures: someone asked a question, and the answer goes to stdout. - ::std::result::Result::Err(usage_argv::Error::Version { long }) => { + usage_argv::Error::Version { long } => { #runtime_program_for_version let __usage_bin = __usage_selected_view .map(|view| view.bin) @@ -1144,7 +1365,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::println!("{__usage_bin} {__usage_version}"); usage_argv::__usage_process_exit(0); } - ::std::result::Result::Err(usage_argv::Error::Help { cmd, long }) => { + usage_argv::Error::Help { cmd, long } => { #effective_spec let __usage_want = if long { usage_argv::help::Page::Long @@ -1161,7 +1382,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::option::Option::None => usage_argv::__usage_process_exit(0), } } - ::std::result::Result::Err(usage_argv::Error::MissingArgsHelp { cmd }) => { + usage_argv::Error::MissingArgsHelp { cmd } => { #effective_spec let __usage_want = usage_argv::help::Page::Short; #render_page_stderr @@ -1173,7 +1394,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::option::Option::None => usage_argv::__usage_process_exit(2), } } - ::std::result::Result::Err(usage_argv::Error::HelpAll { cmd }) => { + usage_argv::Error::HelpAll { cmd } => { #effective_spec let __usage_want = usage_argv::help::Page::All; #render_page @@ -1185,7 +1406,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::option::Option::None => usage_argv::__usage_process_exit(0), } } - ::std::result::Result::Err(e) => { + e => { #effective_spec let __usage_failure = match __usage_selected_view { ::std::option::Option::Some(view) => { @@ -2781,6 +3002,7 @@ fn view_field_active(field: &Field) -> TokenStream { longs, hidden_longs, shorts, + negate, global: true, .. } = &field.kind @@ -2790,17 +3012,27 @@ fn view_field_active(field: &Field) -> TokenStream { let long_selectors = longs .iter() .chain(hidden_longs) + // The negation is a spelling of the same flag, and for a negative-only flag it is + // the *only* one: leaving it out made the selector list empty, and an empty + // `matches!` does not parse. + .chain(negate.iter()) .map(|long| format!("--{long}")); let short_selectors = shorts.iter().map(|short| format!("-{short}")); let selectors: Vec = long_selectors.chain(short_selectors).collect(); + let named = if selectors.is_empty() { + quote!(false) + } else { + quote! { + __usage_view.globals.iter().any(|__usage_selector| { + matches!(*__usage_selector, #(#selectors)|*) + }) + } + }; quote! { match __usage_view { ::std::option::Option::None => true, ::std::option::Option::Some(__usage_view) => { - __usage_view.all_globals - || __usage_view.globals.iter().any(|__usage_selector| { - matches!(*__usage_selector, #(#selectors)|*) - }) + __usage_view.all_globals || #named } } } @@ -2815,6 +3047,7 @@ fn field_active_in_view(field: &Field, view: &ViewDecl) -> bool { longs, hidden_longs, shorts, + negate, global: true, .. } = &field.kind @@ -2825,6 +3058,7 @@ fn field_active_in_view(field: &Field, view: &ViewDecl) -> bool { || longs .iter() .chain(hidden_longs) + .chain(negate.iter()) .map(|long| format!("--{long}")) .chain(shorts.iter().map(|short| format!("-{short}"))) .any(|selector| view.globals.contains(&selector)) @@ -3743,31 +3977,31 @@ fn settings(cli: &Cli) -> Option { /// /// One loop over what every command contributed, so a flattened group's value and the root's own /// become entries the same way. A second conversion is the thing this whole stack keeps deleting. -fn settings_layer() -> TokenStream { +fn settings_layer(config: &TokenStream) -> TokenStream { quote! { /// This command line as a layer, for `usage_config::resolve`. - pub fn settings_layer(partial: &Partial) -> ::usage_config::CliLayer { - let mut __usage_layer = ::usage_config::CliLayer::new( + pub fn settings_layer(partial: &Partial) -> #config::CliLayer { + let mut __usage_layer = #config::CliLayer::new( ::std::iter::empty::<(::std::string::String, ::std::string::String)>(), ); for (__usage_key, __usage_given) in settings_given(partial) { __usage_layer = match __usage_given { usage_argv::spec::SettingGiven::Bool(__usage_value) => { - __usage_layer.with_value(__usage_key, ::usage_config::Value::Bool(__usage_value)) + __usage_layer.with_value(__usage_key, #config::Value::Bool(__usage_value)) } usage_argv::spec::SettingGiven::Int(__usage_value) => { - __usage_layer.with_value(__usage_key, ::usage_config::Value::Int(__usage_value)) + __usage_layer.with_value(__usage_key, #config::Value::Int(__usage_value)) } usage_argv::spec::SettingGiven::Text(__usage_value) => { __usage_layer - .with_value(__usage_key, ::usage_config::Value::String(__usage_value)) + .with_value(__usage_key, #config::Value::String(__usage_value)) } usage_argv::spec::SettingGiven::List(__usage_items) => __usage_layer.with_value( __usage_key, - ::usage_config::Value::List( + #config::Value::List( __usage_items .into_iter() - .map(::usage_config::Value::String) + .map(#config::Value::String) .collect(), ), ), diff --git a/derive/src/config.rs b/derive/src/config.rs new file mode 100644 index 000000000..1ccb364e8 --- /dev/null +++ b/derive/src/config.rs @@ -0,0 +1,1969 @@ +//! `#[derive(usage::Config)]`: a settings struct as the declaration of its own registry. +//! +//! The fleet's pattern — a `settings.toml` registry, a `build.rs` generator, and a settings +//! struct the generator emits — keeps three descriptions of every setting in step by hand. +//! This derive collapses them to one: the author writes the struct the CLI already holds its +//! settings in, and the derive generates the `usage_config` registry, the reader that fills +//! the struct from a resolution, and the spec `config` block that documents it. There is no +//! second declaration left to drift from. +//! +//! ```ignore +//! /// How this tool behaves, resolved from flags, the environment, and files. +//! #[derive(usage::Config)] +//! struct Settings { +//! /// How many jobs to run at once +//! #[usage(env = "EX_JOBS", default = 4, cli("--jobs", "-j"))] +//! jobs: u64, +//! +//! /// Paths to leave alone +//! #[usage(env = "EX_EXCLUDE", merge = "union", parse = "list_by_comma")] +//! exclude: Option>, +//! +//! /// Where the cache lives +//! #[usage(env = "EX_CACHE_DIR", default_fn = default_cache_dir, +//! default_note = "under the user cache directory")] +//! cache_dir: std::path::PathBuf, +//! +//! #[usage(flatten)] +//! task: TaskSettings, +//! } +//! +//! /// The `task.*` settings. +//! #[derive(usage::Config)] +//! #[usage(prefix = "task")] +//! struct TaskSettings { +//! /// How task output is interleaved +//! #[usage(default = "prefix", choices("prefix", "interleave"))] +//! output: String, +//! } +//! ``` +//! +//! The struct's field types are the declaration's types: `bool`, integers, `String`, +//! `PathBuf`, `Vec`, `BTreeMap`, and `Option` for a setting that may be +//! absent. A field is read with `usage_config::FromValue`, so a type this table does not +//! name can still be a field by saying what the spec should call it: `ty = "duration"` on a +//! `String` field holds a span of time the way the registry declares one. +//! +//! Nesting composes through `usage_config::Props`, the way `usage::Cli` composes flattened +//! groups: a child declares its own props (usually under a `prefix`), and the parent joins +//! the slices at compile time. Two groups declaring the same key are refused at compile +//! time by the join. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::spanned::Spanned; +use syn::{Data, DeriveInput, Expr, ExprLit, Fields, Lit, Meta, UnOp}; + +use crate::crate_name::{crate_name, FoundCrate}; +use crate::model::{attrs, doc_comment, flag_value, ident_of, nested, string_value}; + +/// The config crate as the adopter depended on it. +/// +/// The same resolution the other generated paths use: a direct `usage-config` dependency +/// wins, otherwise the `usage-rs` facade provides it as `usage::config`, and the bare name +/// is left to produce the useful compiler error when neither was declared. +pub(crate) fn config_path() -> TokenStream { + match crate_name("usage-config") { + Ok(FoundCrate::Itself) => quote!(::usage_config), + Ok(FoundCrate::Name(name)) => { + let config = format_ident!("{}", name.replace('-', "_")); + quote!(::#config) + } + _ => match crate_name("usage-rs") { + Ok(FoundCrate::Itself) => quote!(::usage_rs::config), + Ok(FoundCrate::Name(name)) => { + let facade = format_ident!("{}", name.replace('-', "_")); + quote!(::#facade::config) + } + _ => quote!(::usage_config), + }, + } +} + +/// The declaration, read from the struct. +pub struct Config { + ident: syn::Ident, + fields: Vec, +} + +enum Field { + Prop(Box), + /// Another `Config` struct whose props follow this position in the joined registry. + Flatten { + ident: syn::Ident, + ty: Box, + }, +} + +struct Prop { + ident: syn::Ident, + key: String, + ty: Ty, + /// The type the field holds and the fold reads, `Option` peeled. + read_ty: syn::Type, + /// Whether the field is an `Option` — absence is a legitimate state. + optional_field: bool, + default: Option, + /// A runtime default: `fn() -> T`, applied after the fold when no layer supplied one. + default_fn: Option, + default_note: Option, + envs: Vec, + deprecated_envs: Vec, + cli: Vec, + aliases: Vec, + /// `(kind, key)` pairs, in declaration order. + bindings: Vec<(String, String)>, + choices: Vec, + merge: Option, + scope: Option, + parse: Option, + hide: bool, + deprecated: Option, + deprecated_warn_at: Option, + deprecated_remove_at: Option, + since: Option, + examples: Vec, + help: Option, + long_help: Option, +} + +#[derive(Clone, Copy, PartialEq)] +enum Merge { + Union, + Deep, +} + +#[derive(Clone, Copy, PartialEq)] +enum Scope { + Global, + Env, +} + +/// The declared type, mirroring `usage_config::Ty`. +#[derive(Clone, PartialEq)] +enum Ty { + Bool, + Int, + Uint, + Float, + String, + Path, + Url, + Duration, + Object, + Any, + List(Box), + Set(Box), + Map(Box), +} + +impl Ty { + fn tokens(&self, config: &TokenStream) -> TokenStream { + match self { + Self::Bool => quote!(#config::Ty::Bool), + Self::Int => quote!(#config::Ty::Int), + Self::Uint => quote!(#config::Ty::Uint), + Self::Float => quote!(#config::Ty::Float), + Self::String => quote!(#config::Ty::String), + Self::Path => quote!(#config::Ty::Path), + Self::Url => quote!(#config::Ty::Url), + Self::Duration => quote!(#config::Ty::Duration), + Self::Object => quote!(#config::Ty::Object), + Self::Any => quote!(#config::Ty::Any), + Self::List(inner) => { + let inner = inner.tokens(config); + quote!(#config::Ty::List(&#inner)) + } + Self::Set(inner) => { + let inner = inner.tokens(config); + quote!(#config::Ty::Set(&#inner)) + } + Self::Map(inner) => { + let inner = inner.tokens(config); + quote!(#config::Ty::Map(&#inner)) + } + } + } + + /// Whether values of this type are a collection, which is what `merge` policies and + /// named parsers are about. + fn is_collection(&self) -> bool { + matches!( + self, + Self::List(_) | Self::Set(_) | Self::Map(_) | Self::Object + ) + } + + /// Whether `value` could be a value of this type, where the declaration puts it. + /// + /// Permissive exactly where something coerces, which is why the position matters: the + /// merge coerces what a *layer* supplies, and `Registry`'s choice comparison coerces + /// before it compares — but nothing coerces a declared default. + fn admits(&self, value: &Const, position: Position) -> bool { + let coerced = position == Position::Choice; + match (self, value) { + (Self::Any, _) => true, + (Self::Bool, Const::Bool(_)) => true, + (Self::Int | Self::Float, Const::Int(_)) => true, + // A `uint` names a non-negative number, so `default = -1` on a `u64` field + // compiled and then failed every `read` with a type error the author could do + // nothing about. The span is here, so refuse it here. + (Self::Uint, Const::Int(i)) => *i >= 0, + (Self::Float, Const::Float(_)) => true, + (Self::String | Self::Path | Self::Url | Self::Duration, Const::Str(_)) => true, + // A spec's `type="string"` does read a bare number as its text, and the merge + // coerces one — but a *declaration written in Rust* has no reason to spell a + // string as anything but a string. Holding both a default and a choice to that + // means one spelling for both, which is what lets them be compared as written: + // the alternative is a third implementation of "how a float is written", the + // drift `config_value_display.rs` exists to catch. + ( + Self::String | Self::Path | Self::Url | Self::Duration, + Const::Bool(_) | Const::Int(_) | Const::Float(_), + ) => false, + (Self::List(item) | Self::Set(item), Const::List(items)) => { + items.iter().all(|value| item.admits(value, position)) + } + // A choice on a list setting names what one *item* may be, which is how the + // registry compares it. A default is the whole value, and `default(80)` is how + // the attribute already spells a list of one — so a bare `default = 80` on a + // `Vec` is refused rather than quietly meaning something else. + (Self::List(item) | Self::Set(item), scalar) => { + coerced && item.admits(scalar, position) + } + _ => false, + } + } +} + +/// Where in a declaration a constant stands, which decides how strictly it is read. +#[derive(Clone, Copy, PartialEq)] +enum Position { + /// A declared default. The resolver seeds it with `Const::to_value` and hands it to the + /// field as it stands, so it has to already be a value of the field's type. + Default, + /// One of the values a setting allows, compared against a resolved value *after* the + /// merge has coerced both. + Choice, +} + +/// A literal a registry can hold as a `const`. +#[derive(Clone, PartialEq)] +enum Const { + Bool(bool), + Int(i64), + Float(f64), + Str(String), + List(Vec), +} + +impl Const { + fn tokens(&self, config: &TokenStream) -> TokenStream { + match self { + Self::Bool(b) => quote!(#config::Const::Bool(#b)), + Self::Int(i) => quote!(#config::Const::Int(#i)), + Self::Float(f) => quote!(#config::Const::Float(#f)), + Self::Str(s) => quote!(#config::Const::Str(#s)), + Self::List(items) => { + let items = items.iter().map(|item| item.tokens(config)); + quote!(#config::Const::List(&[#(#items),*])) + } + } + } +} + +impl Config { + pub fn from_input(input: &DeriveInput) -> syn::Result { + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "usage::Config describes a settings struct, so it needs a struct with named \ + fields", + )); + }; + let Fields::Named(named) = &data.fields else { + return Err(syn::Error::new_spanned( + &data.fields, + "usage::Config reads settings into named fields; a tuple or unit struct has \ + nowhere to put them", + )); + }; + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "usage::Config does not support generic parameters: the generated registry \ + is `const` and every field has to be a concrete type a value can be read \ + into", + )); + } + + let mut prefix = None; + for attr in attrs(&input.attrs) { + for meta in nested(attr)? { + match ident_of(meta.path()).as_str() { + "prefix" => prefix = Some(string_value(&meta)?), + other => { + return Err(syn::Error::new_spanned( + meta.path(), + format!("usage::Config does not understand `{other}` on the struct"), + )); + } + } + } + } + + let mut fields = Vec::new(); + for field in &named.named { + fields.push(Field::from_field(field, prefix.as_deref())?); + } + + // A duplicate within one struct is visible here, so it is refused here, with spans. + // Duplicates across flattened structs are refused by `concat_props` at compile time. + // + // Aliases are names too: `Registry::lookup` checks keys and aliases together and takes + // the first match, so an alias that collides with another setting's key — or with its + // alias — makes one of the two unreachable by that name, silently. Every name a + // setting answers to therefore has to be unique among all of them, not just the keys. + let mut names: Vec<(&str, &syn::Ident, bool)> = Vec::new(); + for field in &fields { + let Field::Prop(prop) = field else { continue }; + for (name, is_alias) in std::iter::once((prop.key.as_str(), false)) + .chain(prop.aliases.iter().map(|alias| (alias.as_str(), true))) + { + if let Some((_, first, first_alias)) = + names.iter().find(|(taken, _, _)| *taken == name) + { + let what = |alias: bool| match alias { + true => "an alias", + false => "the key", + }; + // Three different mistakes, and the message has to name the right one: + // one field colliding with itself is not two settings fighting over a + // name, and which of its own names came first says which mistake it is. + let same_field = std::ptr::eq(*first, &prop.ident); + return Err(syn::Error::new( + prop.ident.span(), + match (same_field, *first_alias) { + // Its own key, which is not a collision between two settings but + // an alias that can never be reached: the key is found first. + (true, false) => format!( + "`{}` lists `{name}` as an alias of its own key, which \ + nothing would ever reach", + prop.ident + ), + (true, true) => { + format!("`{}` lists the alias `{name}` twice", prop.ident) + } + (false, _) => format!( + "`{name}` is {} of `{}` and {} of `{first}`, and a lookup \ + takes the first of them: one of the two could never be \ + reached by that name", + what(is_alias), + prop.ident, + what(*first_alias), + ), + }, + )); + } + names.push((name, &prop.ident, is_alias)); + } + } + + Ok(Self { + ident: input.ident.clone(), + fields, + }) + } +} + +impl Field { + fn from_field(field: &syn::Field, prefix: Option<&str>) -> syn::Result { + let ident = field.ident.clone().expect("named fields only"); + + let mut flatten = false; + let mut key_attr = None; + let mut ty_attr = None; + let mut explicit_optional = None; + let mut prop = Prop { + ident: ident.clone(), + key: String::new(), + ty: Ty::Any, + read_ty: field.ty.clone(), + optional_field: false, + default: None, + default_fn: None, + default_note: None, + envs: Vec::new(), + deprecated_envs: Vec::new(), + cli: Vec::new(), + aliases: Vec::new(), + bindings: Vec::new(), + choices: Vec::new(), + merge: None, + scope: None, + parse: None, + hide: false, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, + since: None, + examples: Vec::new(), + help: None, + long_help: None, + }; + (prop.help, prop.long_help) = doc_comment(&field.attrs, false)?; + + for attr in attrs(&field.attrs) { + for meta in nested(attr)? { + let name = ident_of(meta.path()); + match name.as_str() { + "flatten" => flatten = flag_value(&meta)?, + "key" => key_attr = Some(string_value(&meta)?), + "ty" => ty_attr = Some((string_value(&meta)?, meta.path().span())), + "env" => prop.envs.extend(strings(&meta)?), + "deprecated_env" => prop.deprecated_envs.extend(strings(&meta)?), + "cli" => prop.cli.extend(strings(&meta)?), + "alias" | "aliases" => prop.aliases.extend(strings(&meta)?), + "example" | "examples" => prop.examples.extend(strings(&meta)?), + "source" => { + let mut words = strings(&meta)?; + if words.len() < 2 { + return Err(syn::Error::new_spanned( + &meta, + "`source` takes a kind and at least one key, as in \ + `source(\"git\", \"hk.jobs\")`", + )); + } + let kind = words.remove(0); + prop.bindings + .extend(words.into_iter().map(|key| (kind.clone(), key))); + } + "default" => prop.default = Some(const_value(&meta)?), + "default_fn" => { + prop.default_fn = Some(meta.require_name_value()?.value.clone()); + } + "default_note" => prop.default_note = Some(string_value(&meta)?), + "choices" => prop.choices = consts(&meta)?, + "merge" => { + prop.merge = Some(match string_value(&meta)?.as_str() { + "union" => Merge::Union, + "deep" => Merge::Deep, + "replace" => { + return Err(syn::Error::new_spanned( + &meta, + "`replace` is the default; say nothing instead", + )) + } + other => { + return Err(syn::Error::new_spanned( + &meta, + format!("`merge` is `union` or `deep`, not `{other}`"), + )) + } + }); + } + "scope" => { + prop.scope = Some(match string_value(&meta)?.as_str() { + "global" => Scope::Global, + "env" => Scope::Env, + other => { + return Err(syn::Error::new_spanned( + &meta, + format!("`scope` is `global` or `env`, not `{other}`"), + )) + } + }); + } + "parse" => { + let parse = string_value(&meta)?; + if !matches!( + parse.as_str(), + "list_by_comma" + | "list_by_colon" + | "list_by_os_path_separator" + | "set_by_comma" + ) { + return Err(syn::Error::new_spanned( + &meta, + format!("`{parse}` is not a parser the spec names"), + )); + } + prop.parse = Some(parse); + } + "hide" => prop.hide = flag_value(&meta)?, + "optional" => explicit_optional = Some(flag_value(&meta)?), + "deprecated" => prop.deprecated = Some(string_value(&meta)?), + "deprecated_warn_at" => prop.deprecated_warn_at = Some(string_value(&meta)?), + "deprecated_remove_at" => { + prop.deprecated_remove_at = Some(string_value(&meta)?) + } + "since" => prop.since = Some(string_value(&meta)?), + "help" => prop.help = Some(string_value(&meta)?), + "long_help" => prop.long_help = Some(string_value(&meta)?), + other => { + return Err(syn::Error::new_spanned( + meta.path(), + format!("usage::Config does not understand `{other}`"), + )); + } + } + } + } + + if flatten { + // A flattened group is another declaration, not a prop: everything about its + // settings belongs on its own fields. + let described = [ + (!prop.envs.is_empty(), "env"), + (prop.default.is_some(), "default"), + (prop.default_fn.is_some(), "default_fn"), + (!prop.cli.is_empty(), "cli"), + (!prop.bindings.is_empty(), "source"), + (!prop.choices.is_empty(), "choices"), + (prop.merge.is_some(), "merge"), + (prop.scope.is_some(), "scope"), + (prop.parse.is_some(), "parse"), + (prop.hide, "hide"), + (key_attr.is_some(), "key"), + (ty_attr.is_some(), "ty"), + (!prop.deprecated_envs.is_empty(), "deprecated_env"), + (!prop.aliases.is_empty(), "alias"), + (!prop.examples.is_empty(), "example"), + (prop.default_note.is_some(), "default_note"), + (explicit_optional.is_some(), "optional"), + (prop.deprecated.is_some(), "deprecated"), + (prop.deprecated_warn_at.is_some(), "deprecated_warn_at"), + (prop.deprecated_remove_at.is_some(), "deprecated_remove_at"), + (prop.since.is_some(), "since"), + ]; + if let Some((_, what)) = described.iter().find(|(given, _)| *given) { + return Err(syn::Error::new( + ident.span(), + format!( + "`{what}` describes a setting, and `flatten` says this field is a \ + group of them: put it on the group's own fields" + ), + )); + } + return Ok(Self::Flatten { + ident, + ty: Box::new(field.ty.clone()), + }); + } + + let unraw = ident.to_string(); + let unraw = unraw.strip_prefix("r#").unwrap_or(&unraw); + prop.key = match key_attr { + Some(key) => key, + None => unraw.to_string(), + }; + if let Some(prefix) = prefix { + prop.key = format!("{prefix}.{}", prop.key); + } + + // The field's type is the declaration's type. `Option` peels to T and marks the + // setting optional; a `ty` override renames what the spec calls it without changing + // what the field holds. + let (optional_field, inner) = peel_option(&field.ty); + prop.optional_field = optional_field; + prop.read_ty = inner.clone(); + prop.ty = match ty_attr { + Some((name, span)) => { + let declared = parse_ty_name(&name) + .ok_or_else(|| syn::Error::new(span, format!("`{name}` is not a spec type")))?; + // A spec type the field could never read. The merge coerces to the *declared* + // type, so the shape it hands over is decided here and not by what a layer + // supplied — `ty = "uint"` on a `String` field therefore failed every single + // `read`, whatever anyone configured. Only an always-broken pairing is refused: + // `ty = "int"` on a `u8` is a widening the author may mean, and it reads + // whenever the value fits. + if reads(&inner, &declared) == Some(false) { + return Err(syn::Error::new( + span, + format!( + "a `{name}` setting cannot be read into `{}`: `ty` renames what \ + the spec calls a setting, and cannot change what the field holds", + rust_type_name(&inner), + ), + )); + } + declared + } + None => infer_ty(&inner).ok_or_else(|| { + syn::Error::new( + inner.span(), + "this type does not name a spec type on its own; say what the spec \ + should call it, as in `ty = \"duration\"` on a String field", + ) + })?, + }; + + if let Some(optional) = explicit_optional { + if optional != prop.optional_field { + return Err(syn::Error::new( + ident.span(), + "`optional` and the field's type disagree: `Option` is how a field \ + says a setting may be absent", + )); + } + } + if prop.optional_field && prop.default.is_some() { + return Err(syn::Error::new( + ident.span(), + "a setting with a `default` always has a value, so the field cannot be an \ + `Option`: give it the inner type", + )); + } + if prop.default.is_some() && prop.default_fn.is_some() { + return Err(syn::Error::new( + ident.span(), + "`default` and `default_fn` are two answers to the same question; declare one", + )); + } + if prop.optional_field && prop.default_fn.is_some() { + return Err(syn::Error::new( + ident.span(), + "a setting with a `default_fn` always has a value, so the field cannot be an \ + `Option`: give it the inner type", + )); + } + if let Some(default) = &prop.default { + if !prop.ty.admits(default, Position::Default) { + return Err(syn::Error::new( + ident.span(), + format!( + "the default is not a value `{}` can hold. Nothing coerces a \ + default — the resolver seeds it as written — so write it as the \ + type the field holds", + type_name(&prop.ty) + ), + )); + } + // And what the *field* can hold, which the spec type does not say: `uint` covers + // every unsigned width, so 256 passed the check above and then failed every + // `read` on a `u8`. + if !field_holds(&prop.read_ty, default, Position::Default) { + return Err(syn::Error::new( + ident.span(), + format!( + "the default does not fit `{}`, which is what this field holds", + rust_type_name(&prop.read_ty) + ), + )); + } + // Which values have to be one of the choices depends on what the choices name. + // On a list setting they name what one *item* may be — that is how the registry + // compares a resolved value against them — so it is the items that are checked. + // Comparing the whole list refused `default("a")` beside `choices("a", "b")`, a + // declaration whose every value is one of them. + if !prop.choices.is_empty() { + let declared: Vec<&Const> = match (&prop.ty, default) { + (Ty::List(_) | Ty::Set(_), Const::List(items)) => items.iter().collect(), + _ => vec![default], + }; + if !declared + .iter() + .all(|value| prop.choices.iter().any(|choice| choice == *value)) + { + return Err(syn::Error::new( + ident.span(), + match declared.len() > 1 { + true => { + "the default has a value that is not one of the declared \ + choices" + } + false => "the default is not one of the declared choices", + }, + )); + } + } + } + if !prop.choices.is_empty() && prop.ty == Ty::Bool { + return Err(syn::Error::new( + ident.span(), + "choices on a `bool` cannot say anything its two values do not already", + )); + } + for choice in &prop.choices { + // The spec type first, then the field's own — the same order the default checks + // run in. A plain `u64` should hear that `uint` cannot hold a negative, which is + // the contract the registry will enforce; the field-level complaint is for when + // the spec type is fine and a `ty` override moved the problem. + if !prop.ty.admits(choice, Position::Choice) { + return Err(syn::Error::new( + ident.span(), + format!( + "a choice is not a value `{}` can hold. Write it as the type the \ + setting is — a `string` setting's choices are strings, quoted", + type_name(&prop.ty) + ), + )); + } + if !field_holds(&prop.read_ty, choice, Position::Choice) { + return Err(syn::Error::new( + ident.span(), + format!( + "a choice does not fit `{}`, so nothing could ever supply it", + rust_type_name(&prop.read_ty) + ), + )); + } + } + if prop.merge.is_some() && !prop.ty.is_collection() { + return Err(syn::Error::new( + ident.span(), + "`merge` says how a collection combines across layers, and this is not one", + )); + } + if prop.parse.is_some() && !prop.ty.is_collection() { + return Err(syn::Error::new( + ident.span(), + "`parse` splits one string into several values, and this field holds one", + )); + } + + Ok(Self::Prop(Box::new(prop))) + } +} + +fn type_name(ty: &Ty) -> String { + match ty { + Ty::Bool => "bool".into(), + Ty::Int => "int".into(), + Ty::Uint => "uint".into(), + Ty::Float => "float".into(), + Ty::String => "string".into(), + Ty::Path => "path".into(), + Ty::Url => "url".into(), + Ty::Duration => "duration".into(), + Ty::Object => "object".into(), + Ty::Any => "any".into(), + Ty::List(inner) => format!("list<{}>", type_name(inner)), + Ty::Set(inner) => format!("set<{}>", type_name(inner)), + Ty::Map(inner) => format!("map", type_name(inner)), + } +} + +/// `Option` peeled to `T`, and whether there was one to peel. +fn peel_option(ty: &syn::Type) -> (bool, syn::Type) { + if let syn::Type::Path(path) = ty { + if let Some(last) = path.path.segments.last() { + if last.ident == "Option" { + if let syn::PathArguments::AngleBracketed(args) = &last.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + return (true, inner.clone()); + } + } + } + } + } + (false, ty.clone()) +} + +/// The spec type a Rust type names on its own, or `None` for one that needs `ty = "..."`. +/// The `Value` variant the merge hands a setting of a given spec type. +/// +/// `Ty::coerce` decides the shape from the *declared* type, not from what a layer supplied, so +/// this is what the field's `FromValue` will actually be given. +#[derive(Clone, Copy, PartialEq)] +enum Shape { + Bool, + Int, + Float, + Str, + List, + Map, +} + +impl Ty { + /// `None` for `any`, which is the one type the merge does not coerce: it hands over + /// whatever arrived, so no shape can be promised or refused. + fn shape(&self) -> Option { + Some(match self { + Self::Any => return None, + Self::Bool => Shape::Bool, + // A `uint` is an `int` the merge additionally refuses when negative. Same shape. + Self::Int | Self::Uint => Shape::Int, + Self::Float => Shape::Float, + Self::String | Self::Path | Self::Url | Self::Duration => Shape::Str, + Self::Object | Self::Map(_) => Shape::Map, + Self::List(_) | Self::Set(_) => Shape::List, + }) + } +} + +/// Whether the field's `FromValue` can read what a setting of type `declared` is handed. +/// +/// Structural, not a top-level shape comparison: the merge coerces a list's *items* to the +/// declared item type, so `ty = "list"` on a `Vec` is handed a list of integers +/// and fails on the first one. Comparing only the outer kind called that a match. +/// +/// `None` where the answer is not knowable — a type alias, a type whose `FromValue` an adopter +/// wrote, or a declared type that promises no shape — because a `ty` override exists for +/// exactly the types this cannot measure, and refusing what it cannot see would make the +/// escape hatch useless. +fn reads(field: &syn::Type, declared: &Ty) -> Option { + // `any` is the one type the merge does not coerce: it hands over whatever arrived, so + // there is no shape to promise or refuse. + let shape = declared.shape()?; + let syn::Type::Path(path) = field else { + return None; + }; + let last = path.path.segments.last()?; + match last.ident.to_string().as_str() { + // `Value` is the escape hatch for `any`: it reads whatever it is handed. + "Value" => Some(true), + "bool" => Some(shape == Shape::Bool), + "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => { + Some(shape == Shape::Int) + } + // A whole number is a perfectly good float, which is the rule `FromValue for f64` + // states and this has to agree with. + "f32" | "f64" => Some(shape == Shape::Float || shape == Shape::Int), + "String" | "PathBuf" => Some(shape == Shape::Str), + "Vec" => match declared { + Ty::List(item) | Ty::Set(item) => { + generic_arg(last, 0).and_then(|inner| reads(&inner, item)) + } + _ => Some(false), + }, + "BTreeMap" => match declared { + Ty::Map(value) => generic_arg(last, 1).and_then(|inner| reads(&inner, value)), + // `object` is a table whose value types the spec deliberately does not describe, + // so there is nothing to hold the field's own to. + Ty::Object => None, + _ => Some(false), + }, + _ => None, + } +} + +/// A field's Rust type as written, for a message that has to name it. +fn rust_type_name(ty: &syn::Type) -> String { + quote::ToTokens::to_token_stream(ty) + .to_string() + .replace(" ", "") +} + +/// The `index`th type argument of a path segment, as in the `u64` of `Vec`. +fn generic_arg(segment: &syn::PathSegment, index: usize) -> Option { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args + .iter() + .filter_map(|arg| match arg { + syn::GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + }) + .nth(index) +} + +/// Whether the field's own Rust type can hold `value`. +/// +/// A narrower check than [`Ty::admits`], and it has to be separate: `infer_ty` collapses every +/// unsigned width to `uint`, so the spec-level type cannot see that a `u8` refuses 256. The +/// resolver seeds a default uncoerced and `FromValue` is strict about width, so a default the +/// field cannot hold fails every `read` — the same trap as a negative `uint`, one level down. +/// +/// Takes the `position` for the same reason [`Ty::admits`] does: on a list or set a *choice* +/// names one item rather than the whole value, so it is the item type that has to hold it. +/// Measuring a scalar choice against the container measured nothing at all. +/// +/// `true` for anything this does not recognize: the spec-level check has already had its say, +/// and a type it allows that this cannot measure is not this function's to refuse. +fn field_holds(ty: &syn::Type, value: &Const, position: Position) -> bool { + let syn::Type::Path(path) = ty else { + return true; + }; + let Some(last) = path.path.segments.last() else { + return true; + }; + macro_rules! fits { + ($int:ty) => { + match value { + Const::Int(i) => <$int>::try_from(*i).is_ok(), + _ => true, + } + }; + } + match last.ident.to_string().as_str() { + // Every integer the reader has a `FromValue` for, including the two whose check is a + // formality: `i64` holds anything a `Const::Int` can be, and `u64` refuses only a + // negative. `u64` was left out on the reasoning that `Ty::Uint` already refuses those + // — which a `ty = "int"` or `ty = "any"` override replaces, so the reasoning did not + // hold and the list is exhaustive rather than curated now. + "u8" => fits!(u8), + "u16" => fits!(u16), + "u32" => fits!(u32), + "u64" => fits!(u64), + "usize" => fits!(usize), + "i8" => fits!(i8), + "i16" => fits!(i16), + "i32" => fits!(i32), + "i64" => fits!(i64), + "isize" => fits!(isize), + // The rule `FromValue for f32` follows: rounding a value that fits is ordinary + // precision loss, and turning a finite one into an infinity is not. + "f32" => match value { + Const::Float(f) => !(*f as f32).is_infinite() || f.is_infinite(), + _ => true, + }, + "Vec" | "BTreeSet" | "HashSet" => match generic_arg(last, 0) { + None => true, + Some(inner) => match value { + Const::List(items) => items.iter().all(|item| field_holds(&inner, item, position)), + // One value where the container is: a choice naming an item, which the item + // type is the thing that has to hold it. A default is not that — `admits` has + // already refused a bare scalar there — so it does not reach this arm. + _ => position == Position::Choice && field_holds(&inner, value, position), + }, + }, + _ => true, + } +} + +fn infer_ty(ty: &syn::Type) -> Option { + let syn::Type::Path(path) = ty else { + return None; + }; + let last = path.path.segments.last()?; + let name = last.ident.to_string(); + let generic = |index: usize| -> Option { generic_arg(last, index) }; + Some(match name.as_str() { + "bool" => Ty::Bool, + "u8" | "u16" | "u32" | "u64" | "usize" => Ty::Uint, + "i8" | "i16" | "i32" | "i64" | "isize" => Ty::Int, + "f32" | "f64" => Ty::Float, + "String" => Ty::String, + "PathBuf" => Ty::Path, + "Value" => Ty::Any, + "Vec" => Ty::List(Box::new(infer_ty(&generic(0)?)?)), + "BTreeMap" => { + // The key is part of the shape: a spec map is keyed by strings, and a map keyed + // by anything else has no spelling a config file could write. + let key = generic(0)?; + if !matches!(infer_ty(&key), Some(Ty::String)) { + return None; + } + Ty::Map(Box::new(infer_ty(&generic(1)?)?)) + } + _ => return None, + }) +} + +/// A spec type by the name a spec writes: `uint`, `list`, `map`. +fn parse_ty_name(name: &str) -> Option { + let name = name.trim(); + if let Some(inner) = name.strip_prefix("list<").and_then(|s| s.strip_suffix('>')) { + return Some(Ty::List(Box::new(parse_ty_name(inner)?))); + } + if let Some(inner) = name.strip_prefix("set<").and_then(|s| s.strip_suffix('>')) { + return Some(Ty::Set(Box::new(parse_ty_name(inner)?))); + } + if let Some(inner) = name.strip_prefix("map<").and_then(|s| s.strip_suffix('>')) { + let (key, value) = inner.split_once(',')?; + if key.trim() != "string" { + return None; + } + return Some(Ty::Map(Box::new(parse_ty_name(value)?))); + } + Some(match name { + "bool" => Ty::Bool, + "int" => Ty::Int, + "uint" => Ty::Uint, + "float" => Ty::Float, + "string" => Ty::String, + "path" => Ty::Path, + "url" => Ty::Url, + "duration" => Ty::Duration, + "object" => Ty::Object, + "any" => Ty::Any, + _ => return None, + }) +} + +/// `env = "X"` or `env("A", "B")`, as the strings. +fn strings(meta: &Meta) -> syn::Result> { + match meta { + Meta::NameValue(_) => Ok(vec![string_value(meta)?]), + Meta::List(list) => { + let parsed = list.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + parsed + .into_iter() + .map(|lit| match lit { + Lit::Str(s) => Ok(s.value()), + other => Err(syn::Error::new_spanned(other, "expected a string")), + }) + .collect() + } + Meta::Path(path) => Err(syn::Error::new_spanned( + path, + "expected a value, as in `env = \"EX_JOBS\"` or `env(\"A\", \"B\")`", + )), + } +} + +/// `default = 4`, `default = "x"`, `default = -1`, or `default(80, 443)` for a list. +fn const_value(meta: &Meta) -> syn::Result { + match meta { + Meta::NameValue(nv) => const_expr(&nv.value), + Meta::List(list) => { + let parsed = list.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + Ok(Const::List( + parsed + .into_iter() + .map(|expr| const_expr(&expr)) + .collect::>()?, + )) + } + Meta::Path(path) => Err(syn::Error::new_spanned( + path, + "expected a value, as in `default = 4` or `default(80, 443)`", + )), + } +} + +fn consts(meta: &Meta) -> syn::Result> { + let Meta::List(list) = meta else { + return Err(syn::Error::new_spanned( + meta, + "expected a list, as in `choices(\"a\", \"b\")`", + )); + }; + let parsed = list + .parse_args_with(syn::punctuated::Punctuated::::parse_terminated)?; + parsed.into_iter().map(|expr| const_expr(&expr)).collect() +} + +fn const_expr(expr: &Expr) -> syn::Result { + match expr { + Expr::Lit(ExprLit { lit, .. }) => const_lit(lit, false), + Expr::Unary(unary) if matches!(unary.op, UnOp::Neg(_)) => { + if let Expr::Lit(ExprLit { lit, .. }) = unary.expr.as_ref() { + return const_lit(lit, true); + } + Err(syn::Error::new_spanned(expr, "expected a literal")) + } + other => Err(syn::Error::new_spanned( + other, + "expected a literal the registry can hold as a const; a computed value is \ + `default_fn`", + )), + } +} + +fn const_lit(lit: &Lit, negated: bool) -> syn::Result { + let value = match lit { + Lit::Bool(b) => Const::Bool(b.value()), + Lit::Int(i) => Const::Int(i.base10_parse::()?), + Lit::Float(f) => Const::Float(f.base10_parse::()?), + Lit::Str(s) => Const::Str(s.value()), + other => { + return Err(syn::Error::new_spanned( + other, + "expected a boolean, number, or string", + )) + } + }; + Ok(match (negated, value) { + (false, value) => value, + (true, Const::Int(i)) => Const::Int(-i), + (true, Const::Float(f)) => Const::Float(-f), + (true, _) => { + return Err(syn::Error::new_spanned( + lit, + "only a number can be negative", + )) + } + }) +} + +pub fn emit(config: &Config) -> TokenStream { + let ident = &config.ident; + let cfg = config_path(); + + let metas: Vec = config + .fields + .iter() + .filter_map(|field| match field { + Field::Prop(prop) => Some(prop_meta(prop, &cfg)), + Field::Flatten { .. } => None, + }) + .collect(); + + let has_flatten = config + .fields + .iter() + .any(|field| matches!(field, Field::Flatten { .. })); + + // Without flattening the props are one literal slice. With it, the slices join at + // compile time in field order, so an id is a position in the joined table — the same + // arrangement `usage::Cli` uses for a flattened group's flags. + let metas_len = metas.len(); + let props_decl = if has_flatten { + let parts: Vec = config + .fields + .iter() + .map(|field| match field { + Field::Prop(prop) => { + let meta = prop_meta(prop, &cfg); + quote!(&[#meta]) + } + Field::Flatten { ty, .. } => quote!(<#ty as #cfg::Props>::PROPS), + }) + .collect(); + quote! { + const __USAGE_PARTS: &[&[#cfg::PropMeta]] = &[#(#parts),*]; + const __USAGE_LEN: usize = { + let mut total = 0; + let mut i = 0; + while i < __USAGE_PARTS.len() { + total += __USAGE_PARTS[i].len(); + i += 1; + } + total + }; + static __USAGE_PROPS: [#cfg::PropMeta; __USAGE_LEN] = + #cfg::concat_props(__USAGE_PARTS); + } + } else { + quote! { + static __USAGE_PROPS: [#cfg::PropMeta; #metas_len] = [#(#metas),*]; + } + }; + + // Reads in declaration order, advancing a cursor: one id per own prop, a group's length + // for a flattened child. Everything is read before anything is judged, so the fold holds + // every error rather than the first one. + let reads: Vec = config + .fields + .iter() + .map(|field| match field { + Field::Prop(prop) => { + let local = read_local(&prop.ident); + let read_ty = &prop.read_ty; + let method = if prop.optional_field || prop.default_fn.is_some() { + quote!(optional) + } else { + quote!(required) + }; + quote! { + let #local: ::std::option::Option<#read_ty> = + __usage_fold.#method(#cfg::PropId(__usage_at)); + __usage_at += 1; + } + } + Field::Flatten { ident, ty } => { + let local = read_local(ident); + quote! { + let #local: ::std::option::Option<#ty> = + <#ty as #cfg::Props>::read_at(__usage_fold, __usage_at); + __usage_at += <#ty as #cfg::Props>::PROPS.len() as u16; + } + } + }) + .collect(); + + let builds: Vec = config + .fields + .iter() + .map(|field| match field { + Field::Prop(prop) => { + let name = &prop.ident; + let local = read_local(name); + if let Some(default_fn) = &prop.default_fn { + quote!(#name: #local.unwrap_or_else(#default_fn)) + } else if prop.optional_field { + quote!(#name: #local) + } else { + quote!(#name: #local?) + } + } + Field::Flatten { ident, .. } => { + let local = read_local(ident); + quote!(#ident: #local?) + } + }) + .collect(); + + quote! { + const _: () = { + #props_decl + + impl #cfg::Props for #ident { + const PROPS: &'static [#cfg::PropMeta] = &__USAGE_PROPS; + + fn read_at( + __usage_fold: &mut #cfg::Fold<'_>, + __usage_base: u16, + ) -> ::std::option::Option { + let mut __usage_at: u16 = __usage_base; + #(#reads)* + let _ = __usage_at; + ::std::option::Option::Some(Self { + #(#builds),* + }) + } + } + + impl #ident { + /// Every setting this struct declares, one entry per field, flattened groups + /// included. The registry a `build.rs` used to generate, generated from the + /// struct instead — there is no second declaration to keep in step. + pub const SETTINGS_PROPS: &'static [#cfg::PropMeta] = + ::PROPS; + + /// The registry over [`Self::SETTINGS_PROPS`], for `resolve`, `drift`, and + /// the layers. + pub const SETTINGS_REGISTRY: #cfg::Registry = + #cfg::Registry::new(Self::SETTINGS_PROPS); + + /// This resolution's values, as the struct. + /// + /// Every field is read before anything is returned, so the error is the whole + /// list of what is wrong rather than the first thing found. + pub fn read( + __usage_resolved: &#cfg::Resolved, + ) -> ::std::result::Result { + let mut __usage_fold = __usage_resolved.fold(); + let __usage_read = ::read_at(&mut __usage_fold, 0); + __usage_fold.finish()?; + ::std::result::Result::Ok(__usage_read.expect( + "the fold reported nothing, so every field was read", + )) + } + + /// The spec `config` block for these settings, as KDL. + /// + /// What documents, JSON schema and completions read. A CLI deriving + /// `usage::Cli` names this type in `#[usage(config = ...)]` instead of + /// calling this, and its `to_kdl` carries the block. + pub fn spec_kdl() -> ::std::string::String { + #cfg::spec_kdl(Self::SETTINGS_PROPS) + } + } + }; + } +} + +/// The local a field is read into, unrawed: `r#match` reads into `__usage_read_match`. +fn read_local(ident: &syn::Ident) -> syn::Ident { + let name = ident.to_string(); + let name = name.strip_prefix("r#").unwrap_or(&name); + format_ident!("__usage_read_{name}") +} + +/// One prop as registry metadata, in struct-update form over `PropMeta::new`. +fn prop_meta(prop: &Prop, cfg: &TokenStream) -> TokenStream { + let key = &prop.key; + let ty = if prop.optional_field { + let inner = prop.ty.tokens(cfg); + quote!(#cfg::Ty::Option(&#inner)) + } else { + prop.ty.tokens(cfg) + }; + let mut fields = Vec::new(); + if let Some(default) = &prop.default { + let default = default.tokens(cfg); + fields.push(quote!(default: ::std::option::Option::Some(#default))); + } + if let Some(merge) = prop.merge { + let merge = match merge { + Merge::Union => quote!(#cfg::Merge::Union), + Merge::Deep => quote!(#cfg::Merge::Deep), + }; + fields.push(quote!(merge: #merge)); + } + if let Some(scope) = prop.scope { + let scope = match scope { + Scope::Global => quote!(#cfg::Scope::Global), + Scope::Env => quote!(#cfg::Scope::Env), + }; + fields.push(quote!(scope: #scope)); + } + if let Some(parse) = &prop.parse { + let parser = match parse.as_str() { + "list_by_comma" => quote!(#cfg::Parser::ListByComma), + "list_by_colon" => quote!(#cfg::Parser::ListByColon), + "list_by_os_path_separator" => quote!(#cfg::Parser::ListByOsPathSeparator), + _ => quote!(#cfg::Parser::SetByComma), + }; + fields.push(quote!(parse: ::std::option::Option::Some(#parser))); + } + if !prop.envs.is_empty() { + let envs = &prop.envs; + fields.push(quote!(envs: &[#(#envs),*])); + } + if !prop.deprecated_envs.is_empty() { + let envs = &prop.deprecated_envs; + fields.push(quote!(deprecated_envs: &[#(#envs),*])); + } + if !prop.cli.is_empty() { + let cli = &prop.cli; + fields.push(quote!(cli: &[#(#cli),*])); + } + if !prop.bindings.is_empty() { + let pairs = prop + .bindings + .iter() + .map(|(kind, key)| quote!((#kind, #key))); + fields.push(quote!(bindings: &[#(#pairs),*])); + } + if !prop.choices.is_empty() { + let choices = prop.choices.iter().map(|choice| choice.tokens(cfg)); + fields.push(quote!(choices: &[#(#choices),*])); + } + if prop.hide { + fields.push(quote!(hide: true)); + } + if let Some(deprecated) = &prop.deprecated { + fields.push(quote!(deprecated: ::std::option::Option::Some(#deprecated))); + } + if !prop.aliases.is_empty() { + let aliases = &prop.aliases; + fields.push(quote!(aliases: &[#(#aliases),*])); + } + // The optionality contract, stated rather than inferred. A registry that leaves this + // unset invites the reader's inference — "no default means optional" — and a plain + // non-`Option` field with no default is the one case where that inference disagrees with + // `read`, which reports the key as missing. Docs, the JSON schema and the completers all + // read the registry, so they have to be told what `read` will do. + let optional = if prop.optional_field || prop.default_fn.is_some() { + Some(true) + } else if prop.default.is_none() { + Some(false) + } else { + // A declared default always resolves, and inference already agrees with that. + None + }; + if let Some(optional) = optional { + fields.push(quote!(optional: ::std::option::Option::Some(#optional))); + } + if let Some(help) = &prop.help { + fields.push(quote!(help: ::std::option::Option::Some(#help))); + } + if let Some(long_help) = &prop.long_help { + fields.push(quote!(long_help: ::std::option::Option::Some(#long_help))); + } + if let Some(note) = &prop.default_note { + fields.push(quote!(default_note: ::std::option::Option::Some(#note))); + } + if let Some(since) = &prop.since { + fields.push(quote!(since: ::std::option::Option::Some(#since))); + } + if let Some(at) = &prop.deprecated_warn_at { + fields.push(quote!(deprecated_warn_at: ::std::option::Option::Some(#at))); + } + if let Some(at) = &prop.deprecated_remove_at { + fields.push(quote!(deprecated_remove_at: ::std::option::Option::Some(#at))); + } + if !prop.examples.is_empty() { + let examples = &prop.examples; + fields.push(quote!(examples: &[#(#examples),*])); + } + quote! { + #cfg::PropMeta { + #(#fields,)* + ..#cfg::PropMeta::new(#key, #ty) + } + } +} + +#[cfg(test)] +mod tests { + use super::Config; + + /// The message a bad declaration produces, which is the part worth asserting on: it is + /// what the author sees, and the point of checking here is that they see it *here* rather + /// than at the first `read` in production. + fn rejection(body: &str) -> String { + match Config::from_input(&syn::parse_str::(body).expect("valid Rust")) { + Ok(_) => panic!("should not have compiled"), + Err(e) => e.to_string(), + } + } + + fn accepted(body: &str) { + Config::from_input(&syn::parse_str::(body).expect("valid Rust")) + .unwrap_or_else(|e| panic!("should have compiled: {e}")); + } + + #[test] + fn a_uint_refuses_a_negative_default_or_choice() { + // The resolver seeds a declared default with no coercion, and `u64::from_value` + // refuses a negative value — so this compiled and then failed every `read` with a type + // error the author could do nothing about at run time. A choice is the same shape of + // mistake: a value nothing can ever supply. + let err = rejection( + r#" + struct Settings { + #[usage(default = -1)] + jobs: u64, + } + "#, + ); + assert!( + err.contains("the default is not a value `uint` can hold"), + "unhelpful: {err}" + ); + + let err = rejection( + r#" + struct Settings { + #[usage(choices(1, -1))] + jobs: u64, + } + "#, + ); + assert!( + err.contains("a choice is not a value `uint` can hold"), + "unhelpful: {err}" + ); + + // A signed field still takes one, which is the whole difference. + accepted( + r#" + struct Settings { + #[usage(default = -1)] + offset: i64, + } + "#, + ); + } + + #[test] + fn a_default_is_refused_unless_it_is_already_the_field_s_type() { + // Nothing coerces a declared default: the resolver seeds it with `Const::to_value` + // and the reader is handed it as it stands. So the permissiveness the merge has — + // a string type reading a bare number as its text, a scalar standing in for a list + // of one — is not permissiveness a default gets. Each of these compiled and then + // failed *every* `Settings::read` with a type error nothing at run time could fix. + for (field, default) in [ + ("name: String", "default = 1"), + ("name: String", "default = true"), + ("home: std::path::PathBuf", "default = 1"), + ("ports: Vec", "default = 80"), + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage({default})] + {field}, + }} + "# + )); + assert!( + err.contains("the default is not a value"), + "`{default}` was accepted on `{field}`: {err}" + ); + // And the message says why, because "not a value `string` can hold" reads like a + // lie next to a spec's `default=1`, which the merge does coerce. + assert!( + err.contains("Nothing coerces a default"), + "unhelpful for `{default}` on `{field}`: {err}" + ); + } + + // Written as the type the field holds, each is fine — including a list of one, which + // the attribute already spells apart from a bare scalar. + for (field, default) in [ + ("name: String", r#"default = "1""#), + ("home: std::path::PathBuf", r#"default = "/tmp""#), + ("ports: Vec", "default(80)"), + ("ports: Vec", "default(80, 443)"), + ] { + accepted(&format!( + r#" + struct Settings {{ + #[usage({default})] + {field}, + }} + "# + )); + } + } + + #[test] + fn a_list_default_is_held_against_the_choices_item_by_item() { + // The choices on a list setting name what one item may be, so a default made of them + // is a default made of declared values. Comparing the whole list against each choice + // refused this outright — the opposite failure to the two above, and the one that + // would have hit hk first: its list settings are exactly this shape. + accepted( + r#" + struct Settings { + #[usage(default("a"), choices("a", "b"))] + tags: Vec, + } + "#, + ); + accepted( + r#" + struct Settings { + #[usage(default("a", "b"), choices("a", "b"))] + tags: Vec, + } + "#, + ); + + // An item nothing declared is still refused, which is the point of checking at all. + let err = rejection( + r#" + struct Settings { + #[usage(default("a", "z"), choices("a", "b"))] + tags: Vec, + } + "#, + ); + assert!( + err.contains("not one of the declared choices"), + "unhelpful: {err}" + ); + + // And a scalar setting still compares whole, which is all it can do. + let err = rejection( + r#" + struct Settings { + #[usage(default = "z", choices("a", "b"))] + mode: String, + } + "#, + ); + assert!( + err.contains("the default is not one of the declared choices"), + "unhelpful: {err}" + ); + } + + #[test] + fn a_choice_on_a_list_setting_names_one_item() { + // A choice is compared against a resolved value *after* coercion, and a `list` + // setting's choices name what one item may be — that is what the registry compares. + // Tightening the default check must not tighten this one. + accepted( + r#" + struct Settings { + #[usage(choices("a", "b"))] + tags: Vec, + } + "#, + ); + } + + #[test] + fn a_string_setting_spells_its_values_as_strings() { + // A spec's `type="string"` does read a bare number as its text, and the merge coerces + // one — so the registry would accept `choice 1` here. A declaration written in Rust + // still has no reason to spell a string as anything else, and requiring the quotes is + // what lets a default and a choice be compared as written. The alternative is a third + // implementation of how a float is written, which is the drift + // `conformance/tests/config_value_display.rs` exists to catch. + for attribute in ["choices(1, 2)", "default = 1", "default = true"] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage({attribute})] + level: String, + }} + "# + )); + assert!( + err.contains("`string` can hold"), + "`{attribute}` was accepted on a String field: {err}" + ); + } + + // Quoted, they are the same declaration and they compile — including the pairing that + // the literal comparison would otherwise have refused. + accepted( + r#" + struct Settings { + #[usage(default = "1", choices("1", "2"))] + level: String, + } + "#, + ); + } + + #[test] + fn a_default_that_does_not_fit_the_field_is_refused() { + // `infer_ty` collapses every unsigned width to `uint`, so the spec-level check cannot + // see that a `u8` refuses 256. Uncaught, this is the negative-`uint` trap one level + // down: seeded uncoerced, refused by `FromValue`, and so a type error on every + // `Settings::read` that nothing at run time could fix. + for (field, value) in [ + ("small: u8", "256"), + ("small: u8", "-1"), + ("signed: i8", "128"), + ("medium: u16", "70000"), + ("wide: u32", "5000000000"), + ("ratio: f32", "1e300"), + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(default = {value})] + {field}, + }} + "# + )); + assert!( + err.contains("does not fit") || err.contains("can hold"), + "`default = {value}` was accepted on `{field}`: {err}" + ); + } + + // A choice nothing could supply is the same mistake, and the items of a list are + // measured against the item type rather than the list. + let err = rejection( + r#" + struct Settings { + #[usage(choices(1, 256))] + small: u8, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + + // A `ty` override can replace the spec type that was doing the refusing, so the + // field's own type has to be the thing checked. `Ty::Uint` refuses a negative, but + // `ty = "int"` and `ty = "any"` do not — and a `u64` still cannot hold one, so every + // read failed on a default the derive had accepted. + for ty in ["int", "any"] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(ty = "{ty}", default = -1)] + jobs: u64, + }} + "# + )); + assert!( + err.contains("does not fit `u64`"), + "`ty = \"{ty}\"` let a negative default past: {err}" + ); + } + // Through a list too, where the item is what cannot hold it. + let err = rejection( + r#" + struct Settings { + #[usage(ty = "list", choices(1, -1))] + ports: Vec, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + + // A choice on a *list* setting names one item, so it is the item type that has to + // hold it. Measured against `Vec` instead, a bare `70000` reached the container + // arm and was called fitting — and `Ty::admits` allows a scalar as an item, so this + // declared a choice no `u16` could ever be. + let err = rejection( + r#" + struct Settings { + #[usage(choices(1, 70000))] + ports: Vec, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + accepted( + r#" + struct Settings { + #[usage(choices(1, 65535))] + ports: Vec, + } + "#, + ); + let err = rejection( + r#" + struct Settings { + #[usage(default(80, 70000))] + ports: Vec, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + + // What does fit still compiles, at every width and through a list. + for (field, value) in [ + ("small: u8", "255"), + ("signed: i8", "-128"), + ("medium: u16", "65535"), + ("ratio: f32", "1.5"), + ("jobs: u64", "4"), + ] { + accepted(&format!( + r#" + struct Settings {{ + #[usage(default = {value})] + {field}, + }} + "# + )); + } + accepted( + r#" + struct Settings { + #[usage(default(80, 443))] + ports: Vec, + } + "#, + ); + } + + #[test] + fn a_ty_the_field_could_never_read_is_refused() { + // The merge coerces to the *declared* type, so the shape the field is handed is + // decided by `ty` and not by what a layer supplied. A pairing whose shapes disagree + // therefore fails every `read` for every input — the same "accepted declaration, + // broken at every read" trap as a default the field cannot hold. + for (field, ty) in [ + ("name: String", "uint"), + ("name: String", "bool"), + ("jobs: u64", "string"), + ("jobs: u64", "duration"), + ("flag: bool", "string"), + // A container declared over a scalar field, and the reverse. + ("jobs: u64", "list"), + ("ports: Vec", "uint"), + ( + "table: std::collections::BTreeMap", + "string", + ), + // And the *item* types, not only the outer kind: the merge coerces a list's items + // to the declared item type, so a list of the wrong thing fails on the first one. + ("names: Vec", "list"), + ("ports: Vec", "list"), + ("ports: Vec", "set"), + ("nested: Vec>", "list>"), + ( + "table: std::collections::BTreeMap", + "map", + ), + // A list declared where a map is held, and the reverse. + ( + "table: std::collections::BTreeMap", + "list", + ), + ("names: Vec", "map"), + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(ty = "{ty}")] + {field}, + }} + "# + )); + assert!( + err.contains("cannot be read into"), + "`ty = \"{ty}\"` was accepted on `{field}`: {err}" + ); + } + + // The pairings that do read. `duration` on a `String` is the reason the attribute + // exists — a span of time is carried as its text, and the crate that owns the duration + // type owns its spelling. + for (field, ty) in [ + ("timeout: String", "duration"), + ("home: std::path::PathBuf", "path"), + ("home: std::path::PathBuf", "url"), + // An integer is a perfectly good float, which `FromValue for f64` states. + ("ratio: f64", "int"), + ("ratio: f32", "float"), + // `any` promises no shape, so it refuses none. + ("whatever: String", "any"), + // A widening the author may mean: it reads whenever the value fits, so it is + // theirs to make rather than this check's to refuse. + ("small: u8", "int"), + // Item types that agree, at depth, and through a set — which the merge hands over + // as a list like any other. + ("names: Vec", "list"), + ("paths: Vec", "set"), + ("spans: Vec", "list"), + ("nested: Vec>", "list>"), + ( + "table: std::collections::BTreeMap", + "map", + ), + // `object` is a table whose value types the spec deliberately does not describe, + // so there is nothing to hold the field's own to. + ( + "table: std::collections::BTreeMap", + "object", + ), + // An item type this cannot measure keeps the escape hatch open at depth too. + ("items: Vec", "list"), + ] { + accepted(&format!( + r#" + struct Settings {{ + #[usage(ty = "{ty}")] + {field}, + }} + "# + )); + } + } + + #[test] + fn a_ty_override_does_not_widen_what_the_field_holds() { + // `ty` renames what the *spec* calls a setting; it does not change what the struct + // holds, so it must not be a way around the width check. The two checks are separate + // functions for exactly this reason — one reads `prop.ty`, the other `prop.read_ty`. + let err = rejection( + r#" + struct Settings { + #[usage(ty = "int", default = 256)] + small: u8, + } + "#, + ); + assert!(err.contains("does not fit `u8`"), "unhelpful: {err}"); + + // And a type this cannot measure stays permissive rather than refused: an alias for a + // container is the ordinary reason `ty` is written at all, and refusing what it cannot + // see would make the escape hatch useless. + accepted( + r#" + struct Settings { + #[usage(ty = "list", default(80, 443))] + ports: Ports, + } + "#, + ); + } + + #[test] + fn two_settings_cannot_answer_to_one_name() { + // `Registry::lookup` checks keys and aliases together and takes the first match, so a + // collision does not fail — it makes one of the two settings unreachable by that name, + // which is the quietest possible way to lose a setting. Every name has to be unique + // among all of them, not just the keys among the keys. + let cases = [ + // An alias over another setting's key. + r#" + struct Settings { + #[usage(alias("other"))] + jobs: u64, + other: u64, + } + "#, + // The same alias twice. + r#" + struct Settings { + #[usage(alias("shared"))] + jobs: u64, + #[usage(alias("shared"))] + threads: u64, + } + "#, + // A key over an earlier setting's alias, which is the same collision found in the + // other order. + r#" + struct Settings { + #[usage(alias("threads"))] + jobs: u64, + threads: u64, + } + "#, + ]; + for body in cases { + let err = rejection(body); + assert!( + err.contains("could never be reached"), + "a colliding name was accepted: {err}" + ); + } + + // One field colliding with itself is not two settings fighting over a name, and + // which of its own names came first says which mistake it is — so each gets its own + // message rather than the two-settings one, which would name `jobs` twice and read + // like nonsense. + let err = rejection( + r#" + struct Settings { + #[usage(alias("jobs"))] + jobs: u64, + } + "#, + ); + assert!(err.contains("alias of its own key"), "unhelpful: {err}"); + + let err = rejection( + r#" + struct Settings { + #[usage(alias("concurrency", "concurrency"))] + jobs: u64, + } + "#, + ); + assert!( + err.contains("lists the alias `concurrency` twice"), + "unhelpful: {err}" + ); + + // Distinct names are fine, including an alias that looks like a prefix of another key. + accepted( + r#" + struct Settings { + #[usage(alias("concurrency", "parallelism"))] + jobs: u64, + #[usage(alias("task-jobs"))] + threads: u64, + } + "#, + ); + } + + #[test] + fn every_setting_attribute_on_a_flattened_field_is_refused() { + // `flatten` says the field is a group of settings, so anything describing *one* + // setting was parsed into a prop that the flatten branch then dropped. The checked + // list had grown stale: `#[usage(flatten, alias = "task")]` compiled, and the alias + // simply did not exist. + for attribute in [ + r#"env = "EX_X""#, + r#"deprecated_env = "EX_OLD""#, + r#"alias = "other""#, + r#"example = "1""#, + r#"default_note = "note""#, + "optional = true", + r#"deprecated = "gone""#, + r#"deprecated_warn_at = "6.0.0""#, + r#"deprecated_remove_at = "7.0.0""#, + r#"since = "5.2.0""#, + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(flatten, {attribute})] + task: TaskSettings, + }} + "# + )); + assert!( + err.contains("describes a setting, and `flatten` says this field is a group"), + "`{attribute}` was accepted on a flattened field: {err}" + ); + } + + // A doc comment is not one of them: it describes the group, and `help` is how the + // derive carries a doc comment. + accepted( + r#" + struct Settings { + /// The task settings + #[usage(flatten)] + task: TaskSettings, + } + "#, + ); + } +} diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 0be1e5b02..f1c26fb1f 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -412,6 +412,7 @@ use syn::{parse_macro_input, DeriveInput}; mod case; mod codegen; +mod config; mod crate_name; mod model; @@ -467,6 +468,39 @@ pub fn derive_subcommands(input: TokenStream) -> TokenStream { } } +/// Compile a settings struct into its own registry, reader, and spec `config` block. +/// +/// The struct the CLI already holds its settings in becomes the declaration: field types are +/// the settings' types, doc comments are their help, and `#[usage(...)]` carries what a spec's +/// `prop` node would — `env`, `default`, `merge`, `scope`, `choices`, `source` bindings. +/// The derive generates `SETTINGS_PROPS`, `SETTINGS_REGISTRY`, `read(&Resolved)`, and +/// `spec_kdl()`, so the registry, the reader, and the documentation cannot drift from the +/// struct or from each other. The whole field vocabulary is in the guide: +/// . +/// +/// ```ignore +/// #[derive(usage::Config)] +/// struct Settings { +/// /// How many jobs to run at once +/// #[usage(env = "EX_JOBS", default = 4, cli("--jobs", "-j"))] +/// jobs: u64, +/// #[usage(flatten)] +/// task: TaskSettings, +/// } +/// ``` +/// +/// A group flattens into another with `#[usage(flatten)]`, declaring its dotted keys under +/// its own `#[usage(prefix = "task")]`. The joined registry refuses duplicate keys at +/// compile time. +#[proc_macro_derive(Config, attributes(usage))] +pub fn derive_config(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match config::Config::from_input(&input) { + Ok(config) => config::emit(&config).into(), + Err(e) => e.to_compile_error().into(), + } +} + /// Compile an enum into the words one value may be. /// /// What a CLI calls an enum — `--shell bash` — and what the spec calls `choices`. The diff --git a/derive/src/model.rs b/derive/src/model.rs index ff37f6a08..9e30eaa86 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -87,6 +87,12 @@ pub struct Cli { /// nothing itself. A struct that declares arguments of its own is refused, because /// forwarding would drop them; see [`check`](Self::check). pub dispatch: Dispatch, + /// The type deriving `usage::Config` whose settings this CLI's emitted spec carries. + /// + /// Named rather than discovered — a macro sees one item — so `to_kdl` can append the + /// type's `config` block and the spec documents the settings the way it documents the + /// commands. Root-only, like `settings`: one spec, one `config` block. + pub config: Option, /// The oldest `usage` that can read the emitted spec, when the CLI says. /// /// Declared rather than computed. Working it out would mean a table from every property to @@ -686,6 +692,7 @@ impl Cli { spec_extra: None, settings: false, dispatch: Dispatch::default(), + config: None, min_usage_version: None, usage: None, effect: None, @@ -817,6 +824,17 @@ impl Cli { "run_with" => cli.dispatch.run_with = flag_value(&meta)?, "run_async" => cli.dispatch.run_async = flag_value(&meta)?, "run_async_with" => cli.dispatch.run_async_with = flag_value(&meta)?, + "config" => { + let value = &meta.require_name_value()?.value; + if !matches!(value, Expr::Path(_)) { + return Err(syn::Error::new_spanned( + value, + "`config` names a type deriving `usage::Config`, as in \ + `config = Settings`", + )); + } + cli.config = Some(syn::parse2(quote::ToTokens::to_token_stream(value))?); + } "verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?, "effect" => cli.effect = Some(effect_value(&meta)?), "alias" | "aliases" if clap_attr => { @@ -1183,6 +1201,15 @@ impl Cli { is asked for its own by whoever flattens it", )); } + // One spec, one `config` block: the settings belong to the program, and only the + // root emits a spec for them to land in. + if self.config.is_some() { + return Err(self.misplaced( + ident, + "`config` belongs on the root, where `#[derive(Cli)]` is: the emitted \ + spec carries one `config` block for the whole program", + )); + } // One spec, one claim about which `usage` can read it — and only the root writes a // spec at all, so a command declaring it was storing a value with nowhere to go. if self.min_usage_version.is_some() { @@ -3692,7 +3719,7 @@ pub fn type_name(ty: &Type) -> String { /// Native `#[usage(...)]` plus clap-compatible `#[command(...)]` and /// `#[arg(...)]` attributes. The latter appears on fields (and inline variants), /// while the iterator is shared by all three model readers. -fn attrs(attrs: &[Attribute]) -> impl Iterator { +pub(crate) fn attrs(attrs: &[Attribute]) -> impl Iterator { attrs.iter().filter(|a| { a.path().is_ident("usage") || a.path().is_ident("command") @@ -3710,21 +3737,21 @@ fn value_attrs(attrs: &[Attribute]) -> impl Iterator { .filter(|a| a.path().is_ident("usage") || a.path().is_ident("value")) } -fn nested(attr: &Attribute) -> syn::Result> { +pub(crate) fn nested(attr: &Attribute) -> syn::Result> { let list = attr.meta.require_list()?; let parsed = list .parse_args_with(syn::punctuated::Punctuated::::parse_terminated)?; Ok(parsed.into_iter().collect()) } -fn ident_of(path: &syn::Path) -> String { +pub(crate) fn ident_of(path: &syn::Path) -> String { path.segments .last() .map(|s| s.ident.to_string()) .unwrap_or_default() } -fn string_value(meta: &Meta) -> syn::Result { +pub(crate) fn string_value(meta: &Meta) -> syn::Result { let value = &meta.require_name_value()?.value; match value { Expr::Lit(ExprLit { @@ -4433,7 +4460,7 @@ fn char_value(meta: &Meta) -> syn::Result { } /// A boolean option, where the bare word means true: `global` or `global = true`. -fn flag_value(meta: &Meta) -> syn::Result { +pub(crate) fn flag_value(meta: &Meta) -> syn::Result { match meta { Meta::Path(_) => Ok(true), _ => { @@ -4456,7 +4483,7 @@ fn flag_value(meta: &Meta) -> syn::Result { /// The first paragraph is the short form; the whole comment is the long form and is only /// reported when it says more than the short one. Prose is flowed by default, while /// `verbatim` keeps line breaks and whitespace for tables, examples, and ASCII art. -fn doc_comment( +pub(crate) fn doc_comment( attrs: &[Attribute], verbatim: bool, ) -> syn::Result<(Option, Option)> { @@ -7008,6 +7035,56 @@ mod tests { ); } + #[test] + fn config_names_a_type_deriving_usage_config() { + // A path, because the attribute names a *type* whose `spec_kdl()` the emitted spec + // calls. A string looks close enough to the other metadata attributes to write by + // mistake, and it compiled to a `config` block that was silently never emitted. + let parsed = cli(r#" + #[usage(config = Settings)] + struct Ex { + #[usage(long)] + plain: bool, + } + "#) + .expect("parses"); + assert!(parsed.config.is_some(), "`config = Settings` was dropped"); + + let err = rejection( + r#" + #[usage(config = "Settings")] + struct Ex { + #[usage(long)] + plain: bool, + } + "#, + ); + assert!( + err.contains("names a type deriving `usage::Config`"), + "unhelpful: {err}" + ); + } + + #[test] + fn the_config_attribute_belongs_on_the_root() { + // One program, one `config` block. On a group it would have been parsed and never + // read, which is the silence the position check exists to replace. + let err = position_error( + r#" + #[usage(config = Settings)] + struct Ex { + #[usage(long)] + plain: bool, + } + "#, + false, + ); + assert!( + err.contains("`config` belongs on the root"), + "unhelpful: {err}" + ); + } + #[test] fn a_setting_is_allowed_wherever_a_flag_is() { // It used to be refused outside the root, because only the root generated a layer and one diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f66e769d5..5c5dda36a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -69,6 +69,7 @@ export default defineConfig({ { text: "Migrating from clap", link: "/rust/migrating-from-clap" }, { text: "clap Compatibility", link: "/rust/clap-compatibility" }, { text: "Validation", link: "/rust/validation" }, + { text: "Settings", link: "/rust/settings" }, { text: "Help and Errors", link: "/rust/help" }, { text: "Performance", link: "/rust/performance" }, { text: "Completions", link: "/rust/completions" }, diff --git a/docs/rust/args-and-flags.md b/docs/rust/args-and-flags.md index 5f58c016d..06887bb53 100644 --- a/docs/rust/args-and-flags.md +++ b/docs/rust/args-and-flags.md @@ -236,23 +236,24 @@ A global flag may be given **once per command level**, with the innermost occurr On the root `#[derive(Cli)]` struct: -| Attribute | Effect | -| ----------------------------------- | -------------------------------------------------------------- | -| `bin = "…"` | The binary name (used in help and the spec) | -| `name = "…"` | A friendly display name | -| `version` / `version = "…"` | Enable `--version`/`-V`; bare form uses `CARGO_PKG_VERSION` | -| `long_version = "…"` | Extended `--version` text while `-V` stays concise | -| `about` / `long_about` | Description (doc comments work too) | -| `usage = "…"` | Verbatim synopsis line(s), replacing the generated one | -| `before_help` / `after_help` | Extra text around the help page (`*_long_help` variants too) | -| `unknown_flags = "value"\|"error"` | Treat unknown flags as values instead of errors | -| `default_subcommand = "run"` | Command to assume when argv names none | -| `multicall` | Treat argv[0]'s basename as a subcommand (busybox-style) | -| `view("bin", root = "command")` | Promote a command as another executable surface | -| `completion` | Generate completion support ([Completions](/rust/completions)) | -| `settings` | Generate config-settings bindings | -| `min_usage_version = "…"` | Declare the minimum usage version the spec needs | -| `group("name", required, multiple)` | Declare a flag group ([Validation](/rust/validation#groups)) | +| Attribute | Effect | +| ----------------------------------- | ----------------------------------------------------------------- | +| `bin = "…"` | The binary name (used in help and the spec) | +| `name = "…"` | A friendly display name | +| `version` / `version = "…"` | Enable `--version`/`-V`; bare form uses `CARGO_PKG_VERSION` | +| `long_version = "…"` | Extended `--version` text while `-V` stays concise | +| `about` / `long_about` | Description (doc comments work too) | +| `usage = "…"` | Verbatim synopsis line(s), replacing the generated one | +| `before_help` / `after_help` | Extra text around the help page (`*_long_help` variants too) | +| `unknown_flags = "value"\|"error"` | Treat unknown flags as values instead of errors | +| `default_subcommand = "run"` | Command to assume when argv names none | +| `multicall` | Treat argv[0]'s basename as a subcommand (busybox-style) | +| `view("bin", root = "command")` | Promote a command as another executable surface | +| `completion` | Generate completion support ([Completions](/rust/completions)) | +| `settings` | Generate config-settings bindings | +| `config = Settings` | Emit the named type's `config` block ([Settings](/rust/settings)) | +| `min_usage_version = "…"` | Declare the minimum usage version the spec needs | +| `group("name", required, multiple)` | Declare a flag group ([Validation](/rust/validation#groups)) | On a `#[derive(Args)]` struct (refused on the root): diff --git a/docs/rust/settings.md b/docs/rust/settings.md new file mode 100644 index 000000000..4f2ccdc8f --- /dev/null +++ b/docs/rust/settings.md @@ -0,0 +1,165 @@ +# Settings + +CLIs that resolve configuration from several places — flags, environment variables, config +files — have historically kept three descriptions of every setting in step by hand: a registry +file, a code generator, and the struct the program reads. `#[derive(usage::Config)]` collapses +them to one. The struct the CLI already holds its settings in becomes the declaration; the +derive generates the [usage-config](https://docs.rs/usage-config) registry, the reader that +fills the struct from a resolution, and the spec `config` block that documents it. + +Enable the `config` feature: + +```toml +[dependencies] +usage = { package = "usage-rs", version = "5.1", features = ["config"] } +``` + +## Declaring + +```rust +use usage_rs as usage; + +/// How this tool behaves, resolved from flags, the environment, and files. +#[derive(usage::Config)] +struct Settings { + /// How many jobs to run at once + #[usage(env = "EX_JOBS", default = 4, cli("--jobs", "-j"))] + jobs: u64, + + /// Paths to leave alone + #[usage(env = "EX_EXCLUDE", merge = "union", parse = "list_by_comma")] + exclude: Option>, + + /// Where the cache lives + #[usage(env = "EX_CACHE_DIR", default_fn = default_cache_dir, + default_note = "under the user cache directory")] + cache_dir: std::path::PathBuf, + + #[usage(flatten)] + task: TaskSettings, +} + +/// The `task.*` settings. +#[derive(usage::Config)] +#[usage(prefix = "task")] +struct TaskSettings { + /// How task output is interleaved + #[usage(default = "prefix", choices("prefix", "interleave"))] + output: String, +} + +fn default_cache_dir() -> std::path::PathBuf { + dirs::cache_dir().unwrap_or_default().join("ex") +} +``` + +The field's type is the setting's type. `bool`, integers, `f64`, `String`, `PathBuf`, +`Vec`, and `BTreeMap` name their spec types on their own; `Option` says +absence is a legitimate state. A type outside that table says what the spec should call it +with `ty = "…"` — a `String` field with `ty = "duration"` holds a span of time as its text, +which is how the fleet's registries store one. Doc comments become `help` and `long_help`, +exactly as they do for flags. + +Field attributes mirror the spec's [`prop` vocabulary](/spec/reference/config): + +| Attribute | Effect | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `env = "X"` / `env("A", "B")` | Environment variables, highest precedence first | +| `deprecated_env("OLD")` | Deprecated aliases, consulted afterwards and warned about | +| `default = 4` / `default(80, 443)` | The value when no layer supplies one | +| `default_fn = path` | A computed default (`fn() -> T`), applied after the resolution | +| `default_note = "…"` | Prose beside the default, for docs | +| `cli("--jobs", "-j")` | The flags that set it — what `Registry::drift` holds bindings against | +| `source("git", "hk.jobs")` | Its keys in sources usage does not know about | +| `choices("a", "b")` | The only values it accepts | +| `merge = "union"` / `"deep"` | How a collection combines across layers | +| `scope = "global"` / `"env"` | Where a value is accepted from | +| `parse = "list_by_comma"` | How one string becomes several values | +| `alias("other")` | Equivalent keys accepted without a warning — written in full, so a group's `prefix` is repeated rather than implied | +| `key = "match"` | The dotted key, when the field name is not it | +| `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | +| `flatten` | Splice another `Config` struct's settings in at this position | + +`ty` renames what the spec calls a setting; it cannot change what the field holds. The merge +coerces to the _declared_ type, so that is what decides the shape the field is handed — a +`ty = "uint"` on a `String` field would be given an integer the field cannot read, whatever +anyone configured, and is refused. A pairing that can read is left alone: `ty = "int"` on a +`u8` reads whenever the value fits, which is the author's call to make. + +An `alias` is written in full, including a group's `prefix` — `alias("task.out")` inside a +`#[usage(prefix = "task")]` group, not `alias("out")`. An alias is usually a name a setting +used to have, and one that moved into a group often wants its old unprefixed spelling, so the +full form is the one that can say either. + +A default and a choice are written as the type the field holds. Nothing coerces a declared +default — the resolver seeds it as written and hands it to the field — so `default = 1` on a +`String` field is a compile error rather than the text `1`, and `default(80)` is how a list of +one is spelled apart from a bare `default = 80`. Choices follow the same spelling so the two +can be compared, with one exception that is not a coercion: on a list setting the choices name +what a single _item_ may be, so `choices("a", "b")` beside `default("a", "b")` is a `Vec` +whose every value is one of them. + +A flattened group declares its own dotted keys under its own `#[usage(prefix = "…")]`, and +the parent joins the slices at compile time — two groups declaring the same key are a compile +error, not a shadowed setting. + +## Resolving + +The derive generates `SETTINGS_PROPS`, `SETTINGS_REGISTRY`, `read`, and `spec_kdl` on the +struct. The CLI names the layers it has — that stays its own business — and the registry +decides what every value means: + +```rust +use usage::config::{resolve, EnvLayer, FileLayer, FileScope, Layers}; + +let (cli, cli_layer) = Ex::parse_from_with_settings(&argv)?; +let env = EnvLayer::from_process(); +let project = FileLayer::find_up("ex.toml", &cwd, None, FileScope::Project); +let resolved = resolve( + Settings::SETTINGS_REGISTRY, + Layers::new().then(&cli_layer).then(&env).then(&project), +)?; +let settings = Settings::read(&resolved)?; +for warning in usage::config::explain::warnings(&resolved) { + eprintln!("{warning}"); +} +``` + +`read` visits every field before returning, so the error is the whole list of what is wrong +rather than the first thing found. Provenance is the merge's own output: `explain`, `list`, +and per-setting `origin` come free, without a second merge to drift from the first. + +## The spec carries the settings + +A root deriving [`Cli`](/rust/args-and-flags) names its settings type, and its emitted spec +carries the `config` block — so docs, JSON schema, and the reserved `config_keys` / +`config_values` completers read declarations made in Rust exactly as they read ones made in +KDL: + +```rust +#[derive(usage::Cli)] +#[usage(bin = "ex", config = Settings)] +struct Ex { + /// How many jobs to run at once + #[usage(long, short = 'j', setting = "jobs")] + jobs: Option, +} +``` + +`setting = "key"` on a flag is the executable binding; `cli("--jobs")` on the field is the +documented one. The adopter's whole drift test is one line: + +```rust +assert_eq!(Settings::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS), Vec::::new()); +``` + +## The spec block + +The struct is the only declaration. `Settings::spec_kdl()` renders it as the spec's +`config { prop … }` block, and `#[usage(config = Settings)]` on the `Cli` root puts that +block in the emitted spec — so docs, the JSON schema, and the `config_keys` / `config_values` +completers read settings declared in Rust exactly as they read ones written in KDL. + +There is no second, KDL-first backend to choose between: a `build.rs` that generated the +registry from the spec was a third description of every setting, which is the drift this +derive exists to remove. diff --git a/docs/spec/resolution.md b/docs/spec/resolution.md index 940c07af9..d41174c76 100644 --- a/docs/spec/resolution.md +++ b/docs/spec/resolution.md @@ -190,10 +190,10 @@ cargo test -p usage-conformance --test config ## Implementations [`usage-config`](https://github.com/jdx/usage/tree/main/config) resolves; it carries -no spec parser, so a CLI ships a resolver rather than a KDL reader. -[`usage-config-build`](https://github.com/jdx/usage/tree/main/config-build) reads the -spec at build time and emits the registry as consts, along with the typed `Settings` -struct a CLI reads — so a setting that is declared is a setting that resolves, with -no second declaration to keep in step. It takes nothing from `usage-lib` beyond the -spec parser: the build script that reads your settings does not compile a markdown -renderer, an expression evaluator, or a second argument parser to do it. +no spec parser, so a CLI ships a resolver rather than a KDL reader. A Rust CLI declares +its settings with [`#[derive(usage::Config)]`](/rust/settings) on the typed `Settings` +struct it already holds, and the derive emits the registry as consts beside it — so a +setting that is declared is a setting that resolves, with no second declaration to keep +in step, and the spec's `config` block is rendered from the same declaration. Nothing +reads KDL to do it: the declaration is Rust, so an adopter's build compiles a proc macro +rather than a spec parser. diff --git a/lib/Cargo.toml b/lib/Cargo.toml index ca2f47719..5f9948e05 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -39,7 +39,7 @@ thiserror = "2" versions = "7" # No `xx`: three helpers (a lazy-regex macro, `file::read_to_string`, `check_status`) were # worth 21 crates in every dependent's tree — duct, homedir, nix, rand and their libc -# subtrees — including `usage-config-build`, which runs in an adopter's build script. +# subtrees — including one that ran in an adopter's build script. # `LazyLock` and `std::fs` cover all three, so nothing here needs a process library. usage-validation = { workspace = true, optional = true } diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index fadd24891..edee0e32d 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -777,10 +777,15 @@ const SHORT_COL: usize = 4; /// The twin of `column_usage` in `usage-argv`'s `help` module; the two must agree, and the gate /// over mise's spec is what says they do. fn column_usage(flag: &crate::SpecFlag) -> String { - let rest = flag.negate.as_ref().map_or_else( - || flag.usage.trim().to_string(), - |negate| format!("{} / {}", flag.usage.trim(), negate.trim()), - ); + let usage = flag.usage.trim(); + let rest = match flag.negate.as_deref().map(str::trim) { + // `SpecFlag::usage` already writes the negation for a flag that has no other + // spelling — clap's `SetFalse`, tak's `--no-credit` — and appending it again rendered + // `--color / --color`. + Some(negate) if negate == usage => usage.to_string(), + Some(negate) => format!("{usage} / {negate}"), + None => usage.to_string(), + }; let Some(long) = flag.long.first() else { return rest; }; @@ -823,6 +828,13 @@ fn reference_usage(flag: &crate::SpecFlag) -> String { .map(|long| format!("--{long}")), ) .collect(); + // A flag whose only spelling is its negation — clap's `SetFalse`, tak's `--no-credit` — + // has no long or short form to list, so without this the reference heading was empty. + if forms.is_empty() { + if let Some(negate) = &flag.negate { + forms.push(negate.clone()); + } + } if flag.usage.trim().starts_with(&format!("{}:", flag.name)) { forms.insert(0, format!("{}:", flag.name)); } @@ -1059,3 +1071,29 @@ impl SpecExample { } } } + +#[cfg(test)] +mod tests { + /// A flag whose only spelling is its negation — clap's `SetFalse`, tak's `--no-credit` — + /// carries that spelling as its usage string already, and the flags column appended the + /// negation a second time: `--color / --color`. + #[test] + fn a_flag_spelled_only_as_its_negation_is_listed_once() { + let spec: crate::Spec = "flag \"color:\" negate=\"--color\"\n" + .parse() + .expect("a spec"); + assert_eq!(super::column_usage(&spec.cmd.flags[0]), "--color"); + } + + /// And a flag that has both keeps both, which is what the ` / ` is for. + #[test] + fn a_flag_with_a_positive_spelling_lists_its_negation_beside_it() { + let spec: crate::Spec = "flag \"--color\" negate=\"--no-color\"\n" + .parse() + .expect("a spec"); + assert_eq!( + super::column_usage(&spec.cmd.flags[0]), + " --color / --no-color" + ); + } +} diff --git a/lib/src/spec/config.rs b/lib/src/spec/config.rs index c6adb6b67..eeba5b5ae 100644 --- a/lib/src/spec/config.rs +++ b/lib/src/spec/config.rs @@ -154,7 +154,8 @@ impl SpecConfigValue { // is also what `usage-config` writes a float as, and the two have to agree: a spec's // `default=1.0` on a string-typed prop is coerced *here* and its `choice 1.0` is read // *there*, so a difference of one character refused a default and a choice that were - // written identically. `usage-config-build` has a test that holds the two together. + // written identically. `usage-conformance` has a test that holds the two together, + // because it is the one crate that can see both. Self::Float(f) => { let text = f.to_string(); match f.is_finite() && !text.contains(['.', 'e', 'E']) { diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index 3cb45cd57..52665cb63 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -839,7 +839,18 @@ impl SpecFlag { pub fn usage(&self) -> String { let mut parts = vec![]; let name = get_name_from_short_and_long(&self.short, &self.long).unwrap_or_default(); - if name != self.name { + // A flag whose only spelling is its negation — clap's `SetFalse`, tak's + // `--no-credit` — is named after that spelling, so the `name:` prefix would repeat + // it and the spelling a reader has to type would appear nowhere. + let negation_only = self.short.is_empty() + && self.long.is_empty() + && self + .negate + .as_deref() + .is_some_and(|negate| negate.trim_start_matches('-') == self.name); + if negation_only { + parts.push(self.negate.clone().unwrap_or_default()); + } else if name != self.name { parts.push(format!("{}:", self.name)); } if let Some(short) = self.short.first() { @@ -1791,8 +1802,12 @@ mod tests { assert!(color.long.is_empty()); assert!(color.short.is_empty()); assert_eq!(color.negate.as_deref(), Some("--color")); - assert_eq!(color.usage(), "color:"); - assert_eq!(color.usage, "color:"); + // Displayed as the spelling a reader has to type. `color:` names the flag's + // identity, which the *spec* keeps below, but as a usage string it showed a reader + // nothing they could enter — a docs heading read `### color:` for a flag whose only + // form is `--color`. + assert_eq!(color.usage(), "--color"); + assert_eq!(color.usage, "--color"); let rendered = spec.to_string(); assert!( diff --git a/usage-rs/Cargo.toml b/usage-rs/Cargo.toml index 4e5b2809c..335796b35 100644 --- a/usage-rs/Cargo.toml +++ b/usage-rs/Cargo.toml @@ -15,6 +15,7 @@ usage-argv = { workspace = true } usage-derive = { workspace = true, optional = true } usage-test = { workspace = true, optional = true } usage-validation = { workspace = true, optional = true } +usage-config = { workspace = true, optional = true } [features] # Applications get a usable CLI out of the box: parse tables, help, and @@ -32,6 +33,8 @@ validation = ["dep:usage-validation"] # Assertions about what a command line parses to, what a page says, and what a shell is # offered. A dev-dependency feature: nothing in an application's own code calls it. test = ["spec", "help", "dep:usage-test"] +# Settings: the `usage::Config` derive, and the resolver as `usage::config`. +config = ["spec", "dep:usage-config"] [package.metadata.release] shared-version = true diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index faa235c28..62964f200 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -52,6 +52,10 @@ extern crate self as usage_rs; pub use usage_argv as argv; pub use usage_argv::*; +#[cfg(feature = "config")] +pub use usage_config as config; +#[cfg(feature = "config")] +pub use usage_derive::Config; #[cfg(feature = "spec")] pub use usage_derive::{Args, Cli, Subcommands, ValueEnum}; #[cfg(feature = "test")] @@ -97,6 +101,27 @@ mod tests { assert_eq!(Internal::spec().bin, Some("internal")); } + #[cfg(feature = "config")] + #[derive(crate::Config)] + struct InternalSettings { + /// How many jobs to run at once + #[usage(env = "INTERNAL_JOBS", default = 4)] + jobs: u64, + } + + #[cfg(feature = "config")] + #[test] + fn the_config_derive_resolves_the_facade_from_inside_the_facade() { + let resolved = crate::config::resolve( + InternalSettings::SETTINGS_REGISTRY, + crate::config::Layers::new(), + ) + .expect("resolves"); + let settings = InternalSettings::read(&resolved).expect("reads"); + assert_eq!(settings.jobs, 4); + assert!(InternalSettings::spec_kdl().contains(r#"prop "jobs""#)); + } + #[cfg(feature = "validation")] #[test] fn derives_evaluate_portable_validation_expressions() {