diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 31f0593b0..560dc05af 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -722,7 +722,7 @@ impl<'a> App<'a> { pub async fn completion_request(self, argv: &[OsString]) -> Option { let request = CompletionRequest::parse(argv)?; let answer = self.complete_request(&request).await; - Some(render(&answer, request.shell)) + Some(render_request(&answer, &request)) } /// The words this app actually walks: the request's, with any multicall projection spliced @@ -788,6 +788,12 @@ pub struct CompletionRequest { /// The single named completer being asked for, when a spec's `run=` line asked for one /// rather than for everything the cursor could take. pub candidates_for: Option, + /// Bash's current Readline word, which may omit a colon-prefixed part of [`Split::prefix`]. + bash_word: Option, + /// Bash's configured word-breaking characters. + bash_wordbreaks: Option, + /// Bash's current prefix with escaped and quoted colons distinguished from word breaks. + bash_marked_prefix: Option, } impl CompletionRequest { @@ -800,6 +806,9 @@ impl CompletionRequest { shell: Shell::Bash, split, candidates_for: None, + bash_word: None, + bash_wordbreaks: None, + bash_marked_prefix: None, } } @@ -819,6 +828,8 @@ impl CompletionRequest { let mut line = String::new(); let mut cursor = None; let mut candidates_for = None; + let mut bash_word = None; + let mut bash_wordbreaks = None; let mut words: Option> = None; let mut rest = argv[1..].iter(); while let Some(arg) = rest.next() { @@ -844,6 +855,12 @@ impl CompletionRequest { "--candidates" => { candidates_for = rest.next().map(|v| v.to_string_lossy().into_owned()); } + "--bash-word" => { + bash_word = rest.next().map(|v| v.to_string_lossy().into_owned()); + } + "--bash-wordbreaks" => { + bash_wordbreaks = rest.next().map(|v| v.to_string_lossy().into_owned()); + } "--words" => { words = Some( rest.map(|word| word.to_string_lossy().into_owned()) @@ -854,6 +871,10 @@ impl CompletionRequest { _ => {} } } + let bash_marked_prefix = (shell == Shell::Bash && words.is_none()).then(|| { + let cursor = cursor.unwrap_or(line.len()); + split(&mark_bash_nonbreaking_colons(&line), cursor, shell).prefix + }); let split = match words { Some(mut words) => { if words.is_empty() { @@ -876,10 +897,62 @@ impl CompletionRequest { shell, split, candidates_for, + bash_word, + bash_wordbreaks, + bash_marked_prefix, }) } } +const BASH_NONBREAKING_COLON: char = '\u{1}'; + +/// Mark colons that Bash Readline keeps inside its current word. +/// +/// The regular split intentionally removes quoting and escapes, but the Bash wrapper also needs +/// to distinguish `::` from `:\:`. Reusing [`split`] after marking those colons preserves that +/// distinction without maintaining a second shell-word parser. +fn mark_bash_nonbreaking_colons(line: &str) -> String { + let mut marked = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + let mut quote = None; + while let Some(c) = chars.next() { + match quote { + Some(q) if c == q => { + quote = None; + marked.push(c); + } + Some(_) if c == ':' => marked.push(BASH_NONBREAKING_COLON), + Some('"') if c == '\\' => { + marked.push(c); + if let Some(next) = chars.next() { + marked.push(if next == ':' { + BASH_NONBREAKING_COLON + } else { + next + }); + } + } + Some(_) => marked.push(c), + None if c == '\'' || c == '"' => { + quote = Some(c); + marked.push(c); + } + None if c == '\\' => { + marked.push(c); + if let Some(next) = chars.next() { + marked.push(if next == ':' { + BASH_NONBREAKING_COLON + } else { + next + }); + } + } + None => marked.push(c), + } + } + marked +} + /// The line a shell reads to mean "paths belong here too". /// /// A whole line rather than a flag on the protocol, because every one of the five shells can @@ -894,6 +967,47 @@ pub const EXECUTABLE_PATHS_MARKER: &str = "\u{1}executables"; /// See [`FILES_MARKER`]. Command names from the shell and `PATH` only. pub const COMMANDS_MARKER: &str = "\u{1}commands"; +/// Render an answer with the shell-specific context carried by its request. +/// +/// Bash's Readline splits the word being replaced at `:` by default, while the completion engine +/// deliberately parses the full shell word. Tell the generated wrapper which already-typed prefix +/// Readline will preserve so it can trim that prefix from full candidates before insertion. +pub fn render_request(answer: &Completions<'_>, request: &CompletionRequest) -> String { + let mut out = render(answer, request.shell); + if request.shell == Shell::Bash + && request + .bash_wordbreaks + .as_deref() + .is_some_and(|wordbreaks| wordbreaks.contains(':')) + { + if let Some(word) = request.bash_word.as_deref() { + // COMP_WORDS holds the entire Readline fragment even when the cursor is in its + // middle. Find the colon whose remaining fragment starts that word. This cannot + // simply take the last normalized colon: an escaped colon remains inside Bash's + // word but is indistinguishable in the unescaped Split::prefix. + let marked_prefix = request + .bash_marked_prefix + .as_deref() + .unwrap_or(&request.split.prefix); + let prefix = marked_prefix + .match_indices(':') + .rev() + .find_map(|(colon, _)| { + let fragment = marked_prefix[colon + 1..].replace(BASH_NONBREAKING_COLON, ":"); + ((fragment.is_empty() && word == ":") + || (!fragment.is_empty() && word.starts_with(&fragment))) + .then(|| marked_prefix[..=colon].replace(BASH_NONBREAKING_COLON, ":")) + }); + if let Some(prefix) = prefix.filter(|prefix| !prefix.chars().any(char::is_control)) { + out.push_str("\u{1}prefix\t"); + out.push_str(&prefix); + out.push('\n'); + } + } + } + out +} + /// Write an answer the way `shell` reads it. /// /// One line per candidate, in the shape the shell's own completion machinery expects — which is @@ -4224,6 +4338,86 @@ mod tests { assert_eq!(request.split.prefix, "two words"); } + #[test] + fn a_bash_request_reports_the_colon_prefix_readline_preserves() { + let argv = [ + "__complete_word__", + "--shell", + "bash", + "--line", + "ex update:deps:no", + "--bash-word", + "no", + "--bash-wordbreaks", + " :", + ] + .map(OsString::from); + let request = CompletionRequest::parse(&argv).expect("a completion request"); + let answer = Completions { + candidates: vec![Candidate::new("update:deps:no-cooldown")], + files: None, + }; + + assert_eq!( + render_request(&answer, &request), + "update:deps:no-cooldown\n\u{1}prefix\tupdate:deps:\n" + ); + + let mut escaped_colon = argv.clone(); + escaped_colon[4] = OsString::from(r"ex update:deps\:no"); + escaped_colon[6] = OsString::from("deps:no"); + let request = CompletionRequest::parse(&escaped_colon).expect("a completion request"); + assert_eq!( + render_request(&answer, &request), + "update:deps:no-cooldown\n\u{1}prefix\tupdate:\n" + ); + + let mut in_the_middle = argv.clone(); + in_the_middle[6] = OsString::from("no-cooldown"); + let request = CompletionRequest::parse(&in_the_middle).expect("a completion request"); + assert_eq!( + render_request(&answer, &request), + "update:deps:no-cooldown\n\u{1}prefix\tupdate:deps:\n" + ); + + let mut on_the_colon = argv.clone(); + on_the_colon[4] = OsString::from("ex update:deps:"); + on_the_colon[6] = OsString::from(":"); + let request = CompletionRequest::parse(&on_the_colon).expect("a completion request"); + assert_eq!( + render_request(&answer, &request), + "update:deps:no-cooldown\n\u{1}prefix\tupdate:deps:\n" + ); + + let mut after_consecutive_colons = argv.clone(); + after_consecutive_colons[4] = OsString::from("ex update::"); + after_consecutive_colons[6] = OsString::from(":"); + let request = + CompletionRequest::parse(&after_consecutive_colons).expect("a completion request"); + assert_eq!( + render_request(&answer, &request), + "update:deps:no-cooldown\n\u{1}prefix\tupdate::\n" + ); + + let mut after_escaped_trailing_colon = argv.clone(); + after_escaped_trailing_colon[4] = OsString::from(r"ex update:\:"); + after_escaped_trailing_colon[6] = OsString::from(":"); + let request = + CompletionRequest::parse(&after_escaped_trailing_colon).expect("a completion request"); + assert_eq!( + render_request(&answer, &request), + "update:deps:no-cooldown\n\u{1}prefix\tupdate:\n" + ); + + let mut without_colon_break = argv; + without_colon_break[8] = OsString::from(" "); + let request = CompletionRequest::parse(&without_colon_break).expect("a completion request"); + assert_eq!( + render_request(&answer, &request), + "update:deps:no-cooldown\n" + ); + } + #[test] fn display_text_does_not_change_what_the_shell_inserts() { let answer = Completions { diff --git a/argv/src/script.rs b/argv/src/script.rs index 802fc9990..d9b3c98ef 100644 --- a/argv/src/script.rs +++ b/argv/src/script.rs @@ -184,14 +184,16 @@ fn bash(bin: &str, name: &str) -> String { format!( r#"{head} _usage_complete_{name}() {{ - local __usage_out __usage_line __usage_files= __usage_extensions= + local __usage_out __usage_line __usage_files= __usage_extensions= __usage_prefix= # Truncated here rather than passed with an offset: every shell counts a cursor in its own # units — characters in a UTF-8 locale for bash and zsh, characters for fish and PowerShell — # and a number that means one thing here and another there is a bug waiting for a non-ASCII # command line. Cut with the shell's own offset, the units cancel out and what arrives is # exactly the text before the cursor. __usage_out="$(command '{bin}' __complete_word__ --shell bash \ - --line "${{COMP_LINE:0:$COMP_POINT}}" 2>/dev/null)" || return 1 + --line "${{COMP_LINE:0:$COMP_POINT}}" \ + --bash-word "${{COMP_WORDS[COMP_CWORD]}}" \ + --bash-wordbreaks "$COMP_WORDBREAKS" 2>/dev/null)" || return 1 COMPREPLY=() while IFS= read -r __usage_line; do @@ -204,6 +206,7 @@ _usage_complete_{name}() {{ __usage_files=extensions __usage_extensions="${{__usage_line#*$'\t'}}" ;; + $'\001prefix\t'*) __usage_prefix="${{__usage_line#*$'\t'}}" ;; '') ;; *) COMPREPLY+=("$__usage_line") ;; esac @@ -244,6 +247,17 @@ _usage_complete_{name}() {{ # Guarded, because an empty array expands to one empty word in older bash. (( ${{#__usage_paths[@]}} )) && COMPREPLY+=("${{__usage_paths[@]}}") fi + + # Readline replaces only the text after a colon when `:` is in COMP_WORDBREAKS. The binary + # split the original line without losing that prefix, so remove it from full candidates before + # Readline inserts them. Candidates produced locally for paths do not carry it and stay intact. + if [[ -n $__usage_prefix ]]; then + local __usage_i + for __usage_i in "${{!COMPREPLY[@]}}"; do + [[ ${{COMPREPLY[__usage_i]}} == "$__usage_prefix"* ]] && + COMPREPLY[__usage_i]="${{COMPREPLY[__usage_i]#"$__usage_prefix"}}" + done + fi }} complete -F _usage_complete_{name} '{name}' "#, diff --git a/argv/tests/scripts.rs b/argv/tests/scripts.rs index 7898b5458..5e3c28751 100644 --- a/argv/tests/scripts.rs +++ b/argv/tests/scripts.rs @@ -58,6 +58,20 @@ impl Fixture { Self { dir } } + /// A fixture whose stand-in also verifies the unsplit line forwarded by the wrapper. + fn expecting_line(name: &str, shell: Shell, expected_line: &str, answer: &str) -> Self { + let fixture = Self::new(name, shell, answer); + let stand_in = format!( + "#!/usr/bin/env bash\nif [[ ${{1:-}} != __complete_word__ ]]; then\n echo \"the script called the binary for something else: $*\" >&2\n exit 1\nfi\nline=\nwhile [[ $# -gt 0 ]]; do\n if [[ $1 == --line ]]; then line=$2; shift; fi\n shift\ndone\nif [[ $line != {} ]]; then\n echo \"unexpected --line: $line\" >&2\n exit 1\nfi\nprintf '%s' {}\n", + shell_quote(expected_line), + shell_quote(answer) + ); + let bin = fixture.dir.join("ex"); + fs::write(&bin, stand_in).expect("writing the stand-in"); + make_executable(&bin); + fixture + } + /// A fixture whose stand-in reports the line it was handed, rather than answering. /// /// What the scripts do with the cursor is the part most likely to be silently wrong — every @@ -289,6 +303,110 @@ printf '%s\n' "${COMPREPLY[@]}" assert_eq!(out, "install\nuninstall\n"); } +#[test] +fn bash_trims_the_colon_prefix_readline_preserves() { + if !available("bash") { + println!("bash is not installed; skipping"); + return; + } + let fixture = Fixture::new( + "bash-colon-prefix", + Shell::Bash, + "update:deps:no-cooldown\n\u{1}prefix\tupdate:deps:\n", + ); + // Bash keeps colons as separate COMP_WORDS entries and replaces only the final fragment. + // The binary sees the unsplit line so it can identify the full prefix Readline preserves. + let out = fixture.run( + "bash", + r#"source ./script +COMP_LINE='ex update:deps:no' +COMP_POINT=17 +COMP_WORDS=(ex update : deps : no) +COMP_CWORD=5 +_usage_complete_ex +printf '%s\n' "${COMPREPLY[@]}" +"#, + ); + assert_eq!(out, "no-cooldown\n"); +} + +#[test] +fn bash_keeps_an_escaped_colon_inside_the_readline_word() { + if !available("bash") { + println!("bash is not installed; skipping"); + return; + } + let fixture = Fixture::new( + "bash-escaped-colon", + Shell::Bash, + "update:deps:no-cooldown\n\u{1}prefix\tupdate:\n", + ); + let out = fixture.run( + "bash", + r#"source ./script +COMP_LINE='ex update:deps\:no' +COMP_POINT=18 +COMP_WORDS=(ex update : 'deps:no') +COMP_CWORD=3 +_usage_complete_ex +printf '%s\n' "${COMPREPLY[@]}" +"#, + ); + assert_eq!(out, "deps:no-cooldown\n"); +} + +#[test] +fn bash_keeps_consecutive_colons_in_the_readline_prefix() { + if !available("bash") { + println!("bash is not installed; skipping"); + return; + } + let fixture = Fixture::expecting_line( + "bash-consecutive-colons", + Shell::Bash, + "ex update::", + "update::no-cooldown\n\u{1}prefix\tupdate::\n", + ); + let out = fixture.run( + "bash", + r#"source ./script +COMP_LINE='ex update::' +COMP_POINT=11 +COMP_WORDS=(ex update : :) +COMP_CWORD=3 +_usage_complete_ex +printf '%s\n' "${COMPREPLY[@]}" +"#, + ); + assert_eq!(out, "no-cooldown\n"); +} + +#[test] +fn bash_does_not_treat_an_escaped_trailing_colon_as_a_word_break() { + if !available("bash") { + println!("bash is not installed; skipping"); + return; + } + let fixture = Fixture::expecting_line( + "bash-escaped-trailing-colon", + Shell::Bash, + r"ex update:\:", + "update::no-cooldown\n\u{1}prefix\tupdate:\n", + ); + let out = fixture.run( + "bash", + r#"source ./script +COMP_LINE='ex update:\:' +COMP_POINT=12 +COMP_WORDS=(ex update : :) +COMP_CWORD=3 +_usage_complete_ex +printf '%s\n' "${COMPREPLY[@]}" +"#, + ); + assert_eq!(out, ":no-cooldown\n"); +} + #[test] fn bash_asks_the_shell_for_paths_when_the_marker_says_so() { if !available("bash") { diff --git a/cli/assets/completions/usage.bash b/cli/assets/completions/usage.bash index 14d576816..6ae8af7b0 100644 --- a/cli/assets/completions/usage.bash +++ b/cli/assets/completions/usage.bash @@ -1,14 +1,16 @@ # @generated by usage-argv for `usage __complete_word__ --shell bash` _usage_complete_usage() { - local __usage_out __usage_line __usage_files= __usage_extensions= + local __usage_out __usage_line __usage_files= __usage_extensions= __usage_prefix= # Truncated here rather than passed with an offset: every shell counts a cursor in its own # units — characters in a UTF-8 locale for bash and zsh, characters for fish and PowerShell — # and a number that means one thing here and another there is a bug waiting for a non-ASCII # command line. Cut with the shell's own offset, the units cancel out and what arrives is # exactly the text before the cursor. __usage_out="$(command 'usage' __complete_word__ --shell bash \ - --line "${COMP_LINE:0:$COMP_POINT}" 2>/dev/null)" || return 1 + --line "${COMP_LINE:0:$COMP_POINT}" \ + --bash-word "${COMP_WORDS[COMP_CWORD]}" \ + --bash-wordbreaks "$COMP_WORDBREAKS" 2>/dev/null)" || return 1 COMPREPLY=() while IFS= read -r __usage_line; do @@ -21,6 +23,7 @@ _usage_complete_usage() { __usage_files=extensions __usage_extensions="${__usage_line#*$'\t'}" ;; + $'\001prefix\t'*) __usage_prefix="${__usage_line#*$'\t'}" ;; '') ;; *) COMPREPLY+=("$__usage_line") ;; esac @@ -61,5 +64,16 @@ _usage_complete_usage() { # Guarded, because an empty array expands to one empty word in older bash. (( ${#__usage_paths[@]} )) && COMPREPLY+=("${__usage_paths[@]}") fi + + # Readline replaces only the text after a colon when `:` is in COMP_WORDBREAKS. The binary + # split the original line without losing that prefix, so remove it from full candidates before + # Readline inserts them. Candidates produced locally for paths do not carry it and stay intact. + if [[ -n $__usage_prefix ]]; then + local __usage_i + for __usage_i in "${!COMPREPLY[@]}"; do + [[ ${COMPREPLY[__usage_i]} == "$__usage_prefix"* ]] && + COMPREPLY[__usage_i]="${COMPREPLY[__usage_i]#"$__usage_prefix"}" + done + fi } complete -F _usage_complete_usage 'usage' diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index b4c05b200..6dce135bf 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2203,80 +2203,9 @@ fn completion_fns(cli: &Cli) -> (TokenStream, TokenStream) { pub fn completion_request( argv: &[::std::ffi::OsString], ) -> ::std::option::Option<::std::string::String> { - let first = argv.first()?.to_str()?; - if first != "__complete_word__" { - return ::std::option::Option::None; - } - // Its own flags, read by hand: three of them, and reading them with the parser - // would mean putting them in the tables this is deliberately outside of. - let mut shell = usage_argv::complete::Shell::Bash; - let mut line = ::std::string::String::new(); - let mut cursor = ::std::option::Option::None; - let mut candidates_for: ::std::option::Option<::std::string::String> = - ::std::option::Option::None; - let mut words: ::std::option::Option<::std::vec::Vec<::std::string::String>> = - ::std::option::Option::None; - let mut rest = argv[1..].iter(); - while let ::std::option::Option::Some(arg) = rest.next() { - match arg.to_str().unwrap_or_default() { - "--shell" => { - if let ::std::option::Option::Some(name) = rest.next() { - if let ::std::option::Option::Some(found) = - usage_argv::complete::Shell::from_name( - &name.to_string_lossy(), - ) - { - shell = found; - } - } - } - "--line" => { - if let ::std::option::Option::Some(value) = rest.next() { - line = value.to_string_lossy().into_owned(); - } - } - "--cursor" => { - cursor = rest - .next() - .and_then(|value| value.to_str().and_then(|v| v.parse().ok())); - } - // What the `run=` in this CLI's own emitted spec asks for: one named - // completer's answers, rather than everything the cursor could take. That is - // the shape a spec's `complete` block promises, so anything reading the KDL - // gets what it expects from the binary the KDL names. - "--candidates" => { - candidates_for = rest.next().map(|v| v.to_string_lossy().into_owned()); - } - // Elvish already hands its completer losslessly split words. Keeping them as - // argv avoids re-quoting text just so the shared line splitter can undo it. - "--words" => { - words = ::std::option::Option::Some( - rest.map(|word| word.to_string_lossy().into_owned()).collect(), - ); - break; - } - // Anything else is a shell passing something this version does not know - // about. Ignored rather than refused: a completion that errors out is a - // shell that beeps at every keystroke. - _ => {} - } - } - // No cursor means the end of the line, which is where a shell puts it when it has - // no way to say — nushell, whose completer only ever sees the words. - let mut split = match words { - ::std::option::Option::Some(mut words) => { - if words.is_empty() { - words.push(::std::string::String::new()); - } - let cword = words.len() - 1; - let prefix = words[cword].clone(); - usage_argv::complete::Split { words, cword, prefix } - } - ::std::option::Option::None => { - let cursor = cursor.unwrap_or(line.len()); - usage_argv::complete::split(&line, cursor, shell) - } - }; + let request = usage_argv::complete::CompletionRequest::parse(argv)?; + let candidates_for = request.candidates_for.clone(); + let mut split = request.split.clone(); let __usage_selected_view = split.words.first().and_then(|__usage_program| { usage_argv::spec::view_for_program( Self::spec(), @@ -2330,7 +2259,10 @@ fn completion_fns(cli: &Cli) -> (TokenStream, TokenStream) { candidates: found, files: ::std::option::Option::None, }; - return ::std::option::Option::Some(usage_argv::complete::render(&answer, shell)); + return ::std::option::Option::Some(usage_argv::complete::render_request( + &answer, + &request, + )); } let answer = match __usage_selected_view { ::std::option::Option::Some(view) => @@ -2338,7 +2270,7 @@ fn completion_fns(cli: &Cli) -> (TokenStream, TokenStream) { ::std::option::Option::None => usage_argv::complete::complete(Self::spec(), &split), }; - ::std::option::Option::Some(usage_argv::complete::render(&answer, shell)) + ::std::option::Option::Some(usage_argv::complete::render_request(&answer, &request)) } }; let intercept = quote! {