Skip to main content

winnow_args/
complete.rs

1//! Shell completion, answered by the program itself.
2//!
3//! A generated script ([`script`]) registers a completion function with the
4//! shell; on Tab it runs the program as `PROG __complete_word__ --shell SHELL
5//! --line LINE` (see [`answer`]), and the program answers from its own help
6//! data ([`help::Command`](crate::help::Command)): flag names, a flag's
7//! choices, subcommand names, or a request for the shell's own file
8//! completion. The script stays a few lines and never goes stale: what can be
9//! typed is whatever the binary parses.
10//!
11//! [`Args::completion_request`](crate::Args::completion_request) answers the
12//! callback before the command line is parsed, and
13//! [`Args::completion_script`](crate::Args::completion_script) writes the
14//! script.
15//!
16//! What is completed:
17//!
18//! - after `-` or `--`, the visible flags of the command reached so far, with
19//!   their descriptions;
20//! - a flag's value (`--color <TAB>`, `--color=<TAB>`), from its choices, or
21//!   what its [`Hint`] says the shell knows: files for a path or a word;
22//! - elsewhere, subcommand names and a positional's choices, or its hint's.
23//!
24//! A value's type gives the hint ([`FromArg::HINT`](crate::FromArg::HINT)):
25//! a `PathBuf`, a `String` or an `OsString` is a file, and a number or a
26//! type of the program nothing a shell knows. A field says otherwise with
27//! `#[arg(hint = "dir")]`, `"command"`, `"file"` or `"none"`. A value only
28//! the program knows is answered by a function,
29//! `#[arg(complete = branches)]`, given what is typed of it; each candidate
30//! is one plain word, as the scripts quote nothing for the shell.
31//!
32//! In a program, the callback is answered first and the script is printed on
33//! request, here by a flag of its own:
34//!
35#![cfg_attr(feature = "derive", doc = "```no_run")]
36#![cfg_attr(not(feature = "derive"), doc = "```ignore")]
37//! use winnow_args::Args;
38//! use winnow_args::complete::Shell;
39//!
40//! #[derive(Args)]
41//! #[arg(name = "tool")]
42//! struct Cli {
43//!     /// Print a completion script: `source <(tool --completions bash)`.
44//!     #[arg(long, value_name = "SHELL")]
45//!     completions: Option<Shell>,
46//! }
47//!
48//! let args: Vec<_> = std::env::args_os().skip(1).collect();
49//! if let Some(answer) = Cli::completion_request(&args) {
50//!     print!("{answer}");
51//!     return;
52//! }
53//! let cli = Cli::parse();
54//! if let Some(shell) = cli.completions {
55//!     print!("{}", Cli::completion_script(shell));
56//! }
57//! ```
58
59use std::ffi::OsString;
60use std::fmt::Write as _;
61
62use winnow::stream::BStr;
63
64use crate::help::{Command, Item};
65
66/// The word a completion script calls the program with.
67pub const REQUEST: &str = "__complete_word__";
68
69/// What a value is, when it is not one of a fixed set: what the shell is
70/// asked to complete itself.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
72#[non_exhaustive]
73pub enum Hint {
74    /// Nothing a shell can know: a number, a name, free text. What a value
75    /// is taken for when its field does not say and its type is no path or
76    /// word. PowerShell
77    /// offers files all the same, as it does wherever it is answered nothing.
78    #[default]
79    Nothing,
80    /// A file name, directories among them: what a `PathBuf`, a `String`
81    /// and an `OsString` are.
82    Files,
83    /// A directory.
84    Dirs,
85    /// A command on the `PATH`.
86    Commands,
87}
88
89impl Hint {
90    /// The line in an answer that asks the shell for it.
91    const fn line(self) -> Option<&'static str> {
92        match self {
93            Hint::Files => Some("\u{1}files"),
94            Hint::Dirs => Some("\u{1}dirs"),
95            Hint::Commands => Some("\u{1}commands"),
96            Hint::Nothing => None,
97        }
98    }
99}
100
101/// The shells a script can be written for.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103#[non_exhaustive]
104pub enum Shell {
105    /// GNU bash, with its `complete -F`.
106    Bash,
107    /// zsh's completion system (`compinit`).
108    Zsh,
109    /// fish.
110    Fish,
111    /// Elvish.
112    Elvish,
113    /// PowerShell.
114    PowerShell,
115}
116
117impl Shell {
118    /// Every shell a script can be written for.
119    pub const ALL: &'static [Shell] = &[
120        Shell::Bash,
121        Shell::Zsh,
122        Shell::Fish,
123        Shell::Elvish,
124        Shell::PowerShell,
125    ];
126
127    /// The shell's name: `bash`, `zsh`, `fish`, `elvish`, `powershell`.
128    pub const fn as_str(self) -> &'static str {
129        match self {
130            Shell::Bash => "bash",
131            Shell::Zsh => "zsh",
132            Shell::Fish => "fish",
133            Shell::Elvish => "elvish",
134            Shell::PowerShell => "powershell",
135        }
136    }
137
138    /// The shell named `name`, if a script can be written for it.
139    pub fn from_name(name: &str) -> Option<Shell> {
140        Shell::ALL.iter().copied().find(|s| s.as_str() == name)
141    }
142}
143
144/// So a program's own `--completions <SHELL>` flag can be a `Shell`.
145impl crate::FromArg for Shell {
146    // `winnow-args-spec` knows these choices under this name.
147    const SPEC: &'static str = "Shell";
148
149    const CHOICES: &'static [&'static str] = &["bash", "zsh", "fish", "elvish", "powershell"];
150
151    fn from_arg(value: &BStr) -> Result<Self, crate::error::BoxError> {
152        let name = std::str::from_utf8(value).ok();
153        match name.and_then(Shell::from_name) {
154            Some(shell) => Ok(shell),
155            None => Err(Box::new(crate::ChoiceError {
156                choices: Self::CHOICES,
157            })),
158        }
159    }
160}
161
162impl std::fmt::Display for Shell {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.write_str(self.as_str())
165    }
166}
167
168/// One thing that can be typed at the cursor.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Candidate {
171    /// The whole word, as it replaces the one being typed.
172    pub value: String,
173    /// What it is, for shells that show descriptions.
174    pub help: &'static str,
175}
176
177/// What can be typed at the cursor.
178#[derive(Debug, Clone, Default, PartialEq, Eq)]
179pub struct Completions {
180    /// The candidates, in help order.
181    pub candidates: Vec<Candidate>,
182    /// What fits here too, which the shell completes itself.
183    pub hint: Hint,
184    /// The part of the word that comes before what the shell completes:
185    /// `--name=` for a value attached to its flag, empty for a whole word.
186    pub before: String,
187}
188
189/// What can be typed as the last of `words`, the command line after the
190/// program's name up to the cursor. The last word is the one being typed,
191/// empty after a space.
192pub fn complete(root: &'static Command, words: &[&str]) -> Completions {
193    let (current, before) = match words.split_last() {
194        Some((current, before)) => (*current, before),
195        None => ("", words),
196    };
197    let mut state = Walk {
198        command: root,
199        globals: Vec::new(),
200        owing: None,
201        stopped: false,
202        position: 0,
203    };
204    for word in before {
205        state.step(word);
206    }
207    state.complete(current)
208}
209
210/// Words still taken by the flag just seen: `fixed` of them whole, then
211/// non-flag-like ones, a single word when `once`.
212#[derive(Clone, Copy)]
213struct Owing {
214    item: &'static Item,
215    fixed: usize,
216    flexible: bool,
217    once: bool,
218}
219
220/// Where a command line stands after the words before the cursor.
221struct Walk {
222    command: &'static Command,
223    /// The global flags of the commands above.
224    globals: Vec<&'static Item>,
225    /// A flag still taking values.
226    owing: Option<Owing>,
227    /// Past a `--`.
228    stopped: bool,
229    /// How many positionals were given.
230    position: usize,
231}
232
233impl Walk {
234    fn flags(&self) -> impl Iterator<Item = &'static Item> + '_ {
235        let own = self.command.items.iter().filter(|i| !i.positional);
236        own.chain(self.globals.iter().copied())
237    }
238
239    fn long(&self, name: &str) -> Option<&'static Item> {
240        self.flags()
241            .find(|i| i.long == Some(name) || i.negate == Some(name) || i.aliases.contains(&name))
242    }
243
244    /// Whether `item` takes the next word as its value.
245    fn takes_next(item: &Item) -> bool {
246        item.value_name.is_some() && !item.require_equals
247    }
248
249    fn letter(&self, letter: char, plus: bool) -> Option<&'static Item> {
250        self.flags().find(|i| {
251            if plus {
252                i.plus == Some(letter)
253            } else {
254                i.short == Some(letter) || i.more_shorts.contains(&letter)
255            }
256        })
257    }
258
259    fn plus_options(&self) -> bool {
260        self.flags().any(|i| i.plus.is_some())
261    }
262
263    /// The letters of a `+abc` bundle, when this command has `+` options.
264    fn plus_letters<'a>(&self, word: &'a str) -> Option<&'a str> {
265        let letters = word.strip_prefix('+').filter(|l| !l.is_empty())?;
266        self.plus_options().then_some(letters)
267    }
268
269    /// `attached` is the value already in this word (`--name=value`, `-nrest`).
270    fn owe(&mut self, item: &'static Item, attached: bool) {
271        if item.value_name.is_none() || (!attached && item.require_equals) {
272            return;
273        }
274        if item.more_values {
275            self.owing = Some(Owing {
276                item,
277                fixed: 0,
278                flexible: true,
279                once: false,
280            });
281            return;
282        }
283        if !attached && item.optional_value {
284            self.owing = Some(Owing {
285                item,
286                fixed: 0,
287                flexible: true,
288                once: true,
289            });
290            return;
291        }
292        let left = item.values.max(1) - usize::from(attached);
293        if left > 0 {
294            self.owing = Some(Owing {
295                item,
296                fixed: left,
297                flexible: false,
298                once: false,
299            });
300        }
301    }
302
303    /// Whether `word` is one of the values still owed. A flag-like word ends a
304    /// flexible run and is left for the caller to read as a flag.
305    fn consume_owing(&mut self, word: &str) -> bool {
306        let Some(owing) = self.owing else {
307            return false;
308        };
309        if owing.fixed > 0 {
310            let fixed = owing.fixed - 1;
311            self.owing = (fixed > 0).then_some(Owing { fixed, ..owing });
312            return true;
313        }
314        if owing.flexible && !crate::token::is_flag_like(word.as_bytes()) {
315            if owing.once {
316                self.owing = None;
317            }
318            return true;
319        }
320        self.owing = None;
321        false
322    }
323
324    fn bundle(&mut self, letters: &str, plus: bool) {
325        for (at, letter) in letters.char_indices() {
326            let Some(item) = self.letter(letter, plus) else {
327                break;
328            };
329            if item.value_name.is_some() {
330                // The value is the rest of the word, or the next word.
331                if at + letter.len_utf8() == letters.len() && Self::takes_next(item) {
332                    self.owe(item, false);
333                }
334                break;
335            }
336        }
337    }
338
339    fn step(&mut self, word: &str) {
340        if self.consume_owing(word) {
341            return;
342        }
343        if self.stopped {
344            self.position += 1;
345            return;
346        }
347        if word == "--" {
348            self.stopped = true;
349        } else if let Some(body) = word.strip_prefix("--") {
350            let (name, value) = match body.split_once('=') {
351                Some((name, value)) => (name, Some(value)),
352                None => (body, None),
353            };
354            if let Some(item) = self.long(name) {
355                self.owe(item, value.is_some());
356            }
357        } else if let Some(letters) = word.strip_prefix('-').filter(|l| !l.is_empty()) {
358            self.bundle(letters, false);
359        } else if let Some(letters) = self.plus_letters(word) {
360            self.bundle(letters, true);
361        } else if let Some(sub) = self
362            .command
363            .subcommands
364            .iter()
365            .find(|s| s.names.contains(&word))
366        {
367            let globals = self.command.items.iter().filter(|i| i.global);
368            self.globals.extend(globals);
369            self.command = sub.command;
370            self.position = 0;
371        } else {
372            self.position += 1;
373        }
374    }
375
376    fn complete(&self, current: &str) -> Completions {
377        let mut out = Completions::default();
378        if let Some(owing) = self.owing {
379            let as_value = owing.fixed > 0
380                || (owing.flexible && !crate::token::is_flag_like(current.as_bytes()));
381            if as_value {
382                values(owing.item, "", current, &mut out);
383                return out;
384            }
385        }
386        if !self.stopped && current.starts_with('+') && self.plus_options() {
387            self.plus_names(current, &mut out);
388            return out;
389        }
390        if !self.stopped && current.starts_with('-') {
391            if let Some((name, value)) = current
392                .strip_prefix("--")
393                .and_then(|body| body.split_once('='))
394            {
395                if let Some(item) = self.long(name).filter(|i| i.value_name.is_some()) {
396                    values(
397                        item,
398                        &current[..current.len() - value.len()],
399                        value,
400                        &mut out,
401                    );
402                }
403                return out;
404            }
405            self.flag_names(current, &mut out);
406            return out;
407        }
408        if !self.stopped {
409            for sub in self.command.subcommands.iter().filter(|s| !s.hide) {
410                push(&mut out, sub.name, sub.about, current);
411            }
412        }
413        let mut positionals = self.command.items.iter().filter(|i| i.positional);
414        let positional = positionals
415            .clone()
416            .nth(self.position)
417            .or_else(|| positionals.rfind(|i| i.multiple));
418        if let Some(item) = positional {
419            values(item, "", current, &mut out);
420        }
421        out
422    }
423
424    fn flag_names(&self, current: &str, out: &mut Completions) {
425        // `--` typed: only long names fit.
426        let shorts = !current.starts_with("--");
427        for item in self.flags().filter(|i| !i.hide) {
428            if let Some(long) = item.long {
429                push(out, &format!("--{long}"), item.help, current);
430            }
431            if let Some(negate) = item.negate {
432                push(out, &format!("--{negate}"), item.help, current);
433            }
434            if shorts {
435                if let Some(short) = item.short {
436                    push(out, &format!("-{short}"), item.help, current);
437                }
438                for &letter in item.more_shorts {
439                    push(out, &format!("-{letter}"), item.help, current);
440                }
441            }
442        }
443        let command = self.command;
444        if command.help_flag {
445            if command.help_short && shorts {
446                push(out, "-h", "Print help", current);
447            }
448            push(out, "--help", "Print help", current);
449        }
450        if command.version.is_some() {
451            if shorts {
452                push(out, "-V", "Print version", current);
453            }
454            push(out, "--version", "Print version", current);
455        }
456    }
457
458    fn plus_names(&self, current: &str, out: &mut Completions) {
459        for item in self.flags().filter(|i| !i.hide) {
460            if let Some(letter) = item.plus {
461                push(out, &format!("+{letter}"), item.help, current);
462            }
463        }
464    }
465}
466
467/// A value for `item`: what its function answers, else one of its choices,
468/// else what its hint says the shell knows.
469fn values(item: &'static Item, prefix: &str, typed: &str, out: &mut Completions) {
470    if let Some(complete) = item.complete {
471        let fitting = complete(typed)
472            .into_iter()
473            .filter(|c| c.value.starts_with(typed));
474        out.candidates.extend(fitting.map(|c| Candidate {
475            value: format!("{prefix}{}", c.value),
476            help: c.help,
477        }));
478        return;
479    }
480    if item.choices.is_empty() {
481        out.hint = item.hint;
482        prefix.clone_into(&mut out.before);
483        return;
484    }
485    for choice in item.choices {
486        if choice.starts_with(typed) {
487            out.candidates.push(Candidate {
488                value: format!("{prefix}{choice}"),
489                help: "",
490            });
491        }
492    }
493}
494
495fn push(out: &mut Completions, value: &str, help: &'static str, typed: &str) {
496    if value.starts_with(typed) {
497        out.candidates.push(Candidate {
498            value: value.to_owned(),
499            help,
500        });
501    }
502}
503
504/// Answer a completion script's callback: `args` is the command line after
505/// the program's name. `None` when it is not a callback, so the program goes
506/// on to parse it.
507///
508/// The callback is `__complete_word__ --shell SHELL --line LINE`, `LINE`
509/// being the command line up to the cursor, program name first; bash adds
510/// `--bash-word WORD`, the part of the current word it will replace (it breaks
511/// words at `=` and `:` too). A shell that hands over words already unquoted
512/// (elvish) ends with `--words WORD…`, the program's name first, instead of
513/// `--line`. The answer is one candidate a line, as [`render`] writes it.
514pub fn answer(root: &'static Command, args: &[OsString]) -> Option<String> {
515    let (first, rest) = args.split_first()?;
516    if first.to_str()? != REQUEST {
517        return None;
518    }
519    let (mut shell, mut line, mut replaced) = (None, String::new(), None);
520    let mut given: Option<Vec<String>> = None;
521    let mut rest = rest.iter().map(|a| a.to_string_lossy().into_owned());
522    while let Some(option) = rest.next() {
523        if option == "--words" {
524            // Every later argument is a word, the program's name first.
525            given = Some(rest.by_ref().collect());
526            break;
527        }
528        let value = rest.next().unwrap_or_default();
529        match option.as_str() {
530            "--shell" => shell = Shell::from_name(&value),
531            "--line" => line = value,
532            "--bash-word" => replaced = Some(value),
533            _ => {}
534        }
535    }
536    let shell = shell?;
537    let mut words = given.unwrap_or_else(|| split_line(&line));
538    if !words.is_empty() {
539        words.remove(0);
540    }
541    if words.is_empty() {
542        words.push(String::new());
543    }
544    let words: Vec<&str> = words.iter().map(String::as_str).collect();
545    let completions = complete(root, &words);
546    Some(match (shell, replaced) {
547        (Shell::Bash, Some(replaced)) => {
548            render_bash(&completions, words.last().copied().unwrap_or(""), &replaced)
549        }
550        _ => render(&completions, shell),
551    })
552}
553
554/// The answer as `shell`'s script reads it: one candidate a line, a
555/// description after a colon (zsh) or a tab (the others), and a line asking for
556/// what the shell completes itself when that fits: its mark, a tab, and the
557/// part of the word before what the shell is to complete.
558pub fn render(completions: &Completions, shell: Shell) -> String {
559    let mut out = String::new();
560    for c in &completions.candidates {
561        match shell {
562            Shell::Bash => out.push_str(&c.value),
563            Shell::Zsh => {
564                out.push_str(&c.value.replace(':', "\\:"));
565                if !c.help.is_empty() {
566                    let _ = write!(out, ":{}", first_line(c.help));
567                }
568            }
569            Shell::Fish | Shell::Elvish | Shell::PowerShell => {
570                out.push_str(&c.value);
571                if !c.help.is_empty() {
572                    let _ = write!(out, "\t{}", first_line(c.help));
573                }
574            }
575        }
576        out.push('\n');
577    }
578    ask_the_shell(completions, shell, &mut out);
579    out
580}
581
582/// The line that asks the shell for what it completes itself, when that fits.
583/// bash breaks a word at `=`, and so needs no word of what comes before.
584fn ask_the_shell(completions: &Completions, shell: Shell, out: &mut String) {
585    let Some(line) = completions.hint.line() else {
586        return;
587    };
588    out.push_str(line);
589    if shell != Shell::Bash {
590        out.push('\t');
591        out.push_str(&completions.before);
592    }
593    out.push('\n');
594}
595
596/// bash replaces only the part of the word after its last `=` or `:`: the
597/// candidates lose what came before it.
598fn render_bash(completions: &Completions, typed: &str, replaced: &str) -> String {
599    // Right after `--name=`, bash's current word is the `=` itself.
600    let replaced = if replaced == "=" { "" } else { replaced };
601    let cut = typed
602        .strip_suffix(replaced)
603        .map_or(0, |before| before.len());
604    let mut out = String::new();
605    for c in &completions.candidates {
606        out.push_str(c.value.get(cut..).unwrap_or(&c.value));
607        out.push('\n');
608    }
609    ask_the_shell(completions, Shell::Bash, &mut out);
610    out
611}
612
613fn first_line(text: &str) -> &str {
614    text.lines().next().unwrap_or("")
615}
616
617/// Split a command line as a shell would into words: whitespace between,
618/// `'…'`, `"…"` and `\` quoting. A trailing space starts an empty word.
619fn split_line(line: &str) -> Vec<String> {
620    let mut words = Vec::new();
621    let mut word: Option<String> = None;
622    let mut quote = None;
623    let mut chars = line.chars();
624    while let Some(c) = chars.next() {
625        match (quote, c) {
626            (Some(q), c) if c == q => quote = None,
627            (Some('"') | None, '\\') => {
628                if let Some(next) = chars.next() {
629                    word.get_or_insert_default().push(next);
630                }
631            }
632            (Some(_), c) => word.get_or_insert_default().push(c),
633            (None, '\'' | '"') => {
634                quote = Some(c);
635                word.get_or_insert_default();
636            }
637            (None, c) if c.is_whitespace() => words.extend(word.take()),
638            (None, c) => word.get_or_insert_default().push(c),
639        }
640    }
641    words.push(word.unwrap_or_default());
642    words
643}
644
645/// The completion script for `bin` in `shell`, ready to be sourced or
646/// installed where the shell looks for completions.
647///
648/// # Panics
649///
650/// If `bin` is not one plain word (letters, digits, `-`, `_`, `.`, `+`): the
651/// scripts name it unquoted where no shell allows quotes.
652pub fn script(bin: &str, shell: Shell) -> String {
653    assert!(
654        !bin.is_empty()
655            && bin
656                .chars()
657                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+')),
658        "a completion script needs a plain program name, not {bin:?}"
659    );
660    let function: String = bin
661        .chars()
662        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
663        .collect();
664    match shell {
665        Shell::Bash => format!(
666            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
667_{function}_complete() {{
668    local line ask= cur="${{COMP_WORDS[COMP_CWORD]}}"
669    # Right after `--name=`, the current word is the `=` itself.
670    [[ $cur == = ]] && cur=
671    COMPREPLY=()
672    while IFS= read -r line; do
673        case $line in
674            $'\001files') ask=f ;;
675            $'\001dirs') ask=d ;;
676            $'\001commands') ask=c ;;
677            ?*) COMPREPLY+=("$line") ;;
678        esac
679    done < <(command {bin} {REQUEST} --shell bash --line "${{COMP_LINE:0:COMP_POINT}}" \
680        --bash-word "${{COMP_WORDS[COMP_CWORD]}}" 2>/dev/null)
681    if [[ -n $ask ]]; then
682        [[ $ask == c ]] || compopt -o filenames 2>/dev/null
683        local IFS=$'\n'
684        COMPREPLY+=($(compgen -$ask -- "$cur"))
685    fi
686}}
687complete -F _{function}_complete {bin}
688"#
689        ),
690        Shell::Zsh => format!(
691            r#"#compdef {bin}
692# Completion for {bin}, answered by `{bin} {REQUEST}`.
693_{function}() {{
694    local -a candidates
695    local line ask= before=
696    while IFS= read -r line; do
697        case $line in
698            $'\001files\t'*) ask=_files ;;
699            $'\001dirs\t'*) ask='_path_files -/' ;;
700            $'\001commands\t'*) ask=_command_names ;;
701            ?*) candidates+=("$line"); continue ;;
702        esac
703        before=${{line#*$'\t'}}
704    done < <(command {bin} {REQUEST} --shell zsh --line "${{BUFFER[1,CURSOR]}}" 2>/dev/null)
705    (( ${{#candidates}} )) && _describe -t values '{bin}' candidates
706    if [[ -n $ask ]]; then
707        # `--name=`, when the value is attached: not a part of what is asked for.
708        if [[ -n $before && $PREFIX == "$before"* ]]; then
709            IPREFIX+=$before
710            PREFIX=${{PREFIX#"$before"}}
711        fi
712        ${{=ask}}
713    fi
714    return 0
715}}
716if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
717    _{function} "$@"
718else
719    compdef _{function} {bin}
720fi
721"#
722        ),
723        Shell::Fish => format!(
724            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
725function __{function}_complete
726    set -l token (commandline -ct)
727    for line in (command {bin} {REQUEST} --shell fish --line (commandline -cp) 2>/dev/null)
728        if not string match -q \u0001'*' -- $line
729            test -n "$line"; and printf '%s\n' $line
730            continue
731        end
732        # The mark, then `--name=` when the value is attached to its flag.
733        set -l asked (string split -m 1 \t -- $line)
734        set -l before $asked[2]
735        set -l typed (string sub -s (math (string length -- "$before") + 1) -- $token)
736        switch $asked[1]
737            case \u0001files
738                printf '%s\n' $before(__fish_complete_path "$typed")
739            case \u0001dirs
740                printf '%s\n' $before(__fish_complete_directories "$typed")
741            case \u0001commands
742                printf '%s\n' $before(complete -C "$typed")
743        end
744    end
745end
746complete -c {bin} -f -a '(__{function}_complete)'
747"#
748        ),
749        Shell::Elvish => format!(
750            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
751use str
752
753set edit:completion:arg-completer[{bin}] = {{|@words|
754    for answer [(e:{bin} {REQUEST} --shell elvish --words $@words 2>/dev/null | from-lines)] {{
755        if (str:has-prefix $answer "\x01") {{
756            # The mark, then `--name=` when the value is attached to its flag.
757            var asked before = (str:split &max=2 "\t" $answer)
758            var typed = (str:trim-prefix $words[-1] $before)
759            var found = []
760            if (has-value ["\x01files" "\x01dirs"] $asked) {{
761                set found = [(edit:complete-filename $typed)]
762            }} elif (eq $asked "\x01commands") {{
763                set found = [(edit:complete-sudo sudo $typed)]
764            }}
765            for one $found {{
766                if (eq (kind-of $one) string) {{
767                    put $before$one
768                }} elif (or (not-eq $asked "\x01dirs") (str:has-suffix $one[stem] /)) {{
769                    edit:complex-candidate $before$one[stem] &code-suffix=$one[code-suffix] &display=$one[display]
770                }}
771            }}
772        }} elif (not-eq $answer '') {{
773            var parts = [(str:split "\t" $answer)]
774            var display = $parts[0]
775            if (> (count $parts) 1) {{
776                set display = (str:join '  -- ' $parts)
777            }}
778            edit:complex-candidate $parts[0] &display=$display
779        }}
780    }}
781}}
782"#
783        ),
784        Shell::PowerShell => format!(
785            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
786Register-ArgumentCompleter -Native -CommandName '{bin}' -ScriptBlock {{
787    param($wordToComplete, $commandAst, $cursorPosition)
788
789    # The command's text up to the cursor: `$cursorPosition` counts from the
790    # start of the whole input, the extent from its own start.
791    $extent = $commandAst.Extent
792    $offset = [Math]::Min([Math]::Max($cursorPosition - $extent.StartOffset, 0), $extent.Text.Length)
793    $line = $extent.Text.Substring(0, $offset)
794    # The extent ends at the last token: a word not yet typed needs its space.
795    if ($wordToComplete -eq '' -and -not $line.EndsWith(' ')) {{ $line += ' ' }}
796    $answers = @(& '{bin}' {REQUEST} --shell powershell --line $line 2>$null)
797
798    foreach ($answer in $answers) {{
799        if ([string]::IsNullOrEmpty($answer)) {{ continue }}
800        $parts = $answer -split "`t", 2
801        if ($answer[0] -eq [char]1) {{
802            # The mark, then `--name=` when the value is attached to its flag.
803            $before = $parts[1]
804            $typed = $wordToComplete
805            if ($before -and $typed.StartsWith($before)) {{ $typed = $typed.Substring($before.Length) }}
806            $found = switch ($parts[0].Substring(1)) {{
807                'files' {{ [System.Management.Automation.CompletionCompleters]::CompleteFilename($typed) }}
808                'dirs' {{
809                    [System.Management.Automation.CompletionCompleters]::CompleteFilename($typed) |
810                        Where-Object ResultType -eq 'ProviderContainer'
811                }}
812                'commands' {{ [System.Management.Automation.CompletionCompleters]::CompleteCommand($typed) }}
813            }}
814            foreach ($one in $found) {{
815                [System.Management.Automation.CompletionResult]::new(
816                    $before + $one.CompletionText, $one.ListItemText, $one.ResultType, $one.ToolTip)
817            }}
818            continue
819        }}
820        $help = if ($parts.Count -gt 1 -and $parts[1]) {{ $parts[1] }} else {{ $parts[0] }}
821        [System.Management.Automation.CompletionResult]::new(
822            $parts[0], $parts[0], 'ParameterValue', $help)
823    }}
824}}
825"#
826        ),
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn lines_split_as_a_shell_splits_them() {
836        assert_eq!(
837            split_line("prog -o 'a b' c\\ d"),
838            ["prog", "-o", "a b", "c d"]
839        );
840        assert_eq!(split_line("prog --x="), ["prog", "--x="]);
841        assert_eq!(split_line("prog "), ["prog", ""]);
842        assert_eq!(split_line("prog \"q\\\"x"), ["prog", "q\"x"]);
843    }
844
845    #[test]
846    fn bash_gets_the_part_after_its_word_break() {
847        let c = Completions {
848            candidates: vec![Candidate {
849                value: "--color=always".into(),
850                help: "",
851            }],
852            ..Completions::default()
853        };
854        assert_eq!(render_bash(&c, "--color=al", "al"), "always\n");
855        assert_eq!(render_bash(&c, "--color=", "="), "always\n");
856        assert_eq!(render_bash(&c, "--col", "--col"), "--color=always\n");
857    }
858
859    #[test]
860    fn scripts_call_back_the_program() {
861        for &shell in Shell::ALL {
862            let script = script("my-tool", shell);
863            assert!(script.contains("my-tool __complete_word__"), "{script}");
864        }
865    }
866}