diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 128309e20..762310453 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -198,7 +198,7 @@ jobs: - version: "1.91" crates: usage-argv usage-derive usage-config usage-validation usage-rs usage-test - version: "1.95" - crates: usage-lib clap_usage usage-cli + crates: usage-lib usage-dynamic clap_usage usage-cli steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/Cargo.lock b/Cargo.lock index 437e7bdcd..6aacafc94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2273,6 +2273,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "usage-dynamic" +version = "6.1.1" +dependencies = [ + "futures", + "usage-argv", + "usage-lib", + "usage-rs", +] + [[package]] name = "usage-lib" version = "6.1.1" diff --git a/Cargo.toml b/Cargo.toml index 25a4e3ef9..5e93601ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "derive", "test", "usage-rs", + "usage-dynamic", "validation", "clap_usage", "cli", @@ -52,6 +53,7 @@ usage-derive = { path = "./derive", version = "6.1.1" } # what they need, `docs` included. usage-lib = { path = "./lib", version = "6.1.1", default-features = false } usage-rs = { path = "./usage-rs", version = "6.1.1" } +usage-dynamic = { path = "./usage-dynamic", version = "6.1.1" } usage-test = { path = "./test", version = "6.1.1" } usage-validation = { path = "./validation", version = "6.1.1" } diff --git a/argv/src/complete.rs b/argv/src/complete.rs index b3cca73b8..5ed2c44ce 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -70,6 +70,16 @@ pub struct Position<'t> { /// Taken from the parser rather than gathered again, so what is offered is what would be /// accepted — shadowing included. pub flags: Vec<&'t Flag<'t>>, + /// Where the words left these tables for a program they do not describe, as an index into + /// the words `walk` was given: the external command's own word. + /// + /// `Some` only past an [`external_subcommand`](Command::external_subcommand) catch-all, + /// which forwards that word and everything after it. [`cmd`](Self::cmd) is still the + /// command that declared the catch-all — it is what the words selected — but the rest of + /// this position describes *that* command, and nothing it says is true of the forwarded + /// program. Whoever knows what the external name means answers from there; whoever does + /// not offers nothing. + pub external: Option, } /// Walk the words before the cursor, and report what the cursor is at. @@ -102,6 +112,7 @@ pub fn walk_view<'t>( let mut position = walk_inner(root, &projected, Some(view)); let original_index = |index: usize| index.saturating_sub(count); position.command_start = original_index(position.command_start); + position.external = position.external.map(original_index); for (_, start) in &mut position.path { *start = original_index(*start); } @@ -121,6 +132,7 @@ fn walk_inner<'t>( let mut awaiting_value = None; let mut last_arg = None; let mut last_arg_values = 0u32; + let mut external = None; while let Some(event) = parser.next_event() { match event { @@ -132,6 +144,12 @@ fn walk_inner<'t>( last_arg_values = 1; } } + // The words named a command these tables do not describe. Everything from that + // word on was forwarded in one go, so where it began is what is left of the line + // to say — the parser is finished either way. + Ok(crate::Event::External { values }) => { + external = Some(argv.len() - values.len()); + } Ok(_) => {} // The one error that says something about the cursor rather than about the line: // the last word was a flag that takes a value, so the cursor is standing in it. @@ -157,6 +175,7 @@ fn walk_inner<'t>( command_start: 0, help_topic: true, flags: Vec::new(), + external: None, } } Err(_) => break, @@ -179,6 +198,7 @@ fn walk_inner<'t>( command_start: parser.command_start(), help_topic: false, flags: parser.flags_in_scope().collect(), + external, } } @@ -193,7 +213,7 @@ fn walk_inner<'t>( /// reference resolves a `complete` block by, so the two agree about which completer a `run=` /// belongs to. `None` when nothing of that name declares one. pub fn for_name<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, name: &str, ctx: &CompleteCtx<'_>, ) -> Option>> { @@ -203,7 +223,7 @@ pub fn for_name<'a>( /// Answer for a named completer through an executable view. pub fn for_name_view<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, name: &str, ctx: &CompleteCtx<'_>, view: &'a crate::spec::ViewMeta<'a>, @@ -213,7 +233,7 @@ pub fn for_name_view<'a>( } fn for_name_at<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, name: &str, ctx: &CompleteCtx<'_>, reached: &Position<'a>, @@ -450,7 +470,7 @@ impl core::fmt::Display for CompletionTrace<'_> { } /// Explain a completion answer using the same parser walk and tables that produced it. -pub fn trace<'a>(spec: &'a Spec<'a>, split: &Split) -> CompletionTrace<'a> { +pub fn trace<'a>(spec: &Spec<'a>, split: &Split) -> CompletionTrace<'a> { let position = walk(spec.root.cmd, split.argv()); let answer = complete(spec, split); trace_from(spec, split, position, answer, None) @@ -458,7 +478,7 @@ pub fn trace<'a>(spec: &'a Spec<'a>, split: &Split) -> CompletionTrace<'a> { /// Explain a completion answer through one executable view. pub fn trace_view<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, view: &'a crate::spec::ViewMeta<'a>, ) -> CompletionTrace<'a> { @@ -468,7 +488,7 @@ pub fn trace_view<'a>( } fn trace_from<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, position: Position<'a>, answer: Completions<'a>, @@ -685,8 +705,19 @@ impl<'a> App<'a> { /// Answer a hidden completion invocation, or return `None` for ordinary argv. pub async fn completion_request(self, argv: &[OsString]) -> Option { - let request = Request::parse(argv)?; - let mut split = request.split; + let request = CompletionRequest::parse(argv)?; + let answer = self.complete_request(&request).await; + Some(render(&answer, request.shell)) + } + + /// The words this app actually walks: the request's, with any multicall projection spliced + /// in after argv0. + /// + /// A caller that has to know where in the line the cursor landed — which command it + /// selected, whether it left these tables — has to ask about the same words the answer was + /// computed from, or its indices point one projection away from where it thinks. + pub fn effective_split(&self, split: &Split) -> Split { + let mut split = split.clone(); if let Some(path) = self.projection { let projected: Vec = path.split_ascii_whitespace().map(str::to_string).collect(); @@ -698,13 +729,21 @@ impl<'a> App<'a> { split.cword += count; } } + split + } + + /// The answer to a parsed request, before it is written the way a shell reads it. + /// + /// [`completion_request`](Self::completion_request) is this plus [`render`]. They are + /// separate for a caller that answers part of a line itself — a host whose runtime commands + /// these tables do not describe — and needs this half's answer as data to add to. + pub async fn complete_request(&self, request: &CompletionRequest) -> Completions<'a> { + let split = self.effective_split(&request.split); let spec = self.view.spec(); - let answer = if let Some(name) = request.candidates_for { - complete_named_with(&spec, &split, self.overlays, &name).await - } else { - complete_with(&spec, &split, self.overlays).await - }; - Some(render(&answer, request.shell)) + match &request.candidates_for { + Some(name) => complete_named_with(&spec, &split, self.overlays, name).await, + None => complete_with(&spec, &split, self.overlays).await, + } } } @@ -715,14 +754,49 @@ impl<'a> SpecView<'a> { } } -struct Request { - shell: Shell, - split: Split, - candidates_for: Option, +/// What a shell asked, read off the hidden `__complete_word__` invocation. +/// +/// The protocol is small and its own thing: five options, none of them in any CLI's tables, +/// because a completion is not a command anybody runs. This is the one reader of it — a host +/// that answers part of a line itself parses the request once, here, rather than keeping a +/// second idea of what the flags mean. +/// +/// `#[non_exhaustive]` because the protocol may grow an option; these come from +/// [`parse`](Self::parse), never from a literal. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct CompletionRequest { + /// Which shell is asking, and so how the answer is written. + pub shell: Shell, + /// The line, split the way that shell splits it, with the cursor's word singled out. + pub split: Split, + /// The single named completer being asked for, when a spec's `run=` line asked for one + /// rather than for everything the cursor could take. + pub candidates_for: Option, } -impl Request { - fn parse(argv: &[OsString]) -> Option { +impl CompletionRequest { + /// A request for a line that is already split, with everything else defaulted. + /// + /// For a caller holding a [`Split`] rather than the argv a shell passed — a host answering + /// part of a line itself, a test asking what a position offers. + pub const fn for_split(split: Split) -> Self { + Self { + shell: Shell::Bash, + split, + candidates_for: None, + } + } + + /// Read a completion request, or return `None` for ordinary argv. + /// + /// `None` is the load-bearing case: it is what tells a `main` that this is a real + /// invocation and the parse should go ahead. + /// + /// Unknown options are ignored rather than refused. A stale generated script asking with a + /// flag this version dropped should still complete something; a beeping shell is worse than + /// an ignored word. + pub fn parse(argv: &[OsString]) -> Option { if argv.first()?.to_str()? != "__complete_word__" { return None; } @@ -1071,13 +1145,13 @@ fn declared_files_at_cursor( /// /// Hidden things are never offered, in any branch. What is *not* here yet: the file fallback /// for a word nothing is known about, and the `run=` completions a spec can declare. -pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { +pub fn complete<'a>(spec: &Spec<'a>, split: &Split) -> Completions<'a> { complete_inner(spec, split, None) } /// Complete through an executable view, omitting root globals it does not carry. pub fn complete_view<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, view: &'a crate::spec::ViewMeta<'a>, ) -> Completions<'a> { @@ -1085,7 +1159,7 @@ pub fn complete_view<'a>( } fn complete_inner<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, view: Option<&'a crate::spec::ViewMeta<'a>>, ) -> Completions<'a> { @@ -1093,6 +1167,17 @@ fn complete_inner<'a>( Some(view) => walk_view(spec.root.cmd, split.argv(), view), None => walk(spec.root.cmd, split.argv()), }; + // Past an external catch-all the cursor is inside another program's line, and these tables + // describe none of it. The command that declared the catch-all still has subcommands, flags + // and an unfilled positional to report, and every one of them would be an answer about the + // wrong CLI. Nothing is the honest answer — including no working directory, which would + // claim paths belong somewhere only the external program knows. + if position.external.is_some() { + return Completions { + candidates: Vec::new(), + files: None, + }; + } let meta = metadata_chain_on_route(spec, &position).and_then(|chain| chain.last().copied()); let token = split.prefix.as_str(); let attached = attached_long_value(&position, token); @@ -1192,7 +1277,7 @@ fn complete_inner<'a>( /// /// No future or callback is created until a completion request reaches a field with an overlay. pub async fn complete_with<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, overlays: &[CompletionOverlay<'_>], ) -> Completions<'a> { @@ -1235,7 +1320,7 @@ pub async fn complete_with<'a>( } async fn complete_named_with<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, overlays: &[CompletionOverlay<'_>], name: &str, @@ -1328,7 +1413,11 @@ fn overlay_at_cursor<'o>( overlays: &'o [CompletionOverlay<'_>], ) -> Option<&'o CompletionOverlay<'o>> { let attached = attached_long_value(position, &split.prefix); + // The external case is the same rule as the flag one: past a catch-all the position reports + // the parent's unfilled positional, and firing that positional's completer against another + // program's line answers a question nobody asked. if split.cword == 0 + || position.external.is_some() || (position.awaiting_value.is_none() && attached.is_none() && position.flags_possible @@ -1408,7 +1497,7 @@ fn overlay_at_cursor<'o>( } fn flag_meta_owner_on_route<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, position: &Position<'_>, flag: &Flag<'_>, ) -> Option<(&'a CommandMeta<'a>, &'a FlagMeta<'a>)> { @@ -1423,7 +1512,7 @@ fn flag_meta_owner_on_route<'a>( } fn arg_meta_owner_on_route<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, position: &Position<'_>, arg: &Arg<'_>, ) -> Option<(&'a CommandMeta<'a>, &'a ArgMeta<'a>)> { @@ -1438,7 +1527,7 @@ fn arg_meta_owner_on_route<'a>( } fn default_subcommand_arg<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, position: &Position<'_>, ) -> Option<(&'a CommandMeta<'a>, &'a ArgMeta<'a>)> { @@ -1456,7 +1545,7 @@ fn default_subcommand_arg<'a>( /// The metadata route selected by the parser, preserving parent identity even when two /// wrappers reuse the same nested command tables. fn metadata_chain_on_route<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, position: &Position<'_>, ) -> Option>> { if position.path.is_empty() { @@ -1476,12 +1565,12 @@ fn metadata_chain_on_route<'a>( } /// Just the candidates this CLI knows about, without the question of paths. -pub fn candidates<'a>(spec: &'a Spec<'a>, split: &Split) -> Vec> { +pub fn candidates<'a>(spec: &Spec<'a>, split: &Split) -> Vec> { candidates_inner(spec, split, None) } fn candidates_inner<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, split: &Split, view: Option<&'a crate::spec::ViewMeta<'a>>, ) -> Vec> { @@ -1489,6 +1578,10 @@ fn candidates_inner<'a>( Some(view) => walk_view(spec.root.cmd, split.argv(), view), None => walk(spec.root.cmd, split.argv()), }; + // See `complete_inner`: nothing these tables hold describes the forwarded program. + if position.external.is_some() { + return Vec::new(); + } let meta = metadata_chain_on_route(spec, &position).and_then(|chain| chain.last().copied()); let token = split.prefix.as_str(); @@ -1635,7 +1728,7 @@ fn subcommands<'a>(meta: &'a CommandMeta<'a>, token: &str) -> Vec> } /// The long forms of every flag in scope, and the negations of those that have one. -fn long_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> Vec> { +fn long_flags<'a>(spec: &Spec<'a>, position: &Position<'_>, token: &str) -> Vec> { let mut out = Vec::new(); for flag in &position.flags { let meta = flag_meta(spec.root, flag); @@ -1688,7 +1781,7 @@ fn long_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> V /// /// A token of `-x` is asking about the letter `x`, so only that letter's flag is offered: /// bundling means anything else would be a candidate for a different position in the token. -fn short_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> Vec> { +fn short_flags<'a>(spec: &Spec<'a>, position: &Position<'_>, token: &str) -> Vec> { let wanted = token.as_bytes().get(1).copied(); let mut out = Vec::new(); for flag in &position.flags { @@ -3835,7 +3928,7 @@ mod tests { OsString::from("run"), OsString::from("two words"), ]; - let request = Request::parse(&argv).expect("a completion request"); + let request = CompletionRequest::parse(&argv).expect("a completion request"); assert_eq!(request.shell, Shell::Elvish); assert_eq!(request.split.words, ["mise", "run", "two words"]); assert_eq!(request.split.cword, 2); @@ -4028,4 +4121,98 @@ mod tests { assert_eq!(offered("mise install --source "), ["upstream"]); assert_eq!(offered("mise install "), ["node", "python", "ruby"]); } + + static EXT_LS: Command = Command { + name: "ls", + ..Command::EMPTY + }; + static EXT_QUIET: Flag = Flag { + key: 40, + name: "quiet", + longs: &["quiet"], + ..Flag::BOOL + }; + static EXT_PLUGINS: Command = Command { + name: "plugins", + flags: &[&EXT_QUIET], + subcommands: &[&EXT_LS], + external_subcommand: true, + ..Command::EMPTY + }; + static EXT_ROOT: Command = Command { + name: "host", + subcommands: &[&EXT_PLUGINS], + ..Command::EMPTY + }; + static EXT_META_PLUGINS: CommandMeta = CommandMeta { + cmd: &EXT_PLUGINS, + subcommands: &[&CommandMeta { + cmd: &EXT_LS, + ..CommandMeta::EMPTY + }], + ..CommandMeta::EMPTY + }; + static EXT_META: CommandMeta = CommandMeta { + cmd: &EXT_ROOT, + subcommands: &[&EXT_META_PLUGINS], + ..CommandMeta::EMPTY + }; + static EXT_SPEC: Spec = Spec { + name: "host", + root: &EXT_META, + ..Spec::EMPTY + }; + + #[test] + fn a_walk_reports_where_the_words_left_these_tables() { + // The word after the catch-all is the boundary, and it is an index into the words the + // walk was given — `split.argv()`, which is argv0-less and cursor-less. + let split = at_end("host plugins myplugin "); + let position = walk(&EXT_ROOT, split.argv()); + assert_eq!(position.external, Some(1), "{:?}", split.argv()); + assert_eq!(position.cmd.name, "plugins", "the boundary's declarer"); + + // A flag of the host's own before the name is still the host's: it binds, and the name + // after it is still where the line left. + let split = at_end("host plugins --quiet myplugin "); + assert_eq!(walk(&EXT_ROOT, split.argv()).external, Some(2)); + + // The name being *typed* is not past anything. The cursor's word is excluded from the + // walk, so this is the ordinary "which subcommand?" position and stays static. + let split = at_end("host plugins mypl"); + assert_eq!(walk(&EXT_ROOT, split.argv()).external, None); + let split = at_end("host plugins "); + assert_eq!(walk(&EXT_ROOT, split.argv()).external, None); + + // A dash-prefixed word never forwards — it is a flag or an error, not a command name. + let split = at_end("host plugins --nope "); + assert_eq!(walk(&EXT_ROOT, split.argv()).external, None); + } + + #[test] + fn nothing_is_offered_past_an_external_boundary() { + // The declarer still has a subcommand and a flag to report, and both would be answers + // about the wrong CLI: `myplugin` is a program these tables do not describe. + let answer = complete(&EXT_SPEC, &at_end("host plugins myplugin ")); + assert!(answer.candidates.is_empty(), "{answer:?}"); + assert_eq!(answer.files, None, "no working directory either"); + assert!(candidates(&EXT_SPEC, &at_end("host plugins myplugin ")).is_empty()); + + // Including for a dash-prefixed word: the host's flags are not the plugin's. + let flags = complete(&EXT_SPEC, &at_end("host plugins myplugin --")); + assert!(flags.candidates.is_empty(), "{flags:?}"); + assert_eq!(flags.files, None); + + // The position *before* the boundary is untouched. + let parent = complete(&EXT_SPEC, &at_end("host plugins ")); + assert_eq!( + parent + .candidates + .iter() + .map(|candidate| candidate.value.as_str()) + .collect::>(), + ["ls"], + "{parent:?}" + ); + } } diff --git a/argv/src/help.rs b/argv/src/help.rs index 7a2560271..0d9b8f365 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -2438,7 +2438,7 @@ fn flat_commands_long(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, w /// /// `None` when the command is not in this spec, which means the two came from different CLIs. pub fn find<'a>( - spec: &'a Spec<'a>, + spec: &Spec<'a>, cmd: &Command<'_>, ) -> Option<(Vec<&'a str>, Vec<&'a CommandMeta<'a>>)> { fn walk<'a>( diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index d2ab09d13..36eef22a0 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -152,6 +152,18 @@ impl CompleteWord { let parsed = usage::parse::parse_partial(spec, &words)?; debug!("parsed cmd: {}", parsed.cmd.full_cmd.join(" ")); + // Past an `external_subcommand` catch-all the cursor is inside another program's line. + // The command that declared the catch-all still has subcommands, flags and an unfilled + // positional to offer, and every one of them would describe the wrong CLI — so would + // the working directory, which claims paths belong somewhere only that program knows. + // Whoever knows what the external name means answers from there; this spec does not. + if parsed.external.is_some() { + return Ok(CandidateAnswer { + candidates: vec![], + files: false, + }); + } + // Check if previous token was a restart_token - if so, complete from first arg let prev_token = if cword > 0 { self.words.get(cword - 1).map(|s| s.as_str()) diff --git a/conformance/src/complete.rs b/conformance/src/complete.rs index e22f62f9b..301f24309 100644 --- a/conformance/src/complete.rs +++ b/conformance/src/complete.rs @@ -92,6 +92,14 @@ pub struct Expect { /// Whether the order of `candidates` is itself the claim. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub ordered: bool, + /// Whether offering *nothing* is the claim. + /// + /// An empty expectation is almost always a spec that did not say what its author thought, + /// so the corpus refuses one by default. Saying this marks the rare case where silence is + /// the answer being pinned — past an external boundary, where the words belong to a program + /// this spec does not describe — and keeps the guard for everything else. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub nothing: bool, } /// Whether the reference implementation matches a vector's expectation. diff --git a/conformance/tests/complete.rs b/conformance/tests/complete.rs index 5cc3b4f57..69b240b73 100644 --- a/conformance/tests/complete.rs +++ b/conformance/tests/complete.rs @@ -69,9 +69,14 @@ fn every_vector_says_something() { vector.id ); assert!( - !vector.expect.candidates.is_empty() || vector.expect.files, - "{} [{file}] expects nothing at all — if that is really the claim, say so in `doc` \ - and relax this check", + !vector.expect.candidates.is_empty() || vector.expect.files || vector.expect.nothing, + "{} [{file}] expects nothing at all — if that is really the claim, say so with \ + `\"nothing\": true` and explain it in `doc`", + vector.id + ); + assert!( + !vector.expect.nothing || (vector.expect.candidates.is_empty() && !vector.expect.files), + "{} [{file}] claims to expect nothing while expecting something", vector.id ); } diff --git a/corpus/complete/03-external-boundary.json b/corpus/complete/03-external-boundary.json new file mode 100644 index 000000000..fe8ec00ed --- /dev/null +++ b/corpus/complete/03-external-boundary.json @@ -0,0 +1,64 @@ +{ + "section": "external-boundary", + "about": "An `external_subcommand` catch-all forwards an unrecognized command word and everything after it to a program the spec does not describe. Past that word the cursor is inside another CLI's line, and this spec knows nothing about it — not its flags, not its arguments, not whether a path belongs there. The declaring command still has subcommands, flags and an unfilled positional to report, which is exactly the trap: every one of them is an answer about the wrong CLI. A host that does know what the external name means answers from its own tables; one that does not offers nothing at all.", + "vectors": [ + { + "id": "nothing-past-the-boundary", + "doc": "The word after a forwarded command name belongs to that command. Offering the catch-all's own subcommands there — the position's `cmd` is still the declarer — would complete `plugins ls` into a line that runs `myplugin`.", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"plugins\" {\n external_subcommand #true\n cmd \"ls\" {}\n}\n", + "line": "ex plugins myplugin ", + "expect": { + "candidates": [], + "nothing": true + } + }, + { + "id": "no-path-fallback-past-the-boundary", + "doc": "Deferring to the shell's file completion is itself a claim: that a path is what goes here. Only the external program knows that, so the empty answer has to be empty of files too.", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"plugins\" {\n external_subcommand #true\n cmd \"ls\" {}\n}\n", + "line": "ex plugins myplugin ", + "expect": { + "candidates": [], + "nothing": true + } + }, + { + "id": "no-flags-past-the-boundary", + "doc": "The host's flags are not the plugin's. A dash-prefixed word past the boundary is the plugin's business, and offering `--verbose` there advertises a flag that would be forwarded, not bound.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--verbose\"\ncmd \"plugins\" {\n external_subcommand #true\n flag \"--quiet\"\n}\n", + "line": "ex plugins myplugin --", + "expect": { + "candidates": [], + "nothing": true + } + }, + { + "id": "the-command-position-itself-is-unaffected", + "doc": "The boundary is crossed by a word the walk already passed, not by the one being typed. At the catch-all's own command position the declared subcommands are still the answer — a catch-all means \"and also anything else\", not \"nothing\".", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"plugins\" {\n external_subcommand #true\n cmd \"ls\" {}\n}\n", + "line": "ex plugins ", + "expect": { + "candidates": ["ls"] + } + }, + { + "id": "a-partial-name-is-still-a-command-position", + "doc": "A half-typed word could still become a declared subcommand, so it completes as one. Nothing is forwarded until a word is complete enough to not be `ls`.", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"plugins\" {\n external_subcommand #true\n cmd \"ls\" {}\n}\n", + "line": "ex plugins l", + "expect": { + "candidates": ["ls"] + } + }, + { + "id": "a-declared-flag-before-the-name-does-not-move-the-boundary", + "doc": "A flag of the host's own before the external name binds to the host, and the name after it still forwards. The boundary is where the grammar stopped recognizing words, not where the first dash appeared.", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"plugins\" {\n external_subcommand #true\n flag \"--quiet\"\n cmd \"ls\" {}\n}\n", + "line": "ex plugins --quiet myplugin ", + "expect": { + "candidates": [], + "nothing": true + } + } + ] +} diff --git a/corpus/complete/README.md b/corpus/complete/README.md index bf765625d..d0faff30d 100644 --- a/corpus/complete/README.md +++ b/corpus/complete/README.md @@ -61,6 +61,20 @@ test of the machine it runs on, so it states the _kind_ instead: which asserts that the implementation defers to the shell's own file completion rather than offering words of its own. What the filesystem then contains is not the corpus's business. +## Vectors that expect nothing + +An empty expectation is almost always a spec that did not say what its author thought, so the +corpus refuses one unless the vector says the silence is deliberate: + +```jsonc +"expect": { "nothing": true } +``` + +The case that needs it is [`03-external-boundary.json`](03-external-boundary.json): past an +`external_subcommand` catch-all the words belong to a program the spec does not describe, and +offering the catch-all's own subcommands, flags or the working directory would answer about the +wrong CLI. Offering nothing is the claim, path fallback included. + ## Keeping it honest Every vector carries a `reference` label, defaulting to `agrees`, saying whether `usage-cli` — diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index dff6ded30..41a999f4a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -67,6 +67,7 @@ export default defineConfig({ { text: "Args and Flags", link: "/rust/args-and-flags" }, { text: "Updating Values", link: "/rust/update-from" }, { text: "Subcommands", link: "/rust/subcommands" }, + { text: "Dynamic Commands", link: "/rust/dynamic-commands" }, { text: "Dispatch", link: "/rust/dispatch" }, { text: "Validation", link: "/rust/validation" }, { text: "Help, Version, and Errors", link: "/rust/help" }, diff --git a/docs/rust/dynamic-commands.md b/docs/rust/dynamic-commands.md new file mode 100644 index 000000000..7e38b6026 --- /dev/null +++ b/docs/rust/dynamic-commands.md @@ -0,0 +1,297 @@ +# Dynamic Commands + +::: warning Experimental +`usage-dynamic` is new. Its API may change ahead of the rest of the framework. +::: + +usage compiles a CLI's command tree at build time. A CLI that has plugins doesn't know its full +command tree until runtime: plugins add commands, and which plugins are installed is only known +by looking. Out of the box those commands don't appear in `--help`, don't complete, and reach +the application as unparsed words. + +`usage-dynamic` handles this. The application loads a spec for each plugin and hands them to a +`Catalog`. The catalog merges them into the host's command tree for help and completion, and +parses the argv a plugin command was invoked with. The derived parse tables and the emitted KDL +are not modified, and normal parsing stays exactly as fast. + +```toml +[dependencies] +usage = { package = "usage-rs", version = "6", features = ["completions"] } +usage-dynamic = "6" +# Any executor works; the example below uses futures' to answer completion requests. +futures = "0.3" +``` + +The `completions` feature is only needed for the completion half; help and parsing work +without it. + +`usage-dynamic` does not discover anything itself: it runs no subprocesses, reads no files, and +has no callbacks. The application finds its plugins — a directory scan, a lockfile, a registry +— and decides when to look. + +## Plugins load only when needed + +Because loading plugins is expensive relative to running a built-in command — directory scans, +file reads, spec parses — and most invocations never touch a plugin, the API is arranged so +that work only happens when something needs it: + +1. Parse argv with the static tables first. A built-in command parses and runs without any + plugin being loaded. +2. Words the tables don't recognize land in an `external_subcommand` catch-all. That's when to + load plugins: after the parse, only on the invocations that involve one. +3. Help and completion do need the plugin list up front — but both are interactive, where + reading a directory of KDL files is cheap next to rendering a page. + +The API's costs match that ordering: + +| Call | Cost | Needs | +| ----------------------------- | ------------------------------------------------ | --------------------- | +| `Catalog::builder(…).build()` | validates names and parents; microseconds | the specs you pass it | +| `catalog.parse_external(…)` | one parse of the captured argv, against one spec | the matched spec | +| `catalog.app()` | merges the full command tree; once, then kept | every catalogued spec | + +`app()` is the only expensive call, and only help and completion use it. A catalog built from a +single plugin's spec is a complete dispatcher for that plugin — nothing requires loading the +rest. + +## Plugin specs + +Each plugin provides a [usage spec](/spec/) in KDL, the same format used everywhere else in +usage. How the text gets to the application is its own convention — a file next to the plugin, +a `--usage` flag on its binary, a field in a manifest. Parse it into a `Spec`: + +```rust +use usage_dynamic::Spec; + +let formatter: Spec = std::fs::read_to_string("plugins/formatter.usage.kdl")?.parse()?; +``` + +```kdl +name "formatter" +bin "formatter" +about "Format a project" +flag "--color " { + choices "always" "never" +} +arg "[path]" +cmd "check" help="Check formatting" { + flag "--fix" help="Apply fixes" +} +``` + +The spec is everything the catalog knows about a plugin: `about` becomes its summary in help, +its commands, flags, and choices drive completion, and its argv parses against it. A minimal +spec — just `name` and `about` — is enough for the plugin to show up in help and completion by +name. + +KDL is the format for commands that come from _outside_ the process — it's what a plugin binary +can print and any language can produce. For commands the application itself defines at runtime, +such as tasks read from a config file, don't render KDL just to parse it back: build the +command directly and convert it. `Spec: From` takes the command's `name` as the +spec's name and its `help` as the `about`: + +```rust +use usage_dynamic::{Spec, SpecArgBuilder, SpecCommandBuilder, SpecFlagBuilder}; + +let task: Spec = SpecCommandBuilder::new() + .name("deploy") + .help("Deploy the project") + .flag( + SpecFlagBuilder::new() + .name("env") + .long("env") + .arg(SpecArgBuilder::new().name("ENV").build()) + .help("Target environment") + .build(), + ) + .build() + .into(); +``` + +Either origin behaves identically from here on — the catalog doesn't know or care which one +produced a spec. + +## Declaring where plugins attach + +Plugins attach beneath a command that declares an +[`external_subcommand`](/rust/subcommands#external-subcommands) catch-all — the variant that +captures an unrecognized word and everything after it: + +```rust +use std::ffi::OsString; +use usage::{Cli, Subcommands}; + +#[derive(Cli)] +#[usage(bin = "ex")] +struct Ex { + #[usage(subcommand)] + command: Commands, +} + +#[derive(Subcommands)] +enum Commands { + /// Built into ex + Build, + #[usage(external_subcommand)] + External(Vec), +} +``` + +The catch-all is what makes the parser accept unknown words there in the first place; the +catalog gives those words meaning without changing what parses. Attaching to a command without +one is an error at `build()`. + +Attach one spec per plugin, with either builder call: + +| Method | Result | +| ------------------------- | ------------------------------------------------- | +| `.root(spec)` | `ex formatter` — a top-level command | +| `.under("plugins", spec)` | `ex plugins formatter` — beneath a static command | + +The command's name comes from the spec's `name`. Paths given to `under` may use a static +command's aliases; the catalog stores the canonical path. + +## A complete host + +This example is `usage-dynamic/examples/host.rs` and compiles in CI. Built-in commands run +without loading anything; the catch-all, help, and completion each load plugins at the moment +of use. + +```rust +use std::ffi::{OsStr, OsString}; +use usage_dynamic::{Catalog, Outcome, Spec}; +use usage::complete::{render, CompletionRequest}; +use usage::{Cli, Error, Subcommands}; + +/// How plugins are found is up to the application: a directory scan, a lockfile, a registry. +fn plugin_catalog() -> Catalog<'static> { + let mut builder = Catalog::builder(Ex::app()); + for spec in discover_plugin_specs() { + builder = builder.root(spec); + } + builder.build().unwrap() +} + +fn main() { + let argv: Vec = std::env::args_os().skip(1).collect(); + + // Completion requests are recognized before the parse. They are interactive, so loading + // plugins here is affordable — and it is what puts plugin names in the answer. + if let Some(request) = CompletionRequest::parse(&argv) { + let catalog = plugin_catalog(); + let answer = + futures::executor::block_on(catalog.app().unwrap().complete_request(&request)); + print!("{}", render(&answer, request.shell)); + return; + } + + let words: Vec<&OsStr> = argv.iter().map(OsString::as_os_str).collect(); + match Ex::parse_from(&words) { + // Built-in commands run without loading any plugin. + Ok(Ex { command: Commands::Build }) => build(), + + // The words named something the static tables don't know. Load plugins now. + Ok(Ex { command: Commands::External(captured) }) => { + let catalog = plugin_catalog(); + match catalog.parse_external("", &captured) { + Ok(Some(Outcome::Parsed(parsed))) => run_plugin(&parsed.name, &parsed.output), + Ok(Some(Outcome::Help(help))) => print!("{}", help.page), + Ok(Some(Outcome::Version(version))) => println!("{}", version.version), + // No loaded plugin matches, or the argv is not UTF-8. The captured words are + // untouched — handle them like any unknown command. + _ => fallback(&captured), + } + } + + // Render help through the catalog so plugin commands appear on the page. + // `usage::help::find` converts the command the parser stopped at into the path + // `help` takes. + Err(Error::Help { cmd, long }) => { + let catalog = plugin_catalog(); + let path = usage::help::find(Ex::spec(), cmd) + .map(|(path, _)| path[1..].join(" ")) + .unwrap_or_default(); + print!("{}", catalog.app().unwrap().help(&path, long).unwrap()); + } + Err(Error::Version { .. }) => println!("ex {}", env!("CARGO_PKG_VERSION")), + Err(err) => { + eprint!("{}", Ex::render_failure(&words, &err)); + std::process::exit(2); + } + } +} +``` + +Use `parse_from`, not the process-exiting `Cli::parse()`: `parse()` renders help from the +static tables and exits, so plugin commands would never appear on the page. Rendering help +through the catalog is what adds them. + +If the application can map a command name to its spec file — `plugins/.usage.kdl` — the +catch-all arm can load just that one spec instead of all of them. Watch out for aliases: a +plugin invoked by an alias won't be found by a filename lookup on the typed word. + +## Dispatch + +`catalog.parse_external(parent, argv)` takes the path of the command with the catch-all (`""` +for the root) and the captured words. The first word selects the plugin, by its name or any +alias its spec declares. + +| Outcome | Meaning | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Some(Outcome::Parsed)` | Parsed against the plugin's spec. `output` is a normal `ParseOutput` with defaults and env fallbacks applied; `name` is canonical, `invoked_as` is what was typed. | +| `Some(Outcome::Help)` | The words asked for `--help`; `page` is the rendered page. | +| `Some(Outcome::Version)` | The words asked for `--version`. | +| `None` | No catalogued command matches that name. | + +`None` is not an error: do whatever the application did with unknown commands before it had +plugins. Handle `Err(usage_dynamic::Error::NonUtf8)` the same way — the spec model is UTF-8 +strings, but the captured `OsString`s are intact, so raw dispatch still works. (This `Error` is +the catalog's own type, not the parser's `usage::Error` from the example.) + +## Help and completion + +`catalog.app()` returns the merged tree. `app().help(path, long)` renders any command's page by +path — the empty path is the root, `long` selects the `--help` page over the `-h` summary — +with plugin commands sorted and grouped under their `help_heading` like static ones. + +Completion splits the line at the catch-all: + +- **Before it**, the host's own completion engine answers. Registered completers (sync and + async), multicall projections, and `--candidates` requests work exactly as they do without a + catalog, and plugin names and visible aliases are offered wherever a subcommand could go. +- **After it**, the matched plugin's spec answers: its subcommands, flags, and declared + choices. An unknown name completes nothing — no host flags, no file fallback. + +If the host has runtime completers or a projection of its own, pass them to the builder — the +same values `completion_app` takes: + +```rust +let catalog = Catalog::builder(Ex::app()) + .completions(&OVERLAYS) + .root(spec) + .build()?; +``` + +The catalog never executes a `run=` completer from a plugin spec — that's a subprocess. Those +requests return nothing; answer them in the application if you want them. + +## What `build()` rejects + +| Rejected | Why | +| -------------------------------------------------------------------------------- | ------------------------------------------------------- | +| A parent path that doesn't exist, or has no `external_subcommand` catch-all | No words would ever reach the plugin | +| A name or alias that collides — with a static command, another plugin, or `help` | Two commands can't answer to one word | +| An empty name or alias | Nothing to type | +| A spec containing an unresolved `mount` | Resolving one runs a subprocess; do it before attaching | + +Every check runs against the static tables at `build()`, so a bad configuration fails at +startup instead of producing a command that silently does nothing. + +## `mount` or a catalog? + +[`mount`](/rust/subcommands#mounts-and-restart-tokens) handles a related case: one command +whose subcommands come from a subprocess the parser is allowed to run during completion — mise +tasks. If that's the shape, `mount` is less machinery. + +A catalog is for applications that manage the specs themselves, or want control over when +loading happens. It also covers help and dispatch, and it never runs anything. diff --git a/docs/rust/index.md b/docs/rust/index.md index ed965e37a..630a85f00 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -72,13 +72,14 @@ derive's compiler, which runs at build time ([comparison with clap](/rust/migrat `usage-rs` is a facade. Applications should depend on it alone. The split underneath stays available for low-level adopters that want a thinner surface: -| Crate | Role | -| -------------- | -------------------------------------------------------------------------------------------------------------------- | -| `usage-rs` | The one package an application depends on; re-exports the whole runtime | -| `usage-derive` | The derive macros: `Cli`, `Args`, `Subcommands`, `ValueEnum`, `ArgGroup`, and `Config` (behind the `config` feature) | -| `usage-argv` | The zero-allocation, zero-dependency runtime the derive emits code against | -| `usage-test` | Test helpers: what a command line parses to, what a page says, what a shell is offered | -| `usage-config` | Layered settings resolution with provenance ([Configuration](/rust/configuration)) | +| Crate | Role | +| --------------- | -------------------------------------------------------------------------------------------------------------------- | +| `usage-rs` | The one package an application depends on; re-exports the whole runtime | +| `usage-derive` | The derive macros: `Cli`, `Args`, `Subcommands`, `ValueEnum`, `ArgGroup`, and `Config` (behind the `config` feature) | +| `usage-argv` | The zero-allocation, zero-dependency runtime the derive emits code against | +| `usage-test` | Test helpers: what a command line parses to, what a page says, what a shell is offered | +| `usage-config` | Layered settings resolution with provenance ([Configuration](/rust/configuration)) | +| `usage-dynamic` | Commands discovered at runtime, merged into help and completion ([Dynamic commands](/rust/dynamic-commands)) | ### Cargo features @@ -150,6 +151,7 @@ how to opt out of the endpoint. - [Args and flags](/rust/args-and-flags) — field types, attributes, env vars, defaults - [Updating values](/rust/update-from) — merge another command line into an existing value - [Subcommands](/rust/subcommands) — command enums, nesting, `flatten`, value enums +- [Dynamic commands](/rust/dynamic-commands) — commands discovered at runtime, in help and completion - [Dispatch](/rust/dispatch) — `Run`, `RunWith`, the async pair, and the generated `match` - [Validation](/rust/validation) — choices, groups, `exclusive`, `delimiter`, conflicts, portable `validate` - [Help, version, and errors](/rust/help) — what the parser renders and how to hook it diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 9fbf907e9..d3d50b800 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -45,7 +45,9 @@ usage-validation = { workspace = true, optional = true } [features] default = ["docs"] -docs = ["tera", "roff"] +# CLI help is useful to lightweight consumers that do not generate Markdown or manpages. +cli-help = ["tera"] +docs = ["cli-help", "roff"] validation = ["dep:usage-validation"] unstable_choices_env = [] diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index a878c1893..6e00cf9b6 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -4,7 +4,7 @@ use tera::Tera; pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String { // Convert to docs models to get layout calculations - let docs_spec = crate::docs::models::Spec::from(spec.clone()); + let docs_spec = crate::docs::models::Spec::from(spec); let mut docs_cmd = crate::docs::models::SpecCommand::from(&without_hidden(cmd, long)); let mut ctx = tera::Context::new(); diff --git a/lib/src/docs/mod.rs b/lib/src/docs/mod.rs index 8f980040a..6cadd872e 100644 --- a/lib/src/docs/mod.rs +++ b/lib/src/docs/mod.rs @@ -1,5 +1,10 @@ +#[cfg(feature = "cli-help")] pub mod cli; +#[cfg(feature = "cli-help")] mod layout; +#[cfg(feature = "docs")] pub mod manpage; +#[cfg(feature = "docs")] pub mod markdown; +#[cfg(feature = "cli-help")] pub(crate) mod models; diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 2e315dbce..9ea2d8b3a 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "docs")] use crate::docs::markdown::MarkdownRenderer; use crate::spec::effect::SpecCommandEffect; use crate::{SpecAdmonition, SpecChoices}; @@ -240,6 +241,7 @@ pub struct SpecConfigChoice { } impl SpecConfig { + #[cfg(feature = "docs")] pub fn render_md(&mut self, renderer: &MarkdownRenderer) { if self.rendered { return; @@ -255,12 +257,14 @@ impl SpecConfig { } } + #[cfg(feature = "docs")] pub fn is_empty(&self) -> bool { self.props.is_empty() && self.files.is_empty() } } impl SpecConfigProp { + #[cfg(feature = "docs")] pub fn render_md(&mut self, renderer: &MarkdownRenderer) { if self.rendered { return; @@ -543,9 +547,18 @@ fn fold_outputs( impl From for Spec { fn from(spec: crate::Spec) -> Self { + Self::from(&spec) + } +} + +impl From<&crate::Spec> for Spec { + fn from(spec: &crate::Spec) -> Self { + // One clone, because folding rewrites every command's effective outputs and the + // caller's spec is not ours to change. Taking a reference is what keeps this to one: + // rendering a help page used to clone at the call site and again right here. let spec = { let mut folded = spec.clone(); - fold_outputs(&spec, &mut folded.cmd, &mut Vec::new()); + fold_outputs(spec, &mut folded.cmd, &mut Vec::new()); folded }; Self { @@ -1081,6 +1094,7 @@ impl From<&crate::SpecArg> for SpecArg { } impl Spec { + #[cfg(feature = "docs")] pub fn render_md(&mut self, renderer: &MarkdownRenderer) { if self.rendered { return; @@ -1098,6 +1112,7 @@ impl Spec { } impl SpecCommand { + #[cfg(feature = "docs")] pub fn all_subcommands(&self) -> Vec<&SpecCommand> { let mut cmds = vec![]; for cmd in self.subcommands.values() { @@ -1107,6 +1122,7 @@ impl SpecCommand { cmds } + #[cfg(feature = "docs")] pub fn render_md(&mut self, renderer: &MarkdownRenderer) { if self.rendered { return; @@ -1148,6 +1164,7 @@ impl SpecCommand { /// Rebuild the grouped views from `flags` and `args`. /// /// Anything that mutates either list has to call this, or the groups go stale. + #[cfg(feature = "docs")] fn regroup(&mut self) { self.flag_groups = group_by_heading(&self.flags, |f| f.help_heading.as_deref()); self.arg_groups = group_by_heading(&self.args, |a| a.help_heading.as_deref()); @@ -1155,6 +1172,7 @@ impl SpecCommand { } impl SpecFlag { + #[cfg(feature = "docs")] pub fn render_md(&mut self, renderer: &MarkdownRenderer) { if self.rendered { return; @@ -1176,6 +1194,7 @@ impl SpecFlag { } impl SpecArg { + #[cfg(feature = "docs")] pub fn render_md(&mut self, renderer: &MarkdownRenderer) { if self.rendered { return; @@ -1190,6 +1209,7 @@ impl SpecArg { } impl SpecExample { + #[cfg(feature = "docs")] pub fn render_md(&mut self, renderer: &MarkdownRenderer) { if self.rendered { return; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 859e01fe2..76097a90e 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -33,7 +33,7 @@ pub mod complete; pub mod spec; pub use error::Result; -#[cfg(feature = "docs")] +#[cfg(any(feature = "docs", feature = "cli-help"))] pub mod docs; pub mod go; pub mod help_template; diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 1116e9f7a..aeee62fe1 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -8,7 +8,7 @@ use std::fmt::{Debug, Display, Formatter}; use std::sync::Arc; use strum::EnumTryAs; -#[cfg(feature = "docs")] +#[cfg(feature = "cli-help")] use crate::docs; use crate::error::UsageErr; use crate::spec::arg::SpecDoubleDashChoices; @@ -3192,12 +3192,12 @@ fn apply_flag_overrides( attributed.remove(&flag.name); } -#[cfg(feature = "docs")] +#[cfg(feature = "cli-help")] fn render_help_err(spec: &Spec, cmd: &SpecCommand, long: bool) -> UsageErr { UsageErr::Help(docs::cli::render_help(spec, cmd, long)) } -#[cfg(feature = "docs")] +#[cfg(feature = "cli-help")] fn render_help_all_err(spec: &Spec, cmd: &SpecCommand) -> UsageErr { fn append(out: &mut String, spec: &Spec, cmd: &SpecCommand) { if !out.is_empty() { @@ -3220,12 +3220,12 @@ fn render_help_all_err(spec: &Spec, cmd: &SpecCommand) -> UsageErr { UsageErr::Help(out) } -#[cfg(not(feature = "docs"))] +#[cfg(not(feature = "cli-help"))] fn render_help_err(_spec: &Spec, _cmd: &SpecCommand, _long: bool) -> UsageErr { UsageErr::Help("help".to_string()) } -#[cfg(not(feature = "docs"))] +#[cfg(not(feature = "cli-help"))] fn render_help_all_err(_spec: &Spec, _cmd: &SpecCommand) -> UsageErr { UsageErr::Help("help".to_string()) } diff --git a/lib/src/spec/choices.rs b/lib/src/spec/choices.rs index f99ad643d..63e62944d 100644 --- a/lib/src/spec/choices.rs +++ b/lib/src/spec/choices.rs @@ -283,8 +283,8 @@ impl SpecChoices { } /// The choices as a help page lists them: visible values only, and no details. - // Only `docs` renders help, and without it this is dead code that `-D warnings` fails on. - #[cfg(feature = "docs")] + // Only `cli-help` renders help, and without it this is dead code under `-D warnings`. + #[cfg(feature = "cli-help")] pub(crate) fn for_help(&self) -> Self { let mut choices = self.clone(); choices.choices = self.visible_declared(); diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index ebd10ba61..1a1346a4b 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -670,9 +670,9 @@ impl SpecCommand { self.usage_with_subcommands(true) } - // `docs` only, like `SpecChoices::for_help`: the usage line without the subcommand + // `cli-help` only, like `SpecChoices::for_help`: the usage line without the subcommand // placeholder is a help-page shape, and nothing else asks for it. - #[cfg(feature = "docs")] + #[cfg(feature = "cli-help")] pub(crate) fn usage_without_subcommands(&self) -> String { self.usage_with_subcommands(false) } @@ -727,6 +727,14 @@ impl SpecCommand { } usage.trim().to_string() } + /// Forget which subcommands this command was asked for. + /// + /// `find_subcommand` memoizes names and aliases into a `OnceLock`, so anything that adds or + /// removes a subcommand has to say so or the lookup keeps answering for the old set. + pub(crate) fn reset_subcommand_lookup(&mut self) { + self.subcommand_lookup = OnceLock::new(); + } + pub(crate) fn merge(&mut self, other: Self) { // Merging can add subcommands and aliases, and `find_subcommand` memoizes // its lookup into a OnceLock — so the cache has to go, or a name that diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 0c664ecec..dba155178 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -156,6 +156,22 @@ pub struct Spec { } impl Spec { + /// Recompute everything a command's position in this tree decides, after building one by + /// hand: each command's path, its usage line, and the memoized subcommand lookup. + /// + /// A `SpecCommand` inserted into `subcommands` carries the path it had wherever it came + /// from — a spec of its own, most likely, where it was the root and its path was empty. Its + /// help page would say so. So would its parent's, which gains a `` placeholder + /// the first time it acquires a child. Call this after grafting, and the tree is + /// indistinguishable from one that declared the same commands in KDL. + /// + /// The alternative — writing the tree out and parsing it back, which is how this was reached + /// before — costs a KDL round trip of the whole spec. For a large one that is a quarter of a + /// second to fix up strings. + pub fn restamp(&mut self) { + restamp_paths(&mut self.cmd, &[], true); + } + /// Resolve every mount from supplied command outputs without spawning processes. /// /// This is intended for deterministic generators and conformance harnesses. The @@ -931,6 +947,23 @@ fn extract_usage_from_comments(full: &str) -> String { } fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) { + restamp_paths(cmd, ancestors, false); +} + +/// Recompute what a command's position in the tree decides: its path, its usage line, and the +/// lookup that answers to its subcommands' names and aliases. +/// +/// A command does not know where it sits — `full_cmd` is stamped onto it by whoever assembled +/// the tree, `usage` is rendered from that path, and `find_subcommand` memoizes what it found. +/// Move a command, or graft one in, and all three describe where it used to be. This is the pass +/// that fixes them, and the only one: a tree built by hand is otherwise as stale as one built by +/// hand always was, which is why building one used to mean writing it out as KDL and parsing it +/// back. +/// +/// `force` says whether a usage line that is already there is trusted. Parsing writes them as it +/// goes and only wants the blanks filled; a graft brings a subtree whose lines are all correct +/// for the spec it came from and all wrong here. +fn restamp_paths(cmd: &mut SpecCommand, ancestors: &[String], force: bool) { for subcmd in cmd.subcommands.values_mut() { subcmd.full_cmd = ancestors .iter() @@ -938,9 +971,14 @@ fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) { .chain(once(subcmd.name.clone())) .collect(); let child_ancestors = subcmd.full_cmd.clone(); - set_subcommand_ancestors(subcmd, &child_ancestors); + restamp_paths(subcmd, &child_ancestors, force); } - if cmd.usage.is_empty() { + if force { + // The lookup memoizes names *and* aliases, so a grafted sibling is missing from it and a + // removed one is still in it. Dropping it costs the next lookup and nothing else. + cmd.reset_subcommand_lookup(); + cmd.usage = cmd.usage(); + } else if cmd.usage.is_empty() { cmd.usage = cmd.usage(); } } @@ -1264,6 +1302,28 @@ impl From<&clap::Command> for Spec { } } +/// A spec wrapping one command, for command trees built in Rust rather than parsed from KDL. +/// +/// The command's own `name` becomes the spec's `name` and `bin`, and its `help` becomes the +/// spec's `about` — the same correspondence `usage-dynamic` applies in the other direction when +/// it grafts a spec into a host as a command. +impl From for Spec { + fn from(cmd: SpecCommand) -> Self { + let mut spec = Self { + name: cmd.name.clone(), + bin: cmd.name.clone(), + about: cmd.help.clone(), + about_long: cmd.help_long.clone(), + cmd, + ..Self::default() + }; + // A built tree has never been stamped: nested commands do not know their paths, so + // their usage lines are missing the words a user would type. + spec.restamp(); + spec + } +} + #[inline] pub fn is_true(b: &bool) -> bool { *b @@ -2113,4 +2173,69 @@ echo "hello" assert!(spec.cmd.subcommands.contains_key("leaf")); } + + #[test] + fn restamping_a_grafted_tree_matches_writing_it_out_and_reading_it_back() { + // The claim the whole thing rests on: grafting a command in and restamping produces the + // tree a spec declaring the same commands would have parsed to. If it ever stops being + // true, the cheap path is silently wrong rather than slow. + let host: Spec = + "name \"host\"\nbin \"host\"\ncmd \"plugins\" {\n external_subcommand #true\n}\n" + .parse() + .unwrap(); + let plugin: Spec = + "name \"formatter\"\nbin \"formatter\"\narg \"[path]\"\ncmd \"check\" {\n flag \"--fix\"\n}\n" + .parse() + .unwrap(); + + let mut grafted = host.clone(); + grafted + .cmd + .subcommands + .get_mut("plugins") + .unwrap() + .subcommands + .insert("formatter".to_string(), plugin.cmd.clone()); + grafted.restamp(); + + let reparsed: Spec = grafted.to_string().parse().unwrap(); + let path = |spec: &Spec, words: &[&str]| { + let mut cmd = &spec.cmd; + for word in words { + cmd = cmd.find_subcommand(word).unwrap(); + } + (cmd.full_cmd.clone(), cmd.usage.clone()) + }; + for words in [ + &[][..], + &["plugins"], + &["plugins", "formatter"], + &["plugins", "formatter", "check"], + ] { + assert_eq!( + path(&grafted, words), + path(&reparsed, words), + "at {words:?}" + ); + } + + // Specifically: the plugin no longer claims to be a root, and its new parent has + // learned that it takes a subcommand at all. + assert_eq!( + path(&grafted, &["plugins", "formatter"]).0, + ["plugins", "formatter"] + ); + assert!( + path(&grafted, &["plugins"]).1.contains(""), + "{:?}", + path(&grafted, &["plugins"]).1 + ); + // And the memoized lookup answers for what is there now, aliases included. + assert!(grafted + .cmd + .find_subcommand("plugins") + .unwrap() + .find_subcommand("formatter") + .is_some()); + } } diff --git a/usage-dynamic/Cargo.toml b/usage-dynamic/Cargo.toml new file mode 100644 index 000000000..2d65c36d0 --- /dev/null +++ b/usage-dynamic/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "usage-dynamic" +description = "Runtime command catalogs for usage-rs applications" +version = "6.1.1" +edition = "2021" +rust-version = "1.95" +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +authors = { workspace = true } +license = { workspace = true } + +[dependencies] +usage-argv = { workspace = true, features = ["spec", "complete"] } +usage-parser = { package = "usage-lib", path = "../lib", version = "6.1.1", default-features = false, features = ["cli-help"] } + +[package.metadata.release] +shared-version = true +release = true + +[dev-dependencies] +futures = "0.3" +usage-rs = { workspace = true, features = ["completions"] } diff --git a/usage-dynamic/examples/host.rs b/usage-dynamic/examples/host.rs new file mode 100644 index 000000000..9f463e7d1 --- /dev/null +++ b/usage-dynamic/examples/host.rs @@ -0,0 +1,94 @@ +//! A CLI with plugin commands, end to end. +//! +//! This is the example `docs/rust/dynamic-commands.md` shows, kept here so it compiles. The +//! shape it demonstrates: parse against the static tables first, so built-in commands run +//! without loading any plugin. Plugins load only on the paths that need them — the catch-all, +//! help, and completion. + +use std::ffi::{OsStr, OsString}; +use usage_dynamic::{Catalog, Outcome, ParseOutput, Spec}; +use usage_rs::complete::{render, CompletionRequest}; +use usage_rs::{Cli, Error, Subcommands}; + +#[derive(Cli)] +#[usage(bin = "ex")] +struct Ex { + #[usage(subcommand)] + command: Commands, +} + +#[derive(Subcommands)] +enum Commands { + /// Built into ex + Build, + #[usage(external_subcommand)] + External(Vec), +} + +/// How plugins are found is up to the application: a directory scan, a lockfile, a registry. +fn plugin_catalog() -> Catalog<'static> { + let mut builder = Catalog::builder(Ex::app()); + for spec in discover_plugin_specs() { + builder = builder.root(spec); + } + builder.build().unwrap() +} + +fn discover_plugin_specs() -> Vec { + vec!["name \"formatter\"\nbin \"formatter\"\n".parse().unwrap()] +} +fn build() {} +fn run_plugin(_name: &str, _output: &ParseOutput) {} +fn fallback(_words: &[OsString]) {} + +fn main() { + let argv: Vec = std::env::args_os().skip(1).collect(); + + // Completion requests are recognized before the parse. They are interactive, so loading + // plugins here is affordable — and it is what puts plugin names in the answer. + if let Some(request) = CompletionRequest::parse(&argv) { + let catalog = plugin_catalog(); + let answer = futures::executor::block_on(catalog.app().unwrap().complete_request(&request)); + print!("{}", render(&answer, request.shell)); + return; + } + + let words: Vec<&OsStr> = argv.iter().map(OsString::as_os_str).collect(); + match Ex::parse_from(&words) { + // Built-in commands run without loading any plugin. + Ok(Ex { + command: Commands::Build, + }) => build(), + + // The words named something the static tables don't know. Load plugins now. + Ok(Ex { + command: Commands::External(captured), + }) => { + let catalog = plugin_catalog(); + match catalog.parse_external("", &captured) { + Ok(Some(Outcome::Parsed(parsed))) => run_plugin(&parsed.name, &parsed.output), + Ok(Some(Outcome::Help(help))) => print!("{}", help.page), + Ok(Some(Outcome::Version(version))) => println!("{}", version.version), + // No loaded plugin matches, or the argv is not UTF-8. The captured words are + // untouched — handle them like any unknown command. + _ => fallback(&captured), + } + } + + // Render help through the catalog so plugin commands appear on the page. + // `usage::help::find` converts the command the parser stopped at into the path + // `help` takes. + Err(Error::Help { cmd, long }) => { + let catalog = plugin_catalog(); + let path = usage_rs::help::find(Ex::spec(), cmd) + .map(|(path, _)| path[1..].join(" ")) + .unwrap_or_default(); + print!("{}", catalog.app().unwrap().help(&path, long).unwrap()); + } + Err(Error::Version { .. }) => println!("ex {}", env!("CARGO_PKG_VERSION")), + Err(err) => { + eprint!("{}", Ex::render_failure(&words, &err)); + std::process::exit(2); + } + } +} diff --git a/usage-dynamic/src/lib.rs b/usage-dynamic/src/lib.rs new file mode 100644 index 000000000..b55f36933 --- /dev/null +++ b/usage-dynamic/src/lib.rs @@ -0,0 +1,879 @@ +//! Runtime commands for a derive-generated usage-rs host. +//! +//! A CLI that has plugins knows what `host plugin-x` is only after it has read its own configuration, +//! which is long after the tables describing the rest of its CLI were compiled. Those tables +//! stay as they are: an application discovers and caches plugin specs itself, and a [`Catalog`] +//! attaches them beneath a static command that declared an +//! [`external_subcommand`](usage_parser::SpecCommand::external_subcommand) catch-all. +//! +//! What that buys is the three things the static tables cannot answer for a command they have +//! never seen: help that lists and navigates runtime commands, completion that descends into +//! them, and a parse of the argv the catch-all captured. Nothing here mutates the derived parse +//! tables or the KDL they emit, and nothing here runs a subprocess, reads a file, or calls back +//! into the application. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::fmt; +use std::sync::OnceLock; + +use usage_argv::complete::{self, CompletionOverlay, CompletionRequest, Completions, Files, Split}; +use usage_argv::spec::{Candidate, CandidateKind, CommandMeta, SpecView}; +use usage_parser::error::UsageErr; +use usage_parser::Parser; + +pub use usage_parser::parse::ParseOutput; +pub use usage_parser::Spec; +// For command trees built in Rust rather than parsed from KDL. `Spec: From` +// wraps a built root; the builders come along so an application defining runtime commands +// programmatically needs no direct usage-lib dependency. +pub use usage_parser::{SpecArgBuilder, SpecCommandBuilder, SpecFlagBuilder}; + +/// A validated collection of caller-supplied runtime command specs. +/// +/// Building one validates; it does not assemble. The merged tree help and completion navigate +/// costs a KDL round trip of the whole host spec, and dispatching a plugin command needs none of +/// it — so it is built when something asks for it, and a host that only ever runs plugins never +/// pays. See [`app`](Self::app). +#[derive(Debug)] +pub struct Catalog<'a> { + host: SpecView<'a>, + entries: Vec, + overlays: &'a [CompletionOverlay<'a>], + projection: Option<&'a str>, + merged: OnceLock>, +} + +#[derive(Debug)] +struct Entry { + parent: String, + name: String, + /// Answered to and advertised. + aliases: Vec, + /// Answered to and never advertised — an old name kept working. + hidden_aliases: Vec, + spec: Spec, +} + +impl Entry { + /// Whether this is what the user typed, by any name it answers to. + fn answers_to(&self, word: &str) -> bool { + self.name == word + || self.aliases.iter().any(|alias| alias == word) + || self.hidden_aliases.iter().any(|alias| alias == word) + } +} + +/// Help and completion over the whole command tree, static and runtime alike. +#[derive(Debug, Clone, Copy)] +pub struct App<'a> { + catalog: &'a Catalog<'a>, + merged: &'a Spec, +} + +/// A catalog under construction. +#[derive(Debug)] +pub struct Builder<'a> { + host: SpecView<'a>, + pending: Vec<(String, Spec)>, + overlays: &'a [CompletionOverlay<'a>], + projection: Option<&'a str>, +} + +impl<'a> Catalog<'a> { + /// Begin a catalog over a derive-generated `Cli::app()`. + pub fn builder(host: SpecView<'a>) -> Builder<'a> { + Builder { + host, + pending: Vec::new(), + overlays: &[], + projection: None, + } + } + + /// Help and completion over the whole tree, static commands and runtime ones alike. + /// + /// The merged tree is assembled here, on the first call, and kept. Building it is a KDL + /// round trip of the host spec — cheap next to rendering a page, and not something + /// [`parse_external`](Self::parse_external) should ever pay for, which is why it waits until + /// somebody asks. + /// + /// The error is a disagreement between the derived tables and the spec model that read + /// them, which [`Builder::build`] cannot see because it has not lowered the host yet. + pub fn app(&self) -> Result, &Error> { + let merged = self.merged.get_or_init(|| self.merge()).as_ref()?; + Ok(App { + catalog: self, + merged, + }) + } + + /// Whether the merged tree has been assembled yet. + /// + /// [`app`](Self::app) builds it once and keeps it, so this answers whether the next call is + /// the expensive one. A catalog that is only ever dispatched through never assembles. + pub fn is_assembled(&self) -> bool { + self.merged.get().is_some() + } + + /// Lower the host tables into a spec and graft every entry into it. + fn merge(&self) -> Result { + let mut merged: Spec = self + .host + .clone() + .to_kdl() + .parse() + .map_err(|error: UsageErr| Error::HostSpec(error.to_string()))?; + for entry in &self.entries { + insert_plugin(&mut merged, entry)?; + } + // A grafted command still carries the path it had in the spec it came from, where it was + // the root — and so does its parent's usage line, which has just gained a subcommand. + // Restamping is what makes the result indistinguishable from a spec that declared these + // commands in KDL. + merged.restamp(); + Ok(merged) + } + + /// The entry a word beneath a canonical parent path names, by any name it answers to. + fn entry(&self, parent: &str, word: &str) -> Option<&Entry> { + self.entries + .iter() + .find(|entry| entry.parent == parent && entry.answers_to(word)) + } + + /// The static tables' own completion surface, carrying whatever the host registered. + fn host_app(&self) -> usage_argv::complete::App<'_> { + let app = usage_argv::complete::App::new(self.host.clone()).completions(self.overlays); + match self.projection { + Some(path) => app.project(path), + None => app, + } + } + + /// Parse the argv an external-subcommand variant captured. + /// + /// `parent` is the static command path the catch-all sits on, with the empty string meaning + /// the root. The first token is the runtime command's name, by any spelling it answers to. + /// + /// Two of the outcomes here are the caller's to handle rather than errors: + /// + /// - `Ok(None)` — no catalogued command answers to that name. Whatever the host did with + /// unrecognized words before it had a catalog, it should still do. + /// - `Err(Error::NonUtf8)` — a token the portable spec model cannot represent, because it + /// parses `String`s and this one is not one. The derive handed over `OsString`s, which + /// lose nothing, so the argv is still intact: dispatch it the same way as `Ok(None)` + /// rather than failing a command whose argument happens to be an unusual path. + /// + /// Defaults and environment fallbacks are applied by the plugin's own spec, and `--help` and + /// `--version` come back as [`Outcome`]s rather than as errors. + pub fn parse_external( + &self, + parent: &str, + argv: &[OsString], + ) -> Result, Error> { + let canonical_parent = canonical_parent(self.host.spec().root, parent)?; + let Some(invoked) = argv.first() else { + return Ok(None); + }; + let invoked = invoked.to_str().ok_or(Error::NonUtf8 { index: 0 })?; + let Some(entry) = self.entry(&canonical_parent, invoked) else { + return Ok(None); + }; + let mut input = Vec::with_capacity(argv.len()); + for (index, token) in argv.iter().enumerate() { + input.push(token.to_str().ok_or(Error::NonUtf8 { index })?.to_owned()); + } + let mut output = Parser::new(&entry.spec) + .explain(&input) + .map_err(|error| Error::Parse(error.to_string()))?; + if let Some(index) = output + .errors + .iter() + .position(|error| matches!(error, UsageErr::Help(_) | UsageErr::Version(_))) + { + return Ok(Some(match output.errors.swap_remove(index) { + UsageErr::Help(page) => Outcome::Help(Help { + parent: entry.parent.clone(), + name: entry.name.clone(), + invoked_as: invoked.to_owned(), + page, + }), + UsageErr::Version(version) => Outcome::Version(Version { + parent: entry.parent.clone(), + name: entry.name.clone(), + invoked_as: invoked.to_owned(), + version, + }), + _ => unreachable!(), + })); + } + if !output.errors.is_empty() { + return Err(Error::InvalidArgv( + output.errors.iter().map(ToString::to_string).collect(), + )); + } + Ok(Some(Outcome::Parsed(Box::new(Parsed { + parent: entry.parent.clone(), + name: entry.name.clone(), + invoked_as: invoked.to_owned(), + output, + })))) + } +} + +impl<'a> App<'a> { + /// The merged tree: the host's commands with the catalogued ones grafted in. + pub fn spec(&self) -> &'a Spec { + self.merged + } + + /// Render help for a static or runtime command path. The empty path is the root. + /// + /// `long` is the `--help` / `-h` distinction: the full page or the summary. + pub fn help(self, path: &str, long: bool) -> Option { + let command = find_command(self.merged, path)?; + Some(usage_parser::docs::cli::render_help( + self.merged, + command, + long, + )) + } + + /// Answer a hidden `__complete_word__` invocation, or return `None` for ordinary argv. + /// + /// Two answerers, one line. Up to the catch-all the words are the host's, and the host's own + /// tables answer for them — the same engine, so registered completers, multicall + /// projections and a `--candidates` request all keep working, and the catalogued names are + /// added where a subcommand belongs. Past it the words are a plugin's, and that plugin's + /// spec answers alone. A name in neither is a program nobody here can describe, and the + /// answer is nothing. + pub async fn completion_request(self, argv: &[OsString]) -> Option { + let request = CompletionRequest::parse(argv)?; + let answer = self.complete_request(&request).await; + Some(complete::render(&answer, request.shell)) + } + + /// The answer to a parsed request, before it is written the way a shell reads it. + /// + /// For a caller holding a [`Split`] rather than a shell's argv, build the request with + /// [`CompletionRequest::for_split`]. + pub async fn complete_request(self, request: &CompletionRequest) -> Completions<'static> { + let host = self.catalog.host_app(); + let split = host.effective_split(&request.split); + let position = complete::walk(self.catalog.host.spec().root.cmd, split.argv()); + match position.external { + None => { + let mut answer = own(host.complete_request(request).await); + if request.candidates_for.is_none() { + self.add_catalogued_commands(&position, &split, &mut answer); + } + answer + } + // A spec's `run=` names a completer to *execute*, and executing one is the thing + // this crate does not do. A stale script asking for one gets nothing rather than + // the wrong list. + Some(_) if request.candidates_for.is_some() => Completions { + candidates: Vec::new(), + files: None, + }, + Some(index) => self.complete_plugin(&position, index, &split), + } + } + + /// Add the runtime commands catalogued beneath the command the cursor is at. + /// + /// The static tables have never heard of them, so this is the one place their names come + /// from. Where they belong is wherever a subcommand of that parent would: not in a flag's + /// value, not after a dash, and not where a `help` topic is being asked for. + fn add_catalogued_commands( + self, + position: &usage_argv::complete::Position<'_>, + split: &Split, + answer: &mut Completions<'static>, + ) { + if position.help_topic + || position.awaiting_value.is_some() + || (position.flags_possible && split.prefix.starts_with('-')) + { + return; + } + let parent = position + .path + .iter() + .skip(1) + .map(|(command, _)| command.name) + .collect::>() + .join(" "); + let before = answer.candidates.len(); + for entry in self.catalog.entries.iter().filter(|e| e.parent == parent) { + let description = entry + .spec + .cmd + .help + .clone() + .or_else(|| entry.spec.about.clone()); + for name in std::iter::once(&entry.name).chain(&entry.aliases) { + if name.starts_with(&split.prefix) { + answer.candidates.push(Candidate { + value: name.clone(), + kind: CandidateKind::Command, + display: None, + description: description.clone().map(Cow::Owned), + }); + } + } + } + if answer.candidates.len() == before { + return; + } + sort_and_dedup(&mut answer.candidates); + + // The static tables offered the working directory because *they* had nothing to say + // here; a catalogued name is something to say, and the same rule that closes a position + // with candidates in it closes this one. Only when no argument is at the cursor, since + // an argument may have asked for paths outright — then both belong. + if position.next_arg.is_none() + && position.awaiting_value.is_none() + && matches!(answer.files, Some(Files::Any)) + { + answer.files = None; + } + } + + /// Answer from the plugin's own spec, for the words that belong to it. + fn complete_plugin( + self, + position: &usage_argv::complete::Position<'_>, + index: usize, + split: &Split, + ) -> Completions<'static> { + let nothing = Completions { + candidates: Vec::new(), + files: None, + }; + let parent = position + .path + .iter() + .skip(1) + .map(|(command, _)| command.name) + .collect::>() + .join(" "); + let words = split.argv(); + let Some(invoked) = words.get(index) else { + return nothing; + }; + let Some(entry) = self.catalog.entry(&parent, invoked) else { + return nothing; + }; + // The plugin's spec describes a program of its own, so the words it is given start with + // its own name — its `bin`, not whichever alias was typed, so that a spec selecting an + // applet or a view by argv0 selects the same one either way. + let argv0 = if entry.spec.bin.trim().is_empty() { + entry.name.clone() + } else { + entry.spec.bin.clone() + }; + let mut input = vec![argv0]; + input.extend(words[index + 1..].iter().cloned()); + complete_subtree(&entry.spec, &input, &split.prefix).unwrap_or(nothing) + } +} + +/// Sorted and deduplicated the way the static engine does it, so an answer with catalogued +/// names in it reads as one list rather than two concatenated. +fn sort_and_dedup(candidates: &mut Vec>) { + candidates.sort_unstable(); + candidates.dedup_by(|a, b| a.value == b.value); +} + +/// Take ownership of an answer borrowed from the static tables. +/// +/// The tables are `'static` in a real program, but a caller's are not required to be, and this +/// crate's answers say nothing about where they came from. +fn own(answer: Completions<'_>) -> Completions<'static> { + Completions { + candidates: answer + .candidates + .into_iter() + .map(|candidate| Candidate { + value: candidate.value, + kind: candidate.kind, + display: candidate.display.map(|text| Cow::Owned(text.into_owned())), + description: candidate + .description + .map(|text| Cow::Owned(text.into_owned())), + }) + .collect(), + files: answer.files, + } +} + +/// The command a space-separated path names, static or runtime alike. +fn find_command<'a>(spec: &'a Spec, path: &str) -> Option<&'a usage_parser::SpecCommand> { + let mut command = &spec.cmd; + for component in path.split_ascii_whitespace() { + command = command.find_subcommand(component)?; + } + Some(command) +} + +/// What could go at the cursor, asked of one plugin's own spec. +/// +/// `input` is the plugin's argv as the plugin would see it — its own name first — and `prefix` +/// is the word being typed, which is not in `input`: a half-typed word constrains the answer +/// without being part of the line yet. +fn complete_subtree( + spec: &Spec, + input: &[String], + prefix: &str, +) -> Result, Error> { + let parsed = usage_parser::parse::parse_partial(spec, input) + .map_err(|error| Error::Parse(error.to_string()))?; + // A plugin may itself forward to a program of its own. One level down, the same rule: these + // words describe nothing this spec knows about. + if parsed.external.is_some() { + return Ok(Completions { + candidates: Vec::new(), + files: None, + }); + } + let flags_possible = !parsed.double_dash_seen; + // A dash-prefixed word is a flag or nothing: no path starts with one. + let flag_like = flags_possible && prefix.starts_with('-'); + let mut candidates = Vec::new(); + let mut files = None; + // Whether the position names its own answers. An unmatched prefix against a declared set + // means "nothing matched what you typed", not "ask the filesystem" — offering the working + // directory for a mistyped choice answers a question nobody asked. + let mut closed = false; + let mut at_cursor = None; + + if let Some(flag) = parsed.flag_awaiting_value.first() { + if let Some(arg) = &flag.arg { + candidates.extend(choice_candidates(arg, prefix)); + closed |= declares_its_own(spec, &parsed.cmd, arg); + files = completion_files(spec, &parsed.cmd, &arg.name); + } + } else if flag_like { + for (form, flag) in parsed.completion_flags() { + if flag.hide || !form.starts_with(prefix) || hidden_flag_form(&form, &flag) { + continue; + } + candidates.push(Candidate { + value: form, + kind: CandidateKind::Flag, + display: None, + description: flag.help.clone().map(Cow::Owned), + }); + } + } else { + if let Some(arg) = parsed.next_arg.as_deref() { + at_cursor = Some(arg); + candidates.extend(choice_candidates(arg, prefix)); + closed |= declares_its_own(spec, &parsed.cmd, arg); + files = completion_files(spec, &parsed.cmd, &arg.name); + } + for command in parsed + .cmd + .subcommands + .values() + .filter(|command| !command.hide) + { + for name in std::iter::once(&command.name).chain(&command.aliases) { + if name.starts_with(prefix) { + candidates.push(Candidate { + value: name.clone(), + kind: CandidateKind::Command, + display: None, + description: command.help.clone().map(Cow::Owned), + }); + } + } + } + } + sort_and_dedup(&mut candidates); + + // An argument that requires a `--` is asking for that one word specifically; nothing else + // belongs at the cursor until it is there. + let needs_separator = parsed.flag_awaiting_value.is_empty() + && at_cursor + .is_some_and(|arg| arg.double_dash == usage_parser::SpecDoubleDashChoices::Required) + && !parsed.double_dash_seen; + + if flag_like || needs_separator { + files = None; + } else if files.is_none() && candidates.is_empty() && !closed { + files = Some(Files::Any); + } + Ok(Completions { candidates, files }) +} + +/// Whether this position states what it accepts, so an unmatched prefix means "no matches". +/// +/// Choices are the obvious case. A declared `complete` block is one too — unless it says its +/// values are paths, which is a way of saying the filesystem *is* the answer, or says nothing +/// at all with `unknown`. +fn declares_its_own( + spec: &Spec, + command: &usage_parser::SpecCommand, + arg: &usage_parser::SpecArg, +) -> bool { + if arg.choices.is_some() { + return true; + } + completion_for(spec, command, &arg.name).is_some_and(|completion| { + completion.type_.as_deref().is_some_and(|type_| { + !type_.eq_ignore_ascii_case("unknown") && files_kind(type_).is_none() + }) + }) +} + +fn choice_candidates(arg: &usage_parser::SpecArg, prefix: &str) -> Vec> { + let Some(choices) = &arg.choices else { + return Vec::new(); + }; + let details: HashMap<_, _> = choices + .details + .iter() + .map(|choice| (choice.value.as_str(), choice)) + .collect(); + let mut candidates = Vec::new(); + for value in &choices.choices { + let detail = details.get(value.as_str()).copied(); + if detail.is_some_and(|choice| choice.hide) { + continue; + } + for form in std::iter::once(value.as_str()).chain( + detail + .into_iter() + .flat_map(|choice| &choice.aliases) + .filter(|alias| !alias.hide) + .map(|alias| alias.value.as_str()), + ) { + if form.starts_with(prefix) { + candidates.push(Candidate { + value: form.to_owned(), + kind: CandidateKind::Value, + display: None, + description: detail + .and_then(|choice| choice.help.clone()) + .map(Cow::Owned), + }); + } + } + } + candidates +} + +fn hidden_flag_form(form: &str, flag: &usage_parser::SpecFlag) -> bool { + form.strip_prefix("--") + .is_some_and(|long| flag.hidden_aliases.iter().any(|hidden| hidden == long)) + || form + .strip_prefix('-') + .filter(|short| short.len() == 1) + .and_then(|short| short.chars().next()) + .is_some_and(|short| flag.hidden_short_aliases.contains(&short)) +} + +fn completion_files(spec: &Spec, command: &usage_parser::SpecCommand, name: &str) -> Option { + files_kind(completion_for(spec, command, name)?.type_.as_deref()?) +} + +/// The path fallback a declared `complete` type asks for, if it asks for one at all. +fn files_kind(type_: &str) -> Option { + let (kind, filter) = type_ + .split_once(':') + .map_or((type_, None), |(kind, filter)| (kind, Some(filter))); + match kind { + "path" | "file" => match filter { + Some(filter) => Some(Files::Extensions( + filter + .split(',') + .map(|extension| extension.trim_start_matches('.').to_owned()) + .collect(), + )), + None => Some(Files::Any), + }, + "dir" | "directory" => Some(Files::Dirs), + "executable" | "executable_path" => Some(Files::ExecutablePaths), + "command" => Some(Files::Commands), + _ => None, + } +} + +fn completion_for<'a>( + spec: &'a Spec, + command: &'a usage_parser::SpecCommand, + name: &str, +) -> Option<&'a usage_parser::SpecComplete> { + command + .complete + .get(name) + .or_else(|| spec.complete.get(name)) +} + +impl<'a> Builder<'a> { + /// Attach a supplied plugin spec beneath the static root. + pub fn root(mut self, spec: Spec) -> Self { + self.pending.push((String::new(), spec)); + self + } + + /// Attach a supplied plugin spec beneath a static command path. + /// + /// Components may use visible or hidden static aliases; construction stores the canonical + /// path. The parent must declare an external-subcommand catch-all. + pub fn under(mut self, parent: impl Into, spec: Spec) -> Self { + self.pending.push((parent.into(), spec)); + self + } + + /// Register the host's own completion callbacks, as + /// [`usage_argv::complete::App::completions`] would. + /// + /// Words before a runtime command are the host's, and the host's engine answers for them — + /// so whatever it was given, it keeps. + pub const fn completions(mut self, overlays: &'a [CompletionOverlay<'a>]) -> Self { + self.overlays = overlays; + self + } + + /// Answer as though this command path came after argv0, for a multicall binary. + /// + /// The same projection [`usage_argv::complete::App::project`] applies, applied once, before + /// anything asks where the words are. + pub const fn project(mut self, command_path: &'a str) -> Self { + self.projection = Some(command_path); + self + } + + /// Validate every parent and name, and finish the catalog. + /// + /// What is checked here is what the static tables can answer on their own: that the parent + /// exists, that it invited runtime commands, and that no name collides. Assembling the + /// merged tree is left to [`Catalog::app`] — dispatching a plugin command never needs it. + pub fn build(self) -> Result, Error> { + let host = self.host.spec(); + let mut entries = Vec::with_capacity(self.pending.len()); + let mut claimed: HashMap> = HashMap::new(); + for (requested_parent, mut spec) in self.pending { + reject_mounts(&spec.cmd)?; + // Parsing stamps a tree; building one in Rust does not. Restamping here makes the + // two origins indistinguishable — for a parsed spec it recomputes what is already + // there — so nothing downstream has to know where a spec came from. + spec.restamp(); + let (parent, parent_meta) = resolve_parent(host.root, &requested_parent)?; + if !parent_meta.cmd.external_subcommand { + return Err(Error::ParentNotExternal(parent)); + } + let name = if spec.name.trim().is_empty() { + spec.cmd.name.trim().to_owned() + } else { + spec.name.trim().to_owned() + }; + if name.is_empty() { + return Err(Error::EmptyName); + } + let aliases = spec.cmd.aliases.clone(); + let hidden_aliases: Vec = spec + .cmd + .hidden_aliases + .iter() + .filter(|alias| !aliases.contains(alias)) + .cloned() + .collect(); + let mut forms = Vec::with_capacity(1 + aliases.len() + hidden_aliases.len()); + forms.push(name.clone()); + forms.extend(aliases.iter().cloned()); + forms.extend(hidden_aliases.iter().cloned()); + if forms.iter().any(|form| form.is_empty()) { + return Err(Error::EmptyName); + } + let mut static_forms = HashSet::new(); + for command in parent_meta.subcommands { + static_forms.insert(command.cmd.name); + static_forms.extend(command.cmd.aliases.iter().copied()); + static_forms.extend(command.hidden_aliases.iter().copied()); + } + if !parent_meta.cmd.disable_help_subcommand { + static_forms.insert("help"); + } + let parent_claimed = claimed.entry(parent.clone()).or_default(); + for form in &forms { + if static_forms.contains(form.as_str()) || !parent_claimed.insert(form.clone()) { + return Err(Error::Collision { + parent, + name: form.clone(), + }); + } + } + entries.push(Entry { + parent, + name, + aliases, + hidden_aliases, + spec, + }); + } + Ok(Catalog { + host: self.host, + entries, + overlays: self.overlays, + projection: self.projection, + merged: OnceLock::new(), + }) + } +} + +/// Graft one entry's spec into the merged tree as a command of its parent. +/// +/// A spec describes a program, and a command is one thing a program can be. What the two models +/// keep in different places — a program's `about` is a command's `help` — moves across here. +fn insert_plugin(merged: &mut Spec, entry: &Entry) -> Result<(), Error> { + let plugin = &entry.spec; + let mut command = plugin.cmd.clone(); + command.name = entry.name.clone(); + command.help = command.help.or_else(|| plugin.about.clone()); + command.help_long = command.help_long.or_else(|| plugin.about_long.clone()); + command.before_help = command.before_help.or_else(|| plugin.before_help.clone()); + command.before_help_long = command + .before_help_long + .or_else(|| plugin.before_help_long.clone()); + command.after_help = command.after_help.or_else(|| plugin.after_help.clone()); + command.after_help_long = command + .after_help_long + .or_else(|| plugin.after_help_long.clone()); + for (key, complete) in &plugin.complete { + command.complete.insert(key.clone(), complete.clone()); + } + let mut current = &mut merged.cmd; + for component in entry.parent.split_ascii_whitespace() { + current = current + .subcommands + .get_mut(component) + .ok_or_else(|| Error::MissingParent(entry.parent.clone()))?; + } + current.subcommands.insert(entry.name.clone(), command); + Ok(()) +} + +fn canonical_parent(root: &CommandMeta<'_>, requested: &str) -> Result { + resolve_parent(root, requested).map(|(path, _)| path) +} + +fn resolve_parent<'a>( + root: &'a CommandMeta<'a>, + requested: &str, +) -> Result<(String, &'a CommandMeta<'a>), Error> { + let mut current = root; + let mut canonical = Vec::new(); + for component in requested.split_ascii_whitespace() { + let Some(next) = current.subcommands.iter().copied().find(|candidate| { + candidate.cmd.name == component + || candidate.cmd.aliases.contains(&component) + // A hidden alias is one the CLI answers to, and an application naming its own + // static command is not "a user" being kept from an old spelling. + || candidate.hidden_aliases.contains(&component) + }) else { + return Err(Error::MissingParent(requested.to_owned())); + }; + canonical.push(next.cmd.name); + current = next; + } + Ok((canonical.join(" "), current)) +} + +fn reject_mounts(command: &usage_parser::SpecCommand) -> Result<(), Error> { + if !command.mounts.is_empty() { + return Err(Error::UnresolvedMount(command.name.clone())); + } + for child in command.subcommands.values() { + reject_mounts(child)?; + } + Ok(()) +} + +/// The result of parsing a catalogued external command. +#[derive(Debug)] +#[non_exhaustive] +pub enum Outcome { + Parsed(Box), + Help(Help), + Version(Version), +} + +#[derive(Debug)] +pub struct Parsed { + pub parent: String, + pub name: String, + pub invoked_as: String, + pub output: ParseOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Help { + pub parent: String, + pub name: String, + pub invoked_as: String, + pub page: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Version { + pub parent: String, + pub name: String, + pub invoked_as: String, + pub version: String, +} + +/// A catalog construction or dynamic parse failure. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + HostSpec(String), + MissingParent(String), + ParentNotExternal(String), + EmptyName, + Collision { parent: String, name: String }, + UnresolvedMount(String), + NonUtf8 { index: usize }, + InvalidArgv(Vec), + Parse(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HostSpec(error) => write!(f, "could not construct merged host spec: {error}"), + Self::MissingParent(parent) => write!(f, "static parent `{parent}` does not exist"), + Self::ParentNotExternal(parent) => { + write!( + f, + "static parent `{parent}` has no external-subcommand catch-all" + ) + } + Self::EmptyName => f.write_str("dynamic command name cannot be empty"), + Self::Collision { parent, name } => { + write!( + f, + "dynamic command form `{name}` collides beneath `{parent}`" + ) + } + Self::UnresolvedMount(command) => { + write!( + f, + "dynamic spec contains an unresolved mount beneath `{command}`" + ) + } + Self::NonUtf8 { index } => { + write!(f, "dynamic argv token {index} is not valid UTF-8") + } + Self::InvalidArgv(errors) => f.write_str(&errors.join("\n")), + Self::Parse(error) => f.write_str(error), + } + } +} + +impl std::error::Error for Error {} diff --git a/usage-dynamic/tests/catalog.rs b/usage-dynamic/tests/catalog.rs new file mode 100644 index 000000000..d58d69f77 --- /dev/null +++ b/usage-dynamic/tests/catalog.rs @@ -0,0 +1,798 @@ +use std::ffi::{OsStr, OsString}; + +use futures::executor::block_on; +use usage_dynamic::{Catalog, Error, Outcome, Spec}; +use usage_rs::{Cli, Subcommands}; + +#[derive(Cli)] +#[usage(bin = "host")] +struct Host { + #[usage(subcommand)] + command: HostCommand, +} + +#[derive(Subcommands)] +enum HostCommand { + /// Built into the host. + Builtin, + /// Manage plugins. + #[usage(alias = "p")] + Plugins { + #[usage(subcommand)] + command: PluginCommand, + }, +} + +#[derive(Subcommands)] +enum PluginCommand { + /// A static plugin operation. + #[usage(alias = "ls")] + List, + #[usage(external_subcommand)] + External(Vec), +} + +/// A host with a value flag a runtime completer answers for. +#[derive(Cli)] +#[usage(bin = "themehost")] +#[allow(dead_code)] +struct ThemeHost { + /// Which theme to use. + #[usage(long)] + theme: Option, + #[usage(subcommand)] + command: ThemeCommand, +} + +#[derive(Subcommands)] +#[allow(dead_code)] +enum ThemeCommand { + Plugins { + #[usage(subcommand)] + command: ThemePluginCommand, + }, +} + +#[derive(Subcommands)] +#[allow(dead_code)] +enum ThemePluginCommand { + List, + #[usage(external_subcommand)] + External(Vec), +} + +/// A host flag, declared where runtime commands also appear. +#[derive(Cli)] +#[usage(bin = "flaghost")] +#[allow(dead_code)] +struct FlagHost { + #[usage(subcommand)] + command: FlagHostCommand, +} + +#[derive(Subcommands)] +#[allow(dead_code)] +enum FlagHostCommand { + Plugins { + /// Say less. + #[usage(long)] + quiet: bool, + #[usage(subcommand)] + command: FlagPluginCommand, + }, +} + +#[derive(Subcommands)] +#[allow(dead_code)] +enum FlagPluginCommand { + /// A static plugin operation. + List, + #[usage(external_subcommand)] + External(Vec), +} + +#[derive(Cli)] +#[usage(bin = "root-host")] +#[allow(dead_code)] +struct RootHost { + #[usage(subcommand)] + command: RootCommand, +} + +#[derive(Subcommands)] +#[allow(dead_code)] +enum RootCommand { + Known, + #[usage(external_subcommand)] + External(Vec), +} + +#[derive(Cli)] +#[usage(bin = "closed")] +#[allow(dead_code)] +struct Closed { + #[usage(subcommand)] + command: ClosedCommand, +} + +#[derive(Subcommands)] +enum ClosedCommand { + Child, +} + +fn plugin(name: &str, extra: &str) -> Spec { + format!("name \"{name}\"\nbin \"{name}\"\n{extra}") + .parse() + .unwrap() +} + +fn catalog() -> usage_dynamic::Catalog<'static> { + let mut formatter = plugin( + "formatter", + r#" +version "2.4" +unknown_flags "error" +flag "--color " env="USAGE_DYNAMIC_TEST_COLOR" default="always" { + choices "always" "never" +} +arg "[path]" +cmd "check" help="Check formatting" { + flag "--fix" help="Apply fixes" +} +"#, + ); + formatter.about = Some("Format a project".into()); + formatter.about_long = Some("Format a project using its configured style.".into()); + formatter.cmd.help_heading = Some("Installed plugins".into()); + formatter.cmd.display_order = Some(2); + formatter.cmd.aliases = vec!["fmt".into()]; + formatter.cmd.hidden_aliases = vec!["oldfmt".into()]; + let mut audit = plugin("audit", ""); + audit.about = Some("Audit a project".into()); + audit.cmd.help_heading = Some("Installed plugins".into()); + audit.cmd.display_order = Some(1); + Catalog::builder(Host::app()) + .under("p", formatter) + .under("plugins", audit) + .build() + .unwrap() +} + +#[test] +fn root_summaries_appear_in_help_and_completion() { + let mut spec = plugin("doctor", ""); + spec.about = Some("Inspect plugin health".into()); + let static_kdl = RootHost::app().to_kdl(); + let catalog = Catalog::builder(RootHost::app()) + .root(spec) + .build() + .unwrap(); + assert_eq!(static_kdl, RootHost::app().to_kdl()); + let help = catalog.app().unwrap().help("", false).unwrap(); + assert!(help.contains("doctor Inspect plugin health"), "{help}"); + assert!(catalog + .app() + .unwrap() + .help("doctor", false) + .unwrap() + .contains("Inspect plugin health")); + + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--words"), + OsString::from("root-host"), + OsString::new(), + ]; + let answer = block_on(catalog.app().unwrap().completion_request(&argv)).unwrap(); + assert!( + answer.lines().any(|line| line.starts_with("doctor")), + "{answer}" + ); + let RootCommand::External(argv) = RootHost::parse_from(&[OsStr::new("doctor")]) + .unwrap() + .command + else { + panic!("expected top-level external command") + }; + let argv: Vec = argv.into_iter().map(OsString::from).collect(); + assert!(matches!( + catalog.parse_external("", &argv).unwrap(), + Some(Outcome::Parsed(_)) + )); +} + +#[test] +fn nested_help_merges_summaries_aliases_headings_and_ordering() { + let catalog = catalog(); + let app = catalog.app().unwrap(); + for long in [false, true] { + let help = app.help("plugins", long).unwrap(); + assert!(help.contains("Installed plugins:"), "{help}"); + assert!(help.contains("plugins formatter"), "{help}"); + assert!(help.contains("[aliases: fmt]"), "{help}"); + assert!(!help.contains("oldfmt"), "{help}"); + assert!( + help.find("plugins audit").unwrap() < help.find("plugins formatter").unwrap(), + "{help}" + ); + } + let plugin_help = app.help("plugins formatter", false).unwrap(); + assert!(plugin_help.contains("--color"), "{plugin_help}"); + assert!(plugin_help.contains("formatter check"), "{plugin_help}"); + let nested_help = app.help("plugins fmt check", false).unwrap(); + assert!(nested_help.contains("--fix"), "{nested_help}"); +} + +#[test] +fn command_completion_offers_visible_names_and_delegates_after_selection() { + let catalog = catalog(); + let app = catalog.app().unwrap(); + let parsed = usage_parser::parse::parse_partial( + app.spec(), + &["host".into(), "plugins".into(), "formatter".into()], + ) + .unwrap(); + assert_eq!(parsed.cmd.name, "formatter", "{:?}", parsed.cmds); + let request = |words: &[&str]| { + let mut argv = vec![ + OsString::from("__complete_word__"), + OsString::from("--words"), + ]; + argv.extend(words.iter().map(OsString::from)); + block_on(app.completion_request(&argv)).unwrap() + }; + let answer = request(&["host", "plugins", ""]); + assert!( + answer.lines().any(|line| line.starts_with("formatter")), + "{answer}" + ); + assert!( + answer.lines().any(|line| line.starts_with("fmt")), + "{answer}" + ); + assert!(!answer.contains("oldfmt"), "{answer}"); + let flags = request(&["host", "plugins", "formatter", "--"]); + assert!( + flags.lines().any(|line| line.starts_with("--color")), + "{flags}" + ); + let nested = request(&["host", "plugins", "formatter", "ch"]); + assert!( + nested.lines().any(|line| line.starts_with("check")), + "{nested}" + ); +} + +#[test] +fn canonical_alias_unknown_help_version_and_invalid_argv_are_typed() { + let catalog = catalog(); + let parsed = catalog + .parse_external("p", &[OsString::from("fmt"), OsString::from("project")]) + .unwrap() + .unwrap(); + let Outcome::Parsed(parsed) = parsed else { + panic!("expected parsed") + }; + assert_eq!(parsed.parent, "plugins"); + assert_eq!(parsed.name, "formatter"); + assert_eq!(parsed.invoked_as, "fmt"); + assert!(!parsed.output.tokens.is_empty()); + assert!(!parsed.output.flags.is_empty(), "default should be applied"); + + std::env::set_var("USAGE_DYNAMIC_TEST_COLOR", "never"); + let from_env = catalog + .parse_external("plugins", &[OsString::from("formatter")]) + .unwrap() + .unwrap(); + std::env::remove_var("USAGE_DYNAMIC_TEST_COLOR"); + let Outcome::Parsed(from_env) = from_env else { + panic!("expected parsed") + }; + assert!(from_env + .output + .flag_origins + .values() + .flatten() + .any(|origin| format!("{origin:?}").contains("Env"))); + + assert!(catalog + .parse_external("plugins", &[OsString::from("missing")]) + .unwrap() + .is_none()); + // A hidden alias dispatches like any other spelling; hiding is a help/completion property. + let hidden = catalog + .parse_external("plugins", &[OsString::from("oldfmt")]) + .unwrap() + .unwrap(); + let Outcome::Parsed(hidden) = hidden else { + panic!("expected parsed") + }; + assert_eq!(hidden.name, "formatter"); + assert_eq!(hidden.invoked_as, "oldfmt"); + let help = catalog + .parse_external( + "plugins", + &[OsString::from("formatter"), OsString::from("--help")], + ) + .unwrap(); + assert!(matches!(help, Some(Outcome::Help(_))), "{help:?}"); + assert!(matches!( + catalog + .parse_external( + "plugins", + &[OsString::from("formatter"), OsString::from("--version")] + ) + .unwrap(), + Some(Outcome::Version(_)) + )); + assert!(matches!( + catalog.parse_external( + "plugins", + &[OsString::from("formatter"), OsString::from("--bogus")] + ), + Err(Error::Parse(_)) | Err(Error::InvalidArgv(_)) + )); +} + +#[test] +fn derived_external_variant_receives_the_same_argv_the_catalog_parses() { + let host = + Host::parse_from(&[OsStr::new("plugins"), OsStr::new("fmt"), OsStr::new("src")]).unwrap(); + let HostCommand::Plugins { + command: PluginCommand::External(argv), + } = host.command + else { + panic!("expected nested external command") + }; + assert!(matches!( + catalog().parse_external("plugins", &argv).unwrap(), + Some(Outcome::Parsed(_)) + )); +} + +#[test] +fn validation_rejects_bad_parents_namespaces_and_mounts() { + assert!(matches!( + Catalog::builder(Host::app()) + .under("absent", plugin("x", "")) + .build(), + Err(Error::MissingParent(_)) + )); + assert!(matches!( + Catalog::builder(Closed::app()) + .under("child", plugin("x", "")) + .build(), + Err(Error::ParentNotExternal(_)) + )); + assert!(matches!( + Catalog::builder(Host::app()) + .under("plugins", plugin("", "")) + .build(), + Err(Error::EmptyName) + )); + for name in ["list", "ls", "help"] { + assert!(matches!( + Catalog::builder(Host::app()) + .under("plugins", plugin(name, "")) + .build(), + Err(Error::Collision { .. }) + )); + } + let mut alias_collision = plugin("one", ""); + alias_collision.cmd.aliases.push("same".into()); + assert!(matches!( + Catalog::builder(Host::app()) + .under("plugins", alias_collision) + .under("plugins", plugin("same", "")) + .build(), + Err(Error::Collision { .. }) + )); + assert!(matches!( + Catalog::builder(Host::app()) + .under("plugins", plugin("mounted", "mount run=\"discover\"\n")) + .build(), + Err(Error::UnresolvedMount(_)) + )); +} + +#[cfg(unix)] +#[test] +fn non_utf8_reports_the_external_token_position() { + use std::os::unix::ffi::OsStringExt; + let error = catalog() + .parse_external( + "plugins", + &[OsString::from("formatter"), OsString::from_vec(vec![0xff])], + ) + .unwrap_err(); + assert!(matches!(error, Error::NonUtf8 { index: 1 })); +} + +/// The answer to a completion request, as the lines a shell would read. +fn answer(catalog: &Catalog<'_>, words: &[&str]) -> Vec { + let mut argv = vec![ + OsString::from("__complete_word__"), + OsString::from("--words"), + ]; + argv.extend(words.iter().map(OsString::from)); + block_on(catalog.app().unwrap().completion_request(&argv)) + .unwrap() + .lines() + .map(|line| line.split('\t').next().unwrap_or_default().to_string()) + .collect() +} + +#[test] +fn a_catalogued_name_is_offered_where_a_subcommand_belongs() { + let catalog = catalog(); + let offered = answer(&catalog, &["host", "plugins", ""]); + // Static commands and runtime ones, in one list: a user typing here cannot tell which is + // which, and should not have to. + for name in ["list", "ls", "audit", "formatter", "fmt"] { + assert!(offered.contains(&name.to_string()), "{name}: {offered:?}"); + } + // A hidden alias answers but is never advertised — the same rule static ones follow. + assert!(!offered.contains(&"oldfmt".to_string()), "{offered:?}"); + // One list, sorted and deduplicated, rather than two concatenated. + let mut sorted = offered.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(offered, sorted, "{offered:?}"); + + // A prefix narrows runtime names as it narrows static ones. + assert_eq!(answer(&catalog, &["host", "plugins", "fo"]), ["formatter"]); +} + +#[test] +fn a_flag_position_is_the_hosts_alone() { + // Runtime commands are commands. Where a flag is what could be typed, the host's tables are + // the whole answer and a plugin name would be a word that cannot go there. + let catalog = Catalog::builder(FlagHost::app()) + .under("plugins", plugin("formatter", "")) + .build() + .unwrap(); + let offered = answer(&catalog, &["flaghost", "plugins", "--"]); + assert!(offered.iter().any(|line| line == "--quiet"), "{offered:?}"); + assert!(!offered.contains(&"formatter".to_string()), "{offered:?}"); +} + +#[test] +fn completion_descends_into_a_plugins_own_spec() { + let catalog = catalog(); + // Its subcommands, and not the sibling static ones it is being completed alongside. + let offered = answer(&catalog, &["host", "plugins", "formatter", ""]); + assert!(offered.contains(&"check".to_string()), "{offered:?}"); + assert!(!offered.contains(&"list".to_string()), "{offered:?}"); + + // Its flags. + let flags = answer(&catalog, &["host", "plugins", "formatter", "--"]); + assert!(flags.contains(&"--color".to_string()), "{flags:?}"); + + // Its nested command's flags, reached through an alias of its own. + let nested = answer(&catalog, &["host", "plugins", "fmt", "check", "--f"]); + assert!(nested.contains(&"--fix".to_string()), "{nested:?}"); + + // And its declared choices, which are the whole answer: a mistyped one is no matches, not + // an invitation to complete a path. + let choices = answer(&catalog, &["host", "plugins", "formatter", "--color", ""]); + assert_eq!(choices, ["always", "never"], "{choices:?}"); + let mistyped = answer(&catalog, &["host", "plugins", "formatter", "--color", "zz"]); + assert!(mistyped.is_empty(), "{mistyped:?}"); +} + +#[test] +fn a_hidden_alias_descends_without_being_advertised() { + let catalog = catalog(); + let offered = answer(&catalog, &["host", "plugins", "oldfmt", ""]); + assert!(offered.contains(&"check".to_string()), "{offered:?}"); +} + +#[test] +fn an_uncatalogued_name_is_answered_with_nothing() { + // The host has no idea what `unknownthing` is, and neither does this catalog. Offering the + // parent's static subcommands there would complete a line that runs something else, and + // offering the working directory would claim a path belongs to a program nobody can ask. + let catalog = catalog(); + let offered = answer(&catalog, &["host", "plugins", "unknownthing", ""]); + assert!(offered.is_empty(), "{offered:?}"); + let raw = { + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--words"), + OsString::from("host"), + OsString::from("plugins"), + OsString::from("unknownthing"), + OsString::new(), + ]; + block_on(catalog.app().unwrap().completion_request(&argv)).unwrap() + }; + assert!(!raw.contains('\u{1}'), "no path fallback either: {raw:?}"); +} + +#[test] +fn a_host_flag_before_the_name_does_not_move_the_boundary() { + let catalog = Catalog::builder(FlagHost::app()) + .under("plugins", plugin("formatter", "cmd \"check\" {}\n")) + .build() + .unwrap(); + let offered = answer( + &catalog, + &["flaghost", "plugins", "--quiet", "formatter", ""], + ); + assert!(offered.contains(&"check".to_string()), "{offered:?}"); +} + +#[test] +fn a_line_and_a_word_list_are_the_same_question() { + // Two ways in to the same protocol: elvish hands over pre-split words, the others a line + // and a cursor. A host answering half the line itself must not make them disagree. + let catalog = catalog(); + let by_words = answer(&catalog, &["host", "plugins", "formatter", ""]); + let by_line = { + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--line"), + OsString::from("host plugins formatter "), + ]; + block_on(catalog.app().unwrap().completion_request(&argv)) + .unwrap() + .lines() + .map(|line| line.split('\t').next().unwrap_or_default().to_string()) + .collect::>() + }; + assert_eq!(by_words, by_line); + + // A cursor before the end completes what is under it, and the words after it are not part + // of the question. + let mid = { + let line = "host plugins formatter check"; + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--line"), + OsString::from(line), + OsString::from("--cursor"), + OsString::from("21"), + ]; + block_on(catalog.app().unwrap().completion_request(&argv)) + .unwrap() + .lines() + .map(|line| line.split('\t').next().unwrap_or_default().to_string()) + .collect::>() + }; + assert_eq!(mid, ["formatter"], "completing `form⌶` mid-line"); +} + +#[test] +fn an_open_position_still_defers_to_the_shells_paths() { + // The tightening is about positions that state their own answers. One that has nothing to + // say still hands over to the shell, or completing a plugin's file argument would offer + // nothing at all. `check` declares a flag and no words of its own, so a bare word there is + // the filesystem's to answer. + let catalog = catalog(); + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--words"), + OsString::from("host"), + OsString::from("plugins"), + OsString::from("formatter"), + OsString::from("check"), + OsString::new(), + ]; + let raw = block_on(catalog.app().unwrap().completion_request(&argv)).unwrap(); + assert!(raw.contains("\u{1}files"), "{raw:?}"); +} + +#[test] +fn dispatch_never_builds_the_merged_tree() { + // The point of the split: running a plugin command is the hot path, and the merged tree is + // a KDL round trip of the whole host spec. A catalog that is only ever dispatched through + // must never assemble one — which is observable, because `app()` is what assembles it. + let catalog = catalog(); + assert!(matches!( + catalog + .parse_external("plugins", &[OsString::from("fmt")]) + .unwrap(), + Some(Outcome::Parsed(_)) + )); + assert!(!catalog.is_assembled()); + catalog.app().unwrap(); + assert!(catalog.is_assembled()); +} + +/// A runtime completer of the host's own, of the kind `completions` registers. +fn theme_values( + _: &usage_rs::complete::CompleteCtx<'_>, +) -> Vec> { + vec![usage_rs::complete::Candidate::new("solarized")] +} + +fn theme_values_async( + ctx: usage_rs::complete::CompleteCtx<'_>, +) -> usage_rs::complete::CompletionFuture<'_> { + let _ = ctx; + Box::pin(async { vec![usage_rs::complete::Candidate::new("midnight")] }) +} + +static OVERLAYS: [usage_rs::complete::CompletionOverlay<'static>; 1] = + [usage_rs::complete::CompletionOverlay::sync_any( + "theme", + theme_values, + )]; + +static ASYNC_OVERLAYS: [usage_rs::complete::CompletionOverlay<'static>; 1] = + [usage_rs::complete::CompletionOverlay::async_any( + "theme", + theme_values_async, + )]; + +#[test] +fn the_hosts_own_completers_still_answer_for_the_hosts_words() { + // The catalog answers part of the line, not all of it. Everything the host registered has + // to keep working, or adopting runtime commands would quietly cost a CLI its completions. + for (overlays, expected) in [ + (&OVERLAYS[..], "solarized"), + (&ASYNC_OVERLAYS[..], "midnight"), + ] { + let catalog = Catalog::builder(ThemeHost::app()) + .completions(overlays) + .under("plugins", plugin("formatter", "")) + .build() + .unwrap(); + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--line"), + OsString::from("themehost --theme "), + ]; + let rendered = block_on(catalog.app().unwrap().completion_request(&argv)).unwrap(); + assert!(rendered.contains(expected), "{rendered:?}"); + } +} + +#[test] +fn a_named_completer_request_is_answered_by_the_host() { + // What a spec's `run=` line asks for: one named completer's values rather than everything + // the cursor could take. The request has its own flag, and a host that dropped it would + // answer a different question than the KDL it emitted promised. + let catalog = Catalog::builder(ThemeHost::app()) + .completions(&OVERLAYS) + .under("plugins", plugin("formatter", "")) + .build() + .unwrap(); + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--candidates"), + OsString::from("theme"), + OsString::from("--line"), + OsString::from("themehost "), + ]; + let rendered = block_on(catalog.app().unwrap().completion_request(&argv)).unwrap(); + assert!(rendered.contains("solarized"), "{rendered:?}"); + + // Past a runtime command there is nothing to run it with: a `run=` completer is a + // subprocess, and this crate spawns none. + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--candidates"), + OsString::from("theme"), + OsString::from("--line"), + OsString::from("themehost plugins formatter "), + ]; + let rendered = block_on(catalog.app().unwrap().completion_request(&argv)).unwrap(); + assert!(rendered.trim().is_empty(), "{rendered:?}"); +} + +#[test] +fn a_builtin_never_needs_a_catalog_and_a_fallback_needs_one_spec() { + // The design's cost promise: parse first, against the static tables alone. A built-in + // command dispatches before any catalog exists, so an application pays for discovery + // only when the catch-all actually fired — and then only for the specs it chose to load. + let host = Host::parse_from(&[OsStr::new("builtin")]).unwrap(); + assert!(matches!(host.command, HostCommand::Builtin)); + // No catalog was constructed on that path at all. + + let host = Host::parse_from(&[ + OsStr::new("plugins"), + OsStr::new("formatter"), + OsStr::new("src"), + ]) + .unwrap(); + let HostCommand::Plugins { + command: PluginCommand::External(captured), + } = host.command + else { + panic!("expected the catch-all") + }; + // Load exactly one plugin's spec, after the parse decided one is needed. + let catalog = Catalog::builder(Host::app()) + .under("plugins", plugin("formatter", "arg \"[path]\"\n")) + .build() + .unwrap(); + assert!(matches!( + catalog.parse_external("plugins", &captured).unwrap(), + Some(Outcome::Parsed(_)) + )); + assert!(!catalog.is_assembled(), "dispatch built no merged tree"); + + // A name the loaded specs do not answer to falls through, same as always. + let catalog = Catalog::builder(Host::app()) + .under("plugins", plugin("audit", "")) + .build() + .unwrap(); + assert!(catalog + .parse_external("plugins", &captured) + .unwrap() + .is_none()); +} + +#[test] +fn a_spec_built_in_rust_works_like_one_parsed_from_kdl() { + // KDL is the contract with out-of-process plugins. Commands the application itself defines + // at runtime — tasks from a config file, say — should not have to render KDL text just so + // the catalog can parse it back. A `Spec` assembled with the builders must behave + // identically. + use usage_dynamic::{SpecArgBuilder, SpecCommandBuilder, SpecFlagBuilder}; + + let task: Spec = SpecCommandBuilder::new() + .name("deploy") + .help("Deploy the project") + .flag( + SpecFlagBuilder::new() + .name("env") + .long("env") + .arg(SpecArgBuilder::new().name("ENV").build()) + .help("Target environment") + .build(), + ) + .subcommand( + SpecCommandBuilder::new() + .name("status") + .help("Show deploy status") + .build(), + ) + .build() + .into(); + assert_eq!(task.name, "deploy"); + assert_eq!(task.about.as_deref(), Some("Deploy the project")); + + let catalog = Catalog::builder(Host::app()) + .under("plugins", task) + .build() + .unwrap(); + + // Dispatch parses against the built spec, help/version included. + let parsed = catalog + .parse_external( + "plugins", + &[ + OsString::from("deploy"), + OsString::from("--env"), + OsString::from("prod"), + ], + ) + .unwrap() + .unwrap(); + let Outcome::Parsed(parsed) = parsed else { + panic!("expected parsed") + }; + assert!(!parsed.output.flags.is_empty()); + let help = catalog + .parse_external( + "plugins", + &[OsString::from("deploy"), OsString::from("--help")], + ) + .unwrap() + .unwrap(); + let Outcome::Help(help) = help else { + panic!("expected help") + }; + // The page's usage line reflects where the command sits, same as a KDL-parsed spec's would. + assert!(help.page.contains("deploy"), "{}", help.page); + assert!(help.page.contains("status"), "{}", help.page); + assert!(help.page.contains("--env"), "{}", help.page); + + // Merged help and completion see it too. + let app = catalog.app().unwrap(); + assert!(app.help("plugins deploy", false).unwrap().contains("--env")); + let offered = answer(&catalog, &["host", "plugins", "deploy", ""]); + assert!(offered.contains(&"status".to_string()), "{offered:?}"); +}