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    /// Where the line stands once `sub` is named: its flags are read, and
235    /// those of this command that are global.
236    fn within(&self, sub: &'static crate::help::Sub) -> Walk {
237        let mut globals = self.globals.clone();
238        globals.extend(self.command.items.iter().filter(|i| i.global));
239        Walk {
240            command: sub.command,
241            globals,
242            owing: None,
243            stopped: false,
244            position: 0,
245        }
246    }
247
248    /// The default subcommand, entered, when a flag this command does not
249    /// take selects it.
250    fn handed_over(&self) -> Option<Walk> {
251        let command = self.command;
252        let sub = command.default_target()?;
253        command.default_subcommand_flags.then(|| self.within(sub))
254    }
255
256    fn flags(&self) -> impl Iterator<Item = &'static Item> + '_ {
257        let own = self.command.items.iter().filter(|i| !i.positional);
258        own.chain(self.globals.iter().copied())
259    }
260
261    fn long(&self, name: &str) -> Option<&'static Item> {
262        self.flags()
263            .find(|i| i.long == Some(name) || i.negate == Some(name) || i.aliases.contains(&name))
264    }
265
266    /// Whether `item` takes the next word as its value.
267    fn takes_next(item: &Item) -> bool {
268        item.value_name.is_some() && !item.require_equals
269    }
270
271    fn letter(&self, letter: char, plus: bool) -> Option<&'static Item> {
272        self.flags().find(|i| {
273            if plus {
274                i.plus == Some(letter)
275            } else {
276                i.short == Some(letter) || i.more_shorts.contains(&letter)
277            }
278        })
279    }
280
281    fn plus_options(&self) -> bool {
282        self.flags().any(|i| i.plus.is_some())
283    }
284
285    /// The letters of a `+abc` bundle, when this command has `+` options.
286    fn plus_letters<'a>(&self, word: &'a str) -> Option<&'a str> {
287        let letters = word.strip_prefix('+').filter(|l| !l.is_empty())?;
288        self.plus_options().then_some(letters)
289    }
290
291    /// `attached` is the value already in this word (`--name=value`, `-nrest`).
292    fn owe(&mut self, item: &'static Item, attached: bool) {
293        if item.value_name.is_none() || (!attached && item.require_equals) {
294            return;
295        }
296        if item.more_values {
297            self.owing = Some(Owing {
298                item,
299                fixed: 0,
300                flexible: true,
301                once: false,
302            });
303            return;
304        }
305        if !attached && item.optional_value {
306            self.owing = Some(Owing {
307                item,
308                fixed: 0,
309                flexible: true,
310                once: true,
311            });
312            return;
313        }
314        let left = item.values.max(1) - usize::from(attached);
315        if left > 0 {
316            self.owing = Some(Owing {
317                item,
318                fixed: left,
319                flexible: false,
320                once: false,
321            });
322        }
323    }
324
325    /// Whether `word` is one of the values still owed. A flag-like word ends a
326    /// flexible run and is left for the caller to read as a flag.
327    fn consume_owing(&mut self, word: &str) -> bool {
328        let Some(owing) = self.owing else {
329            return false;
330        };
331        if owing.fixed > 0 {
332            let fixed = owing.fixed - 1;
333            self.owing = (fixed > 0).then_some(Owing { fixed, ..owing });
334            return true;
335        }
336        if owing.flexible && !crate::token::is_flag_like(word.as_bytes()) {
337            if owing.once {
338                self.owing = None;
339            }
340            return true;
341        }
342        self.owing = None;
343        false
344    }
345
346    fn bundle(&mut self, letters: &str, plus: bool) {
347        for (at, letter) in letters.char_indices() {
348            let handed = || {
349                self.handed_over()
350                    .filter(|walk| walk.letter(letter, plus).is_some())
351            };
352            if self.letter(letter, plus).is_none()
353                && let Some(walk) = handed()
354            {
355                *self = walk;
356            }
357            let Some(item) = self.letter(letter, plus) else {
358                break;
359            };
360            if item.value_name.is_some() {
361                // The value is the rest of the word, or the next word.
362                if at + letter.len_utf8() == letters.len() && Self::takes_next(item) {
363                    self.owe(item, false);
364                }
365                break;
366            }
367        }
368    }
369
370    fn step(&mut self, word: &str) {
371        if self.consume_owing(word) {
372            return;
373        }
374        if self.stopped {
375            self.position += 1;
376            return;
377        }
378        if word == "--" {
379            self.stopped = true;
380        } else if let Some(body) = word.strip_prefix("--") {
381            let (name, value) = match body.split_once('=') {
382                Some((name, value)) => (name, Some(value)),
383                None => (body, None),
384            };
385            let handed = || self.handed_over().filter(|walk| walk.long(name).is_some());
386            if self.long(name).is_none()
387                && let Some(walk) = handed()
388            {
389                *self = walk;
390            }
391            if let Some(item) = self.long(name) {
392                self.owe(item, value.is_some());
393            }
394        } else if let Some(letters) = word.strip_prefix('-').filter(|l| !l.is_empty()) {
395            self.bundle(letters, false);
396        } else if let Some(letters) = self.plus_letters(word) {
397            self.bundle(letters, true);
398        } else if let Some(sub) = self
399            .command
400            .subcommands
401            .iter()
402            .find(|s| s.names.contains(&word))
403        {
404            *self = self.within(sub);
405        } else if let Some(sub) = self.command.default_target() {
406            // A word that names no subcommand is the default one's.
407            *self = self.within(sub);
408            self.position = 1;
409        } else {
410            self.position += 1;
411        }
412    }
413
414    fn complete(&self, current: &str) -> Completions {
415        let mut out = Completions::default();
416        if let Some(owing) = self.owing {
417            let as_value = owing.fixed > 0
418                || (owing.flexible && !crate::token::is_flag_like(current.as_bytes()));
419            if as_value {
420                values(owing.item, "", current, &mut out);
421                return out;
422            }
423        }
424        if !self.stopped && current.starts_with('+') && self.plus_options() {
425            self.plus_names(current, &mut out);
426            return out;
427        }
428        if !self.stopped && current.starts_with('-') {
429            if let Some((name, value)) = current
430                .strip_prefix("--")
431                .and_then(|body| body.split_once('='))
432            {
433                let handed = self.handed_over();
434                let item = self
435                    .long(name)
436                    .or_else(|| handed.as_ref().and_then(|walk| walk.long(name)));
437                if let Some(item) = item.filter(|i| i.value_name.is_some()) {
438                    values(
439                        item,
440                        &current[..current.len() - value.len()],
441                        value,
442                        &mut out,
443                    );
444                }
445                return out;
446            }
447            self.flag_names(current, &mut out);
448            // And those that select the default subcommand, less its own
449            // help and version.
450            if let Some(walk) = self.handed_over() {
451                let shorts = !current.starts_with("--");
452                let own = walk.command.items.iter().filter(|i| !i.positional);
453                for item in own.filter(|i| !i.hide && !self.takes(i)) {
454                    push_names(item, shorts, current, &mut out);
455                }
456            }
457            return out;
458        }
459        if !self.stopped {
460            for sub in self.command.subcommands.iter().filter(|s| !s.hide) {
461                push(&mut out, sub.name, sub.about, current);
462            }
463        }
464        self.positional(current, &mut out);
465        // A word that names no subcommand is the default one's first.
466        if !self.stopped
467            && self.position == 0
468            && let Some(sub) = self.command.default_target()
469        {
470            self.within(sub).positional(current, &mut out);
471        }
472        out
473    }
474
475    /// The values of the positional the next word fills.
476    fn positional(&self, current: &str, out: &mut Completions) {
477        let mut positionals = self.command.items.iter().filter(|i| i.positional);
478        let positional = positionals
479            .clone()
480            .nth(self.position)
481            .or_else(|| positionals.rfind(|i| i.multiple));
482        if let Some(item) = positional {
483            values(item, "", current, out);
484        }
485    }
486
487    /// Whether this command reads a flag spelled as `item` is.
488    fn takes(&self, item: &Item) -> bool {
489        item.long.is_some_and(|long| self.long(long).is_some())
490            || item.short.is_some_and(|c| self.letter(c, false).is_some())
491    }
492
493    fn flag_names(&self, current: &str, out: &mut Completions) {
494        // `--` typed: only long names fit.
495        let shorts = !current.starts_with("--");
496        for item in self.flags().filter(|i| !i.hide) {
497            push_names(item, shorts, current, out);
498        }
499        let command = self.command;
500        if command.help_flag {
501            if command.help_short && shorts {
502                push(out, "-h", "Print help", current);
503            }
504            push(out, "--help", "Print help", current);
505        }
506        if command.version.is_some() {
507            if shorts {
508                push(out, "-V", "Print version", current);
509            }
510            push(out, "--version", "Print version", current);
511        }
512    }
513
514    fn plus_names(&self, current: &str, out: &mut Completions) {
515        for item in self.flags().filter(|i| !i.hide) {
516            if let Some(letter) = item.plus {
517                push(out, &format!("+{letter}"), item.help, current);
518            }
519        }
520    }
521}
522
523/// Each spelling of the flag `item` that `current` starts; no letter when
524/// `shorts` is unset.
525fn push_names(item: &'static Item, shorts: bool, current: &str, out: &mut Completions) {
526    if let Some(long) = item.long {
527        push(out, &format!("--{long}"), item.help, current);
528    }
529    if let Some(negate) = item.negate {
530        push(out, &format!("--{negate}"), item.help, current);
531    }
532    if shorts {
533        if let Some(short) = item.short {
534            push(out, &format!("-{short}"), item.help, current);
535        }
536        for &letter in item.more_shorts {
537            push(out, &format!("-{letter}"), item.help, current);
538        }
539    }
540}
541
542/// A value for `item`: what its function answers, else one of its choices,
543/// else what its hint says the shell knows.
544fn values(item: &'static Item, prefix: &str, typed: &str, out: &mut Completions) {
545    if let Some(complete) = item.complete {
546        let fitting = complete(typed)
547            .into_iter()
548            .filter(|c| c.value.starts_with(typed));
549        out.candidates.extend(fitting.map(|c| Candidate {
550            value: format!("{prefix}{}", c.value),
551            help: c.help,
552        }));
553        return;
554    }
555    if item.choices.is_empty() {
556        out.hint = item.hint;
557        prefix.clone_into(&mut out.before);
558        return;
559    }
560    for choice in item.choices {
561        if choice.starts_with(typed) {
562            out.candidates.push(Candidate {
563                value: format!("{prefix}{choice}"),
564                help: "",
565            });
566        }
567    }
568}
569
570fn push(out: &mut Completions, value: &str, help: &'static str, typed: &str) {
571    if value.starts_with(typed) {
572        out.candidates.push(Candidate {
573            value: value.to_owned(),
574            help,
575        });
576    }
577}
578
579/// Answer a completion script's callback: `args` is the command line after
580/// the program's name. `None` when it is not a callback, so the program goes
581/// on to parse it.
582///
583/// The callback is `__complete_word__ --shell SHELL --line LINE`, `LINE`
584/// being the command line up to the cursor, program name first; bash adds
585/// `--bash-word WORD`, the part of the current word it will replace (it breaks
586/// words at `=` and `:` too). A shell that hands over words already unquoted
587/// (elvish) ends with `--words WORD…`, the program's name first, instead of
588/// `--line`. The answer is one candidate a line, as [`render`] writes it.
589pub fn answer(root: &'static Command, args: &[OsString]) -> Option<String> {
590    let (first, rest) = args.split_first()?;
591    if first.to_str()? != REQUEST {
592        return None;
593    }
594    let (mut shell, mut line, mut replaced) = (None, String::new(), None);
595    let mut given: Option<Vec<String>> = None;
596    let mut rest = rest.iter().map(|a| a.to_string_lossy().into_owned());
597    while let Some(option) = rest.next() {
598        if option == "--words" {
599            // Every later argument is a word, the program's name first.
600            given = Some(rest.by_ref().collect());
601            break;
602        }
603        let value = rest.next().unwrap_or_default();
604        match option.as_str() {
605            "--shell" => shell = Shell::from_name(&value),
606            "--line" => line = value,
607            "--bash-word" => replaced = Some(value),
608            _ => {}
609        }
610    }
611    let shell = shell?;
612    let mut words = given.unwrap_or_else(|| split_line(&line));
613    if !words.is_empty() {
614        words.remove(0);
615    }
616    if words.is_empty() {
617        words.push(String::new());
618    }
619    let words: Vec<&str> = words.iter().map(String::as_str).collect();
620    let completions = complete(root, &words);
621    Some(match (shell, replaced) {
622        (Shell::Bash, Some(replaced)) => {
623            render_bash(&completions, words.last().copied().unwrap_or(""), &replaced)
624        }
625        _ => render(&completions, shell),
626    })
627}
628
629/// The answer as `shell`'s script reads it: one candidate a line, a
630/// description after a colon (zsh) or a tab (the others), and a line asking for
631/// what the shell completes itself when that fits: its mark, a tab, and the
632/// part of the word before what the shell is to complete.
633pub fn render(completions: &Completions, shell: Shell) -> String {
634    let mut out = String::new();
635    for c in &completions.candidates {
636        match shell {
637            Shell::Bash => out.push_str(&c.value),
638            Shell::Zsh => {
639                out.push_str(&c.value.replace(':', "\\:"));
640                if !c.help.is_empty() {
641                    let _ = write!(out, ":{}", first_line(c.help));
642                }
643            }
644            Shell::Fish | Shell::Elvish | Shell::PowerShell => {
645                out.push_str(&c.value);
646                if !c.help.is_empty() {
647                    let _ = write!(out, "\t{}", first_line(c.help));
648                }
649            }
650        }
651        out.push('\n');
652    }
653    ask_the_shell(completions, shell, &mut out);
654    out
655}
656
657/// The line that asks the shell for what it completes itself, when that fits.
658/// bash breaks a word at `=`, and so needs no word of what comes before.
659fn ask_the_shell(completions: &Completions, shell: Shell, out: &mut String) {
660    let Some(line) = completions.hint.line() else {
661        return;
662    };
663    out.push_str(line);
664    if shell != Shell::Bash {
665        out.push('\t');
666        out.push_str(&completions.before);
667    }
668    out.push('\n');
669}
670
671/// bash replaces only the part of the word after its last `=` or `:`: the
672/// candidates lose what came before it.
673fn render_bash(completions: &Completions, typed: &str, replaced: &str) -> String {
674    // Right after `--name=`, bash's current word is the `=` itself.
675    let replaced = if replaced == "=" { "" } else { replaced };
676    let cut = typed
677        .strip_suffix(replaced)
678        .map_or(0, |before| before.len());
679    let mut out = String::new();
680    for c in &completions.candidates {
681        out.push_str(c.value.get(cut..).unwrap_or(&c.value));
682        out.push('\n');
683    }
684    ask_the_shell(completions, Shell::Bash, &mut out);
685    out
686}
687
688fn first_line(text: &str) -> &str {
689    text.lines().next().unwrap_or("")
690}
691
692/// Split a command line as a shell would into words: whitespace between,
693/// `'…'`, `"…"` and `\` quoting. A trailing space starts an empty word.
694fn split_line(line: &str) -> Vec<String> {
695    let mut words = Vec::new();
696    let mut word: Option<String> = None;
697    let mut quote = None;
698    let mut chars = line.chars();
699    while let Some(c) = chars.next() {
700        match (quote, c) {
701            (Some(q), c) if c == q => quote = None,
702            (Some('"') | None, '\\') => {
703                if let Some(next) = chars.next() {
704                    word.get_or_insert_default().push(next);
705                }
706            }
707            (Some(_), c) => word.get_or_insert_default().push(c),
708            (None, '\'' | '"') => {
709                quote = Some(c);
710                word.get_or_insert_default();
711            }
712            (None, c) if c.is_whitespace() => words.extend(word.take()),
713            (None, c) => word.get_or_insert_default().push(c),
714        }
715    }
716    words.push(word.unwrap_or_default());
717    words
718}
719
720/// The completion script for `bin` in `shell`, ready to be sourced or
721/// installed where the shell looks for completions.
722///
723/// # Panics
724///
725/// If `bin` is not one plain word (letters, digits, `-`, `_`, `.`, `+`): the
726/// scripts name it unquoted where no shell allows quotes.
727pub fn script(bin: &str, shell: Shell) -> String {
728    assert!(
729        !bin.is_empty()
730            && bin
731                .chars()
732                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+')),
733        "a completion script needs a plain program name, not {bin:?}"
734    );
735    let function: String = bin
736        .chars()
737        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
738        .collect();
739    match shell {
740        Shell::Bash => format!(
741            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
742_{function}_complete() {{
743    local line ask= cur="${{COMP_WORDS[COMP_CWORD]}}"
744    # Right after `--name=`, the current word is the `=` itself.
745    [[ $cur == = ]] && cur=
746    COMPREPLY=()
747    while IFS= read -r line; do
748        case $line in
749            $'\001files') ask=f ;;
750            $'\001dirs') ask=d ;;
751            $'\001commands') ask=c ;;
752            ?*) COMPREPLY+=("$line") ;;
753        esac
754    done < <(command {bin} {REQUEST} --shell bash --line "${{COMP_LINE:0:COMP_POINT}}" \
755        --bash-word "${{COMP_WORDS[COMP_CWORD]}}" 2>/dev/null)
756    if [[ -n $ask ]]; then
757        [[ $ask == c ]] || compopt -o filenames 2>/dev/null
758        local IFS=$'\n'
759        COMPREPLY+=($(compgen -$ask -- "$cur"))
760    fi
761}}
762complete -F _{function}_complete {bin}
763"#
764        ),
765        Shell::Zsh => format!(
766            r#"#compdef {bin}
767# Completion for {bin}, answered by `{bin} {REQUEST}`.
768_{function}() {{
769    local -a candidates
770    local line ask= before=
771    while IFS= read -r line; do
772        case $line in
773            $'\001files\t'*) ask=_files ;;
774            $'\001dirs\t'*) ask='_path_files -/' ;;
775            $'\001commands\t'*) ask=_command_names ;;
776            ?*) candidates+=("$line"); continue ;;
777        esac
778        before=${{line#*$'\t'}}
779    done < <(command {bin} {REQUEST} --shell zsh --line "${{BUFFER[1,CURSOR]}}" 2>/dev/null)
780    (( ${{#candidates}} )) && _describe -t values '{bin}' candidates
781    if [[ -n $ask ]]; then
782        # `--name=`, when the value is attached: not a part of what is asked for.
783        if [[ -n $before && $PREFIX == "$before"* ]]; then
784            IPREFIX+=$before
785            PREFIX=${{PREFIX#"$before"}}
786        fi
787        ${{=ask}}
788    fi
789    return 0
790}}
791if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
792    _{function} "$@"
793else
794    compdef _{function} {bin}
795fi
796"#
797        ),
798        Shell::Fish => format!(
799            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
800function __{function}_complete
801    set -l token (commandline -ct)
802    for line in (command {bin} {REQUEST} --shell fish --line (commandline -cp) 2>/dev/null)
803        if not string match -q \u0001'*' -- $line
804            test -n "$line"; and printf '%s\n' $line
805            continue
806        end
807        # The mark, then `--name=` when the value is attached to its flag.
808        set -l asked (string split -m 1 \t -- $line)
809        set -l before $asked[2]
810        set -l typed (string sub -s (math (string length -- "$before") + 1) -- $token)
811        switch $asked[1]
812            case \u0001files
813                printf '%s\n' $before(__fish_complete_path "$typed")
814            case \u0001dirs
815                printf '%s\n' $before(__fish_complete_directories "$typed")
816            case \u0001commands
817                printf '%s\n' $before(complete -C "$typed")
818        end
819    end
820end
821complete -c {bin} -f -a '(__{function}_complete)'
822"#
823        ),
824        Shell::Elvish => format!(
825            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
826use str
827
828set edit:completion:arg-completer[{bin}] = {{|@words|
829    for answer [(e:{bin} {REQUEST} --shell elvish --words $@words 2>/dev/null | from-lines)] {{
830        if (str:has-prefix $answer "\x01") {{
831            # The mark, then `--name=` when the value is attached to its flag.
832            var asked before = (str:split &max=2 "\t" $answer)
833            var typed = (str:trim-prefix $words[-1] $before)
834            var found = []
835            if (has-value ["\x01files" "\x01dirs"] $asked) {{
836                set found = [(edit:complete-filename $typed)]
837            }} elif (eq $asked "\x01commands") {{
838                set found = [(edit:complete-sudo sudo $typed)]
839            }}
840            for one $found {{
841                if (eq (kind-of $one) string) {{
842                    put $before$one
843                }} elif (or (not-eq $asked "\x01dirs") (str:has-suffix $one[stem] /)) {{
844                    edit:complex-candidate $before$one[stem] &code-suffix=$one[code-suffix] &display=$one[display]
845                }}
846            }}
847        }} elif (not-eq $answer '') {{
848            var parts = [(str:split "\t" $answer)]
849            var display = $parts[0]
850            if (> (count $parts) 1) {{
851                set display = (str:join '  -- ' $parts)
852            }}
853            edit:complex-candidate $parts[0] &display=$display
854        }}
855    }}
856}}
857"#
858        ),
859        Shell::PowerShell => format!(
860            r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
861Register-ArgumentCompleter -Native -CommandName '{bin}' -ScriptBlock {{
862    param($wordToComplete, $commandAst, $cursorPosition)
863
864    # The command's text up to the cursor: `$cursorPosition` counts from the
865    # start of the whole input, the extent from its own start.
866    $extent = $commandAst.Extent
867    $offset = [Math]::Min([Math]::Max($cursorPosition - $extent.StartOffset, 0), $extent.Text.Length)
868    $line = $extent.Text.Substring(0, $offset)
869    # The extent ends at the last token: a word not yet typed needs its space.
870    if ($wordToComplete -eq '' -and -not $line.EndsWith(' ')) {{ $line += ' ' }}
871    $answers = @(& '{bin}' {REQUEST} --shell powershell --line $line 2>$null)
872
873    foreach ($answer in $answers) {{
874        if ([string]::IsNullOrEmpty($answer)) {{ continue }}
875        $parts = $answer -split "`t", 2
876        if ($answer[0] -eq [char]1) {{
877            # The mark, then `--name=` when the value is attached to its flag.
878            $before = $parts[1]
879            $typed = $wordToComplete
880            if ($before -and $typed.StartsWith($before)) {{ $typed = $typed.Substring($before.Length) }}
881            $found = switch ($parts[0].Substring(1)) {{
882                'files' {{ [System.Management.Automation.CompletionCompleters]::CompleteFilename($typed) }}
883                'dirs' {{
884                    [System.Management.Automation.CompletionCompleters]::CompleteFilename($typed) |
885                        Where-Object ResultType -eq 'ProviderContainer'
886                }}
887                'commands' {{ [System.Management.Automation.CompletionCompleters]::CompleteCommand($typed) }}
888            }}
889            foreach ($one in $found) {{
890                [System.Management.Automation.CompletionResult]::new(
891                    $before + $one.CompletionText, $one.ListItemText, $one.ResultType, $one.ToolTip)
892            }}
893            continue
894        }}
895        $help = if ($parts.Count -gt 1 -and $parts[1]) {{ $parts[1] }} else {{ $parts[0] }}
896        [System.Management.Automation.CompletionResult]::new(
897            $parts[0], $parts[0], 'ParameterValue', $help)
898    }}
899}}
900"#
901        ),
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    #[test]
910    fn lines_split_as_a_shell_splits_them() {
911        assert_eq!(
912            split_line("prog -o 'a b' c\\ d"),
913            ["prog", "-o", "a b", "c d"]
914        );
915        assert_eq!(split_line("prog --x="), ["prog", "--x="]);
916        assert_eq!(split_line("prog "), ["prog", ""]);
917        assert_eq!(split_line("prog \"q\\\"x"), ["prog", "q\"x"]);
918    }
919
920    #[test]
921    fn bash_gets_the_part_after_its_word_break() {
922        let c = Completions {
923            candidates: vec![Candidate {
924                value: "--color=always".into(),
925                help: "",
926            }],
927            ..Completions::default()
928        };
929        assert_eq!(render_bash(&c, "--color=al", "al"), "always\n");
930        assert_eq!(render_bash(&c, "--color=", "="), "always\n");
931        assert_eq!(render_bash(&c, "--col", "--col"), "--color=always\n");
932    }
933
934    #[test]
935    fn scripts_call_back_the_program() {
936        for &shell in Shell::ALL {
937            let script = script("my-tool", shell);
938            assert!(script.contains("my-tool __complete_word__"), "{script}");
939        }
940    }
941}