From c5e28e43a93e661d705111258166df4ed362688b Mon Sep 17 00:00:00 2001 From: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:30:25 +0200 Subject: [PATCH 1/2] fix bash colon completion handling Backport Usage 6.9.0s Bash completion fix for colon-separated values. Preserve the distinction between breaking colons and colons kept inside quoted or escaped words, pass Bashs current Readline word and COMP_WORDBREAKS through the private completion protocol, and report the prefix Readline will preserve during replacement. Strip that preserved prefix from generated candidates before populating COMPREPLY so colon-separated completions insert only the fragment Bash is actually replacing. Add regression coverage for normal, escaped, quoted, consecutive, and trailing escaped colon cases. --- crates/argx/src/completion/engine.rs | 102 +++++++++++++++++++++++++-- crates/argx/src/completion/mod.rs | 4 ++ crates/argx/src/completion/script.rs | 30 ++++++-- 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/crates/argx/src/completion/engine.rs b/crates/argx/src/completion/engine.rs index 655ccf4..a45b76a 100644 --- a/crates/argx/src/completion/engine.rs +++ b/crates/argx/src/completion/engine.rs @@ -3,7 +3,8 @@ use std::{collections::HashSet, env, ffi::OsStr}; use super::{ - PROTOCOL_COMMAND, PROTOCOL_ENV, PROTOCOL_LINE_ENV, PROTOCOL_VERSION, PROTOCOL_WORDS_ENV, + PROTOCOL_BASH_WORD_ENV, PROTOCOL_BASH_WORDBREAKS_ENV, PROTOCOL_COMMAND, PROTOCOL_ENV, + PROTOCOL_LINE_ENV, PROTOCOL_VERSION, PROTOCOL_WORDS_ENV, }; use crate::{ cli::{ @@ -41,6 +42,9 @@ where return Some(String::new()); } + let bash_word = env::var(PROTOCOL_BASH_WORD_ENV).ok(); + let bash_wordbreaks = env::var(PROTOCOL_BASH_WORDBREAKS_ENV).ok(); + let mut bash_marked_prefix = None; let candidates = match env::var(PROTOCOL_WORDS_ENV) { Ok(encoded) => { let Ok(spans) = serde_json::from_str::>(&encoded) else { @@ -50,17 +54,83 @@ where } Err(env::VarError::NotUnicode(_)) => return Some(String::new()), Err(env::VarError::NotPresent) => { - let Some(line) = env::var_os(PROTOCOL_LINE_ENV) else { + let Ok(line) = env::var(PROTOCOL_LINE_ENV) else { return Some(String::new()); }; - let Some(line) = line.to_str() else { - return Some(String::new()); - }; - complete_line_with_schema(T::COMMAND, line, T::SCHEMA_ENABLED) + if bash_word.is_some() { + bash_marked_prefix = Some(split_line(&mark_bash_nonbreaking_colons(&line)).prefix); + } + complete_line_with_schema(T::COMMAND, &line, T::SCHEMA_ENABLED) } }; - Some(render_candidates(&candidates)) + let mut output = render_candidates(&candidates); + if bash_wordbreaks + .as_deref() + .is_some_and(|wordbreaks| wordbreaks.contains(':')) + && let Some(word) = bash_word.as_deref() + && let Some(marked_prefix) = bash_marked_prefix.as_deref() + { + 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)) { + output.push_str("\u{1}prefix\t"); + output.push_str(&prefix); + output.push('\n'); + } + } + + Some(output) +} + +/// Marker used to preserve colons Bash Readline keeps inside its current word. +const BASH_NONBREAKING_COLON: char = '\u{1}'; + +/// Marks colons that Bash Readline keeps inside its current word. +fn mark_bash_nonbreaking_colons(line: &str) -> String { + let mut marked = String::with_capacity(line.len()); + let mut chars = line.chars(); + 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 } /// One shell-independent completion candidate. @@ -790,6 +860,24 @@ mod tests { .collect() } + #[test] + fn bash_marking_preserves_nonbreaking_colons() { + assert_eq!( + split_line(&mark_bash_nonbreaking_colons(r"tool update:deps\:no")).prefix, + format!("update:deps{BASH_NONBREAKING_COLON}no"), + ); + assert_eq!( + split_line(&mark_bash_nonbreaking_colons(r"tool update:\:")).prefix, + format!("update:{BASH_NONBREAKING_COLON}"), + ); + assert_eq!( + split_line(&mark_bash_nonbreaking_colons(r#"tool "update:deps:no""#)).prefix, + format!( + "update{BASH_NONBREAKING_COLON}deps{BASH_NONBREAKING_COLON}no" + ), + ); + } + #[test] fn split_reconstructs_quoted_completed_words_and_current_prefix() { assert_eq!( diff --git a/crates/argx/src/completion/mod.rs b/crates/argx/src/completion/mod.rs index 5acfcfe..4dcf459 100644 --- a/crates/argx/src/completion/mod.rs +++ b/crates/argx/src/completion/mod.rs @@ -26,6 +26,10 @@ const PROTOCOL_COMMAND: &str = "__argx_complete__"; const PROTOCOL_LINE_ENV: &str = "ARGX_COMPLETE_LINE"; /// Environment variable carrying Nushell's already-tokenized completion spans as JSON. const PROTOCOL_WORDS_ENV: &str = "ARGX_COMPLETE_WORDS"; +/// Bash's current Readline word. +const PROTOCOL_BASH_WORD_ENV: &str = "ARGX_COMPLETE_BASH_WORD"; +/// Bash's configured Readline word-breaking characters. +const PROTOCOL_BASH_WORDBREAKS_ENV: &str = "ARGX_COMPLETE_BASH_WORDBREAKS"; /// Shells for which Argx can generate dynamic completion adapters. /// diff --git a/crates/argx/src/completion/script.rs b/crates/argx/src/completion/script.rs index 2a64c3c..b2e6416 100644 --- a/crates/argx/src/completion/script.rs +++ b/crates/argx/src/completion/script.rs @@ -1,8 +1,8 @@ //! Thin shell adapters for the private dynamic-completion protocol. use super::{ - PROTOCOL_COMMAND, PROTOCOL_ENV, PROTOCOL_LINE_ENV, PROTOCOL_VERSION, PROTOCOL_WORDS_ENV, - ScriptError, Shell, + PROTOCOL_BASH_WORD_ENV, PROTOCOL_BASH_WORDBREAKS_ENV, PROTOCOL_COMMAND, PROTOCOL_ENV, + PROTOCOL_LINE_ENV, PROTOCOL_VERSION, PROTOCOL_WORDS_ENV, ScriptError, Shell, }; /// Renders one supported shell adapter after validating the executable name. @@ -34,17 +34,32 @@ fn bash_script(command: &str) -> String { format!( r#"# @generated by argx _argx_complete_{command}() {{ - local __argx_out __argx_line __argx_value + local __argx_out __argx_line __argx_value __argx_prefix= __argx_out="$({PROTOCOL_ENV}={PROTOCOL_VERSION} \ {PROTOCOL_LINE_ENV}="${{COMP_LINE:0:$COMP_POINT}}" \ + {PROTOCOL_BASH_WORD_ENV}="${{COMP_WORDS[COMP_CWORD]}}" \ + {PROTOCOL_BASH_WORDBREAKS_ENV}="$COMP_WORDBREAKS" \ command '{command}' {PROTOCOL_COMMAND} 2>/dev/null)" || return 0 COMPREPLY=() while IFS= read -r __argx_line; do - [[ -z "$__argx_line" ]] && continue - __argx_value="${{__argx_line%%$'\t'*}}" - COMPREPLY+=("$__argx_value") + case "$__argx_line" in + $'\001prefix\t'*) __argx_prefix="${{__argx_line#*$'\t'}}" ;; + '') ;; + *) + __argx_value="${{__argx_line%%$'\t'*}}" + COMPREPLY+=("$__argx_value") + ;; + esac done <<< "$__argx_out" + + if [[ -n $__argx_prefix ]]; then + local __argx_i + for __argx_i in "${{!COMPREPLY[@]}}"; do + [[ ${{COMPREPLY[__argx_i]}} == "$__argx_prefix"* ]] && + COMPREPLY[__argx_i]="${{COMPREPLY[__argx_i]#"$__argx_prefix"}}" + done + fi }} complete -F _argx_complete_{command} '{command}' "#, @@ -171,6 +186,9 @@ mod tests { let bash = render("tool", Shell::Bash).unwrap(); assert!(bash.contains("ARGX_COMPLETE=1")); assert!(bash.contains("ARGX_COMPLETE_LINE=")); + assert!(bash.contains("ARGX_COMPLETE_BASH_WORD=")); + assert!(bash.contains("ARGX_COMPLETE_BASH_WORDBREAKS=")); + assert!(bash.contains("$'\\001prefix\\t'*")); assert!(bash.contains("command 'tool' __argx_complete__")); assert!(bash.contains("complete -F _argx_complete_tool 'tool'")); From b1038b88a9270d11b1cd9255912959669488dca0 Mon Sep 17 00:00:00 2001 From: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:33:23 +0200 Subject: [PATCH 2/2] fix fmt --- crates/argx/src/completion/engine.rs | 29 +++++----------------------- 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/crates/argx/src/completion/engine.rs b/crates/argx/src/completion/engine.rs index a45b76a..f28a8de 100644 --- a/crates/argx/src/completion/engine.rs +++ b/crates/argx/src/completion/engine.rs @@ -65,9 +65,7 @@ where }; let mut output = render_candidates(&candidates); - if bash_wordbreaks - .as_deref() - .is_some_and(|wordbreaks| wordbreaks.contains(':')) + if bash_wordbreaks.as_deref().is_some_and(|wordbreaks| wordbreaks.contains(':')) && let Some(word) = bash_word.as_deref() && let Some(marked_prefix) = bash_marked_prefix.as_deref() { @@ -102,32 +100,17 @@ fn mark_bash_nonbreaking_colons(line: &str) -> String { 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 == '\\' => { + Some('"') | None if c == '\\' => { marked.push(c); if let Some(next) = chars.next() { - marked.push(if next == ':' { - BASH_NONBREAKING_COLON - } else { - next - }); + marked.push(if next == ':' { BASH_NONBREAKING_COLON } else { next }); } } - None => marked.push(c), + Some(_) | None => marked.push(c), } } marked @@ -872,9 +855,7 @@ mod tests { ); assert_eq!( split_line(&mark_bash_nonbreaking_colons(r#"tool "update:deps:no""#)).prefix, - format!( - "update{BASH_NONBREAKING_COLON}deps{BASH_NONBREAKING_COLON}no" - ), + format!("update{BASH_NONBREAKING_COLON}deps{BASH_NONBREAKING_COLON}no"), ); }