From 34b68b0fbfc998dab9380a23bba866b7e6692808 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:11:22 +0000 Subject: [PATCH 1/3] feat(complete): add presentation labels to candidates --- argv/src/complete.rs | 54 +++++++++++++++++++++++++++++++++++----- argv/src/script.rs | 5 ++-- argv/src/spec.rs | 16 +++++++++--- docs/rust/completions.md | 4 ++- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 4d5ee9f5b..24c20189e 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -628,9 +628,9 @@ pub const COMMANDS_MARKER: &str = "\u{1}commands"; /// Write an answer the way `shell` reads it. /// /// One line per candidate, in the shape the shell's own completion machinery expects — which is -/// where the five differ. bash reads values only; fish, nu and PowerShell take a description -/// after a tab; zsh takes a third field, the text to insert, because what it displays and what -/// it types are not always the same string. +/// where the five differ. bash reads values only; fish and nu take a description after a tab; +/// PowerShell takes value, description and display text; zsh takes display text, description and +/// the quoted text to insert. /// /// A trailing [`FILES_MARKER`] says the generated script should hand the position to the /// shell's own path completion afterwards. @@ -649,13 +649,24 @@ pub fn render(answer: &Completions<'_>, shell: Shell) -> String { Shell::Zsh => { // Display, then description, then what to type: a candidate containing a space // or a quote has to reach the command line intact. - out.push_str(&candidate.value); + out.push_str(&one_line( + candidate.display.as_deref().unwrap_or(&candidate.value), + )); out.push('\t'); out.push_str(description); out.push('\t'); out.push_str(&zsh_quote(&candidate.value)); } - Shell::Fish | Shell::Nu | Shell::PowerShell => { + Shell::PowerShell => { + out.push_str(&candidate.value); + out.push('\t'); + out.push_str(description); + out.push('\t'); + out.push_str(&one_line( + candidate.display.as_deref().unwrap_or(&candidate.value), + )); + } + Shell::Fish | Shell::Nu => { out.push_str(&candidate.value); if described { out.push('\t'); @@ -1303,6 +1314,7 @@ fn subcommands<'a>(meta: &'a CommandMeta<'a>, token: &str) -> Vec> if name.starts_with(token) { out.push(Candidate { value: (*name).to_string(), + display: None, description: deprecated_description( sub.about, sub.deprecated, @@ -1340,6 +1352,7 @@ fn long_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> V if value.starts_with(token) { out.push(Candidate { value, + display: None, description: description.clone(), }); } @@ -1354,6 +1367,7 @@ fn long_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> V if value.starts_with(token) { out.push(Candidate { value, + display: None, description: description.clone(), }); } @@ -1386,6 +1400,7 @@ fn short_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> if asked_about { out.push(Candidate { value: format!("-{}", short as char), + display: None, description: meta.and_then(|m| { deprecated_description( m.help, @@ -1443,6 +1458,7 @@ fn positional<'a>( if token.is_empty() { return vec![Candidate { value: "--".to_string(), + display: None, description: None, }]; } @@ -1519,6 +1535,7 @@ fn choices<'a>( .filter(|c| c.starts_with(token)) .map(|c| Candidate { value: (*c).to_string(), + display: None, description: details .iter() .find(|detail| { @@ -3277,9 +3294,15 @@ mod tests { // bash shows values and nothing else. assert_eq!(render(&answer, Shell::Bash), "plugins\n"); - // fish, nu and PowerShell take a description after a tab. + // fish and nu take a description after a tab. assert_eq!(render(&answer, Shell::Fish), "plugins\tManage plugins\n"); + // PowerShell also receives its separately selectable display text. + assert_eq!( + render(&answer, Shell::PowerShell), + "plugins\tManage plugins\tplugins\n" + ); + // zsh takes a third field: what to type, which is not always what is shown. assert_eq!( render(&answer, Shell::Zsh), @@ -3287,6 +3310,25 @@ mod tests { ); } + #[test] + fn display_text_does_not_change_what_the_shell_inserts() { + let answer = Completions { + candidates: vec![Candidate::described("iad", "US East").displayed("IAD · Virginia")], + files: None, + }; + + assert_eq!(render(&answer, Shell::Bash), "iad\n"); + assert_eq!(render(&answer, Shell::Fish), "iad\tUS East\n"); + assert_eq!( + render(&answer, Shell::PowerShell), + "iad\tUS East\tIAD · Virginia\n" + ); + assert_eq!( + render(&answer, Shell::Zsh), + "IAD · Virginia\tUS East\tiad\n" + ); + } + #[test] fn a_candidate_a_shell_could_not_read_is_quoted_for_zsh() { static ODD: Command = Command { diff --git a/argv/src/script.rs b/argv/src/script.rs index 3b9ef5371..38b5871b0 100644 --- a/argv/src/script.rs +++ b/argv/src/script.rs @@ -426,12 +426,13 @@ Register-ArgumentCompleter -Native -CommandName '{name}' -ScriptBlock {{ if ($entry -eq ($marker + 'dirs')) {{ $files = 'dirs'; continue }} if ($entry -eq ($marker + 'executables')) {{ $files = 'executables'; continue }} if ($entry -eq ($marker + 'commands')) {{ $files = 'commands'; continue }} - $parts = $entry -split "`t", 2 + $parts = $entry -split "`t", 3 $value = $parts[0] $description = if ($parts.Count -gt 1 -and $parts[1]) {{ $parts[1] }} else {{ $value }} + $display = if ($parts.Count -gt 2 -and $parts[2]) {{ $parts[2] }} else {{ $value }} $results.Add( [System.Management.Automation.CompletionResult]::new( - $value, $value, 'ParameterValue', $description + $value, $display, 'ParameterValue', $description ) ) }} diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 7d692489b..53842a998 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -256,12 +256,14 @@ pub type Completer = fn(&CompleteCtx<'_>) -> Vec>; /// Something a shell could offer at the cursor. /// -/// The description is what fish, zsh, nu and PowerShell show beside a candidate; bash shows -/// only the value. It is borrowed from the spec rather than built, because it is already -/// there — the help text a page would print for the same thing. +/// `value` is what the shell inserts. `display` may replace it in shells whose completion API +/// separates presentation from insertion, and `description` is the help shown beside it. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Candidate<'a> { pub value: String, + /// A presentation-only label. Zsh and PowerShell support this distinction; other shells + /// display [`Self::value`] because their native candidate format couples the two. + pub display: Option<::std::borrow::Cow<'a, str>>, /// Borrowed from the spec where it is already there, owned where a callback made it. pub description: Option<::std::borrow::Cow<'a, str>>, } @@ -271,6 +273,7 @@ impl Candidate<'_> { pub fn new(value: impl Into) -> Self { Self { value: value.into(), + display: None, description: None, } } @@ -279,9 +282,16 @@ impl Candidate<'_> { pub fn described(value: impl Into, description: impl Into) -> Self { Self { value: value.into(), + display: None, description: Some(::std::borrow::Cow::Owned(description.into())), } } + + /// Use a different label in shells that can display one value while inserting another. + pub fn displayed(mut self, display: impl Into) -> Self { + self.display = Some(::std::borrow::Cow::Owned(display.into())); + self + } } /// A whole CLI: the root command plus what describes the program itself. diff --git a/docs/rust/completions.md b/docs/rust/completions.md index d8475cee6..9548f0237 100644 --- a/docs/rust/completions.md +++ b/docs/rust/completions.md @@ -174,4 +174,6 @@ fn tasks_in_file( The first parameter is the _partial parse_ of the completer's own command — flags the user has already typed are available, so a `--file` flag can steer what gets completed. Build candidates with `Candidate::new(value)` or `Candidate::described(value, description)`; shells that display -descriptions (zsh, fish) show them, shells that don't get the value alone. +descriptions show them, shells that don't get the value alone. Chain `.displayed(label)` when a +short insertion needs a more explanatory presentation; zsh and PowerShell keep that label +separate from the text inserted into the command line, while other shells display the value. From 7905cad1c970df95c7b40b17308fb04f29656f91 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:39:31 +0000 Subject: [PATCH 2/3] fix(complete): preserve metadata when deduplicating candidates --- argv/src/complete.rs | 71 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 24c20189e..d58aa53a7 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -993,10 +993,7 @@ pub async fn complete_with<'a>( let mut answer = complete(spec, split); answer.candidates.extend(dynamic); - answer.candidates.sort(); - answer - .candidates - .dedup_by(|left, right| left.value == right.value); + sort_and_dedup_candidates(&mut answer.candidates); // Even an empty callback suppresses the cwd fallback, but a field that // explicitly declares files or directories keeps that shell completion. answer.files = declared_files_at_cursor(spec, split, &position); @@ -1032,14 +1029,39 @@ async fn complete_named_with<'a>( candidates.append(&mut dynamic); } candidates.retain(|candidate| candidate.value.starts_with(&split.prefix)); - candidates.sort(); - candidates.dedup_by(|left, right| left.value == right.value); + sort_and_dedup_candidates(&mut candidates); Completions { candidates, files: None, } } +/// Sort candidates by insertion value and merge presentation metadata from duplicates. +/// +/// Static metadata and a runtime overlay can legitimately offer the same value. Keeping the +/// first candidate after derived sorting would prefer `None` over `Some`, discarding the richer +/// display label or description. +fn sort_and_dedup_candidates(candidates: &mut Vec>) { + candidates.sort(); + let mut deduped: Vec> = Vec::with_capacity(candidates.len()); + for mut candidate in candidates.drain(..) { + if let Some(existing) = deduped + .last_mut() + .filter(|existing| existing.value == candidate.value) + { + if existing.display.is_none() { + existing.display = candidate.display.take(); + } + if existing.description.is_none() { + existing.description = candidate.description.take(); + } + } else { + deduped.push(candidate); + } + } + *candidates = deduped; +} + fn overlay_for_name<'o>( spec: &Spec<'_>, split: &Split, @@ -2265,12 +2287,18 @@ mod tests { }) } + fn labeled_ruby(_ctx: &CompleteCtx<'_>) -> Vec> { + vec![Candidate::new("ruby").displayed("Ruby runtime")] + } + static RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] = [CompletionOverlay::asynchronous( "use", "tool", runtime_tools, )]; + static LABELED_COMPLETIONS: [CompletionOverlay<'static>; 1] = + [CompletionOverlay::sync("install", "tool", labeled_ruby)]; static GLOBAL_RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] = [CompletionOverlay::async_any("tool", runtime_tools)]; static FILE_RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] = @@ -2545,6 +2573,37 @@ mod tests { assert_eq!(answer.candidates[0].value, "ls"); } + #[test] + fn cursor_completion_keeps_presentation_metadata_from_duplicate_values() { + let answer = run_ready(complete_with( + &SPEC, + &at_end("mise install r"), + &LABELED_COMPLETIONS, + )); + assert_eq!(answer.candidates.len(), 1, "{answer:?}"); + assert_eq!(answer.candidates[0].value, "ruby"); + assert_eq!( + answer.candidates[0].display.as_deref(), + Some("Ruby runtime") + ); + } + + #[test] + fn named_completion_keeps_presentation_metadata_from_duplicate_values() { + let answer = run_ready(complete_named_with( + &SPEC, + &at_end("mise install r"), + &LABELED_COMPLETIONS, + "tool", + )); + assert_eq!(answer.candidates.len(), 1, "{answer:?}"); + assert_eq!(answer.candidates[0].value, "ruby"); + assert_eq!( + answer.candidates[0].display.as_deref(), + Some("Ruby runtime") + ); + } + /// The position at the cursor of a line, which is what a completion asks about. fn position_at(line: &str) -> Position<'static> { let s = at_end(line); From b1bdcbc0427ef3ffae6e0e02663b81d3f281cf5f Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:49:22 +0000 Subject: [PATCH 3/3] fix(complete): preserve static candidate metadata --- argv/src/complete.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index d58aa53a7..10d60bbae 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -1297,8 +1297,7 @@ fn candidates_inner<'a>( found }; - out.sort(); - out.dedup_by(|a, b| a.value == b.value); + sort_and_dedup_candidates(&mut out); out } @@ -2069,6 +2068,7 @@ mod tests { Candidate::described("node", "JavaScript"), Candidate::described("python", "Snakes"), Candidate::new("ruby"), + Candidate::new("ruby").displayed("Ruby runtime"), ] } @@ -3494,6 +3494,10 @@ mod tests { assert_eq!(found[0].value, "node"); assert_eq!(found[0].description.as_deref(), Some("JavaScript")); + let ruby = candidates(&SPEC, &at_end("mise install ru")); + assert_eq!(ruby.len(), 1, "{ruby:?}"); + assert_eq!(ruby[0].display.as_deref(), Some("Ruby runtime")); + // For a flag's value as well as an argument's. assert_eq!(offered("mise install --only "), ["node"]); }