diff --git a/Cargo.lock b/Cargo.lock index 891ec66d..f5085db8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2014,6 +2014,7 @@ dependencies = [ name = "usage-conformance" version = "0.0.0" dependencies = [ + "insta", "serde", "serde_json", "usage-argv", diff --git a/PLAN.md b/PLAN.md index 01a939d0..1059a675 100644 --- a/PLAN.md +++ b/PLAN.md @@ -97,14 +97,16 @@ manpages, and SDKs — never a runtime dependency of somebody else's program. ### Next: the derive -- [ ] **Static metadata tables** — a second, cold table holding what the hot one +- [x] **Static metadata tables** — a second, cold tree holding what the hot one deliberately omits: help and long help, about text, hidden-ness, visible and - hidden aliases, value names and hints, `choices`, defaults, `env`, effects, - mounts, examples. Separate from the parse tables so the hot path keeps its - cache locality. -- [ ] **KDL emission** — write the spec from those tables without depending on - usage-lib, which pulls kdl, miette, tera, and regex. Verified by - round-tripping the output through `Spec::from_str` and comparing. + hidden aliases, value names, `choices`, defaults, `env`, effects, mounts, + restart tokens, examples. Behind the `spec` feature, and each entry borrows + the parse-table entry it describes, so names have one definition and cannot + drift. +- [x] **KDL emission** — `Spec::to_kdl`, written by hand so the crate keeps having + no dependencies. Verified by parsing the output back with usage-lib and + checking the resulting spec field by field, then rendering it through the + markdown and manpage generators an adopter's docs build actually uses. - [ ] **`usage-derive` v0** — flags, positionals, subcommands, doc-comment help (first paragraph short, whole block long), spec emission. Usage-native attributes mirroring the KDL vocabulary rather than a clap dialect. @@ -117,6 +119,21 @@ manpages, and SDKs — never a runtime dependency of somebody else's program. they belong with the derive rather than in the parser. Closes the 24 corpus vectors usage-argv reports as out of scope. +### Spec gaps found on the way + +Each of these is a thing a CLI wants to say that the spec has no way to record. +Per the canonicality rule the spec gets extended first, so these block the derive +carrying them rather than being worked around. + +- [ ] **`help_heading`** — grouping flags under a heading in help output. clap has + it and mise uses it (in `watch`, for its vendored watchexec arguments), and + the metadata tree deliberately omits it rather than dropping it silently on + the way out. +- [ ] **A mount on the root command** — the spec accepts `mount` only inside a + `cmd` block, so a CLI whose _top-level_ subcommands are discovered by running + something cannot say so. Worth deciding whether that is a gap or a deliberate + restriction. + ### Then: what a CLI framework has to have - [ ] **Help rendering** — `--help` and `-h` from the static metadata, with diff --git a/argv/Cargo.toml b/argv/Cargo.toml index cb1de1ee..cab566b8 100644 --- a/argv/Cargo.toml +++ b/argv/Cargo.toml @@ -14,6 +14,11 @@ license = { workspace = true } # invocation of every CLI built on it. [dependencies] +[features] +# Cold-path metadata and spec emission. Off by default so a CLI that wants only +# a parser does not carry it. +spec = [] + [package.metadata.release] shared-version = true release = true diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 2af5501e..422eb81c 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -64,12 +64,22 @@ //! know a value's type, so they belong to the layer that owns the target struct. //! Keeping them out is what makes this loop small. //! +//! # Features +//! +//! - `spec` — a parallel tree of cold metadata (help text, choices, defaults, +//! effects) and a writer that emits it as a usage spec. Off by default: a +//! successful parse never reads any of it, so a CLI that only wants a parser +//! should not compile it. +//! //! [the argv grammar]: https://usage.jdx.dev/spec/argv #![forbid(unsafe_code)] use std::ffi::OsStr; +#[cfg(feature = "spec")] +pub mod spec; + /// How deep a command tree this parser will descend. /// /// The ancestor chain is kept in a fixed-size array so that a parse allocates diff --git a/argv/src/spec.rs b/argv/src/spec.rs new file mode 100644 index 00000000..cbec8f5b --- /dev/null +++ b/argv/src/spec.rs @@ -0,0 +1,718 @@ +//! Cold-path metadata, and writing it out as a spec. +//! +//! The tables in the crate root are what a parse reads: names, shorts, whether a +//! flag takes a value. Everything *else* a CLI knows about itself — help text, +//! choices, defaults, what a command does to the world — lives here instead, in a +//! parallel tree that points at those tables. +//! +//! Two reasons for the split. A successful parse never touches any of this, so +//! keeping it out of the hot tables keeps them dense; and a CLI that wants only a +//! parser does not compile it at all, since this module is behind the `spec` +//! feature. +//! +//! What the metadata does *not* do is repeat the tables. [`FlagMeta`] borrows the +//! [`Flag`] it describes, so long and short forms have exactly one definition and +//! cannot drift from what the parser matches. +//! +//! # Emitting +//! +//! Everything here lowers into the spec without loss, which is deliberate: a +//! field the spec cannot express would make the emitted KDL a summary rather than +//! a definition. Grouping flags under a heading in help output is the one thing a +//! CLI might want that has no spec node today, so it is absent here rather than +//! quietly dropped on the way out — the spec would have to gain it first. +//! +use core::fmt::Write as _; + +use crate::{Arg, Command, DoubleDash, Flag}; + +/// A whole CLI: the root command plus what describes the program itself. +#[derive(Debug, Clone, Copy)] +pub struct Spec<'a> { + /// The program's name. + pub name: &'a str, + /// The binary as invoked, when it differs from `name`. + pub bin: Option<&'a str>, + pub version: Option<&'a str>, + pub about: Option<&'a str>, + pub long_about: Option<&'a str>, + /// Which command the root falls back to when a word matches no subcommand. + /// mise uses this so `mise foo` completes as `mise run foo`. + pub default_subcommand: Option<&'a str>, + pub root: &'a CommandMeta<'a>, +} + +/// What a command knows about itself beyond how it parses. +#[derive(Debug, Clone, Copy)] +pub struct CommandMeta<'a> { + /// The parse table this describes. Names, aliases, and structure come from + /// here rather than being repeated. + pub cmd: &'a Command<'a>, + pub about: Option<&'a str>, + pub long_about: Option<&'a str>, + /// Aliases that work but are not shown in help or completions. Everything in + /// `cmd.aliases` and not here is visible. + pub hidden_aliases: &'a [&'a str], + /// Whether the command is hidden from help and completions. + pub hide: bool, + /// What running this does to the world, for a caller deciding whether to + /// confirm first. clap cannot express this, which is why mise keeps a + /// 330-entry table to bolt it on afterwards. + pub effect: Option, + /// A command to run at parse time to discover further subcommands. + /// + /// Only meaningful on a subcommand. The spec accepts `mount` inside a `cmd` + /// block and nowhere else, so setting this on the root is a mistake that + /// [`Spec::to_kdl`] catches in debug builds. + pub mount: Option<&'a str>, + /// A token that starts a fresh invocation of this command, such as mise's + /// `:::`. + pub restart_token: Option<&'a str>, + pub examples: &'a [Example<'a>], + /// Metadata for `cmd.flags`, in the same order. + pub flags: &'a [FlagMeta<'a>], + /// Metadata for `cmd.args`, in the same order. + pub args: &'a [ArgMeta<'a>], + /// Metadata for `cmd.subcommands`, in the same order. + pub subcommands: &'a [&'a CommandMeta<'a>], +} + +impl CommandMeta<'_> { + /// Metadata for a command with nothing declared, for struct update syntax. + pub const EMPTY: CommandMeta<'static> = CommandMeta { + cmd: &Command::EMPTY, + about: None, + long_about: None, + hidden_aliases: &[], + hide: false, + effect: None, + mount: None, + restart_token: None, + examples: &[], + flags: &[], + args: &[], + subcommands: &[], + }; +} + +/// What a flag knows about itself beyond how it parses. +#[derive(Debug, Clone, Copy)] +pub struct FlagMeta<'a> { + pub flag: &'a Flag<'a>, + /// Short help, shown by `-h`. + pub help: Option<&'a str>, + /// Long help, shown by `--help`. + pub long_help: Option<&'a str>, + /// The placeholder for the flag's value, such as `n` in `--jobs `. + pub value_name: Option<&'a str>, + pub env: Option<&'a str>, + pub default: &'a [&'a str], + pub choices: &'a [&'a str], + pub required: bool, + pub hide: bool, + /// Whether repetition is counted rather than collected, as in `-vvv`. + pub count: bool, + /// Whether the flag may be given more than once. Distinct from + /// [`Flag::variadic`], which is one occurrence taking several values. + pub repeatable: bool, + pub var_min: Option, + pub var_max: Option, + /// Flags this one displaces when both are given. + pub overrides: &'a [&'a str], + /// Flags that make this one unnecessary. + pub required_unless: &'a [&'a str], + pub effect: Option, +} + +impl FlagMeta<'_> { + /// Metadata for a flag with nothing declared, for struct update syntax. + pub const EMPTY: FlagMeta<'static> = FlagMeta { + flag: &Flag::BOOL, + help: None, + long_help: None, + value_name: None, + env: None, + default: &[], + choices: &[], + required: false, + hide: false, + count: false, + repeatable: false, + var_min: None, + var_max: None, + overrides: &[], + required_unless: &[], + effect: None, + }; +} + +/// What a positional argument knows about itself beyond how it parses. +#[derive(Debug, Clone, Copy)] +pub struct ArgMeta<'a> { + pub arg: &'a Arg<'a>, + pub help: Option<&'a str>, + pub long_help: Option<&'a str>, + pub env: Option<&'a str>, + pub default: &'a [&'a str], + pub choices: &'a [&'a str], + /// Whether the argument must be filled. The parser does not enforce this — + /// it is checked once the last token has been read — but the spec has to say + /// it, and help output has to show it. + pub required: bool, + pub hide: bool, + pub var_min: Option, + pub var_max: Option, +} + +impl ArgMeta<'_> { + /// Metadata for an argument with nothing declared, for struct update syntax. + pub const EMPTY: ArgMeta<'static> = ArgMeta { + arg: &Arg::REQUIRED, + help: None, + long_help: None, + env: None, + default: &[], + choices: &[], + required: true, + hide: false, + var_min: None, + var_max: None, + }; +} + +/// A worked example, for documentation. +#[derive(Debug, Clone, Copy)] +pub struct Example<'a> { + pub code: &'a str, + pub header: Option<&'a str>, + pub help: Option<&'a str>, +} + +/// What running a command does to the world. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Effect { + /// Only inspects state. + Read, + /// Creates or modifies state, but removes nothing unrecoverable. + Write, + /// May delete or irreversibly overwrite something. + Destructive, +} + +impl Effect { + /// The spelling used in a spec. + pub fn as_str(self) -> &'static str { + match self { + Effect::Read => "read", + Effect::Write => "write", + Effect::Destructive => "destructive", + } + } +} + +impl Spec<'_> { + /// Write this CLI as a usage spec, in KDL. + pub fn to_kdl(&self) -> String { + let mut out = String::new(); + // Unwrap-free: writing into a String cannot fail, and `write!` returning + // Result is an artifact of the trait rather than a real outcome. + let _ = self.write_kdl(&mut out); + out + } + + fn write_kdl(&self, out: &mut String) -> core::fmt::Result { + prop(out, "name", self.name)?; + prop(out, "bin", self.bin.unwrap_or(self.name))?; + if let Some(version) = self.version { + prop(out, "version", version)?; + } + // A description may be given on the spec or on its root command — a derive + // naturally has one doc comment and no reason to care which field it lands + // in — so either is written, the spec's first. + if let Some(about) = self.about.or(self.root.about) { + prop(out, "about", about)?; + } + if let Some(long_about) = self.long_about.or(self.root.long_about) { + prop(out, "long_about", long_about)?; + } + if let Some(default_subcommand) = self.default_subcommand { + prop(out, "default_subcommand", default_subcommand)?; + } + // The root's own nodes sit at the top level rather than inside a `cmd` + // block, so they are written here instead of by write_command. + // + // A mount is the exception: the spec only accepts one inside a `cmd` + // block, so a root mount is not expressible. Emitting it anyway would + // produce a document that does not parse, and dropping it quietly is the + // lossiness this module claims not to have — so it fails loudly in debug + // builds instead, and PLAN.md carries it as a possible spec extension. + debug_assert!( + self.root.mount.is_none(), + "a mount on the root command cannot be written: the spec accepts \ + `mount` only inside a `cmd` block" + ); + // The same is true of everything else that lives on a `cmd` node. Setting + // one on the root is a mistake, and silently dropping it is the lossiness + // this module claims not to have. + debug_assert!( + self.root.effect.is_none() + && !self.root.hide + && self.root.restart_token.is_none() + && self.root.cmd.aliases.is_empty() + && self.root.hidden_aliases.is_empty(), + "the root command cannot carry an effect, hide, a restart token, or \ + aliases: the spec accepts those only inside a `cmd` block" + ); + for example in self.root.examples { + write_example(out, example, 0)?; + } + write_body(out, self.root, 0) + } +} + +/// Write a command's contents: its flags, arguments, and subcommands. +/// +/// Separate from [`write_command`] because the root's contents sit at the top +/// level of the document rather than inside a `cmd` node. +fn write_body(out: &mut String, meta: &CommandMeta<'_>, depth: usize) -> core::fmt::Result { + // Indexing by metadata position below cannot see a table entry with no + // metadata, which would be silently unwritten. Check the lengths first. + debug_assert_eq!( + meta.cmd.flags.len(), + meta.flags.len(), + "every flag in the parse table needs metadata, or it will not be written" + ); + debug_assert_eq!( + meta.cmd.args.len(), + meta.args.len(), + "every argument in the parse table needs metadata" + ); + debug_assert_eq!( + meta.cmd.subcommands.len(), + meta.subcommands.len(), + "every subcommand in the parse table needs metadata" + ); + for (i, flag) in meta.flags.iter().enumerate() { + // The two tables are written in the same order by construction, so a + // mismatch means a table was edited without its metadata. + debug_assert!( + meta.cmd + .flags + .get(i) + .is_some_and(|f| core::ptr::eq(*f, flag.flag)), + "flag metadata is out of step with the parse table" + ); + write_flag(out, flag, depth)?; + } + for (i, arg) in meta.args.iter().enumerate() { + debug_assert!( + meta.cmd + .args + .get(i) + .is_some_and(|a| core::ptr::eq(*a, arg.arg)), + "argument metadata is out of step with the parse table" + ); + write_arg(out, arg, depth)?; + } + for sub in meta.subcommands { + write_command(out, sub, depth)?; + } + Ok(()) +} + +fn write_command(out: &mut String, meta: &CommandMeta<'_>, depth: usize) -> core::fmt::Result { + indent(out, depth)?; + write!(out, "cmd {}", quoted(meta.cmd.name))?; + if let Some(help) = meta.about { + write!(out, " help={}", quoted(help))?; + } + if meta.hide { + out.push_str(" hide=#true"); + } + if let Some(effect) = meta.effect { + write!(out, " effect={}", quoted(effect.as_str()))?; + } + if let Some(token) = meta.restart_token { + write!(out, " restart_token={}", quoted(token))?; + } + out.push_str(" {\n"); + + let inner = depth + 1; + for alias in meta.cmd.aliases { + indent(out, inner)?; + write!(out, "alias {}", quoted(alias))?; + if meta.hidden_aliases.contains(alias) { + out.push_str(" hide=#true"); + } + out.push('\n'); + } + if let Some(long_about) = meta.long_about { + indent(out, inner)?; + writeln!(out, "long_help {}", quoted(long_about))?; + } + if let Some(mount) = meta.mount { + indent(out, inner)?; + writeln!(out, "mount run={}", quoted(mount))?; + } + for example in meta.examples { + write_example(out, example, inner)?; + } + write_body(out, meta, inner)?; + + indent(out, depth)?; + out.push_str("}\n"); + Ok(()) +} + +fn write_example(out: &mut String, example: &Example<'_>, depth: usize) -> core::fmt::Result { + indent(out, depth)?; + write!(out, "example {}", quoted(example.code))?; + if let Some(header) = example.header { + write!(out, " header={}", quoted(header))?; + } + if let Some(help) = example.help { + write!(out, " help={}", quoted(help))?; + } + out.push('\n'); + Ok(()) +} + +fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt::Result { + indent(out, depth)?; + write!(out, "flag {}", quoted(&flag_forms(meta.flag)))?; + + if let Some(help) = meta.help { + write!(out, " help={}", quoted(help))?; + } + if meta.required { + out.push_str(" required=#true"); + } + if meta.flag.global { + out.push_str(" global=#true"); + } + if meta.hide { + out.push_str(" hide=#true"); + } + if meta.count { + out.push_str(" count=#true"); + } + if meta.repeatable { + out.push_str(" var=#true"); + } + if let Some(min) = meta.var_min { + write!(out, " var_min={min}")?; + } + if let Some(max) = meta.var_max { + write!(out, " var_max={max}")?; + } + if let Some(negate) = meta.flag.negate { + // The spec writes the negation with its dashes; the table stores the bare + // name, since that is what a token is matched against. + write!(out, " negate={}", quoted(&format!("--{negate}")))?; + } + if let Some(effect) = meta.effect { + write!(out, " effect={}", quoted(effect.as_str()))?; + } + if let Some(env) = meta.env { + write!(out, " env={}", quoted(env))?; + } + write_single_default(out, meta.default)?; + write_single_list(out, "overrides", meta.overrides)?; + write_single_list(out, "required_unless", meta.required_unless)?; + + let has_children = meta.long_help.is_some() + || meta.flag.takes_value + || !meta.choices.is_empty() + || meta.default.len() > 1 + || meta.overrides.len() > 1 + || meta.required_unless.len() > 1; + if !has_children { + out.push('\n'); + return Ok(()); + } + + out.push_str(" {\n"); + let inner = depth + 1; + if let Some(long_help) = meta.long_help { + indent(out, inner)?; + writeln!(out, "long_help {}", quoted(long_help))?; + } + write_many_defaults(out, meta.default, inner)?; + write_many_list(out, "overrides", meta.overrides, inner)?; + write_many_list(out, "required_unless", meta.required_unless, inner)?; + if meta.flag.takes_value { + indent(out, inner)?; + let name = meta.value_name.unwrap_or(meta.flag.name); + write!( + out, + "arg {}", + quoted(&placeholder(name, meta.flag.variadic)) + )?; + if meta.choices.is_empty() { + out.push('\n'); + } else { + out.push_str(" {\n"); + write_choices(out, meta.choices, inner + 1)?; + indent(out, inner)?; + out.push_str("}\n"); + } + } else if !meta.choices.is_empty() { + write_choices(out, meta.choices, inner)?; + } + indent(out, depth)?; + out.push_str("}\n"); + Ok(()) +} + +fn write_arg(out: &mut String, meta: &ArgMeta<'_>, depth: usize) -> core::fmt::Result { + indent(out, depth)?; + let name = if meta.arg.name.is_empty() { + "arg" + } else { + meta.arg.name + }; + write!(out, "arg {}", quoted(&arg_placeholder(name, meta)))?; + + if let Some(help) = meta.help { + write!(out, " help={}", quoted(help))?; + } + if meta.hide { + out.push_str(" hide=#true"); + } + if let Some(min) = meta.var_min { + write!(out, " var_min={min}")?; + } + if let Some(max) = meta.var_max { + write!(out, " var_max={max}")?; + } + if meta.arg.double_dash != DoubleDash::Optional { + let mode = match meta.arg.double_dash { + DoubleDash::Required => "required", + DoubleDash::Preserve => "preserve", + DoubleDash::Automatic => "automatic", + DoubleDash::Optional => unreachable!("excluded by the branch above"), + }; + write!(out, " double_dash={}", quoted(mode))?; + } + if let Some(env) = meta.env { + write!(out, " env={}", quoted(env))?; + } + write_single_default(out, meta.default)?; + + let has_children = + meta.long_help.is_some() || !meta.choices.is_empty() || meta.default.len() > 1; + if !has_children { + out.push('\n'); + return Ok(()); + } + + out.push_str(" {\n"); + let inner = depth + 1; + if let Some(long_help) = meta.long_help { + indent(out, inner)?; + writeln!(out, "long_help {}", quoted(long_help))?; + } + write_many_defaults(out, meta.default, inner)?; + write_choices(out, meta.choices, inner)?; + indent(out, depth)?; + out.push_str("}\n"); + Ok(()) +} + +/// A lone value goes on the node as a property; see [`write_many_list`] for why +/// several cannot. +fn write_single_list(out: &mut String, key: &str, values: &[&str]) -> core::fmt::Result { + if let [only] = values { + write!(out, " {key}={}", quoted(only))?; + } + Ok(()) +} + +/// Several values, as `overrides "a" "b"`. +/// +/// The same trap as defaults: `overrides="a" overrides="b"` is one node with a +/// property set twice, and only the last one survives. +fn write_many_list( + out: &mut String, + key: &str, + values: &[&str], + depth: usize, +) -> core::fmt::Result { + if values.len() < 2 { + return Ok(()); + } + indent(out, depth)?; + write!(out, "{key}")?; + for value in values { + write!(out, " {}", quoted(value))?; + } + out.push('\n'); + Ok(()) +} + +/// A lone default goes on the node as a property. +/// +/// Several cannot: KDL properties are unique per node, so `default="a" +/// default="b"` keeps only the last one. Those go in a child block instead, which +/// is what [`write_many_defaults`] emits. +fn write_single_default(out: &mut String, defaults: &[&str]) -> core::fmt::Result { + if let [only] = defaults { + write!(out, " default={}", quoted(only))?; + } + Ok(()) +} + +/// Several defaults, as `default { "a"; "b" }`. +fn write_many_defaults(out: &mut String, defaults: &[&str], depth: usize) -> core::fmt::Result { + if defaults.len() < 2 { + return Ok(()); + } + indent(out, depth)?; + out.push_str("default {\n"); + for value in defaults { + indent(out, depth + 1)?; + writeln!(out, "{}", quoted(value))?; + } + indent(out, depth)?; + out.push_str("}\n"); + Ok(()) +} + +fn write_choices(out: &mut String, choices: &[&str], depth: usize) -> core::fmt::Result { + if choices.is_empty() { + return Ok(()); + } + indent(out, depth)?; + out.push_str("choices"); + for choice in choices { + write!(out, " {}", quoted(choice))?; + } + out.push('\n'); + Ok(()) +} + +/// The `-s --long` form a spec uses to declare a flag. +fn flag_forms(flag: &Flag<'_>) -> String { + let mut forms = String::new(); + for short in flag.shorts { + if !forms.is_empty() { + forms.push(' '); + } + // A short is one byte, and a non-ASCII one could not have been matched + // against a token in the first place. + forms.push('-'); + forms.push(*short as char); + } + for long in flag.longs { + if !forms.is_empty() { + forms.push(' '); + } + forms.push_str("--"); + forms.push_str(long); + } + forms +} + +/// `` or `...`, the spec's way of writing a value placeholder. +fn placeholder(name: &str, variadic: bool) -> String { + let ellipsis = if variadic { "..." } else { "" }; + format!("<{name}>{ellipsis}") +} + +/// A positional's placeholder: angle brackets when required, square when not. +fn arg_placeholder(name: &str, meta: &ArgMeta<'_>) -> String { + let ellipsis = if meta.arg.var { "..." } else { "" }; + if meta.required { + format!("<{name}>{ellipsis}") + } else { + format!("[{name}]{ellipsis}") + } +} + +fn indent(out: &mut String, depth: usize) -> core::fmt::Result { + for _ in 0..depth { + out.push_str(" "); + } + Ok(()) +} + +fn prop(out: &mut String, key: &str, value: &str) -> core::fmt::Result { + writeln!(out, "{key} {}", quoted(value)) +} + +/// Quote a value as a KDL string. +/// +/// Always quoted, even where KDL would accept a bare identifier: deciding when a +/// string is bare-safe means encoding KDL's identifier rules, and getting that +/// subtly wrong produces a spec that parses as something else. Quoting always is +/// less pretty and cannot be wrong. +fn quoted(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + // KDL forbids other literal control characters in a quoted string, and + // help text really does contain them: a CLI that colors its help with + // ANSI codes has an escape character in the middle of it. + 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::*; + + #[test] + fn quoting_escapes_what_would_break_a_document() { + assert_eq!(quoted("plain"), r#""plain""#); + assert_eq!(quoted(r#"say "hi""#), r#""say \"hi\"""#); + assert_eq!(quoted("a\\b"), r#""a\\b""#); + assert_eq!(quoted("one\ntwo"), r#""one\ntwo""#); + } + + #[test] + fn flag_forms_lists_shorts_then_longs() { + static F: Flag = Flag { + longs: &["jobs", "workers"], + shorts: b"jw", + ..Flag::VALUE + }; + assert_eq!(flag_forms(&F), "-j -w --jobs --workers"); + } + + #[test] + fn placeholders_show_arity_and_optionality() { + static REQ: Arg = Arg { + name: "file", + ..Arg::REQUIRED + }; + static VAR: Arg = Arg { + name: "rest", + ..Arg::VAR + }; + let required = ArgMeta { + arg: &REQ, + ..ArgMeta::EMPTY + }; + let optional_var = ArgMeta { + arg: &VAR, + required: false, + ..ArgMeta::EMPTY + }; + assert_eq!(arg_placeholder("file", &required), ""); + assert_eq!(arg_placeholder("rest", &optional_var), "[rest]..."); + assert_eq!(placeholder("n", false), ""); + assert_eq!(placeholder("pattern", true), "..."); + } +} diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index 2e01905b..8b835899 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -14,9 +14,12 @@ license = { workspace = true } [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -usage-argv = { workspace = true } +usage-argv = { workspace = true, features = ["spec"] } usage-lib = { workspace = true } +[dev-dependencies] +insta = "1" + [[bin]] name = "oracle" path = "src/bin/oracle.rs" diff --git a/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap b/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap new file mode 100644 index 00000000..38de3c8f --- /dev/null +++ b/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap @@ -0,0 +1,63 @@ +--- +source: conformance/tests/spec_roundtrip.rs +expression: SPEC.to_kdl() +--- +name "ex" +bin "ex" +version "1.2.3" +about "does things" +long_about "Does things, at length." +default_subcommand "run" +example "ex a.txt" header="Basic" help="the simplest thing" +flag "-j --jobs" help="how many jobs, and a quote: \"" global=#true env="EX_JOBS" default="4" { + long_help "More about jobs.\nOn two lines." + arg "" +} +flag "--color" help="colorize output" negate="--no-color" default="true" +flag "-v --verbose" hide=#true count=#true +flag "--include" help="patterns to include" var=#true var_min=1 var_max=5 overrides="--exclude" { + arg "..." +} +flag "--shell" required=#true { + required_unless "--jobs" "--color" + arg "" { + choices "bash" "zsh" "fish" + } +} +flag "--prune" help="delete anything unused" effect="destructive" { + long_help "Deletes things.\u{1b}[0m Carefully." + overrides "--keep" "--dry-run" +} +flag "--paths" { + default { + "/usr/bin" + "/usr/local/bin" + } + arg "" +} +arg "[file]" help="the file" env="EX_FILE" default="a.txt" +cmd "install" help="install a tool" effect="write" { + alias "i" + alias "add" hide=#true + long_help "Installs a tool.\n\nTakes a while." + example "ex install node@20" help="install a specific version" + flag "-f --force" help="overwrite an existing install" + arg "" help="the tool to install" +} +cmd "settings" help="manage settings" { + cmd "set" help="set a value" { + arg "" { + choices "on" "off" + } + } +} +cmd "run" help="run a task" restart_token=":::" { + mount run="ex tasks --usage" + arg "[args]..." double_dash="preserve" +} +cmd "exec" help="run a command" effect="destructive" { + arg "..." double_dash="required" +} +cmd "watch" help="watch files" hide=#true { + arg "[files]..." double_dash="automatic" +} diff --git a/conformance/tests/spec_roundtrip.rs b/conformance/tests/spec_roundtrip.rs new file mode 100644 index 00000000..f32ad4f4 --- /dev/null +++ b/conformance/tests/spec_roundtrip.rs @@ -0,0 +1,663 @@ +//! Checks that what usage-argv emits is a spec usage-lib understands. +//! +//! The emitted KDL is the interface to everything downstream — `usage g +//! markdown`, manpages, the SDK generators, completions — so "it looks right" is +//! not a standard. Here the output is parsed back by usage-lib and the resulting +//! [`Spec`] is compared against what was declared, field by field. +//! +//! The fixture is deliberately awkward: text needing escapes, a hidden alias, a +//! negated flag, choices on both a flag and an argument, every `double_dash` mode, +//! an effect, a mount, a restart token, and two levels of subcommand. Anything the +//! writer quotes wrongly shows up as a parse failure or a changed value rather +//! than as a diff nobody reads. + +use usage::Spec as LibSpec; +use usage_argv::spec::{ArgMeta, CommandMeta, Effect, Example, FlagMeta, Spec}; +use usage_argv::{Arg, Command, DoubleDash, Flag}; + +static JOBS: Flag = Flag { + key: 1, + name: "jobs", + longs: &["jobs"], + shorts: b"j", + global: true, + ..Flag::VALUE +}; +static COLOR: Flag = Flag { + key: 2, + name: "color", + longs: &["color"], + negate: Some("no-color"), + ..Flag::BOOL +}; +static VERBOSE: Flag = Flag { + key: 3, + name: "verbose", + longs: &["verbose"], + shorts: b"v", + ..Flag::BOOL +}; +static INCLUDE: Flag = Flag { + key: 4, + name: "include", + longs: &["include"], + variadic: true, + ..Flag::VALUE +}; +static SHELL: Flag = Flag { + key: 5, + name: "shell", + longs: &["shell"], + ..Flag::VALUE +}; +static FORCE: Flag = Flag { + key: 6, + name: "force", + longs: &["force"], + shorts: b"f", + ..Flag::BOOL +}; +static PRUNE: Flag = Flag { + key: 7, + name: "prune", + longs: &["prune"], + ..Flag::BOOL +}; +static PATHS: Flag = Flag { + key: 8, + name: "paths", + longs: &["paths"], + ..Flag::VALUE +}; + +static FILE: Arg = Arg { + key: 10, + name: "file", + ..Arg::REQUIRED +}; +static MODE: Arg = Arg { + key: 11, + name: "mode", + ..Arg::REQUIRED +}; +static PASSTHROUGH: Arg = Arg { + key: 12, + name: "cmd", + double_dash: DoubleDash::Required, + ..Arg::VAR +}; +static TOOL: Arg = Arg { + key: 13, + name: "tool", + ..Arg::REQUIRED +}; +static TASK_ARGS: Arg = Arg { + key: 14, + name: "args", + double_dash: DoubleDash::Preserve, + ..Arg::VAR +}; +static FILES: Arg = Arg { + key: 15, + name: "files", + double_dash: DoubleDash::Automatic, + ..Arg::VAR +}; + +static SET: Command = Command { + name: "set", + args: &[&MODE], + key: 100, + ..Command::EMPTY +}; +static SETTINGS: Command = Command { + name: "settings", + subcommands: &[&SET], + key: 101, + ..Command::EMPTY +}; +static INSTALL: Command = Command { + name: "install", + aliases: &["i", "add"], + flags: &[&FORCE], + args: &[&TOOL], + key: 102, + ..Command::EMPTY +}; +static RUN: Command = Command { + name: "run", + args: &[&TASK_ARGS], + key: 103, + ..Command::EMPTY +}; +static EXEC: Command = Command { + name: "exec", + args: &[&PASSTHROUGH], + key: 104, + ..Command::EMPTY +}; +static WATCH: Command = Command { + name: "watch", + args: &[&FILES], + key: 105, + ..Command::EMPTY +}; +static ROOT: Command = Command { + name: "ex", + flags: &[&JOBS, &COLOR, &VERBOSE, &INCLUDE, &SHELL, &PRUNE, &PATHS], + args: &[&FILE], + subcommands: &[&INSTALL, &SETTINGS, &RUN, &EXEC, &WATCH], + key: 106, + ..Command::EMPTY +}; + +static SET_META: CommandMeta = CommandMeta { + cmd: &SET, + about: Some("set a value"), + args: &[ArgMeta { + arg: &MODE, + choices: &["on", "off"], + ..ArgMeta::EMPTY + }], + ..CommandMeta::EMPTY +}; +static SETTINGS_META: CommandMeta = CommandMeta { + cmd: &SETTINGS, + about: Some("manage settings"), + subcommands: &[&SET_META], + ..CommandMeta::EMPTY +}; +static INSTALL_META: CommandMeta = CommandMeta { + cmd: &INSTALL, + about: Some("install a tool"), + long_about: Some("Installs a tool.\n\nTakes a while."), + // `i` stays visible; `add` works but is not advertised. + hidden_aliases: &["add"], + effect: Some(Effect::Write), + examples: &[Example { + code: "ex install node@20", + header: None, + help: Some("install a specific version"), + }], + flags: &[FlagMeta { + flag: &FORCE, + help: Some("overwrite an existing install"), + ..FlagMeta::EMPTY + }], + args: &[ArgMeta { + arg: &TOOL, + help: Some("the tool to install"), + ..ArgMeta::EMPTY + }], + ..CommandMeta::EMPTY +}; +static RUN_META: CommandMeta = CommandMeta { + cmd: &RUN, + about: Some("run a task"), + mount: Some("ex tasks --usage"), + restart_token: Some(":::"), + args: &[ArgMeta { + arg: &TASK_ARGS, + required: false, + ..ArgMeta::EMPTY + }], + ..CommandMeta::EMPTY +}; +static EXEC_META: CommandMeta = CommandMeta { + cmd: &EXEC, + about: Some("run a command"), + effect: Some(Effect::Destructive), + args: &[ArgMeta { + arg: &PASSTHROUGH, + ..ArgMeta::EMPTY + }], + ..CommandMeta::EMPTY +}; +static WATCH_META: CommandMeta = CommandMeta { + cmd: &WATCH, + about: Some("watch files"), + hide: true, + args: &[ArgMeta { + arg: &FILES, + required: false, + ..ArgMeta::EMPTY + }], + ..CommandMeta::EMPTY +}; +static ROOT_META: CommandMeta = CommandMeta { + cmd: &ROOT, + // The root's own examples live at the top level of the document rather than + // inside a `cmd` block, which is easy to forget when writing it out. + examples: &[Example { + code: "ex a.txt", + header: Some("Basic"), + help: Some("the simplest thing"), + }], + flags: &[ + FlagMeta { + flag: &JOBS, + help: Some(r#"how many jobs, and a quote: ""#), + long_help: Some("More about jobs.\nOn two lines."), + value_name: Some("n"), + env: Some("EX_JOBS"), + default: &["4"], + ..FlagMeta::EMPTY + }, + FlagMeta { + flag: &COLOR, + help: Some("colorize output"), + default: &["true"], + ..FlagMeta::EMPTY + }, + FlagMeta { + flag: &VERBOSE, + count: true, + hide: true, + ..FlagMeta::EMPTY + }, + FlagMeta { + flag: &INCLUDE, + help: Some("patterns to include"), + value_name: Some("pattern"), + repeatable: true, + var_min: Some(1), + var_max: Some(5), + overrides: &["--exclude"], + ..FlagMeta::EMPTY + }, + FlagMeta { + flag: &SHELL, + required: true, + // Two of them, which cannot be written as repeated properties. + required_unless: &["--jobs", "--color"], + choices: &["bash", "zsh", "fish"], + ..FlagMeta::EMPTY + }, + // A flag that destroys something: the effect belongs on the flag, not + // only on the command. + FlagMeta { + flag: &PRUNE, + help: Some("delete anything unused"), + // A control character, which KDL will not take literally. Help text + // really does contain these: ANSI-colored help has an escape in it. + long_help: Some("Deletes things.\u{1b}[0m Carefully."), + effect: Some(Effect::Destructive), + overrides: &["--keep", "--dry-run"], + ..FlagMeta::EMPTY + }, + // More than one default, which cannot be written as a property. + FlagMeta { + flag: &PATHS, + value_name: Some("path"), + default: &["/usr/bin", "/usr/local/bin"], + ..FlagMeta::EMPTY + }, + ], + args: &[ArgMeta { + arg: &FILE, + help: Some("the file"), + env: Some("EX_FILE"), + default: &["a.txt"], + required: false, + ..ArgMeta::EMPTY + }], + subcommands: &[ + &INSTALL_META, + &SETTINGS_META, + &RUN_META, + &EXEC_META, + &WATCH_META, + ], + ..CommandMeta::EMPTY +}; +static SPEC: Spec = Spec { + name: "ex", + bin: Some("ex"), + version: Some("1.2.3"), + about: Some("does things"), + long_about: Some("Does things, at length."), + default_subcommand: Some("run"), + root: &ROOT_META, +}; + +fn parsed() -> LibSpec { + let kdl = SPEC.to_kdl(); + kdl.parse() + .unwrap_or_else(|e| panic!("usage-lib could not parse the emitted spec: {e}\n\n{kdl}")) +} + +#[test] +fn the_program_itself_survives() { + let spec = parsed(); + assert_eq!(spec.name, "ex"); + assert_eq!(spec.bin, "ex"); + assert_eq!(spec.version.as_deref(), Some("1.2.3")); + assert_eq!(spec.about.as_deref(), Some("does things")); + assert_eq!(spec.about_long.as_deref(), Some("Does things, at length.")); + assert_eq!(spec.default_subcommand.as_deref(), Some("run")); +} + +#[test] +fn flags_keep_their_forms_and_metadata() { + let spec = parsed(); + let jobs = spec + .cmd + .flags + .iter() + .find(|f| f.name == "jobs") + .expect("--jobs should be in the spec"); + + assert_eq!(jobs.long, vec!["jobs".to_string()]); + assert_eq!(jobs.short, vec!['j']); + assert!(jobs.global); + assert_eq!(jobs.env.as_deref(), Some("EX_JOBS")); + assert_eq!(jobs.default, vec!["4".to_string()]); + assert!(jobs.arg.is_some(), "--jobs takes a value"); + // The help text contains a quote, which is the point of including it. + assert_eq!( + jobs.help.as_deref(), + Some(r#"how many jobs, and a quote: ""#) + ); + assert_eq!( + jobs.help_long.as_deref(), + Some("More about jobs.\nOn two lines.") + ); +} + +#[test] +fn a_negated_flag_keeps_its_dashes() { + let spec = parsed(); + let color = spec + .cmd + .flags + .iter() + .find(|f| f.name == "color") + .expect("--color should be in the spec"); + // The table stores the bare name because that is what a token is matched + // against; the spec wants it written with dashes. + assert_eq!(color.negate.as_deref(), Some("--no-color")); +} + +#[test] +fn counted_and_hidden_flags_are_marked() { + let spec = parsed(); + let verbose = spec + .cmd + .flags + .iter() + .find(|f| f.name == "verbose") + .expect("--verbose should be in the spec"); + assert!(verbose.count); + assert!(verbose.hide); +} + +#[test] +fn variadic_bounds_and_overrides_survive() { + let spec = parsed(); + let include = spec + .cmd + .flags + .iter() + .find(|f| f.name == "include") + .expect("--include should be in the spec"); + assert!(include.var); + assert_eq!(include.var_min, Some(1)); + assert_eq!(include.var_max, Some(5)); + assert_eq!(include.overrides, vec!["--exclude".to_string()]); +} + +#[test] +fn choices_survive_on_both_flags_and_args() { + let spec = parsed(); + let shell = spec + .cmd + .flags + .iter() + .find(|f| f.name == "shell") + .expect("--shell should be in the spec"); + let choices = shell + .arg + .as_ref() + .and_then(|a| a.choices.as_ref()) + .expect("--shell should declare choices"); + assert_eq!(choices.choices, vec!["bash", "zsh", "fish"]); + assert!(shell.required); + assert_eq!( + shell.required_unless, + vec!["--jobs".to_string(), "--color".to_string()] + ); + + let set = spec + .cmd + .subcommands + .get("settings") + .and_then(|s| s.subcommands.get("set")) + .expect("settings set should be in the spec"); + let mode_choices = set.args[0] + .choices + .as_ref() + .expect(" should declare choices"); + assert_eq!(mode_choices.choices, vec!["on", "off"]); +} + +#[test] +fn positionals_keep_arity_optionality_and_fallbacks() { + let spec = parsed(); + let file = &spec.cmd.args[0]; + assert_eq!(file.name, "file"); + assert!(!file.required, "a defaulted argument is optional"); + assert_eq!(file.env.as_deref(), Some("EX_FILE")); + assert_eq!(file.default, vec!["a.txt".to_string()]); + assert_eq!(file.help.as_deref(), Some("the file")); +} + +#[test] +fn every_double_dash_mode_survives() { + use usage::SpecDoubleDashChoices as Mode; + let spec = parsed(); + let mode_of = |cmd: &str| { + spec.cmd + .subcommands + .get(cmd) + .unwrap_or_else(|| panic!("{cmd} should be in the spec")) + .args[0] + .double_dash + .clone() + }; + assert!(matches!(mode_of("exec"), Mode::Required)); + assert!(matches!(mode_of("run"), Mode::Preserve)); + assert!(matches!(mode_of("watch"), Mode::Automatic)); + assert!(matches!( + spec.cmd.args[0].double_dash.clone(), + Mode::Optional + )); +} + +#[test] +fn subcommands_nest_and_keep_visible_and_hidden_aliases() { + let spec = parsed(); + let install = spec + .cmd + .subcommands + .get("install") + .expect("install should be in the spec"); + assert_eq!(install.aliases, vec!["i".to_string()]); + assert_eq!(install.hidden_aliases, vec!["add".to_string()]); + assert_eq!(install.help.as_deref(), Some("install a tool")); + assert_eq!( + install.help_long.as_deref(), + Some("Installs a tool.\n\nTakes a while.") + ); + + // Two levels down, reached through the parent. + let set = spec + .cmd + .subcommands + .get("settings") + .and_then(|s| s.subcommands.get("set")) + .expect("settings set should be in the spec"); + assert_eq!(set.help.as_deref(), Some("set a value")); + + let watch = spec + .cmd + .subcommands + .get("watch") + .expect("watch should be in the spec"); + assert!(watch.hide); +} + +#[test] +fn effects_mounts_restart_tokens_and_examples_survive() { + use usage::SpecCommandEffect as Eff; + let spec = parsed(); + + let install = spec.cmd.subcommands.get("install").unwrap(); + assert!(matches!(install.effect, Some(Eff::Write))); + assert_eq!(install.examples.len(), 1); + assert_eq!(install.examples[0].code, "ex install node@20"); + + let exec = spec.cmd.subcommands.get("exec").unwrap(); + assert!(matches!(exec.effect, Some(Eff::Destructive))); + + let run = spec.cmd.subcommands.get("run").unwrap(); + assert_eq!(run.restart_token.as_deref(), Some(":::")); + assert_eq!(run.mounts.len(), 1, "run should declare a mount"); +} + +#[test] +fn a_flag_can_carry_an_effect() { + use usage::SpecCommandEffect as Eff; + let spec = parsed(); + let prune = spec + .cmd + .flags + .iter() + .find(|f| f.name == "prune") + .expect("--prune should be in the spec"); + assert!(matches!(prune.effect, Some(Eff::Destructive))); +} + +#[test] +fn several_defaults_all_survive() { + // KDL properties are unique per node, so writing these as `default="a" + // default="b"` would keep only the last. They need a child block. + let spec = parsed(); + let paths = spec + .cmd + .flags + .iter() + .find(|f| f.name == "paths") + .expect("--paths should be in the spec"); + assert_eq!( + paths.default, + vec!["/usr/bin".to_string(), "/usr/local/bin".to_string()] + ); +} + +#[test] +fn root_level_examples_survive() { + let spec = parsed(); + // A top-level `example` node lands on the spec itself rather than on its root + // command, which is worth pinning: writing it into the root's `cmd` block + // instead would put it somewhere nothing reads. + assert_eq!( + spec.examples.len(), + 1, + "the root's examples belong at the top level of the document" + ); + assert_eq!(spec.examples[0].code, "ex a.txt"); + assert_eq!(spec.examples[0].header.as_deref(), Some("Basic")); +} + +#[test] +fn the_emitted_spec_is_stable() { + // A snapshot of the text itself, so a change to the writer has to be looked + // at rather than only inferred from the assertions above passing. + insta::assert_snapshot!(SPEC.to_kdl()); +} + +#[test] +fn usage_lib_can_reserialize_what_we_emit() { + // A weaker claim than it looks, and worth being honest about: this shows + // usage-lib's serializer is a fixed point on our input. It cannot catch a + // field we never wrote, because the field would be missing from both sides. + // Loss is caught by the field-by-field assertions above and by the counts + // below. + let once = parsed(); + let twice: LibSpec = once + .to_string() + .parse() + .expect("usage-lib should reparse its own output"); + assert_eq!(once.to_string(), twice.to_string()); +} + +#[test] +fn nothing_is_dropped_on_the_way_out() { + // Counts, so an entry the writer skips entirely shows up here rather than in + // whichever assertion happened to name it. + let spec = parsed(); + assert_eq!( + spec.cmd.flags.len(), + ROOT.flags.len(), + "every declared flag should reach the spec" + ); + assert_eq!( + spec.cmd.args.len(), + ROOT.args.len(), + "every declared argument should reach the spec" + ); + assert_eq!( + spec.cmd.subcommands.len(), + ROOT.subcommands.len(), + "every declared subcommand should reach the spec" + ); + + // And one level down, since nesting is where a writer tends to lose things. + let settings = spec.cmd.subcommands.get("settings").unwrap(); + assert_eq!(settings.subcommands.len(), 1); + + let shell = spec.cmd.flags.iter().find(|f| f.name == "shell").unwrap(); + assert_eq!( + shell.required_unless, + vec!["--jobs".to_string(), "--color".to_string()], + "several values need a child node, or all but the last are lost" + ); + let prune = spec.cmd.flags.iter().find(|f| f.name == "prune").unwrap(); + assert_eq!( + prune.overrides, + vec!["--keep".to_string(), "--dry-run".to_string()] + ); + assert_eq!( + prune.help_long.as_deref(), + Some("Deletes things.\u{1b}[0m Carefully."), + "a control character has to survive being escaped and read back" + ); +} + +#[test] +fn the_docs_pipeline_accepts_it() { + // The end the spec exists for: an adopter runs `usage g markdown` and + // `usage g manpage` over this text at build time. Rendering through + // usage-lib's own generators is the same code those commands use, so a spec + // that parses but renders to nothing fails here rather than in someone's docs + // build. + let spec = parsed(); + + let markdown = usage::docs::markdown::MarkdownRenderer::new(spec.clone()) + .with_multi(true) + .render_index() + .expect("the emitted spec should render as markdown"); + assert!(markdown.contains("install"), "subcommands should be listed"); + assert!( + !markdown.contains("watch"), + "a hidden command should stay out of generated docs" + ); + + let manpage = usage::docs::manpage::ManpageRenderer::new(spec) + .render() + .expect("the emitted spec should render as a manpage"); + assert!( + manpage.contains("ex"), + "the manpage should name the program" + ); +} diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index ff3f3fc8..f88b7d40 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -658,6 +658,33 @@ mod tests { assert_snapshot!("myflag: -f --flag ".parse::().unwrap(), @"myflag: -f --flag "); } + #[test] + fn a_serialized_spec_can_always_be_read_back() { + // Both of these produced KDL that this crate could not reparse: a node + // argument beginning with a dash was rendered bare, and a control character + // was rendered literally. Help text carries the second whenever a CLI + // colors its output. + let spec: Spec = "flag \"--shell \" {\n required_unless \"--jobs\" \"--color\"\n overrides \"--keep\" \"--dry-run\"\n long_help \"Colored.\\u{1b}[0m Text.\"\n}\n" + .parse() + .unwrap(); + + let serialized = spec.to_string(); + let reparsed: Spec = serialized + .parse() + .unwrap_or_else(|e| panic!("a serialized spec should reparse: {e}\n\n{serialized}")); + + let flag = &reparsed.cmd.flags[0]; + assert_eq!( + flag.required_unless, + vec!["--jobs".to_string(), "--color".to_string()] + ); + assert_eq!( + flag.overrides, + vec!["--keep".to_string(), "--dry-run".to_string()] + ); + assert_eq!(flag.help_long.as_deref(), Some("Colored.\u{1b}[0m Text.")); + } + #[test] fn test_flag_with_env() { let spec = Spec::parse( diff --git a/lib/src/spec/helpers.rs b/lib/src/spec/helpers.rs index 04070206..44367169 100644 --- a/lib/src/spec/helpers.rs +++ b/lib/src/spec/helpers.rs @@ -21,6 +21,40 @@ fn raw_multiline_hash_count(value: &str) -> usize { max_count + 1 } +/// A KDL quoted string, with everything that has to be escaped, escaped. +fn escape_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// An entry format that keeps a literal representation as written. +fn quoted_format(value_repr: &str) -> KdlEntryFormat { + KdlEntryFormat { + value_repr: value_repr.to_string(), + leading: " ".into(), + trailing: "".into(), + after_ty: "".into(), + before_ty_name: "".into(), + after_ty_name: "".into(), + after_key: "".into(), + after_eq: "".into(), + autoformat_keep: true, + } +} + /// Create a KdlEntry for a string value, using KDL raw multiline string syntax (`#"""..."""#`) /// when the value contains newlines. The number of `#` characters is automatically determined /// to ensure the value can be embedded safely. @@ -29,6 +63,25 @@ pub(crate) fn string_entry(key: Option<&str>, value: &str) -> KdlEntry { Some(k) => KdlEntry::new_prop(k, KdlValue::String(value.to_string())), None => KdlEntry::new(KdlValue::String(value.to_string())), }; + // Two kinds of value the kdl crate renders in a form this crate cannot read + // back. Both produced specs that failed to reparse, which the argv round-trip + // tests caught. + // + // A node argument starting with a dash: KDL reads `overrides "--keep"` but not + // `overrides --keep`. Properties are left alone, since `negate=--no-color` + // renders and parses today and quoting it would rewrite every committed spec + // for no gain. + let dashed_argument = key.is_none() && value.starts_with('-'); + // A control character other than a newline or tab, which KDL requires as an + // escape rather than a literal. Help text really does contain these: a CLI that + // colors its help has an escape character in the middle of it. + let has_control = value + .chars() + .any(|c| c.is_control() && c != '\n' && c != '\t'); + if dashed_argument || has_control { + entry.set_format(quoted_format(&escape_string(value))); + return entry; + } if value.contains('\n') { let n = raw_multiline_hash_count(value); let hashes = "#".repeat(n);