diff --git a/crates/argx/src/completion/engine.rs b/crates/argx/src/completion/engine.rs index 655ccf4..f28a8de 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,66 @@ 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), + None if c == '\'' || c == '"' => { + quote = Some(c); + marked.push(c); + } + Some('"') | None if c == '\\' => { + marked.push(c); + if let Some(next) = chars.next() { + marked.push(if next == ':' { BASH_NONBREAKING_COLON } else { next }); + } + } + Some(_) | None => marked.push(c), + } + } + marked } /// One shell-independent completion candidate. @@ -790,6 +843,22 @@ 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'"));