Skip to main content

usage_config/
spec.rs

1//! A registry written back out as the spec's `config` block.
2//!
3//! A CLI declares its settings in code with `#[derive(usage::Config)]`, and this is the way
4//! back out: the registry it derived, rendered as the `config { prop … }` block the spec
5//! grammar defines, so docs, JSON schema, completions and every other spec consumer read
6//! declarations made in Rust exactly as they read ones made in KDL.
7//!
8//! Written by hand rather than through a KDL library for the same reason the argv crate's
9//! spec writer is: this crate has no dependencies, and the grammar being emitted is the small
10//! fixed one the spec parser defines.
11
12use crate::registry::{Merge, PropMeta, Scope};
13use crate::source::FileScope;
14use crate::value::Const;
15use std::fmt::Write;
16
17/// Documentation-only metadata for one property.
18///
19/// Kept beside, rather than in, [`PropMeta`]: resolution never interprets these fields. A
20/// derived [`crate::Props`] exposes a parallel slice in the same declaration order.
21#[derive(Debug, Copy, Clone, PartialEq)]
22pub struct PropSpec {
23    pub help_heading: Option<&'static str>,
24    pub writes_to: Option<&'static str>,
25    /// Tool-private metadata, emitted as `x "key" value` nodes.
26    pub extensions: &'static [(&'static str, Const)],
27}
28
29impl PropSpec {
30    pub const EMPTY: Self = Self {
31        help_heading: None,
32        writes_to: None,
33        extensions: &[],
34    };
35}
36
37/// A custom source declaration at the top of a spec's `config` block.
38#[derive(Debug, Copy, Clone)]
39pub struct SpecSource {
40    pub kind: &'static str,
41    pub name: Option<&'static str>,
42    pub doc_hint: Option<&'static str>,
43    pub set_hint: Option<&'static str>,
44}
45
46/// A config file declaration. Slice order is ascending precedence.
47#[derive(Debug, Copy, Clone)]
48pub struct SpecFile {
49    pub path: &'static str,
50    pub findup: bool,
51    pub scope: FileScope,
52    pub format: Option<&'static str>,
53}
54
55/// Spec-only declarations generated from a settings struct.
56#[derive(Debug, Copy, Clone)]
57pub struct ConfigSpec {
58    pub props: &'static [PropSpec],
59    pub sources: &'static [SpecSource],
60    pub files: &'static [SpecFile],
61}
62
63impl ConfigSpec {
64    pub const fn new(
65        props: &'static [PropSpec],
66        sources: &'static [SpecSource],
67        files: &'static [SpecFile],
68    ) -> Self {
69        Self {
70            props,
71            sources,
72            files,
73        }
74    }
75}
76
77/// The spec `config` block for these settings, as KDL.
78///
79/// Ends with a newline, so it can be appended to an emitted spec as-is. Props are written in
80/// registry order, which for a derived registry is declaration order.
81pub fn spec_kdl(props: &[PropMeta]) -> String {
82    spec_kdl_with(props, ConfigSpec::new(&[], &[], &[]))
83}
84
85/// The complete spec `config` block, including metadata resolution does not use.
86pub fn spec_kdl_with(props: &[PropMeta], spec: ConfigSpec) -> String {
87    assert!(
88        spec.props.is_empty() || spec.props.len() == props.len(),
89        "property spec metadata must have one entry per property"
90    );
91    let mut out = String::from("config {\n");
92    // Source kinds are sorted by the derive; files deliberately retain author order because
93    // their order is precedence.
94    for source in spec.sources {
95        let _ = write!(out, "    source {}", quoted(source.kind));
96        if let Some(name) = source.name {
97            let _ = write!(out, " name={}", quoted(name));
98        }
99        if let Some(hint) = source.doc_hint {
100            let _ = write!(out, " doc_hint={}", quoted(hint));
101        }
102        if let Some(hint) = source.set_hint {
103            let _ = write!(out, " set_hint={}", quoted(hint));
104        }
105        out.push('\n');
106    }
107    for file in spec.files {
108        let _ = write!(out, "    file {}", quoted(file.path));
109        if file.findup {
110            out.push_str(" findup=#true");
111        }
112        match file.scope {
113            FileScope::Project => {}
114            FileScope::Global => out.push_str(" scope=\"global\""),
115            FileScope::System => out.push_str(" scope=\"system\""),
116        }
117        if let Some(format) = file.format {
118            let _ = write!(out, " format={}", quoted(format));
119        }
120        out.push('\n');
121    }
122    for (index, meta) in props.iter().enumerate() {
123        let prop_spec = spec.props.get(index).copied().unwrap_or(PropSpec::EMPTY);
124        let _ = write_prop(&mut out, meta, prop_spec);
125    }
126    out.push_str("}\n");
127    out
128}
129
130fn write_prop(out: &mut String, meta: &PropMeta, spec: PropSpec) -> std::fmt::Result {
131    write!(
132        out,
133        "    prop {} type={}",
134        quoted(meta.key),
135        quoted(&meta.ty.name())
136    )?;
137    if let Some(default) = scalar_default(meta.default) {
138        write!(out, " default={default}")?;
139    }
140    if let Some(note) = meta.default_note {
141        write!(out, " default_note={}", quoted(note))?;
142    }
143    if let Some(optional) = meta.optional {
144        write!(out, " optional=#{optional}")?;
145    }
146    match meta.merge {
147        Merge::Replace => {}
148        Merge::Union => out.push_str(" merge=\"union\""),
149        Merge::Deep => out.push_str(" merge=\"deep\""),
150    }
151    if let Some(parse) = meta.parse {
152        write!(out, " parse={}", quoted(parse.name()))?;
153    }
154    match meta.scope {
155        Scope::Any => {}
156        Scope::Global => out.push_str(" scope=\"global\""),
157        Scope::Env => out.push_str(" scope=\"env\""),
158    }
159    if meta.hide {
160        out.push_str(" hide=#true");
161    }
162    if let Some(deprecated) = meta.deprecated {
163        write!(out, " deprecated={}", quoted(deprecated))?;
164    }
165    if let Some(at) = meta.deprecated_warn_at {
166        write!(out, " deprecated_warn_at={}", quoted(at))?;
167    }
168    if let Some(at) = meta.deprecated_remove_at {
169        write!(out, " deprecated_remove_at={}", quoted(at))?;
170    }
171    if let Some(renamed_to) = meta.renamed_to {
172        write!(out, " renamed_to={}", quoted(renamed_to))?;
173    }
174    if let Some(since) = meta.since {
175        write!(out, " since={}", quoted(since))?;
176    }
177    if let Some(help) = meta.help {
178        write!(out, " help={}", quoted(help))?;
179    }
180    if let Some(long_help) = meta.long_help {
181        write!(out, " long_help={}", quoted(long_help))?;
182    }
183    if let Some(heading) = spec.help_heading {
184        write!(out, " help_heading={}", quoted(heading))?;
185    }
186    if let Some(writes_to) = spec.writes_to {
187        write!(out, " writes_to={}", quoted(writes_to))?;
188    }
189
190    let mut children = Vec::new();
191    if let Some(Const::List(items)) = meta.default {
192        // A list default is a child node — `default 80 443` — because several values do not
193        // fit one `default=` entry.
194        let rendered: Vec<String> = items.iter().map(|item| const_kdl(*item)).collect();
195        children.push(format!("default {}", rendered.join(" ")));
196    }
197    if !meta.envs.is_empty() {
198        children.push(word_list("env", meta.envs));
199    }
200    if !meta.deprecated_envs.is_empty() {
201        children.push(word_list("deprecated_env", meta.deprecated_envs));
202    }
203    if !meta.aliases.is_empty() {
204        children.push(word_list("alias", meta.aliases));
205    }
206    if !meta.cli.is_empty() {
207        children.push(word_list("cli", meta.cli));
208    }
209    for example in meta.examples {
210        children.push(format!("example {}", quoted(example)));
211    }
212    // One `source` node per kind, holding every key bound in it, in declaration order —
213    // `source "pkl" "exclude" "defaults.exclude"`.
214    let mut kinds: Vec<&str> = Vec::new();
215    for (kind, _) in meta.bindings {
216        if !kinds.contains(kind) {
217            kinds.push(kind);
218        }
219    }
220    for kind in kinds {
221        let keys: Vec<String> = meta
222            .bindings
223            .iter()
224            .filter(|(k, _)| *k == kind)
225            .map(|(_, key)| quoted(key))
226            .collect();
227        children.push(format!("source {} {}", quoted(kind), keys.join(" ")));
228    }
229    // One `choice` node carries one value, so only a scalar belongs here. A registry written
230    // by hand can hold a list or a table; rendering one produced `choice 1 2` or a bare
231    // `choice`, neither of which the prop grammar can read back.
232    let choices: Vec<String> = meta
233        .choices
234        .iter()
235        .filter(|choice| !matches!(choice, Const::List(_) | Const::Map(_)))
236        .map(|choice| const_kdl(*choice))
237        .collect();
238    if !choices.is_empty() {
239        let mut block = String::from("choices {\n");
240        for choice in choices {
241            let _ = writeln!(block, "            choice {choice}");
242        }
243        block.push_str("        }");
244        children.push(block);
245    }
246    for (key, value) in spec.extensions {
247        children.push(format!("x {} {}", quoted(key), const_kdl(*value)));
248    }
249
250    if children.is_empty() {
251        out.push('\n');
252    } else {
253        out.push_str(" {\n");
254        for child in children {
255            let _ = writeln!(out, "        {child}");
256        }
257        out.push_str("    }\n");
258    }
259    Ok(())
260}
261
262/// A scalar default as a KDL entry value, or `None` for a list (a child node) or a table
263/// (which the prop grammar cannot hold, and a generator refuses before it gets here).
264fn scalar_default(default: Option<Const>) -> Option<String> {
265    match default? {
266        Const::List(_) | Const::Map(_) => None,
267        scalar => Some(const_kdl(scalar)),
268    }
269}
270
271/// One constant as KDL writes it: `#true`, `4`, `1.5`, `"text"`.
272fn const_kdl(value: Const) -> String {
273    match value {
274        Const::Bool(b) => format!("#{b}"),
275        Const::Int(i) => i.to_string(),
276        // `{:?}` keeps the decimal point, which KDL requires of a float — `1.0`, not `1`.
277        Const::Float(f) => format!("{f:?}"),
278        Const::Str(s) => quoted(s),
279        Const::List(items) => items
280            .iter()
281            .map(|item| const_kdl(*item))
282            .collect::<Vec<_>>()
283            .join(" "),
284        // Unreachable from a derived registry — the derive refuses table defaults — and not
285        // something the prop grammar can spell.
286        Const::Map(_) => String::new(),
287    }
288}
289
290fn word_list(name: &str, words: &[&str]) -> String {
291    let quoted: Vec<String> = words.iter().map(|word| quoted(word)).collect();
292    format!("{name} {}", quoted.join(" "))
293}
294
295/// `text` as a quoted KDL string.
296fn quoted(text: &str) -> String {
297    let mut out = String::with_capacity(text.len() + 2);
298    out.push('"');
299    for c in text.chars() {
300        match c {
301            '\\' => out.push_str("\\\\"),
302            '"' => out.push_str("\\\""),
303            '\n' => out.push_str("\\n"),
304            '\r' => out.push_str("\\r"),
305            '\t' => out.push_str("\\t"),
306            // KDL forbids a raw control character in a document at all, so the rest go through
307            // the escape it does spell. A `help` string that carried an ANSI escape wrote a
308            // block no parser would read back.
309            c if c.is_control() => {
310                let _ = write!(out, "\\u{{{:x}}}", c as u32);
311            }
312            c => out.push(c),
313        }
314    }
315    out.push('"');
316    out
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::ty::{Parser, Ty};
323
324    #[test]
325    fn a_registry_renders_as_the_config_block_the_spec_grammar_defines() {
326        static PROPS: &[PropMeta] = &[
327            PropMeta {
328                default: Some(Const::Int(4)),
329                default_note: Some("0 = one per core"),
330                envs: &["HK_JOBS", "HK_JOB"],
331                deprecated_envs: &["HK_JOBS_OLD"],
332                cli: &["--jobs", "-j"],
333                bindings: &[("git", "hk.jobs")],
334                help: Some("How many jobs to run at once"),
335                ..PropMeta::new("jobs", Ty::Uint)
336            },
337            PropMeta {
338                merge: Merge::Union,
339                parse: Some(Parser::ListByComma),
340                envs: &["HK_EXCLUDE"],
341                bindings: &[("pkl", "exclude"), ("pkl", "defaults.exclude")],
342                ..PropMeta::new("exclude", Ty::List(&Ty::String))
343            },
344            PropMeta {
345                default: Some(Const::Str("git")),
346                choices: &[
347                    Const::Str("git"),
348                    Const::Str("patch-file"),
349                    Const::Str("none"),
350                ],
351                help: Some("How to \"stash\" first"),
352                ..PropMeta::new("stash", Ty::String)
353            },
354            PropMeta {
355                default: Some(Const::List(&[Const::Int(80), Const::Int(443)])),
356                ..PropMeta::new("ports", Ty::List(&Ty::Uint))
357            },
358            PropMeta {
359                scope: Scope::Env,
360                hide: true,
361                envs: &["CI"],
362                ..PropMeta::new("ci", Ty::Bool)
363            },
364            // The rest of the vocabulary, in one prop: where each of these lands — an entry on
365            // the `prop` node, or a child of it — is exactly what a golden string is for.
366            PropMeta {
367                optional: Some(true),
368                aliases: &["fail-fast.legacy", "failfast"],
369                examples: &["true", "false"],
370                deprecated: Some("use `stop-on-error`"),
371                deprecated_warn_at: Some("6.0.0"),
372                deprecated_remove_at: Some("7.0.0"),
373                since: Some("5.2.0"),
374                help: Some("Stop at the first failure"),
375                long_help: Some("Whether a failing job stops the rest."),
376                ..PropMeta::new("fail_fast", Ty::Option(&Ty::Bool))
377            },
378            // A `choice` node carries one value, and a registry written by hand can hold a
379            // list where one belongs. The scalar survives; the list is not something the prop
380            // grammar can spell, so it is left out rather than written as two arguments.
381            PropMeta {
382                choices: &[
383                    Const::Str("plain"),
384                    Const::List(&[Const::Int(1), Const::Int(2)]),
385                ],
386                ..PropMeta::new("level", Ty::Any)
387            },
388        ];
389        let kdl = spec_kdl(PROPS);
390        assert_eq!(
391            kdl,
392            r#"config {
393    prop "jobs" type="uint" default=4 default_note="0 = one per core" help="How many jobs to run at once" {
394        env "HK_JOBS" "HK_JOB"
395        deprecated_env "HK_JOBS_OLD"
396        cli "--jobs" "-j"
397        source "git" "hk.jobs"
398    }
399    prop "exclude" type="list<string>" merge="union" parse="list_by_comma" {
400        env "HK_EXCLUDE"
401        source "pkl" "exclude" "defaults.exclude"
402    }
403    prop "stash" type="string" default="git" help="How to \"stash\" first" {
404        choices {
405            choice "git"
406            choice "patch-file"
407            choice "none"
408        }
409    }
410    prop "ports" type="list<uint>" {
411        default 80 443
412    }
413    prop "ci" type="bool" scope="env" hide=#true {
414        env "CI"
415    }
416    prop "fail_fast" type="option<bool>" 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." {
417        alias "fail-fast.legacy" "failfast"
418        example "true"
419        example "false"
420    }
421    prop "level" type="any" {
422        choices {
423            choice "plain"
424        }
425    }
426}
427"#
428        );
429    }
430
431    /// KDL forbids a raw control character anywhere in a document, so a value carrying one has
432    /// to go out as the escape KDL does spell — or the block this renders is one no parser,
433    /// including usage's own, will read back.
434    #[test]
435    fn a_control_character_in_a_value_is_escaped_rather_than_written() {
436        static PROPS: &[PropMeta] = &[PropMeta {
437            help: Some("plain\u{1b}[0m and \u{0}"),
438            ..PropMeta::new("color", Ty::Bool)
439        }];
440        assert_eq!(
441            spec_kdl(PROPS),
442            "config {\n    prop \"color\" type=\"bool\" help=\"plain\\u{1b}[0m and \\u{0}\"\n}\n"
443        );
444    }
445
446    /// A prop whose only choices are shapes the grammar cannot spell gets no `choices` block at
447    /// all, rather than one holding a node with no argument.
448    #[test]
449    fn spec_only_metadata_is_written_without_changing_property_order() {
450        static PROPS: &[PropMeta] = &[
451            PropMeta::new("jobs", Ty::Uint),
452            PropMeta::new("exclude", Ty::List(&Ty::String)),
453        ];
454        static PROP_SPECS: &[PropSpec] = &[
455            PropSpec {
456                help_heading: Some("Performance"),
457                writes_to: Some("git"),
458                extensions: &[("ex.restart_required", Const::Bool(true))],
459            },
460            PropSpec::EMPTY,
461        ];
462        let spec = ConfigSpec::new(
463            PROP_SPECS,
464            &[
465                SpecSource {
466                    kind: "git",
467                    name: Some("git config"),
468                    doc_hint: Some("git config `{key}`"),
469                    set_hint: None,
470                },
471                SpecSource {
472                    kind: "npmrc",
473                    name: Some(".npmrc"),
474                    doc_hint: None,
475                    set_hint: None,
476                },
477            ],
478            &[
479                SpecFile {
480                    path: "/etc/ex.toml",
481                    findup: false,
482                    scope: FileScope::System,
483                    format: Some("toml"),
484                },
485                SpecFile {
486                    path: "ex.toml",
487                    findup: true,
488                    scope: FileScope::Project,
489                    format: None,
490                },
491            ],
492        );
493        assert_eq!(
494            spec_kdl_with(PROPS, spec),
495            r#"config {
496    source "git" name="git config" doc_hint="git config `{key}`"
497    source "npmrc" name=".npmrc"
498    file "/etc/ex.toml" scope="system" format="toml"
499    file "ex.toml" findup=#true
500    prop "jobs" type="uint" help_heading="Performance" writes_to="git" {
501        x "ex.restart_required" #true
502    }
503    prop "exclude" type="list<string>"
504}
505"#
506        );
507    }
508
509    #[test]
510    fn choices_no_single_value_can_hold_leave_no_block_behind() {
511        static PROPS: &[PropMeta] = &[PropMeta {
512            choices: &[Const::Map(&[("a", Const::Int(1))])],
513            ..PropMeta::new("shape", Ty::Any)
514        }];
515        assert_eq!(
516            spec_kdl(PROPS),
517            "config {\n    prop \"shape\" type=\"any\"\n}\n"
518        );
519    }
520}