diff --git a/docs/spec/reference/cmd.md b/docs/spec/reference/cmd.md index 12c1dfc1..43e932ed 100644 --- a/docs/spec/reference/cmd.md +++ b/docs/spec/reference/cmd.md @@ -83,6 +83,23 @@ task commands as if they were statically defined in the usage spec. a shebang script therefore needs a POSIX shell to be available; one that invokes a program directly, like `mycli mount-usage-tasks` above, works either way. +### Mounting at the top level + +A `mount` also works as a top-level node, for a CLI whose _own_ commands are +discovered rather than declared — one whose subcommands come from plugins, say: + +```kdl +name "mycli" +bin "mycli" +cmd "install" +mount run="mycli plugin-commands" +``` + +The root's mount runs only when a _word_ matches nothing already declared, so +`mycli install` costs nothing extra and only `mycli something-from-a-plugin` pays +for discovery. Flags never trigger it — `mycli --help` does not run your mount +command — so declaring the commands you know about keeps the common path free. + ### Global flags and mounted commands A mounted command describes a different program, so the flags of the commands it is mounted under diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 90b40e99..f9daef5b 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -351,7 +351,12 @@ impl<'a> Parser<'a> { /// Returns the parsed arguments and flags, with defaults and env vars applied. pub fn parse(self, input: &[String]) -> Result { let custom_env = self.env.as_ref(); - let (mut out, overridden_flags) = parse_partial_with_env(self.spec, input, custom_env)?; + let (mut out, overridden_flags) = parse_partial_with_env( + self.spec, + input, + custom_env, + MountTiming::WhenAWordIsUnknown, + )?; trace!("{out:?}"); let get_env = |key: &str| -> Option { @@ -539,14 +544,28 @@ pub fn parse(spec: &Spec, input: &[String]) -> Result Result { - parse_partial_with_env(spec, input, None).map(|(out, _)| out) + parse_partial_with_env(spec, input, None, MountTiming::Eager).map(|(out, _)| out) } /// Internal version of parse_partial that accepts an optional custom env map. +/// When a command's own `mount` runs, for the root — which nothing descends into. +/// +/// A completion has to know every command before it can offer one, even with +/// nothing typed yet, so it resolves up front. An execution knows the word it was +/// given, so it only pays for discovery when that word matches nothing declared — +/// and a CLI that declares its commands and mounts a few more does not spawn a +/// process on every invocation. +#[derive(Clone, Copy, PartialEq, Eq)] +enum MountTiming { + Eager, + WhenAWordIsUnknown, +} + fn parse_partial_with_env( spec: &Spec, input: &[String], custom_env: Option<&HashMap>, + mount_timing: MountTiming, ) -> Result<(ParseOutput, HashSet), miette::Error> { trace!("parse_partial: {input:?}"); let mut input = input.iter().cloned().collect::>(); @@ -592,8 +611,60 @@ fn parse_partial_with_env( // Track whether we've already applied the default_subcommand to prevent // multiple switches (e.g., if default is "run" and there's a task named "run") let mut used_default_subcommand = false; + // Whether the command in scope has had its own mounts run. A mount on the root + // is the case that needs this: a subcommand's mounts are run when the parser + // descends into it, but nothing descends into the root. + let mut mounts_resolved = false; + // A completion needs the whole command list before it can offer anything, and + // `mycli ` has no word to trigger discovery with — so waiting for one would + // mean a root mount never contributed to the very thing it exists for. + // + // The default-subcommand gate applies here too, and has to: offering a discovered + // command that a real parse would hand to the default instead would be worse than + // not offering it. A root mount under a `default_subcommand` that does not say + // `overrides_default` therefore contributes nothing anywhere, which is what + // "the default outranks discovery" means. + let default_outranks_mounts = + spec.default_subcommand.is_some() && !out.cmd.mounts.iter().any(|m| m.overrides_default); + if mount_timing == MountTiming::Eager && !default_outranks_mounts && !out.cmd.mounts.is_empty() + { + mounts_resolved = true; + let mut mounted = out.cmd.clone(); + mounted.mount(&[])?; + merge_subcommand_flags(&mut out.available_flags, gather_flags(&mounted), false); + if let Some(last) = out.cmds.last_mut() { + *last = mounted.clone(); + } + out.cmd = mounted; + } while idx < input.len() { + // Only for a word that could name a command, and only when it matches + // nothing already declared. A CLI that declares its commands and mounts more + // does not spawn a process for every invocation, and a flag — `--help`, or + // anything unrecognized — never triggers discovery at all, which it would + // otherwise do simply by not being a subcommand. + // A declared `default_subcommand` already says what an unmatched word means, + // and it costs nothing — so discovery waits behind it unless a mount asks to + // outrank it. Without this, a task runner would spawn its discovery process + // once per task invocation. + let default_catches_it = spec.default_subcommand.is_some() + && !out.cmd.mounts.iter().any(|m| m.overrides_default); + if !mounts_resolved + && !out.cmd.mounts.is_empty() + && !default_catches_it + && !input[idx].starts_with('-') + && out.cmd.find_subcommand(&input[idx]).is_none() + { + mounts_resolved = true; + let mut mounted = out.cmd.clone(); + mounted.mount(&mount_prefix_words(&prefix_flags))?; + merge_subcommand_flags(&mut out.available_flags, gather_flags(&mounted), false); + if let Some(last) = out.cmds.last_mut() { + *last = mounted.clone(); + } + out.cmd = mounted; + } if let Some(subcommand) = out.cmd.find_subcommand(&input[idx]) { let mut subcommand = subcommand.clone(); // Pass prefix words (global flags before this subcommand) to mount @@ -610,6 +681,8 @@ fn parse_partial_with_env( input.remove(idx); out.cmds.push(subcommand.clone()); out.cmd = subcommand.clone(); + // A descent already ran the new command's mounts, above. + mounts_resolved = true; prefix_flags.clear(); // Continue from current position (don't reset to 0) // After remove(), idx now points to the next element @@ -670,6 +743,9 @@ fn parse_partial_with_env( out.cmds.push(subcommand.clone()); out.cmd = subcommand.clone(); prefix_flags.clear(); + // This descent ran the new command's mounts, so lazy + // discovery must not run them a second time. + mounts_resolved = true; used_default_subcommand = true; // Continue the loop to check if this word is a subcommand of the // default subcommand (e.g., a task name added via mount). @@ -1524,6 +1600,173 @@ arg "[input]" assert_eq!(first_string_value(&parsed), "input.txt"); } + #[cfg(unix)] + #[test] + fn a_mount_on_the_root_discovers_subcommands() { + // The root is a command like any other, so it can find its own subcommands + // by running something. Uses `echo` rather than a fixture because resolving + // a mount is what is being tested. + let spec: Spec = r#" +name "ex" +bin "ex" +cmd "declared" +mount run="echo 'cmd \"discovered\"'" +"# + .parse() + .unwrap(); + + let out = parse(&spec, &["ex".to_string(), "discovered".to_string()]).unwrap(); + assert_eq!(out.cmd.name, "discovered"); + } + + #[cfg(unix)] + #[test] + fn completion_sees_root_mounted_commands_with_nothing_typed() { + // The case a root mount exists for. `mycli ` has no word to trigger + // discovery with, so a completion has to resolve up front or the mounted + // commands are never offered. + let spec: Spec = r#" +name "ex" +bin "ex" +cmd "declared" +mount run="echo 'cmd \"discovered\"'" +"# + .parse() + .unwrap(); + + let out = parse_partial(&spec, &["ex".to_string()]).unwrap(); + assert!( + out.cmd.subcommands.contains_key("discovered"), + "a completion should see mounted commands; got {:?}", + out.cmd.subcommands.keys().collect::>() + ); + } + + #[cfg(unix)] + #[test] + fn completion_and_execution_agree_about_discovery() { + // Offering a command that a real parse would hand to the default instead is + // worse than not offering it, so the gate applies to both paths. The mount + // fails if it runs, which is how both halves are checked at once. + let spec: Spec = r#" +name "ex" +bin "ex" +default_subcommand "run" +cmd "run" { + arg "" +} +mount run="exit 1" +"# + .parse() + .unwrap(); + + let out = parse_partial(&spec, &["ex".to_string()]).unwrap(); + assert!( + !out.cmd.subcommands.contains_key("discovered"), + "a completion must not offer what execution will not route" + ); + + let out = parse(&spec, &["ex".to_string(), "mytask".to_string()]).unwrap(); + assert_eq!(out.cmd.name, "run"); + } + + #[cfg(unix)] + #[test] + fn a_default_subcommand_outranks_discovery() { + // The default already says what an unmatched word means, and says it for + // free. The mount fails if it runs, so parsing proves discovery was skipped. + let spec: Spec = r#" +name "ex" +bin "ex" +default_subcommand "run" +cmd "run" { + arg "" +} +mount run="exit 1" +"# + .parse() + .unwrap(); + + let out = parse(&spec, &["ex".to_string(), "mytask".to_string()]).unwrap(); + assert_eq!(out.cmd.name, "run"); + } + + #[cfg(unix)] + #[test] + fn a_mount_may_ask_to_outrank_the_default() { + // Opting in, and paying for it: discovery runs first, so a discovered + // command wins over the fallback. + let spec: Spec = r#" +name "ex" +bin "ex" +default_subcommand "run" +cmd "run" { + arg "" +} +mount run="echo 'cmd \"discovered\"'" overrides_default=#true +"# + .parse() + .unwrap(); + + let out = parse(&spec, &["ex".to_string(), "discovered".to_string()]).unwrap(); + assert_eq!(out.cmd.name, "discovered"); + + // A word it does not know still reaches the default. + let out = parse(&spec, &["ex".to_string(), "mytask".to_string()]).unwrap(); + assert_eq!(out.cmd.name, "run"); + } + + #[cfg(unix)] + #[test] + fn a_flag_does_not_run_the_mount() { + // A flag matches no subcommand, which would have been enough to trigger + // discovery — so `ex --help` spawned a process. The mount fails if it runs, + // so parsing at all is the proof that it did not. + let spec: Spec = r#" +name "ex" +bin "ex" +flag "--verbose" +cmd "declared" +mount run="exit 1" +"# + .parse() + .unwrap(); + + let out = parse(&spec, &["ex".to_string(), "--verbose".to_string()]).unwrap(); + assert_eq!(out.cmd.name, "ex"); + } + + #[cfg(unix)] + #[test] + fn a_declared_subcommand_does_not_run_the_mount() { + // The mount would fail if it ran, so this parsing at all is the proof that + // discovery is skipped when the word is already known. Worth pinning: a root + // mount that resolved eagerly would spawn a process on every invocation. + let spec: Spec = r#" +name "ex" +bin "ex" +cmd "declared" +mount run="exit 1" +"# + .parse() + .unwrap(); + + let out = parse(&spec, &["ex".to_string(), "declared".to_string()]).unwrap(); + assert_eq!(out.cmd.name, "declared"); + } + + #[test] + fn a_root_mount_survives_being_written_out() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nmount run=\"ex plugins --usage\"\n" + .parse() + .unwrap(); + assert_eq!(spec.cmd.mounts.len(), 1); + + let reparsed: Spec = spec.to_string().parse().unwrap(); + assert_eq!(reparsed.cmd.mounts.len(), 1, "written:\n{spec}"); + assert_eq!(reparsed.cmd.mounts[0].run, "ex plugins --usage"); + } + #[test] fn test_mount_prefix_applies_flag_overrides() { let stdin = Arc::new( diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index 18221850..8d1a9ded 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -424,6 +424,11 @@ impl SpecCommand { usage.trim().to_string() } 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 + // arrived here would not be findable. This worked before only because + // mounting happened to precede the first lookup on a given command. + self.subcommand_lookup = OnceLock::new(); // Destructured exhaustively (no `..`) so that adding a field to // SpecCommand fails to compile until this decides what merging it means. // Runtime-derived fields are explicitly ignored rather than skipped. diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 261a65dd..cfe9ba93 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -204,6 +204,10 @@ impl Spec { "usage" => schema.usage = node.arg(0)?.ensure_string()?, "arg" => schema.cmd.args.push(SpecArg::parse(ctx, &node)?), "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?), + // The root is a command like any other, so it can discover its own + // subcommands by running something. A CLI whose top-level commands + // come from plugins has no other way to say so. + "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?), "cmd" => { let node: SpecCommand = SpecCommand::parse(ctx, &node)?; schema.cmd.subcommands.insert(node.name.to_string(), node); @@ -489,6 +493,11 @@ impl Display for Spec { for arg in self.cmd.args.iter() { nodes.push(arg.into()); } + // Written here rather than by SpecCommand, because the root's own nodes + // live at the top level of the document instead of inside a `cmd` block. + for mount in self.cmd.mounts.iter() { + nodes.push(mount.into()); + } for example in self.examples.iter() { nodes.push(example.into()); } diff --git a/lib/src/spec/mount.rs b/lib/src/spec/mount.rs index 5d4bc58e..9c9c5474 100644 --- a/lib/src/spec/mount.rs +++ b/lib/src/spec/mount.rs @@ -1,6 +1,6 @@ use std::fmt::Display; -use kdl::KdlNode; +use kdl::{KdlEntry, KdlNode}; use serde::Serialize; use crate::error::Result; @@ -11,12 +11,32 @@ use crate::spec::helpers::{string_entry, NodeHelper}; #[non_exhaustive] pub struct SpecMount { pub run: String, + /// Whether a discovered command may take precedence over + /// [`Spec::default_subcommand`](crate::Spec::default_subcommand). + /// + /// Off by default, because resolving a mount runs a process: with a default + /// subcommand declared, every word that is not a known command would otherwise + /// pay for discovery before falling back — for a task runner, that is a + /// subprocess per task invocation. Turn it on when a discovered command should + /// win, and accept the cost. + pub overrides_default: bool, } impl SpecMount { /// A mount that runs `run` to produce a spec for the subcommands here. pub fn new(run: impl Into) -> Self { - Self { run: run.into() } + Self { + run: run.into(), + overrides_default: false, + } + } + + /// The same, but a discovered command outranks the default subcommand. + pub fn overriding_default(run: impl Into) -> Self { + Self { + run: run.into(), + overrides_default: true, + } } } @@ -26,12 +46,14 @@ impl SpecMount { for (k, v) in node.props() { match k { "run" => mount.run = v.ensure_string()?, + "overrides_default" => mount.overrides_default = v.ensure_bool()?, k => bail_parse!(ctx, v.entry.span(), "unsupported mount key {k}"), } } for child in node.children() { match child.name() { "run" => mount.run = child.arg(0)?.ensure_string()?, + "overrides_default" => mount.overrides_default = child.arg(0)?.ensure_bool()?, k => bail_parse!( ctx, child.node.name().span(), @@ -53,6 +75,9 @@ impl From<&SpecMount> for KdlNode { fn from(mount: &SpecMount) -> KdlNode { let mut node = KdlNode::new("mount"); node.push(string_entry(Some("run"), &mount.run)); + if mount.overrides_default { + node.push(KdlEntry::new_prop("overrides_default", true)); + } node } }