Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 119 additions & 14 deletions argv/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
Expand Down Expand Up @@ -982,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);
Comment thread
jdx marked this conversation as resolved.
// 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);
Expand Down Expand Up @@ -1021,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<Candidate<'_>>) {
candidates.sort();
let mut deduped: Vec<Candidate<'_>> = 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,
Expand Down Expand Up @@ -1264,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
}

Expand Down Expand Up @@ -1303,6 +1335,7 @@ fn subcommands<'a>(meta: &'a CommandMeta<'a>, token: &str) -> Vec<Candidate<'a>>
if name.starts_with(token) {
out.push(Candidate {
value: (*name).to_string(),
display: None,
description: deprecated_description(
sub.about,
sub.deprecated,
Expand Down Expand Up @@ -1340,6 +1373,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(),
});
}
Expand All @@ -1354,6 +1388,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(),
});
}
Expand Down Expand Up @@ -1386,6 +1421,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,
Expand Down Expand Up @@ -1443,6 +1479,7 @@ fn positional<'a>(
if token.is_empty() {
return vec![Candidate {
value: "--".to_string(),
display: None,
description: None,
}];
}
Expand Down Expand Up @@ -1519,6 +1556,7 @@ fn choices<'a>(
.filter(|c| c.starts_with(token))
.map(|c| Candidate {
value: (*c).to_string(),
display: None,
description: details
.iter()
.find(|detail| {
Expand Down Expand Up @@ -2030,6 +2068,7 @@ mod tests {
Candidate::described("node", "JavaScript"),
Candidate::described("python", "Snakes"),
Candidate::new("ruby"),
Candidate::new("ruby").displayed("Ruby runtime"),
]
}

Expand Down Expand Up @@ -2248,12 +2287,18 @@ mod tests {
})
}

fn labeled_ruby(_ctx: &CompleteCtx<'_>) -> Vec<Candidate<'static>> {
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] =
Expand Down Expand Up @@ -2528,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);
Expand Down Expand Up @@ -3277,16 +3353,41 @@ 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),
"plugins\tManage plugins\tplugins\n"
);
}

#[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 {
Expand Down Expand Up @@ -3393,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"]);
}
Expand Down
5 changes: 3 additions & 2 deletions argv/src/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}}
Expand Down
16 changes: 13 additions & 3 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,14 @@ pub type Completer = fn(&CompleteCtx<'_>) -> Vec<Candidate<'static>>;

/// 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>>,
Comment thread
jdx marked this conversation as resolved.
/// Borrowed from the spec where it is already there, owned where a callback made it.
pub description: Option<::std::borrow::Cow<'a, str>>,
}
Expand All @@ -271,6 +273,7 @@ impl Candidate<'_> {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
display: None,
description: None,
}
}
Expand All @@ -279,9 +282,16 @@ impl Candidate<'_> {
pub fn described(value: impl Into<String>, description: impl Into<String>) -> 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<String>) -> Self {
self.display = Some(::std::borrow::Cow::Owned(display.into()));
self
}
}

/// A whole CLI: the root command plus what describes the program itself.
Expand Down
4 changes: 3 additions & 1 deletion docs/rust/completions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.