From 376ff73cfa394fff464026650bf6e8d337adbb81 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:12:27 +0000 Subject: [PATCH 1/3] feat(argv): hand paths to the shell instead of listing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A completion answer now says two things: what this CLI knows the word could be, and whether *paths* belong there. Listing them is the shell's job — it already does it better than a CLI can, with the user's own completion styles, colours, escaping and directory-aware widgets — and doing it here would put a directory read inside a binary whose whole claim is that it does not touch the filesystem to understand a command line. Which positions admit a path is the reference's rule, read off the same names: a completer is looked up by the lowercased argument name and that name doubles as the type, so `` takes paths and `` directories without a spec saying so, and a flag's value is named by its placeholder rather than by the flag. Then the suppressions: never after a dash, because no path starts with one; never where the position knows its whole set, because offering the working directory beside a mistyped choice is how a typo completes to whatever was lying around; and always where this CLI has nothing to say, which is the reference's fallback too. This is the one deliberate divergence in the completion path, so the conformance test holds the two *equivalent* rather than equal: for a line whose answer is paths, every candidate the reference offers must be an entry in the working directory — proving it was listing files — and this side must be the marker. A listing where we claim to know the answer, or a marker where the reference had real candidates, both fail. Co-Authored-By: Claude Opus 5 --- argv/src/complete.rs | 188 ++++++++++++++++++++++++++++++++- benches/gate/tests/complete.rs | 48 +++++++++ 2 files changed, 232 insertions(+), 4 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 29436d30b..7a82e80cb 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -120,6 +120,52 @@ pub struct Candidate<'a> { pub description: Option<&'a str>, } +/// Paths a shell should offer, on top of whatever this crate knows about. +/// +/// Listing them is the shell's job, not ours. It already does it better than a CLI can — the +/// user's own completion styles, colours, escaping, directory-aware widgets — and doing it here +/// would put a directory read in a binary whose whole claim is that it does not touch the +/// filesystem to parse a command line. So the answer says *that* paths belong here, and the +/// generated script hands the position to `_files`, `__fish_complete_path` or `compgen -f`. +/// +/// This is a deliberate divergence from usage-lib, which reads the directory itself and returns +/// the names. The conformance comparison holds the two equivalent rather than equal: where the +/// reference answers with a listing, this answers with the marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Files { + /// Anything: files, directories, whatever the shell shows for a path. + Any, + /// Directories only. + Dirs, +} + +/// Everything a shell needs to answer one Tab. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Completions<'a> { + /// What this CLI knows the word could be. + pub candidates: Vec>, + /// Paths the shell should add, if the position admits them. + pub files: Option, +} + +/// The paths an argument or value name asks for, by the name itself. +/// +/// The reference resolves a completer by the *lowercased name* and falls back to treating that +/// name as the type, so an argument called `` completes files and `` directories +/// without a spec saying so. Reimplemented rather than modelled as new vocabulary, because it +/// is the same rule read off the same names. +fn files_for(name: &str) -> Option { + // Compared without allocating a lowercased copy: a name is short, and this is a parser. + let matches = |want: &str| name.eq_ignore_ascii_case(want); + if matches("file") || matches("path") || matches("config_file") { + Some(Files::Any) + } else if matches("dir") || matches("directory") { + Some(Files::Dirs) + } else { + None + } +} + /// What could be typed at the cursor, given a spec and a split line. /// /// The rules are usage-lib's, because a CLI's completions should not change with the @@ -131,6 +177,44 @@ pub struct Candidate<'a> { /// /// 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> { + let position = walk(spec.root.cmd, split.argv()); + let token = split.prefix.as_str(); + let candidates = candidates(spec, split); + + // A dash-prefixed word is a flag or nothing: no path starts with one, and the reference + // suppresses its own listing there for the same reason. + let flag_like = position.flags_possible && token.starts_with('-'); + + // The name a value here would have, which is what says whether paths belong. + let named = if let Some(flag) = position.awaiting_value { + flag_meta(spec.root, flag) + .and_then(|m| m.value_name) + .or(Some(flag.name)) + } else { + position.next_arg.map(|arg| arg.name) + }; + let asked_for = named.and_then(files_for); + + // Closed: the position knows its whole set, so an unmatched prefix means no matches rather + // than "ask somebody else". Offering the working directory for a mistyped choice is worse + // than offering nothing. + let closed = !candidates.is_empty() || position.help_topic; + + let files = if flag_like { + None + } else if asked_for.is_some() { + asked_for + } else if closed { + None + } else { + Some(Files::Any) + }; + + Completions { candidates, files } +} + +/// Just the candidates this CLI knows about, without the question of paths. pub fn candidates<'a>(spec: &'a Spec<'a>, split: &Split) -> Vec> { let position = walk(spec.root.cmd, split.argv()); let meta = crate::help::find(spec, position.cmd).map(|(_, meta)| meta); @@ -682,6 +766,23 @@ mod tests { args: &[&FIRST, &SECOND], ..Command::EMPTY }; + static FILE: Arg = Arg { + key: 9, + name: "FILE", + ..Arg::REQUIRED + }; + static EDIT: Command = Command { + name: "edit", + flags: &[&INTO], + args: &[&FILE], + ..Command::EMPTY + }; + static INTO: Flag = Flag { + key: 10, + name: "into", + longs: &["into"], + ..Flag::VALUE + }; static AFTER: Arg = Arg { key: 7, name: "AFTER", @@ -744,6 +845,25 @@ mod tests { }], ..CommandMeta::EMPTY }; + static META_EDIT: CommandMeta = CommandMeta { + cmd: &EDIT, + about: Some("Edit a file"), + flags: &[FlagMeta { + flag: &INTO, + help: Some("Where to write it"), + value_name: Some("DIR"), + ..FlagMeta::EMPTY + }], + args: &[ArgMeta { + arg: &FILE, + help: Some("Which file"), + // Two well-known ones, so the position has candidates *and* wants paths — which is + // what makes the name the deciding rule rather than the emptiness. + choices: &["mise.toml", "mise.local.toml"], + ..ArgMeta::EMPTY + }], + ..CommandMeta::EMPTY + }; static META_WRAP: CommandMeta = CommandMeta { cmd: &WRAP, about: Some("Wrap something"), @@ -810,13 +930,14 @@ mod tests { &META_LIST, &META_TASK, &META_WRAP, + &META_EDIT, ], ..CommandMeta::EMPTY }; static ROOT_WITH_META: Command = Command { name: "mise", flags: &[&GLOBAL], - subcommands: &[&USE, &EXEC, &PLUGINS, &SECRET, &LIST, &TASK, &WRAP], + subcommands: &[&USE, &EXEC, &PLUGINS, &SECRET, &LIST, &TASK, &WRAP, &EDIT], ..Command::EMPTY }; static SPEC: Spec = Spec { @@ -1109,7 +1230,10 @@ mod tests { offered("mise "), // `node` and `python` are the fallback command's, which the root offers too — see // `the_root_offers_what_the_command_it_falls_back_to_accepts`. - ["exec", "list", "ls", "node", "plugins", "python", "task", "u", "use", "wrap"], + [ + "edit", "exec", "list", "ls", "node", "plugins", "python", "task", "u", "use", + "wrap", + ], "sorted, and a hidden command is offered under none of its names" ); assert_eq!(offered("mise pl"), ["plugins"]); @@ -1164,7 +1288,10 @@ mod tests { // word is not a flag, not that it is not a word. assert_eq!( offered("mise -- "), - ["exec", "list", "ls", "node", "plugins", "python", "task", "u", "use", "wrap"] + [ + "edit", "exec", "list", "ls", "node", "plugins", "python", "task", "u", "use", + "wrap", + ] ); } @@ -1186,7 +1313,7 @@ mod tests { // read about, not for a word to run. assert_eq!( offered("mise help "), - ["exec", "list", "ls", "plugins", "task", "u", "use", "wrap"] + ["edit", "exec", "list", "ls", "plugins", "task", "u", "use", "wrap"] ); } #[test] @@ -1244,4 +1371,57 @@ mod tests { let found = offered("mise "); assert!(found.contains(&"node".to_string()), "{found:?}"); } + /// The whole answer for a line: what this CLI knows, and whether paths belong. + fn answer(line: &str) -> Completions<'static> { + complete(&SPEC, &at_end(line)) + } + + #[test] + fn a_word_named_like_a_path_asks_the_shell_for_paths() { + // The reference resolves a completer by the lowercased name and treats that name as the + // type when nothing else says otherwise, so `` completes files without the spec + // mentioning it. Same rule, read off the same name. + assert_eq!(answer("mise edit ").files, Some(Files::Any)); + // Even though it has choices of its own, which would otherwise close the position: a + // path argument that names two well-known files still takes any other path. + assert_eq!(offered("mise edit "), ["mise.local.toml", "mise.toml"]); + // And a flag's value is named by its placeholder, not by the flag. + assert_eq!(answer("mise edit --into ").files, Some(Files::Dirs)); + } + + #[test] + fn a_position_that_knows_its_answers_does_not_ask_for_paths() { + // Offering the working directory beside a known set is how a mistyped choice completes + // to whatever happened to be lying around. + assert_eq!(answer("mise use ").files, None); + assert_eq!(answer("mise plugins ").files, None); + // Nor for a flag, which no path starts with — including one that matches nothing, where + // there is otherwise no candidate to suppress the fallback. + assert_eq!(answer("mise use --").files, None); + assert_eq!(answer("mise -").files, None); + let a = answer("mise use --zzz"); + assert!(a.candidates.is_empty()); + assert_eq!(a.files, None); + // Nor for a help topic, which is a command name and nothing else. + assert_eq!(answer("mise help ").files, None); + } + + #[test] + fn a_position_with_nothing_to_say_lets_the_shell_answer() { + // `mise edit some-file ⌶` has filled its only argument and has no subcommands, so this + // CLI has nothing to say — and a path is the shell's best guess, which is the + // reference's fallback too. + let a = answer("mise edit some-file "); + assert!(a.candidates.is_empty(), "{:?}", a.candidates); + assert_eq!(a.files, Some(Files::Any)); + } + + #[test] + fn an_argument_that_needs_a_separator_asks_for_nothing_else() { + // The separator is the only useful candidate, and a path is not one: the parser would + // reject it until the `--` is typed. + let a = answer("mise wrap "); + assert_eq!(a.candidates.len(), 1); + assert_eq!(a.files, None); + } } diff --git a/benches/gate/tests/complete.rs b/benches/gate/tests/complete.rs index fb934454b..ef928e5d1 100644 --- a/benches/gate/tests/complete.rs +++ b/benches/gate/tests/complete.rs @@ -117,3 +117,51 @@ fn the_short_flags_offered_are_the_reference_s() { assert_same("mise use -g"); assert_same("mise install -f"); } + +/// Where the reference reads the directory, this says the shell should. +/// +/// The one deliberate divergence in the completion path, so it is checked as an equivalence +/// rather than waived: for a line whose answer is paths, the reference's candidates must all be +/// entries that exist in the working directory, and ours must be the marker saying so. A +/// difference either way — a listing where we claim to know the answer, or a marker where the +/// reference had real candidates — fails. +#[test] +fn where_the_reference_lists_files_this_asks_the_shell_for_them() { + use usage_argv::complete::{complete, Files}; + + let spec = mise_spec(); + let cwd = std::env::current_dir().expect("a working directory"); + let entries: Vec = std::fs::read_dir(&cwd) + .expect("readable") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + !entries.is_empty(), + "the fixture needs a non-empty directory" + ); + + // `mise trust ⌶` takes a `[CONFIG_FILE]`, which the reference completes as a path. + for line in ["mise trust ", "mise config get --file "] { + let s = split(line, line.len(), Shell::Bash); + let ours = complete(shadow_mise::Cli::spec(), &s); + let theirs = usage_cli::complete_candidates(&spec, &s.words, s.cword, "bash") + .expect("the reference should answer"); + + assert_eq!( + ours.files, + Some(Files::Any), + "{line:?} should hand paths to the shell, got {ours:?}" + ); + // Every name the reference offered is something in this directory — i.e. it answered + // with a listing, which is the thing we are replacing rather than contradicting. + for (value, _) in &theirs { + let bare = value.trim_end_matches('/'); + assert!( + entries.iter().any(|e| e == bare), + "{line:?}: the reference offered {value:?}, which is not a directory entry — so \ + it was not doing file completion and the marker is wrong here" + ); + } + } +} From 9335202a3d0574aa230c68b29c61f9eb01e04f96 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:47:52 +0000 Subject: [PATCH 2/3] fix(argv): ask the declaration whether a position is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise use nodx⌶` — the argument declares its whole set, so a prefix matching none of it means no matches, not "here is the working directory". The condition was reading the *filtered* list, which answers a different question: "nothing matched what you typed" was being treated as "there is nothing else this can be", which is exactly the mistyped-choice fallback the comment beside it set out to avoid. Both questions matter, and the reference asks both — was anything found, and does the position declare its set. A mistyped *command* still falls back to paths, because nothing declared a set there. And an argument that requires a separator is not a path yet either, however it is named: until the `--` is typed the parser rejects a path exactly as it rejects any other value, so the separator is still the only thing that belongs. That check now comes before the by-name rule rather than after it. The conformance comparison gained the case, because the reference is the oracle for it: `mise activate zsx` over mise's real spec, where a test on the filtered list would have passed while the rule was wrong. Found by greptile and Cursor Bugbot, independently, which is usually a sign. Co-Authored-By: Claude Opus 5 --- argv/src/complete.rs | 124 ++++++++++++++++++++++++++++----- benches/gate/tests/complete.rs | 22 ++++++ 2 files changed, 127 insertions(+), 19 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 7a82e80cb..1b8ad6cf7 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -186,22 +186,44 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { // suppresses its own listing there for the same reason. let flag_like = position.flags_possible && token.starts_with('-'); - // The name a value here would have, which is what says whether paths belong. - let named = if let Some(flag) = position.awaiting_value { - flag_meta(spec.root, flag) - .and_then(|m| m.value_name) - .or(Some(flag.name)) + // The name a value here would have, which is what says whether paths belong, and whether + // that value declares its own set. + let (named, declares_choices) = if let Some(flag) = position.awaiting_value { + let meta = flag_meta(spec.root, flag); + ( + meta.and_then(|m| m.value_name).or(Some(flag.name)), + meta.is_some_and(|m| !m.choices.is_empty()), + ) + } else if let Some(arg) = position.next_arg { + let meta = arg_meta(spec.root, arg); + (Some(arg.name), meta.is_some_and(|m| !m.choices.is_empty())) } else { - position.next_arg.map(|arg| arg.name) + (None, false) }; let asked_for = named.and_then(files_for); - // Closed: the position knows its whole set, so an unmatched prefix means no matches rather - // than "ask somebody else". Offering the working directory for a mistyped choice is worse - // than offering nothing. - let closed = !candidates.is_empty() || position.help_topic; - - let files = if flag_like { + // An argument that requires a separator is not fillable yet, so nothing else belongs here — + // not even a path, which the parser would reject exactly as it rejects a value. + // + // Only when the cursor is *at* that argument, though. A flag waiting for its value is a + // different position that happens to have an unfilled positional behind it, and a rule about + // the positional has nothing to say about the flag: `ex --from ⌶` takes a path whatever the + // argument after it needs. + let needs_separator = position.awaiting_value.is_none() + && position + .next_arg + .is_some_and(|arg| arg.double_dash == crate::DoubleDash::Required) + && !position.separator_seen; + + // Two questions, and the reference asks both. Was anything found — because a position that + // answered does not need help. And does the position *declare* its set — because then an + // unmatched prefix means no matches rather than "ask somebody else", which is the difference + // between "there is nothing else this can be" and "nothing matched what you typed". + // Offering the working directory for a mistyped choice answers the second as though it were + // the first. + let closed = !candidates.is_empty() || declares_choices || position.help_topic; + + let files = if flag_like || needs_separator { None } else if asked_for.is_some() { asked_for @@ -771,6 +793,25 @@ mod tests { name: "FILE", ..Arg::REQUIRED }; + /// A path-named argument that is not fillable until a `--` is typed. + static PIPED: Arg = Arg { + key: 11, + name: "PATH", + double_dash: crate::DoubleDash::Required, + ..Arg::REQUIRED + }; + static FROM: Flag = Flag { + key: 12, + name: "from", + longs: &["from"], + ..Flag::VALUE + }; + static PIPE: Command = Command { + name: "pipe", + flags: &[&FROM], + args: &[&PIPED], + ..Command::EMPTY + }; static EDIT: Command = Command { name: "edit", flags: &[&INTO], @@ -845,6 +886,22 @@ mod tests { }], ..CommandMeta::EMPTY }; + static META_PIPE: CommandMeta = CommandMeta { + cmd: &PIPE, + about: Some("Pipe a file"), + flags: &[FlagMeta { + flag: &FROM, + help: Some("Read from"), + value_name: Some("FILE"), + ..FlagMeta::EMPTY + }], + args: &[ArgMeta { + arg: &PIPED, + help: Some("Where from"), + ..ArgMeta::EMPTY + }], + ..CommandMeta::EMPTY + }; static META_EDIT: CommandMeta = CommandMeta { cmd: &EDIT, about: Some("Edit a file"), @@ -931,13 +988,16 @@ mod tests { &META_TASK, &META_WRAP, &META_EDIT, + &META_PIPE, ], ..CommandMeta::EMPTY }; static ROOT_WITH_META: Command = Command { name: "mise", flags: &[&GLOBAL], - subcommands: &[&USE, &EXEC, &PLUGINS, &SECRET, &LIST, &TASK, &WRAP, &EDIT], + subcommands: &[ + &USE, &EXEC, &PLUGINS, &SECRET, &LIST, &TASK, &WRAP, &EDIT, &PIPE, + ], ..Command::EMPTY }; static SPEC: Spec = Spec { @@ -1231,8 +1291,8 @@ mod tests { // `node` and `python` are the fallback command's, which the root offers too — see // `the_root_offers_what_the_command_it_falls_back_to_accepts`. [ - "edit", "exec", "list", "ls", "node", "plugins", "python", "task", "u", "use", - "wrap", + "edit", "exec", "list", "ls", "node", "pipe", "plugins", "python", "task", "u", + "use", "wrap", ], "sorted, and a hidden command is offered under none of its names" ); @@ -1289,8 +1349,8 @@ mod tests { assert_eq!( offered("mise -- "), [ - "edit", "exec", "list", "ls", "node", "plugins", "python", "task", "u", "use", - "wrap", + "edit", "exec", "list", "ls", "node", "pipe", "plugins", "python", "task", "u", + "use", "wrap", ] ); } @@ -1301,7 +1361,7 @@ mod tests { let jobs = found.iter().find(|c| c.value == "--jobs").expect("--jobs"); assert_eq!(jobs.description, Some("How many at once")); - let found = candidates(&SPEC, &at_end("mise p")); + let found = candidates(&SPEC, &at_end("mise pl")); assert_eq!(found[0].description, Some("Manage plugins")); } #[test] @@ -1313,7 +1373,7 @@ mod tests { // read about, not for a word to run. assert_eq!( offered("mise help "), - ["edit", "exec", "list", "ls", "plugins", "task", "u", "use", "wrap"] + ["edit", "exec", "list", "ls", "pipe", "plugins", "task", "u", "use", "wrap"] ); } #[test] @@ -1399,6 +1459,15 @@ mod tests { // there is otherwise no candidate to suppress the fallback. assert_eq!(answer("mise use --").files, None); assert_eq!(answer("mise -").files, None); + // And a prefix that matches none of a declared set: the set is still the whole answer, + // so "nothing matched what you typed" must not become "here is the working directory". + let a = answer("mise use nodx"); + assert!(a.candidates.is_empty()); + assert_eq!(a.files, None); + // A mistyped *command*, though, does fall back — nothing declared a set there, and the + // reference offers paths for the same reason. + assert_eq!(answer("mise plugni").files, Some(Files::Any)); + let a = answer("mise use --zzz"); assert!(a.candidates.is_empty()); assert_eq!(a.files, None); @@ -1416,6 +1485,23 @@ mod tests { assert_eq!(a.files, Some(Files::Any)); } + #[test] + fn a_path_that_needs_a_separator_is_still_not_a_path_yet() { + // Named ``, so paths are what it takes — but not until the separator is typed, + // because until then the parser rejects a path exactly as it rejects any other value. + let a = answer("mise pipe "); + assert_eq!(a.files, None); + assert_eq!(a.candidates.len(), 1, "the separator, and nothing else"); + assert_eq!(a.candidates[0].value, "--"); + + // Past it, they are. + assert_eq!(answer("mise pipe -- ").files, Some(Files::Any)); + + // And a flag waiting for its value is a different position that happens to have that + // argument behind it: `--from ⌶` takes a path whatever the positional after it needs. + assert_eq!(answer("mise pipe --from ").files, Some(Files::Any)); + } + #[test] fn an_argument_that_needs_a_separator_asks_for_nothing_else() { // The separator is the only useful candidate, and a path is not one: the parser would diff --git a/benches/gate/tests/complete.rs b/benches/gate/tests/complete.rs index ef928e5d1..41f75fb9c 100644 --- a/benches/gate/tests/complete.rs +++ b/benches/gate/tests/complete.rs @@ -109,6 +109,28 @@ fn the_long_flags_offered_are_the_reference_s() { assert_same("mise ls --"); } +#[test] +fn a_prefix_matching_no_declared_choice_is_answered_the_same_way() { + // `mise activate zsx⌶` — the argument declares its whole set, so nothing matching means no + // matches, not "here is the working directory". Both sides must agree, and both must be + // empty: this is the case where a filtered-list test would have passed while the rule was + // wrong, because the rule is about what the position *declares*. + let spec = mise_spec(); + for line in ["mise activate zs", "mise activate zsx"] { + let s = split(line, line.len(), Shell::Bash); + let ours = usage_argv::complete::complete(shadow_mise::Cli::spec(), &s); + let theirs = usage_cli::complete_candidates(&spec, &s.words, s.cword, "bash") + .expect("the reference should answer"); + let theirs: Vec = theirs.into_iter().map(|(v, _)| v).collect(); + let ours_values: Vec = ours.candidates.iter().map(|c| c.value.clone()).collect(); + assert_eq!(ours_values, theirs, "{line:?}"); + assert_eq!( + ours.files, None, + "{line:?} declares its set, so no paths belong" + ); + } +} + #[test] fn the_short_flags_offered_are_the_reference_s() { // A lone dash offers both forms; a letter narrows to the flag that has it. From 9e8f0dd4d0413aeac424da2e32d8e8d9356296a5 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:34:51 +0000 Subject: [PATCH 3/3] fix(argv): decide which argument the cursor is at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The candidates knew about a restart token and the path decision did not, so after `::: ` they disagreed about which argument the cursor was at — one answering about the command's first, the other about whatever the words before the token had left unfilled. A mistyped prefix on a closed first argument then looked undeclared and opened the working directory. Asked once now, and both halves follow from the answer: whether paths belong, whether the set is declared, whether a separator is owed. The fixture's first argument declares its choices and its second takes paths, so the two answers differ — which the first version of this test did not arrange, and the mutation passed straight through it. Found by Cursor Bugbot. Co-Authored-By: Claude Opus 5 --- argv/src/complete.rs | 105 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 1b8ad6cf7..deab74a9e 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -179,9 +179,20 @@ fn files_for(name: &str) -> Option { /// 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> { let position = walk(spec.root.cmd, split.argv()); + let meta = crate::help::find(spec, position.cmd).map(|(_, meta)| meta); let token = split.prefix.as_str(); let candidates = candidates(spec, split); + // Which argument the cursor is at — the same question `candidates` answers, asked once so + // that the two halves cannot disagree. Past a restart token it is the command's *first* + // argument, whatever the words before the token filled, and everything below follows from + // that: whether paths belong, whether the set is declared, whether a separator is owed. + let at_cursor = if restarted(meta, split) { + meta.and_then(|m| m.args.first()).map(|m| m.arg) + } else { + position.next_arg + }; + // A dash-prefixed word is a flag or nothing: no path starts with one, and the reference // suppresses its own listing there for the same reason. let flag_like = position.flags_possible && token.starts_with('-'); @@ -194,7 +205,7 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { meta.and_then(|m| m.value_name).or(Some(flag.name)), meta.is_some_and(|m| !m.choices.is_empty()), ) - } else if let Some(arg) = position.next_arg { + } else if let Some(arg) = at_cursor { let meta = arg_meta(spec.root, arg); (Some(arg.name), meta.is_some_and(|m| !m.choices.is_empty())) } else { @@ -210,9 +221,7 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { // the positional has nothing to say about the flag: `ex --from ⌶` takes a path whatever the // argument after it needs. let needs_separator = position.awaiting_value.is_none() - && position - .next_arg - .is_some_and(|arg| arg.double_dash == crate::DoubleDash::Required) + && at_cursor.is_some_and(|arg| arg.double_dash == crate::DoubleDash::Required) && !position.separator_seen; // Two questions, and the reference asks both. Was anything found — because a position that @@ -788,6 +797,25 @@ mod tests { args: &[&FIRST, &SECOND], ..Command::EMPTY }; + /// A restarting command whose first argument takes paths and whose second does not, so that + /// "which argument is the cursor at" has two different answers. + static SCRIPT_ARG: Arg = Arg { + key: 13, + name: "FILE", + ..Arg::REQUIRED + }; + static MODE: Arg = Arg { + key: 14, + name: "MODE", + ..Arg::REQUIRED + }; + static SHIP: Command = Command { + name: "ship", + // The choices-bearing one first, so that "which argument is the cursor at" has two + // different *answers* rather than two routes to the same one. + args: &[&MODE, &SCRIPT_ARG], + ..Command::EMPTY + }; static FILE: Arg = Arg { key: 9, name: "FILE", @@ -886,6 +914,24 @@ mod tests { }], ..CommandMeta::EMPTY }; + static META_SHIP: CommandMeta = CommandMeta { + cmd: &SHIP, + about: Some("Ship a file"), + restart_token: Some(":::"), + args: &[ + ArgMeta { + arg: &MODE, + choices: &["fast", "slow"], + ..ArgMeta::EMPTY + }, + ArgMeta { + arg: &SCRIPT_ARG, + help: Some("Which file"), + ..ArgMeta::EMPTY + }, + ], + ..CommandMeta::EMPTY + }; static META_PIPE: CommandMeta = CommandMeta { cmd: &PIPE, about: Some("Pipe a file"), @@ -989,6 +1035,7 @@ mod tests { &META_WRAP, &META_EDIT, &META_PIPE, + &META_SHIP, ], ..CommandMeta::EMPTY }; @@ -996,7 +1043,7 @@ mod tests { name: "mise", flags: &[&GLOBAL], subcommands: &[ - &USE, &EXEC, &PLUGINS, &SECRET, &LIST, &TASK, &WRAP, &EDIT, &PIPE, + &USE, &EXEC, &PLUGINS, &SECRET, &LIST, &TASK, &WRAP, &EDIT, &PIPE, &SHIP, ], ..Command::EMPTY }; @@ -1291,8 +1338,8 @@ mod tests { // `node` and `python` are the fallback command's, which the root offers too — see // `the_root_offers_what_the_command_it_falls_back_to_accepts`. [ - "edit", "exec", "list", "ls", "node", "pipe", "plugins", "python", "task", "u", - "use", "wrap", + "edit", "exec", "list", "ls", "node", "pipe", "plugins", "python", "ship", "task", + "u", "use", "wrap", ], "sorted, and a hidden command is offered under none of its names" ); @@ -1349,8 +1396,8 @@ mod tests { assert_eq!( offered("mise -- "), [ - "edit", "exec", "list", "ls", "node", "pipe", "plugins", "python", "task", "u", - "use", "wrap", + "edit", "exec", "list", "ls", "node", "pipe", "plugins", "python", "ship", "task", + "u", "use", "wrap", ] ); } @@ -1373,7 +1420,7 @@ mod tests { // read about, not for a word to run. assert_eq!( offered("mise help "), - ["edit", "exec", "list", "ls", "pipe", "plugins", "task", "u", "use", "wrap"] + ["edit", "exec", "list", "ls", "pipe", "plugins", "ship", "task", "u", "use", "wrap"] ); } #[test] @@ -1510,4 +1557,42 @@ mod tests { assert_eq!(a.candidates.len(), 1); assert_eq!(a.files, None); } + #[test] + fn a_restart_asks_about_the_first_argument_for_paths_too() { + // Both halves of an answer have to agree about which argument the cursor is at. `ship` + // takes a `MODE` and then a `FILE`, so its two arguments want different things: the first + // declares its whole set, the second takes any path. + let first = answer("mise ship "); + assert_eq!(first.files, None, "MODE declares its set"); + assert_eq!( + first + .candidates + .iter() + .map(|c| c.value.as_str()) + .collect::>(), + ["fast", "slow"] + ); + assert_eq!( + answer("mise ship fast ").files, + Some(Files::Any), + "FILE takes paths" + ); + + // Past the restart token the cursor is back at the first argument, whatever the words + // before it filled — so a prefix matching none of `MODE`'s set means no matches, not + // "here is the working directory". + let after = answer("mise ship fast ::: "); + assert_eq!(after.files, None, "back at MODE, which declares its set"); + assert_eq!( + after + .candidates + .iter() + .map(|c| c.value.as_str()) + .collect::>(), + ["fast", "slow"] + ); + let mistyped = answer("mise ship fast ::: zzz"); + assert!(mistyped.candidates.is_empty()); + assert_eq!(mistyped.files, None, "a mistyped choice is still a choice"); + } }