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
83 changes: 76 additions & 7 deletions crates/argx/src/completion/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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::<Vec<String>>(&encoded) else {
Expand All @@ -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.
Expand Down Expand Up @@ -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!(
Expand Down
4 changes: 4 additions & 0 deletions crates/argx/src/completion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
30 changes: 24 additions & 6 deletions crates/argx/src/completion/script.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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}'
"#,
Expand Down Expand Up @@ -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'"));

Expand Down
Loading