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
65 changes: 62 additions & 3 deletions cli/src/cli/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,48 @@ impl CompleteWord {
.max_by_key(|(_, _, sigil, _)| sigil.len())
})
.flatten();
let attached_long_value = flags_possible
.then(|| Self::attached_long_value(&flags, &ctoken))
.flatten();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let mut used_file_fallback = false;
let mut choices = if flags_possible && ctoken == "-" {
let shorts = self.complete_short_flag_names(&flags, "");
let longs = self.complete_long_flag_names(&flags, "");
shorts.into_iter().chain(longs).collect::<Vec<_>>()
} else if flags_possible && ctoken.starts_with("--") {
self.complete_long_flag_names(&flags, &ctoken)
if let Some((flag, form, prefix)) = attached_long_value {
let arg = flag.arg.as_ref().unwrap();
// Dynamic completers inspect `words[CURRENT]`; expose the value fragment just
// like sigil completion does, since the flag prefix is handled separately.
let mut attached_ctx = ctx.clone();
let mut attached_words = self.words.clone();
if let Some(current) = attached_words.get_mut(cword) {
*current = prefix.to_string();
}
attached_ctx.insert("words", &attached_words);
let attached_cx = Ctx {
tera: &attached_ctx,
spec: cx.spec,
parsed: cx.parsed,
after_restart_token: cx.after_restart_token,
};
let (mut found, closed) =
self.complete_arg(&attached_cx, &parsed.cmd, arg, prefix)?;
has_explicit_choices = closed || arg.choices.is_some();
if found.is_empty() && !has_explicit_choices {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
found = self
.complete_path(&cwd, prefix, |_| true)
.into_iter()
.map(|name| (name, String::new()))
.collect();
used_file_fallback = true;
}
Self::attach_long_value_candidates(&mut found, form);
found
} else {
self.complete_long_flag_names(&flags, &ctoken)
}
} else if flags_possible && ctoken.starts_with('-') {
self.complete_short_flag_names(&flags, &ctoken)
} else if after_restart_token {
Expand Down Expand Up @@ -343,8 +379,9 @@ impl CompleteWord {
// flag. Past a `--` a dash-prefixed word is not a flag but a value, so a path like
// `-input` still gets completed there.
let looks_like_a_flag = flags_possible && ctoken.starts_with('-');
let files = choices.is_empty() && !looks_like_a_flag && !has_explicit_choices;
if files {
let files = used_file_fallback
|| (choices.is_empty() && !looks_like_a_flag && !has_explicit_choices);
if files && choices.is_empty() {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let files = self.complete_path(&cwd, &ctoken, |_| true);
choices = files.into_iter().map(|n| (n, String::new())).collect();
Expand Down Expand Up @@ -430,6 +467,28 @@ impl CompleteWord {
.collect()
}

/// A value being typed in the same word as its long flag.
///
/// The shell replaces the whole word, so callers complete against the fragment after `=`
/// and then put the flag spelling back on each candidate.
fn attached_long_value<'f, 't>(
flags: &'f BTreeMap<String, Arc<SpecFlag>>,
token: &'t str,
) -> Option<(&'f SpecFlag, &'t str, &'t str)> {
let (form, prefix) = token.split_once('=')?;
let long = form.strip_prefix("--")?;
flags
.values()
.find(|flag| flag.arg.is_some() && flag.long.iter().any(|candidate| candidate == long))
.map(|flag| (flag.as_ref(), form, prefix))
}

fn attach_long_value_candidates(candidates: &mut [(String, String)], form: &str) {
for (candidate, _) in candidates {
*candidate = format!("{form}={candidate}");
}
}

/// Completions for a reserved `type=`, and whether the set they came from is *closed*.
///
/// Closed means an unmatched prefix has no completions at all, rather than falling
Expand Down
56 changes: 56 additions & 0 deletions cli/tests/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ fn complete_word_long_flag_val() {
&["--", "plugins", "install", "--dir", ""],
)
.stdout(contains("src").and(contains("tests")));
assert_cmd("basic.usage.kdl", &["--", "plugins", "install", "--dir=s"])
.stdout(contains("--dir=src/"));
}

#[test]
Expand Down Expand Up @@ -252,6 +254,60 @@ fn complete_word_choices() {
.stdout("bash\nelvish\nfish\nnu\nxonsh\nzsh\npwsh\n");
}

#[test]
fn complete_word_attached_long_flag_value_choices() {
let spec = r#"
name "ex"
bin "ex"
flag "--format <F>" {
arg "<F>" {
choices "json" "yaml" "toml"
}
}
"#;
Command::new(cargo::cargo_bin!("usage"))
.args([
"cw",
"--shell",
"fish",
"--spec",
spec,
"--",
"ex",
"--format=j",
])
.assert()
.success()
.stdout("--format=json\n");
}

#[test]
fn complete_word_attached_long_flag_value_run_receives_value_fragment() {
let spec = r#"
name "ex"
bin "ex"
flag "--format <F>" {
arg "<F>" {
}
}
complete "F" run="printf '%s\\n' {{ words[CURRENT] | shell_quote }}"
"#;
Command::new(cargo::cargo_bin!("usage"))
.args([
"cw",
"--shell",
"fish",
"--spec",
spec,
"--",
"ex",
"--format=j",
])
.assert()
.success()
.stdout("--format=j\n");
}

#[test]
fn complete_word_sigil_arg_strips_for_matching_and_restores_for_output() {
assert_cmd("sigil.usage.kdl", &["--", "+n"]).stdout("+node@22\n+node@24\n");
Expand Down
9 changes: 9 additions & 0 deletions corpus/complete/02-values-and-restarts.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
"candidates": ["toml"]
}
},
{
"id": "an-attached-long-flag-value-narrows",
"doc": "A value typed after `=` in the same long-flag word is still that flag's value. Completion narrows against the fragment after `=` and keeps the flag spelling in the replacement because shells replace the whole word.",
"spec": "name \"ex\"\nbin \"ex\"\nflag \"--format <F>\" {\n arg \"<F>\" {\n choices \"json\" \"yaml\" \"toml\"\n }\n}\n",
"line": "ex --format=j",
"expect": {
"candidates": ["--format=json"]
}
},
{
"id": "a-positionals-choices-are-offered",
"doc": "A positional carrying choices answers at command position, alongside the subcommands rather than instead of them \u2014 a word there could be either.",
Expand Down