Skip to main content

usage_cli/cli/
complete_word.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::env;
3use std::fmt::Debug;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use itertools::Itertools;
8use regex::Regex;
9use std::sync::LazyLock;
10use usage::miette::IntoDiagnostic;
11use usage_rs::Args;
12
13use usage::parse::{ParseOutput, ParseValue, TokenRole};
14use usage::sh::sh;
15use usage::spec::config::SpecConfigProp;
16use usage::spec::config_type::{Base, SpecConfigType};
17use usage::{
18    Spec, SpecArg, SpecCommand, SpecComplete, SpecDoubleDashChoices, SpecFlag, UnknownFlags,
19};
20
21use crate::cli::{complete_delegate, generate};
22
23static COMPLETER_TERA: LazyLock<tera::Tera> = LazyLock::new(|| {
24    let mut tera = tera::Tera::default();
25    tera.register_filter(
26        "shell_quote",
27        |value: &tera::Value, _: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
28            let value = value
29                .as_str()
30                .ok_or_else(|| tera::Error::message("shell_quote expects a string"))?;
31            Ok(shell_words::quote(value).into_owned())
32        },
33    );
34    tera.register_filter(
35        "shell_join",
36        |value: &tera::Value, _: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
37            let values = value
38                .as_array()
39                .ok_or_else(|| tera::Error::message("shell_join expects a list of strings"))?;
40            let words = values
41                .iter()
42                .map(|value| {
43                    value
44                        .as_str()
45                        .ok_or_else(|| tera::Error::message("shell_join expects a list of strings"))
46                })
47                .collect::<tera::TeraResult<Vec<_>>>()?;
48            Ok(shell_words::join(words))
49        },
50    );
51    tera
52});
53
54fn render_completer_run(run: &str, ctx: &tera::Context) -> tera::TeraResult<String> {
55    COMPLETER_TERA.render_str(run, ctx, false)
56}
57
58/// Generate shell completion candidates for a partial command line
59///
60/// What the scripts from `usage generate completion` run on every Tab: they pass the words
61/// typed so far and read back one candidate per line. Useful by hand, too, for checking what
62/// a spec offers at a given point without installing anything.
63#[derive(Debug, Args)]
64#[usage(alias = "cw", effect = "read")]
65pub struct CompleteWord {
66    /// The words typed so far, starting with the program name
67    words: Vec<String>,
68
69    /// A usage spec file, or a script with a usage shebang; "-" reads stdin
70    #[usage(short, long)]
71    file: Option<PathBuf>,
72
73    /// The spec itself, as a string, instead of a file
74    #[usage(short, long, required_unless = "--file", overrides = "--file")]
75    spec: Option<String>,
76
77    /// Index of the word being completed; defaults to the last word
78    #[usage(long)]
79    cword: Option<usize>,
80
81    /// The shell the candidates are for, which decides how they are quoted
82    #[usage(
83        long,
84        default = "bash",
85        choices("bash", "fish", "nu", "powershell", "zsh")
86    )]
87    shell: String,
88}
89
90/// The candidates for a partially-typed command line, as data rather than as printed lines.
91///
92/// A seam for the conformance comparison: usage-argv computes the same list from compiled
93/// tables, and "the same" is only a checkable claim if this side's answer can be read instead
94/// of watched going past on stdout.
95pub fn candidates(
96    spec: &Spec,
97    words: &[String],
98    cword: usize,
99    shell: &str,
100) -> usage::miette::Result<Vec<(String, String)>> {
101    Ok(answer(spec, words, cword, shell)?.candidates)
102}
103
104/// The reference implementation's candidates and whether they came from its path fallback.
105///
106/// `candidates` keeps returning the concrete paths the CLI has always printed. Conformance
107/// needs the extra bit because a portable corpus can say "files belong here" but cannot pin
108/// whichever files happen to be in the checkout running it.
109#[derive(Debug, PartialEq, Eq)]
110pub struct CandidateAnswer {
111    /// The concrete values the CLI would print for the shell.
112    pub candidates: Vec<(String, String)>,
113    /// Whether the CLI generated those values by scanning the filesystem.
114    pub files: bool,
115}
116
117/// Complete a partial command line while preserving path-fallback metadata.
118pub fn answer(
119    spec: &Spec,
120    words: &[String],
121    cword: usize,
122    shell: &str,
123) -> usage::miette::Result<CandidateAnswer> {
124    CompleteWord {
125        words: words.to_vec(),
126        file: None,
127        spec: None,
128        cword: Some(cword),
129        shell: shell.to_string(),
130    }
131    .complete_word_answer(spec)
132}
133
134impl CompleteWord {
135    pub fn complete_word(&self, spec: &Spec) -> usage::miette::Result<Vec<(String, String)>> {
136        Ok(self.complete_word_answer(spec)?.candidates)
137    }
138
139    fn complete_word_answer(&self, spec: &Spec) -> usage::miette::Result<CandidateAnswer> {
140        let cword = self.cword.unwrap_or(self.words.len().max(1) - 1);
141        let ctoken = self.words.get(cword).cloned().unwrap_or_default();
142        let words: Vec<_> = self.words.iter().take(cword).cloned().collect();
143
144        trace!(
145            "cword: {cword} ctoken: {ctoken} words: {}",
146            words.iter().join(" ")
147        );
148
149        let mut ctx = tera::Context::new();
150        ctx.insert("words", &self.words);
151        ctx.insert("CURRENT", &cword);
152        if cword > 0 {
153            ctx.insert("PREV", &(cword - 1));
154        }
155
156        let parsed = usage::parse::parse_partial(spec, &words)?;
157        debug!("parsed cmd: {}", parsed.cmd.full_cmd.join(" "));
158
159        // Past an `external_subcommand` catch-all the cursor is inside another program's line.
160        // The command that declared the catch-all still has subcommands, flags and an unfilled
161        // positional to offer, and every one of them would describe the wrong CLI — so would
162        // the working directory, which claims paths belong somewhere only that program knows.
163        // Whoever knows what the external name means answers from there; this spec does not.
164        if parsed.external.is_some() {
165            return Ok(CandidateAnswer {
166                candidates: vec![],
167                files: false,
168            });
169        }
170
171        // Check if previous token was a restart_token - if so, complete from the first
172        // ordinary arg. Sigil arguments are overlays and do not occupy that cursor.
173        let prev_token = if cword > 0 {
174            self.words.get(cword - 1).map(|s| s.as_str())
175        } else {
176            None
177        };
178        let after_restart_token = parsed
179            .cmd
180            .restart_token
181            .as_ref()
182            .is_some_and(|rt| prev_token == Some(rt.as_str()))
183            || parsed
184                .cmd
185                .clause
186                .as_ref()
187                .and_then(|clause| clause.separator.as_deref())
188                .is_some_and(|separator| prev_token == Some(separator));
189
190        let cx = Ctx {
191            tera: &ctx,
192            spec,
193            parsed: &parsed,
194            after_restart_token,
195        };
196        let mut has_explicit_choices = false;
197        // Not `available_flags`: inside a mounted command, the mounting CLI's flags stay
198        // recognized for parsing but are not accepted there, so they must not be offered.
199        let mut flags = parsed.completion_flags();
200        // Before the default command's flags: the parser answers a supplied `--help` ahead of
201        // choosing the default command, so a hidden `--help` of its own does not take it.
202        Self::add_supplied_flags(spec, &parsed, &mut flags);
203        if spec.default_subcommand_flags
204            && parsed.cmds.len() == 1
205            && let Some(default) = spec
206                .default_subcommand
207                .as_deref()
208                .and_then(|name| spec.cmd.find_subcommand(name))
209        {
210            for flag in &default.flags {
211                let flag = Arc::new(flag.clone());
212                for key in flag
213                    .long
214                    .iter()
215                    .map(|name| format!("--{name}"))
216                    .chain(flag.short.iter().map(|name| format!("-{name}")))
217                    .chain(flag.negate.iter().cloned())
218                {
219                    flags.entry(key).or_insert_with(|| Arc::clone(&flag));
220                }
221            }
222        }
223        // An explicit `--` stops the parser reading flags, so past one there is no such thing
224        // as a flag to complete — a dash-prefixed word is a positional value.
225        let restart_seen = parsed.tokens.iter().any(|token| {
226            token
227                .roles
228                .iter()
229                .any(|role| matches!(role, usage::parse::TokenRole::Restart))
230        });
231        let automatic_trailing_seen = parsed
232            .tokens
233            .iter()
234            .rev()
235            .take_while(|token| {
236                !token.roles.iter().any(|role| {
237                    matches!(
238                        role,
239                        usage::parse::TokenRole::Restart
240                            | usage::parse::TokenRole::ClauseSeparator { .. }
241                    )
242                })
243            })
244            .flat_map(|token| &token.roles)
245            .any(|role| match role {
246                usage::parse::TokenRole::Arg { arg, .. }
247                | usage::parse::TokenRole::Sigil { arg, .. } => {
248                    arg.double_dash == usage::SpecDoubleDashChoices::Automatic
249                }
250                _ => false,
251            });
252        let flags_possible = !parsed.double_dash_seen && !automatic_trailing_seen;
253        let sigil_arg = (flags_possible
254            && !restart_seen
255            && !after_restart_token
256            && parsed.flag_awaiting_value.is_empty())
257        .then(|| {
258            parsed
259                .cmds
260                .iter()
261                .flat_map(|cmd| cmd.args.iter().map(move |arg| (cmd, arg)))
262                .filter_map(|(cmd, arg)| {
263                    let sigil = arg.sigil.as_deref()?;
264                    ctoken
265                        .strip_prefix(sigil)
266                        .map(|prefix| (cmd, arg, sigil, prefix))
267                })
268                .max_by_key(|(_, _, sigil, _)| sigil.len())
269        })
270        .flatten();
271        let attached_long_value = flags_possible
272            .then(|| Self::attached_long_value(&flags, &ctoken))
273            .flatten();
274        let mut used_file_fallback = false;
275        let mut choices = if flags_possible && ctoken == "-" {
276            let shorts = self.complete_short_flag_names(&flags, "");
277            let longs = self.complete_long_flag_names(&flags, "");
278            shorts.into_iter().chain(longs).collect::<Vec<_>>()
279        } else if flags_possible && ctoken.starts_with("--") {
280            if let Some((flag, form, prefix)) = attached_long_value {
281                let arg = flag.arg.as_ref().unwrap();
282                // Dynamic completers inspect `words[CURRENT]`; expose the value fragment just
283                // like sigil completion does, since the flag prefix is handled separately.
284                let mut attached_ctx = ctx.clone();
285                let mut attached_words = self.words.clone();
286                if let Some(current) = attached_words.get_mut(cword) {
287                    *current = prefix.to_string();
288                }
289                attached_ctx.insert("words", &attached_words);
290                let attached_cx = Ctx {
291                    tera: &attached_ctx,
292                    spec: cx.spec,
293                    parsed: cx.parsed,
294                    after_restart_token: cx.after_restart_token,
295                };
296                let (mut found, closed) =
297                    self.complete_arg(&attached_cx, &parsed.cmd, arg, prefix)?;
298                has_explicit_choices = closed || arg.choices.is_some();
299                if found.is_empty() && !has_explicit_choices {
300                    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
301                    found = self
302                        .complete_path(&cwd, prefix, |_| true)
303                        .into_iter()
304                        .map(|name| (name, String::new()))
305                        .collect();
306                    used_file_fallback = true;
307                }
308                Self::attach_long_value_candidates(&mut found, form);
309                found
310            } else {
311                self.complete_long_flag_names(&flags, &ctoken)
312            }
313        } else if flags_possible && ctoken.starts_with('-') {
314            self.complete_short_flag_names(&flags, &ctoken)
315        } else if after_restart_token {
316            // After a restart_token, complete from the first ordinary arg of the current command
317            // This must be checked after flag checks (to allow --flag after :::)
318            // but before flag_awaiting_value (since restart clears pending flag values)
319            let mut choices = vec![];
320            if let Some(arg) = first_active_arg(&parsed.cmd) {
321                let (found, constrained) = self.complete_positional(
322                    &cx,
323                    &parsed.cmd,
324                    arg,
325                    &ctoken,
326                    parsed.double_dash_seen,
327                )?;
328                has_explicit_choices = constrained;
329                choices.extend(found);
330            }
331            choices
332        } else if let Some(flag) = parsed.flag_awaiting_value.first() {
333            let arg = flag.arg.as_ref().unwrap();
334            let owner = flag_value_owner(&parsed, flag);
335            let (found, closed) = self.complete_arg(&cx, owner, arg, &ctoken)?;
336            has_explicit_choices = closed || arg.choices.is_some();
337            found
338        } else if let Some((owner, arg, sigil, prefix)) = sigil_arg {
339            // A dynamic completer and the candidate filter must see the same word. The
340            // parser removes a sigil before binding its value, so expose that stripped
341            // value through `words[CURRENT]` too; the prefix is restored only after the
342            // completer has answered.
343            let mut sigil_ctx = ctx.clone();
344            let mut sigil_words = self.words.clone();
345            if let Some(current) = sigil_words.get_mut(cword) {
346                *current = prefix.to_string();
347            }
348            sigil_ctx.insert("words", &sigil_words);
349            let sigil_cx = Ctx {
350                tera: &sigil_ctx,
351                spec: cx.spec,
352                parsed: cx.parsed,
353                after_restart_token: cx.after_restart_token,
354            };
355            let (mut found, closed) = self.complete_arg(&sigil_cx, owner, arg, prefix)?;
356            for (candidate, _) in &mut found {
357                candidate.insert_str(0, sigil);
358            }
359            has_explicit_choices = closed || arg.choices.is_some() || found.is_empty();
360            found
361        } else {
362            let mut choices = vec![];
363            if let Some(arg) = parsed.next_arg.as_deref() {
364                let (found, constrained) = self.complete_positional(
365                    &cx,
366                    &parsed.cmd,
367                    arg,
368                    &ctoken,
369                    parsed.double_dash_seen,
370                )?;
371                has_explicit_choices = constrained;
372                choices.extend(found);
373            }
374            if !parsed.cmd.subcommands.is_empty() {
375                choices.extend(self.complete_subcommands(&parsed.cmd, &ctoken));
376            }
377            // If at root command with default_subcommand, also include completions from it
378            if parsed.cmd.name == spec.cmd.name
379                && let Some(default_name) = &spec.default_subcommand
380                && let Some(default_cmd) = spec.cmd.find_subcommand(default_name)
381            {
382                // Include completions from default subcommand's first ordinary arg.
383                //
384                // The `constrained` half is dropped on purpose: unlike the two call
385                // sites above, this arg belongs to a *different* command and is only
386                // a guess that the user means to elide the subcommand name. Letting
387                // its choices set `has_explicit_choices` would suppress the root
388                // command's own file fallback whenever the token failed to match
389                // them — see `complete_word_default_subcommand_choices_do_not_block_
390                // root_file_fallback`. The `double_dash="required"` rule does apply,
391                // which is why this goes through the helper at all.
392                if let Some(arg) = first_active_arg(default_cmd) {
393                    let (found, _) = self.complete_positional(
394                        &cx,
395                        default_cmd,
396                        arg,
397                        &ctoken,
398                        parsed.double_dash_seen,
399                    )?;
400                    choices.extend(found);
401                }
402            }
403            choices
404        };
405        if flags_possible
406            && attached_long_value.is_none()
407            && sigil_arg.is_none()
408            && let Some(arg) = self.delegated_arg_at_flag(&cx, &ctoken)
409        {
410            let (found, _) = self.complete_arg(&cx, &parsed.cmd, arg, &ctoken)?;
411            for candidate in found {
412                if !choices.iter().any(|(name, _)| *name == candidate.0) {
413                    choices.push(candidate);
414                }
415            }
416        }
417        // Fallback to file completions if nothing is known about this argument and it's not a
418        // flag. Past a `--` a dash-prefixed word is not a flag but a value, so a path like
419        // `-input` still gets completed there.
420        let looks_like_a_flag = flags_possible && ctoken.starts_with('-');
421        let files = used_file_fallback
422            || (choices.is_empty() && !looks_like_a_flag && !has_explicit_choices);
423        if files && choices.is_empty() {
424            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
425            let files = self.complete_path(&cwd, &ctoken, |_| true);
426            choices = files.into_iter().map(|n| (n, String::new())).collect();
427        }
428        trace!("choices: {}", choices.iter().map(|(c, _)| c).join(", "));
429        Ok(CandidateAnswer {
430            candidates: choices,
431            files,
432        })
433    }
434
435    fn complete_subcommands(&self, cmd: &SpecCommand, ctoken: &str) -> Vec<(String, String)> {
436        trace!("complete_subcommands: {ctoken}");
437        let mut choices = vec![];
438        for subcommand in cmd.subcommands.values() {
439            if subcommand.hide {
440                continue;
441            }
442            choices.push((
443                subcommand.name.clone(),
444                subcommand.help.clone().unwrap_or_default(),
445            ));
446            for alias in &subcommand.aliases {
447                choices.push((alias.clone(), subcommand.help.clone().unwrap_or_default()));
448            }
449        }
450        choices
451            .into_iter()
452            .filter(|(c, _)| c.starts_with(ctoken))
453            .sorted()
454            .collect()
455    }
456
457    /// The flags the parser answers to without a declaration: `--help`, and `--version` on
458    /// the root of a spec that has a version.
459    ///
460    /// Under the rules `parse` follows (`is_help_arg`, `is_version_arg`), and only for a
461    /// spelling no declared flag took — a spec that spends `-V` on something else keeps it,
462    /// and still offers `--version` beside it.
463    fn add_supplied_flags(
464        spec: &Spec,
465        parsed: &usage::parse::ParseOutput,
466        flags: &mut BTreeMap<String, Arc<SpecFlag>>,
467    ) {
468        let mut supply = |name: &str, help: &str, long: Option<&str>, short: Option<char>| {
469            let key = match (long, short) {
470                (Some(long), _) => format!("--{long}"),
471                (_, Some(short)) => format!("-{short}"),
472                _ => return,
473            };
474            // `available_flags` as well: a host global stays bound inside a mount even where it
475            // is not offered, and so keeps its spelling.
476            if flags.contains_key(&key) || parsed.available_flags.contains_key(&key) {
477                return;
478            }
479            let mut flag = SpecFlag::default();
480            flag.name = name.to_string();
481            flag.help = Some(help.to_string());
482            flag.long = long.map(str::to_string).into_iter().collect();
483            flag.short = short.into_iter().collect();
484            flag.usage = flag.usage();
485            flags.insert(key, Arc::new(flag));
486        };
487        if spec.disable_help != Some(true) && !parsed.cmd.disable_help_flag {
488            supply("help", "Print help", Some("help"), None);
489            supply("help", "Print help", None, Some('h'));
490        }
491        if (spec.version.is_some() || spec.long_version.is_some())
492            && parsed.cmds.len() == 1
493            && !spec.cmd.disable_version_flag
494        {
495            supply("version", "Print version", Some("version"), None);
496            supply("version", "Print version", None, Some('V'));
497        }
498    }
499
500    fn complete_long_flag_names(
501        &self,
502        flags: &BTreeMap<String, Arc<SpecFlag>>,
503        ctoken: &str,
504    ) -> Vec<(String, String)> {
505        debug!("complete_long_flag_names: {ctoken}");
506        trace!("flags: {}", flags.keys().join(", "));
507        flags
508            .values()
509            .filter(|f| !f.hide)
510            .flat_map(|f| {
511                let mut flags = f
512                    .long
513                    .iter()
514                    .filter(|long| !f.hidden_aliases.contains(long))
515                    .map(|l| (format!("--{l}"), f.help.clone().unwrap_or_default()))
516                    .collect::<Vec<_>>();
517                if let Some(negate) = &f.negate {
518                    flags.push((negate.clone(), String::new()))
519                }
520                flags
521            })
522            .unique_by(|(f, _)| f.to_string())
523            .filter(|(f, _)| f.starts_with(ctoken))
524            // TODO: get flag description
525            .sorted()
526            .collect()
527    }
528
529    fn complete_short_flag_names(
530        &self,
531        flags: &BTreeMap<String, Arc<SpecFlag>>,
532        ctoken: &str,
533    ) -> Vec<(String, String)> {
534        debug!("complete_short_flag_names: {ctoken}");
535        let cur = ctoken.chars().nth(1);
536        flags
537            .values()
538            .flat_map(|f| f.short.iter().copied())
539            .unique()
540            .filter(|short| cur.is_none() || cur == Some(*short))
541            .filter_map(|short| {
542                let owner = flags.get(&format!("-{short}"))?;
543                if owner.hide || owner.hidden_short_aliases.contains(&short) {
544                    return None;
545                }
546                Some((short, owner.help.clone().unwrap_or_default()))
547            })
548            .map(|(short, help)| (format!("-{short}"), help))
549            .sorted()
550            .collect()
551    }
552
553    /// A value being typed in the same word as its long flag.
554    ///
555    /// The shell replaces the whole word, so callers complete against the fragment after `=`
556    /// and then put the flag spelling back on each candidate.
557    fn attached_long_value<'f, 't>(
558        flags: &'f BTreeMap<String, Arc<SpecFlag>>,
559        token: &'t str,
560    ) -> Option<(&'f SpecFlag, &'t str, &'t str)> {
561        let (form, prefix) = token.split_once('=')?;
562        let long = form.strip_prefix("--")?;
563        flags
564            .values()
565            .find(|flag| flag.arg.is_some() && flag.long.iter().any(|candidate| candidate == long))
566            .map(|flag| (flag.as_ref(), form, prefix))
567    }
568
569    fn attach_long_value_candidates(candidates: &mut [(String, String)], form: &str) {
570        for (candidate, _) in candidates {
571            *candidate = format!("{form}={candidate}");
572        }
573    }
574
575    /// Completions for a reserved `type=`, and whether the set they came from is *closed*.
576    ///
577    /// Closed means an unmatched prefix has no completions at all, rather than falling
578    /// through to the file fallback: there is a known set of settings, and `config set
579    /// log_leve<TAB>` offering the contents of the working directory is worse than offering
580    /// nothing. `file`/`path`/`dir` are the opposite — they *are* the fallback — so they stay
581    /// open and an empty result there means only that the directory had no match.
582    fn complete_builtin(
583        &self,
584        cx: &Ctx<'_>,
585        type_: &str,
586        ctoken: &str,
587    ) -> (Vec<(String, String)>, bool) {
588        if let Some(encoded) = type_
589            .strip_prefix("path:")
590            .or_else(|| type_.strip_prefix("file:"))
591        {
592            let extensions = encoded
593                .split(',')
594                .map(|extension| {
595                    extension
596                        .trim()
597                        .trim_start_matches('.')
598                        .to_ascii_lowercase()
599                })
600                .filter(|extension| !extension.is_empty())
601                .collect::<Vec<_>>();
602            if !extensions.is_empty() {
603                let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
604                let paths = self.complete_path(&cwd, ctoken, |path| {
605                    path.is_dir()
606                        || path
607                            .file_name()
608                            .and_then(|name| name.to_str())
609                            .is_some_and(|name| {
610                                let name = name.to_ascii_lowercase();
611                                extensions
612                                    .iter()
613                                    .any(|wanted| name.ends_with(&format!(".{wanted}")))
614                            })
615                });
616                return (
617                    paths
618                        .into_iter()
619                        .map(|value| (value, String::new()))
620                        .collect(),
621                    true,
622                );
623            }
624        }
625        // The two config completers describe values, so they carry their own descriptions
626        // rather than going through the path branch's empty ones.
627        match type_ {
628            "config_keys" => return (self.complete_config_keys(cx.spec, ctoken), true),
629            "config_values" => return self.complete_config_values(cx, ctoken),
630            "executable" => {
631                let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
632                return (
633                    self.complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
634                        .into_iter()
635                        .map(|value| (value, String::new()))
636                        .collect(),
637                    true,
638                );
639            }
640            "command" => return (self.complete_commands(ctoken), true),
641            "username" => return (complete_usernames(ctoken), true),
642            "hostname" => return (complete_hostnames(ctoken), true),
643            "none" | "url" | "email" => return (vec![], true),
644            // `Unknown` asks for the shell's normal fallback, so this stays open.
645            "unknown" => {}
646            "command_args" => {
647                let command_was_bound = cx.parsed.next_arg.as_ref().is_some_and(|next| {
648                    // `parse_partial` records the cursor with a fresh `Arc` around a
649                    // cloned argument, so pointer identity cannot connect it to the
650                    // separately cloned map key. Argument names are the stable identity
651                    // within a command (and the one `SpecArg` equality uses).
652                    cx.parsed
653                        .args
654                        .keys()
655                        .any(|bound| bound.as_ref() == next.as_ref())
656                });
657                if cx.after_restart_token || !command_was_bound {
658                    return (self.complete_commands(ctoken), true);
659                }
660            }
661            _ => {}
662        }
663        let names = match (type_, env::current_dir()) {
664            ("path" | "file", Ok(cwd)) => self.complete_path(&cwd, ctoken, |_| true),
665            ("dir", Ok(cwd)) => self.complete_path(&cwd, ctoken, |p| p.is_dir()),
666            // ("file", Ok(cwd)) => self.complete_path(&cwd, ctoken, |p| p.is_file()),
667            _ => vec![],
668        };
669        (
670            names.into_iter().map(|n| (n, String::new())).collect(),
671            false,
672        )
673    }
674
675    /// The settings a `config` block declares, for the key argument of a `config get`/`set`.
676    ///
677    /// Every CLI in the fleet writes this by hand — a `run=` shell command that asks the
678    /// binary for its own settings list. Declared as `type="config_keys"`, the spec already
679    /// says what the keys are, so the completion needs no subprocess.
680    ///
681    /// `hide` filters here, which is what distinguishes this from the JSON schema: a hidden
682    /// setting is still settable, so a schema must accept it, but nothing should suggest it.
683    fn complete_config_keys(&self, spec: &Spec, ctoken: &str) -> Vec<(String, String)> {
684        spec.config
685            .props
686            .iter()
687            .filter(|(_, prop)| !prop.hide)
688            .filter(|(key, _)| key.starts_with(ctoken))
689            .map(|(key, prop)| {
690                let help = one_line(prop.help.as_deref());
691                let help = help.as_str();
692                // Still offered when deprecated — it remains settable, and a config file in
693                // the wild still names it — but never without saying so.
694                let description = match &prop.deprecated {
695                    Some(_) if help.is_empty() => "deprecated".to_string(),
696                    Some(_) => format!("deprecated — {help}"),
697                    None => help.to_string(),
698                };
699                (key.clone(), description)
700            })
701            .collect()
702    }
703
704    /// The values the setting named earlier on the command line accepts.
705    ///
706    /// Its `choices` when it declares them, each with its own help; `true`/`false` for a
707    /// boolean. Anything else returns nothing, which lets the file fallback do the obvious
708    /// thing for a path-valued setting.
709    fn complete_config_values(&self, cx: &Ctx<'_>, ctoken: &str) -> (Vec<(String, String)>, bool) {
710        let Some(prop) = self.config_key_before_cursor(cx) else {
711            // Not a setting at all, so there is nothing to be authoritative about and the
712            // usual fallback is as good an answer as any.
713            return (vec![], false);
714        };
715        if !prop.choices.is_empty() {
716            return (
717                prop.choices
718                    .iter()
719                    .map(|choice| (choice.value.display(), one_line(choice.help.as_deref())))
720                    .filter(|(value, _)| value.starts_with(ctoken))
721                    .collect(),
722                // Closed: a spec that lists `choices` is declaring what the setting accepts,
723                // whatever its base type says. `mise`'s `python.uv_venv_auto` is `bool|string`
724                // and lists all four of its values.
725                true,
726            );
727        }
728        // Any boolean anywhere in the type, so `string|bool` behaves like `bool|string`:
729        // `simplified()` returns a union's *first* member, which made the two words appear or
730        // not depending on the order the spec happened to list them in.
731        if holds_a_boolean(&prop.value_type.clone().unwrap_or_default()) {
732            let declared = prop.value_type.clone().unwrap_or_default();
733            return (
734                ["false", "true"]
735                    .into_iter()
736                    .filter(|value| value.starts_with(ctoken))
737                    .map(|value| (value.to_string(), String::new()))
738                    .collect(),
739                // `bool|path` accepts both words *and* any path, so the two words are worth
740                // offering but they are not the whole set: claiming they were meant a prefix
741                // like `src/` completed to nothing at all.
742                !accepts_unenumerable_values(&declared),
743            );
744        }
745        // A path, a number, free text: the spec does not enumerate what belongs here, so the
746        // file fallback is left to do what it does for any other unconstrained argument.
747        (vec![], false)
748    }
749
750    /// The setting a `config_values` completion is for: the most recent *positional* value
751    /// before the cursor that names one.
752    ///
753    /// Taken from the parser's own bindings rather than by scanning the raw words, because the
754    /// key's place on the line is the CLI's business — `config set jobs 4`,
755    /// `config --global set jobs 4` and `config set --toml jobs 4` all have it somewhere
756    /// different — and a raw scan cannot tell a positional from the value of a flag. Given
757    /// `config set jobs --tag color <TAB>`, scanning words found `color`, a setting in its own
758    /// right, and offered its booleans as though the user were setting it.
759    fn config_key_before_cursor<'a>(&self, cx: &Ctx<'a>) -> Option<&'a SpecConfigProp> {
760        // The argument the *spec* says holds a key — the one completed with `config_keys` —
761        // rather than whichever positional happens to name a setting. Both guesses were wrong
762        // in their own direction: scanning backwards took a variadic's own last value
763        // (`set-many log_level color <TAB>` offered `color`'s booleans), and scanning forwards
764        // would take an unrelated positional that happened to name one. The spec already says
765        // which argument is which, so there is nothing to guess.
766        cx.parsed
767            .args
768            .iter()
769            .filter(|(arg, _)| self.completer_type(cx, arg) == Some("config_keys"))
770            .filter_map(|(_, value)| match value {
771                ParseValue::String(word) => Some(word.as_str()),
772                ParseValue::MultiString(words) => words.last().map(String::as_str),
773                ParseValue::Bool(_) | ParseValue::MultiBool(_) => None,
774            })
775            // The nearest one, for a command with more than one key argument — the same rule as
776            // the last element of a variadic. Taking the first offered values for whichever key
777            // came earliest on the line.
778            //
779            // No filter for "did the user type this": a key argument bound from its `default=`
780            // rather than from the line would win the nearest-wins rule below, but a partial
781            // parse does not produce such a binding — measured against a spec shaped exactly
782            // that way, with the defaulted argument *after* the value being completed, where
783            // the guard would have been the only thing standing between them. Unreachable code
784            // in a completion path is worse than the case it defends against;
785            // `complete_word_the_key_is_the_argument_the_spec_says_holds_one` pins the
786            // behaviour so that if a partial parse ever starts filling defaults, this fails.
787            //
788            // Only the nearest: looking further back when it names no setting
789            // offered another key's values for a line whose own key is a typo, where an unknown
790            // key on its own correctly offers nothing.
791            .next_back()
792            .and_then(|word| resolve_config_key(&cx.spec.config, word))
793    }
794
795    /// The reserved `type=` of the completer for an argument, if it has one.
796    fn completer_type<'b>(&self, cx: &'b Ctx<'_>, arg: &'b SpecArg) -> Option<&'b str> {
797        cx.spec
798            .completer(&cx.parsed.cmd, arg)
799            .and_then(|complete| complete.type_.as_deref())
800    }
801
802    /// Completions for a positional argument, under the rule the parser enforces for
803    /// `double_dash="required"`: nothing reaches such an argument until an explicit `--` has
804    /// been typed, so before that the separator is the only useful candidate.
805    ///
806    /// Every path that completes a positional goes through here — the one at the parser's
807    /// cursor, the first argument after a `restart_token`, and the default subcommand's — so
808    /// the rule cannot be honoured in one and forgotten in another.
809    ///
810    /// The second return value says whether the argument constrains what may go there, which
811    /// is what suppresses the file-path fallback.
812    fn complete_positional(
813        &self,
814        cx: &Ctx<'_>,
815        cmd: &SpecCommand,
816        arg: &SpecArg,
817        ctoken: &str,
818        double_dash_seen: bool,
819    ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
820        if arg.double_dash == SpecDoubleDashChoices::Required && !double_dash_seen {
821            // No filename is valid here either, so the fallback stays off.
822            let separator = ctoken.is_empty().then(|| ("--".to_string(), String::new()));
823            return Ok((separator.into_iter().collect(), true));
824        }
825        let (found, closed) = self.complete_arg(cx, cmd, arg, ctoken)?;
826        Ok((found, closed || arg.choices.is_some()))
827    }
828
829    fn complete_arg(
830        &self,
831        cx: &Ctx<'_>,
832        cmd: &SpecCommand,
833        arg: &SpecArg,
834        ctoken: &str,
835    ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
836        static EMPTY_COMPL: LazyLock<SpecComplete> = LazyLock::new(SpecComplete::default);
837
838        trace!("complete_arg: {arg} {ctoken}");
839        let complete = cx.spec.completer(cmd, arg).unwrap_or(&EMPTY_COMPL);
840        if let Some(type_) = complete.type_.as_deref() {
841            // An explicitly declared closed completer answers even when its answer is nothing:
842            // it knows the whole set of candidates, so an unmatched prefix means no matches
843            // rather than "ask somebody else".
844            let (builtin, closed) = self.complete_builtin(cx, type_, ctoken);
845            if !builtin.is_empty() || closed {
846                return Ok((builtin, closed));
847            }
848        }
849
850        if let Some(choices) = &arg.choices {
851            // Declared values and whatever `run=` prints, as one list: a `choices run=` is
852            // the same command a `complete run=` would be, with help and validation to match.
853            // A command that fails still leaves the declared values to offer, and its error
854            // goes to the trace log rather than over the prompt.
855            let values = choices.resolved_values(None).unwrap_or_else(|err| {
856                trace!("choices run= failed: {err}");
857                choices.values()
858            });
859            return Ok((
860                values
861                    .into_iter()
862                    .filter(|c| c.starts_with(ctoken))
863                    .map(|value| {
864                        // The description a shell shows beside a candidate. `details`
865                        // has carried per-choice help since choices grew a long form,
866                        // and nothing here read it — so `--format <TAB>` offered bare
867                        // words while the spec had "One report object" written down.
868                        let help = choices
869                            .details
870                            .iter()
871                            .find(|detail| detail.value == value)
872                            .and_then(|detail| detail.help.clone())
873                            .unwrap_or_default();
874                        (value, help)
875                    })
876                    .collect(),
877                true,
878            ));
879        }
880        if let Some(delegate) = &complete.delegate {
881            let found = self
882                .complete_delegated(cx, arg, delegate, ctoken)
883                .into_iter()
884                .filter_map(|candidate| whole_word_candidate(candidate, ctoken))
885                .collect();
886            // Open, like `run`: the other program saying nothing is not a claim that nothing
887            // belongs here, and with bash's `-o default` it would have fallen back to files too.
888            return Ok((found, false));
889        }
890        if let Some(run) = &complete.run {
891            let run = render_completer_run(run, cx.tera).into_diagnostic()?;
892            trace!("run: {run}");
893            let stdout = sh(&run)?;
894            // trace!("stdout: {stdout}");
895            static DESCRIPTION_SEPARATOR: LazyLock<Regex> =
896                LazyLock::new(|| Regex::new(r"[^\\]:").unwrap());
897            let re = &*DESCRIPTION_SEPARATOR;
898            return Ok((
899                stdout
900                    .lines()
901                    .map(|l| {
902                        if complete.descriptions {
903                            match re.find(l).map(|m| l.split_at(m.end() - 1)) {
904                                Some((l, d)) if d.len() <= 1 => {
905                                    (l.trim().replace("\\:", ":"), String::new())
906                                }
907                                Some((l, d)) => (
908                                    l.trim().replace("\\:", ":"),
909                                    d[1..].trim().replace("\\:", ":"),
910                                ),
911                                None => (l.trim().replace("\\:", ":"), String::new()),
912                            }
913                        } else {
914                            (l.trim().to_string(), String::new())
915                        }
916                    })
917                    .filter(|(name, _)| name.starts_with(ctoken))
918                    .collect(),
919                // Left open, as it always was: a script that prints nothing may simply have
920                // had nothing to say about this prefix.
921                false,
922            ));
923        }
924
925        // Argument-name inference is only a fallback. An existing spec may legitimately name
926        // an argument `url`, `email`, `username`, or another reserved word and attach `run=`;
927        // treating the inferred type as explicit would close the set before that command ran.
928        // The same is true in the other direction: an explicitly declared open type such as
929        // `command_args` must not fall through to a different builtin inferred from its name.
930        if complete.type_.is_none() {
931            let (builtin, closed) = self.complete_builtin(cx, &arg.name.to_lowercase(), ctoken);
932            if !builtin.is_empty() || closed {
933                return Ok((builtin, closed));
934            }
935        }
936
937        Ok((vec![], false))
938    }
939
940    /// Ask the shell what `delegate` would complete, given the words `arg` already holds.
941    fn complete_delegated(
942        &self,
943        cx: &Ctx<'_>,
944        arg: &SpecArg,
945        delegate: &str,
946        ctoken: &str,
947    ) -> Vec<(String, String)> {
948        // Validated when the spec was parsed; a spec built in code can still carry bad quoting.
949        let Ok(mut words) = usage::shell_words::split(delegate) else {
950            return vec![];
951        };
952        words.extend(delegated_words(cx.parsed, arg));
953        words.push(ctoken.to_string());
954        trace!("delegate: {}", words.iter().join(" "));
955        complete_delegate::complete(&self.shell, &words)
956    }
957
958    /// The delegated argument whose command line a dash-prefixed word at the cursor continues.
959    ///
960    /// The flag branches answer with this CLI's own flags, which is right until the delegated
961    /// argument has started: after `wrapper layer1 plan`, `-o` names a flag of the wrapped
962    /// command, and the parser agrees — a flag this CLI does not declare binds to that argument
963    /// like any other word. This CLI's flags stay on offer beside the delegated ones, because
964    /// the parser still gives those to this CLI.
965    fn delegated_arg_at_flag<'a>(&self, cx: &Ctx<'a>, ctoken: &str) -> Option<&'a SpecArg> {
966        if !ctoken.starts_with('-') || !cx.parsed.flag_awaiting_value.is_empty() {
967            return None;
968        }
969        let unknown_flags = cx
970            .parsed
971            .cmds
972            .iter()
973            .rev()
974            .find_map(|cmd| cmd.unknown_flags)
975            .or(cx.spec.unknown_flags)
976            .unwrap_or_default();
977        if unknown_flags != UnknownFlags::Value {
978            return None;
979        }
980        let arg = cx.parsed.next_arg.as_deref()?;
981        let complete = cx.spec.completer(&cx.parsed.cmd, arg)?;
982        (complete.delegate.is_some() && !delegated_words(cx.parsed, arg).is_empty()).then_some(arg)
983    }
984
985    fn complete_path(
986        &self,
987        base: &Path,
988        ctoken: &str,
989        filter: impl Fn(&Path) -> bool,
990    ) -> Vec<String> {
991        trace!("complete_path: {ctoken}");
992        let separator = rendered_separator(ctoken);
993        let path = PathBuf::from(ctoken);
994        let exact = if path.is_absolute() {
995            path.clone()
996        } else {
997            base.join(&path)
998        };
999        // A slash means "show this directory's children" only after the directory itself is
1000        // exact. For an abbreviated segment (`tar/` or `target/de/`), Path still exposes the
1001        // final non-empty component as `file_name`; keep completing that component first.
1002        let trailing_separator = (ctoken.ends_with(std::path::MAIN_SEPARATOR)
1003            || (cfg!(windows) && ctoken.ends_with('/')))
1004            && exact.is_dir();
1005        let (parent, prefix) = if trailing_separator {
1006            (path.as_path(), "")
1007        } else {
1008            (
1009                path.parent().unwrap_or_else(|| Path::new("")),
1010                path.file_name()
1011                    .unwrap_or_default()
1012                    .to_str()
1013                    .unwrap_or_default(),
1014            )
1015        };
1016
1017        resolve_path_dirs(base, parent)
1018            .into_iter()
1019            .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
1020            .filter_map(Result::ok)
1021            .filter(|de| {
1022                let name = de.file_name();
1023                let name = name.to_string_lossy();
1024                !name.starts_with('.') && name.starts_with(prefix)
1025            })
1026            .filter(|de| filter(&de.path()))
1027            .map(|de| {
1028                let p = de.path();
1029                let is_dir = de
1030                    .file_type()
1031                    .map(|ft| ft.is_dir())
1032                    .unwrap_or_else(|_| p.is_dir());
1033                let mut s = p
1034                    .strip_prefix(base)
1035                    .unwrap_or(&p)
1036                    .to_string_lossy()
1037                    .replace(std::path::MAIN_SEPARATOR, separator);
1038                if is_dir {
1039                    s.push_str(separator);
1040                }
1041                s
1042            })
1043            .sorted()
1044            .collect()
1045    }
1046
1047    fn complete_commands(&self, ctoken: &str) -> Vec<(String, String)> {
1048        if ctoken.contains(std::path::MAIN_SEPARATOR) || (cfg!(windows) && ctoken.contains('/')) {
1049            let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1050            return self
1051                .complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
1052                .into_iter()
1053                .map(|value| (value, String::new()))
1054                .collect();
1055        }
1056
1057        let mut found = BTreeSet::new();
1058        if let Some(path) = env::var_os("PATH") {
1059            for dir in env::split_paths(&path) {
1060                for entry in std::fs::read_dir(dir).ok().into_iter().flatten().flatten() {
1061                    let path = entry.path();
1062                    let name = entry.file_name().to_string_lossy().into_owned();
1063                    if command_name_starts_with(&name, ctoken, cfg!(windows))
1064                        && is_executable(&path)
1065                    {
1066                        found.insert(name);
1067                    }
1068                }
1069            }
1070        }
1071        found
1072            .into_iter()
1073            .map(|value| (value, String::new()))
1074            .collect()
1075    }
1076}
1077
1078impl usage_rs::Run for CompleteWord {
1079    type Output = usage::miette::Result<()>;
1080
1081    fn run(self) -> Self::Output {
1082        let spec = generate::file_or_spec(&self.file, &self.spec)?;
1083        let choices = self.complete_word(&spec)?;
1084        let shell = self.shell.as_ref();
1085        let any_descriptions = choices.iter().any(|(_, d)| !d.is_empty());
1086        for (c, description) in choices {
1087            // Applied here rather than at each source so that none of them — subcommand and
1088            // flag help, custom completers — can leak a second line as a candidate.
1089            let description = one_line(Some(&description));
1090            match shell {
1091                "bash" => println!("{c}"),
1092                "fish" | "nu" | "powershell" => {
1093                    if any_descriptions {
1094                        println!("{c}\t{description}")
1095                    } else {
1096                        println!("{c}")
1097                    }
1098                }
1099                "zsh" => {
1100                    // Three tab-separated columns per line:
1101                    //   1. The raw value (used as the menu display label).
1102                    //   2. The description (may be empty).
1103                    //   3. The shell-quoted form that `compadd -Q` should
1104                    //      insert verbatim — wrapped in single quotes when
1105                    //      the value contains shell metacharacters, raw
1106                    //      otherwise.
1107                    // The generated zsh script builds the formatted display
1108                    // (`value -- description`) from columns 1 and 2 and uses
1109                    // column 3 as the inserted match. Keeping these as three
1110                    // distinct fields avoids the `\:`-escaping acrobatics
1111                    // that `_describe`'s `value:description` format required.
1112                    let insert = zsh_shell_quote(&c);
1113                    println!("{c}\t{description}\t{insert}")
1114                }
1115                _ => {
1116                    usage::miette::bail!("unsupported shell: {}", shell);
1117                }
1118            }
1119        }
1120
1121        Ok(())
1122    }
1123}
1124
1125/// The separator to render a completion with: the one already in the token.
1126///
1127/// `read_dir` hands back the platform's, so on Windows a token typed with `/` came back as
1128/// `target\debug\incremental/` — the segments in one spelling and the trailing marker in another,
1129/// which is neither what was typed nor a path the shell will match against what follows. A
1130/// completion is finishing a word a person is in the middle of typing, so their spelling is the
1131/// one to continue.
1132///
1133/// A backslash counts as a separator on Windows only: on Unix it is an ordinary character in a
1134/// filename, and a file called `a\b` must not be read as two components.
1135///
1136/// `/` when the token has neither, which is what the trailing marker has always used and what
1137/// every POSIX shell wants.
1138fn rendered_separator(ctoken: &str) -> &'static str {
1139    if cfg!(windows) && ctoken.contains('\\') && !ctoken.contains('/') {
1140        "\\"
1141    } else {
1142        "/"
1143    }
1144}
1145
1146/// Existing directories described by a possibly abbreviated path.
1147///
1148/// Exact parents keep the old single-directory fast path. When one does not exist, resolve its
1149/// parent first and expand the final segment as a directory prefix; recursion is what lets every
1150/// component be partial (`tar/de` -> `target/debug`) rather than only the component at the cursor.
1151fn resolve_path_dirs(base: &Path, path: &Path) -> Vec<PathBuf> {
1152    let exact = if path.is_absolute() {
1153        path.to_path_buf()
1154    } else {
1155        base.join(path)
1156    };
1157    if exact.is_dir() {
1158        return vec![exact];
1159    }
1160
1161    let Some(prefix) = path.file_name().and_then(|name| name.to_str()) else {
1162        return Vec::new();
1163    };
1164    let parent = path.parent().unwrap_or_else(|| Path::new(""));
1165    resolve_path_dirs(base, parent)
1166        .into_iter()
1167        .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
1168        .filter_map(Result::ok)
1169        .filter(|entry| {
1170            entry.file_type().is_ok_and(|kind| kind.is_dir())
1171                && entry
1172                    .file_name()
1173                    .to_str()
1174                    .is_some_and(|name| !name.starts_with('.') && name.starts_with(prefix))
1175        })
1176        .map(|entry| entry.path())
1177        .collect()
1178}
1179
1180/// The command whose completers answer for the value of `flag`, the flag awaiting one.
1181///
1182/// Usually the parsed command. Inside a mounted command, though, the parser still binds a flag
1183/// of the mounting CLI typed there, and a mounted command does not read the mounting spec's
1184/// top-level completers (see `Spec::completer`). Such a flag is the host's, so its value is
1185/// completed from the nearest command above the mount, as it would be before the mount.
1186///
1187/// Whose flag it is comes from the parser, which recorded it while merging each command's
1188/// flags (see [`ParseOutput::is_mounted_flag`]), so aliases, merged re-declarations and
1189/// shadowed spellings are settled the way the parse settled them.
1190fn flag_value_owner<'a>(parsed: &'a ParseOutput, flag: &Arc<SpecFlag>) -> &'a SpecCommand {
1191    let Some(boundary) = parsed.cmds.iter().position(|cmd| cmd.mounted) else {
1192        return &parsed.cmd;
1193    };
1194    if parsed.is_mounted_flag(flag) {
1195        return &parsed.cmd;
1196    }
1197    parsed.cmds[..boundary].last().unwrap_or(&parsed.cmd)
1198}
1199
1200fn complete_usernames(prefix: &str) -> Vec<(String, String)> {
1201    let mut found = BTreeSet::new();
1202    for key in ["USER", "USERNAME"] {
1203        if let Ok(value) = env::var(key)
1204            && value.starts_with(prefix)
1205        {
1206            found.insert(value);
1207        }
1208    }
1209    if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
1210        for line in passwd.lines() {
1211            if let Some(name) = line
1212                .split(':')
1213                .next()
1214                .filter(|name| name.starts_with(prefix))
1215            {
1216                found.insert(name.to_string());
1217            }
1218        }
1219    }
1220    found
1221        .into_iter()
1222        .map(|value| (value, String::new()))
1223        .collect()
1224}
1225
1226fn complete_hostnames(prefix: &str) -> Vec<(String, String)> {
1227    let mut found = BTreeSet::new();
1228    for key in ["HOSTNAME", "COMPUTERNAME"] {
1229        if let Ok(value) = env::var(key)
1230            && value.starts_with(prefix)
1231        {
1232            found.insert(value);
1233        }
1234    }
1235    if let Ok(hosts) = std::fs::read_to_string("/etc/hosts") {
1236        for line in hosts.lines() {
1237            let line = line.split('#').next().unwrap_or_default();
1238            for name in line.split_whitespace().skip(1) {
1239                if name.starts_with(prefix) {
1240                    found.insert(name.to_string());
1241                }
1242            }
1243        }
1244    }
1245    found
1246        .into_iter()
1247        .map(|value| (value, String::new()))
1248        .collect()
1249}
1250
1251fn command_name_starts_with(name: &str, prefix: &str, case_insensitive: bool) -> bool {
1252    if case_insensitive {
1253        name.get(..prefix.len())
1254            .is_some_and(|start| start.eq_ignore_ascii_case(prefix))
1255    } else {
1256        name.starts_with(prefix)
1257    }
1258}
1259
1260#[cfg(unix)]
1261fn is_executable(path: &Path) -> bool {
1262    use std::os::unix::fs::PermissionsExt;
1263    path.metadata()
1264        .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
1265}
1266
1267#[cfg(windows)]
1268fn is_executable(path: &Path) -> bool {
1269    let extensions = env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
1270    path.is_file()
1271        && path.extension().is_some_and(|extension| {
1272            let extension = format!(".{}", extension.to_string_lossy());
1273            extensions
1274                .to_string_lossy()
1275                .split(';')
1276                .any(|candidate| candidate.eq_ignore_ascii_case(&extension))
1277        })
1278}
1279
1280/// Wrap a completion value in single quotes if any character would otherwise
1281/// be interpreted by the shell. The result is meant to be inserted by
1282/// `compadd -Q` verbatim, so the user sees consistent single-quote quoting
1283/// instead of zsh's default mix of backslash and single-quote styles.
1284/// What a completion is computed against.
1285///
1286/// These three always travel together — the template context a `run=` is rendered with, the
1287/// spec, and what the parser made of the words before the cursor — so they are one parameter
1288/// rather than three threaded through every helper.
1289struct Ctx<'a> {
1290    tera: &'a tera::Context,
1291    spec: &'a Spec,
1292    parsed: &'a ParseOutput,
1293    after_restart_token: bool,
1294}
1295
1296/// A delegated candidate as a replacement for the whole word at the cursor, or `None` when it
1297/// does not continue that word.
1298///
1299/// fish and zsh answer `-out=pl` with whole words (`-out=plan.tfplan`). A bash completion
1300/// answers with the part after the last `=`, because `=` is a word break to readline and bash
1301/// replaces only what follows it — so `plan.tfplan` is put back behind `-out=`, the whole-word
1302/// form complete-word gives its own `--flag=value` completions. Checked against the cursor word
1303/// only after that, so a candidate for some other word is still dropped.
1304fn whole_word_candidate(
1305    (name, description): (String, String),
1306    ctoken: &str,
1307) -> Option<(String, String)> {
1308    if name.starts_with(ctoken) {
1309        return Some((name, description));
1310    }
1311    let (head, _) = ctoken.rsplit_once('=')?;
1312    let name = format!("{head}={name}");
1313    name.starts_with(ctoken).then_some((name, description))
1314}
1315
1316/// The words of the command line that `arg` has taken so far, as typed.
1317///
1318/// From the token table rather than the bound value, so a flag of the delegated command that
1319/// this CLI does not know — bound to `arg` as a word — reaches the delegate spelled the way the
1320/// user wrote it. A `restart_token` or clause separator starts the argument over, so only what
1321/// came after the last one belongs to the command line being completed.
1322fn delegated_words(parsed: &ParseOutput, arg: &SpecArg) -> Vec<String> {
1323    let mut words = vec![];
1324    for token in &parsed.tokens {
1325        for role in &token.roles {
1326            match role {
1327                TokenRole::Restart | TokenRole::ClauseSeparator { .. } => words.clear(),
1328                TokenRole::Arg { arg: bound, .. }
1329                | TokenRole::UnknownFlag {
1330                    bound_as: Some(bound),
1331                } if bound.name == arg.name => words.push(token.word.clone()),
1332                _ => {}
1333            }
1334        }
1335    }
1336    words
1337}
1338
1339/// The first argument that advances the ordinary positional cursor.
1340fn first_ordinary_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1341    cmd.args.iter().find(|arg| arg.sigil.is_none())
1342}
1343
1344/// The first argument in the command's active positional grammar.
1345fn first_active_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1346    cmd.clause
1347        .as_ref()
1348        .and_then(|clause| clause.args.first())
1349        .or_else(|| first_ordinary_arg(cmd))
1350}
1351
1352/// A description reduced to one line.
1353///
1354/// A completion is one row in a menu, and the shells are handed one candidate per line with
1355/// tab-separated columns — so a description with a newline in it splits one candidate into
1356/// several rows of nonsense. `long_help` is where prose belongs.
1357fn one_line(text: Option<&str>) -> String {
1358    text.unwrap_or_default()
1359        .lines()
1360        .next()
1361        .unwrap_or_default()
1362        .trim()
1363        .to_string()
1364}
1365
1366/// The setting a key on the command line names, following the names it is also known by.
1367///
1368/// A plain lookup was not enough, because the key a user types is not always the key a spec
1369/// declares. `alias` names are accepted by the config layer without so much as a warning
1370/// (`Registry::deprecation` resolves them the same way), so a config file in the wild carries
1371/// them and a user reading that file types them — and completion answering an accepted key
1372/// with the contents of the working directory is worse than answering nothing.
1373///
1374/// `renamed_to` is followed for the same reason and one more: the old name is by definition
1375/// the one people still have written down, and the values it takes are whatever its
1376/// replacement takes. The chain is walked rather than the one hop taken, since a setting
1377/// renamed twice is still reachable from the oldest name.
1378///
1379/// Bounded by the number of props, so a registry whose renames form a cycle stops instead of
1380/// following them forever — the same guard, and the same reasoning, as `Registry::deprecation`
1381/// in `usage-config`: that is an authoring mistake, and a completion that hangs reports it
1382/// worse than one that goes quiet.
1383fn resolve_config_key<'a>(
1384    config: &'a usage::spec::config::SpecConfig,
1385    key: &str,
1386) -> Option<&'a SpecConfigProp> {
1387    let (_, mut prop) = config
1388        .props
1389        .iter()
1390        .find(|(name, prop)| name.as_str() == key || prop.aliases.iter().any(|a| a == key))?;
1391    for _ in 0..config.props.len() {
1392        let Some(target) = &prop.renamed_to else {
1393            return Some(prop);
1394        };
1395        // A rename pointing at nothing is as far as the chain goes. The old declaration is
1396        // still a real setting with its own type and choices, so it answers for itself rather
1397        // than the key being treated as unknown.
1398        let Some(next) = config.props.get(target) else {
1399            return Some(prop);
1400        };
1401        prop = next;
1402    }
1403    Some(prop)
1404}
1405
1406/// Whether this type accepts values no list could enumerate.
1407///
1408/// A boolean has two values and `choices` names its own, so either can be offered in full.
1409/// A path, a number or free text cannot be, so a union containing one is never a closed set —
1410/// however many of its members are enumerable.
1411fn accepts_unenumerable_values(ty: &SpecConfigType) -> bool {
1412    match ty {
1413        SpecConfigType::Base(Base::Bool) => false,
1414        SpecConfigType::Option(inner) => accepts_unenumerable_values(inner),
1415        SpecConfigType::Union(members) => members.iter().any(accepts_unenumerable_values),
1416        // Anything else — a path, a number, a string, a list — takes values that cannot be
1417        // written down in advance.
1418        _ => true,
1419    }
1420}
1421
1422/// Whether a boolean is one of the things this type accepts.
1423///
1424/// A union may list it anywhere, and `option<bool|string>` nests one. Recursive rather than a
1425/// look at the first member, because which member comes first is a spec author's formatting
1426/// choice and should not decide whether `true` and `false` are offered.
1427fn holds_a_boolean(ty: &SpecConfigType) -> bool {
1428    match ty {
1429        SpecConfigType::Base(Base::Bool) => true,
1430        SpecConfigType::Option(inner) => holds_a_boolean(inner),
1431        SpecConfigType::Union(members) => members.iter().any(holds_a_boolean),
1432        // A list or map *of* booleans is not itself one: what goes on the command line there
1433        // is a list, and offering `true` would be offering it in the wrong shape.
1434        _ => false,
1435    }
1436}
1437
1438fn zsh_shell_quote(s: &str) -> String {
1439    fn safe(c: char) -> bool {
1440        matches!(c,
1441            'a'..='z' | 'A'..='Z' | '0'..='9'
1442            | '_' | '-' | '.' | '/' | ':' | '@' | '+' | '=' | '%' | ','
1443        )
1444    }
1445    if !s.is_empty() && s.chars().all(safe) {
1446        return s.to_string();
1447    }
1448    // Wrap in single quotes; close-open dance escapes any internal apostrophes.
1449    let escaped = s.replace('\'', "'\\''");
1450    format!("'{escaped}'")
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::{command_name_starts_with, render_completer_run, rendered_separator};
1456
1457    #[test]
1458    fn a_slash_in_the_token_is_kept() {
1459        // Every platform: `/` is a separator on all of them, and what was typed comes back.
1460        assert_eq!(rendered_separator("target/de"), "/");
1461        assert_eq!(rendered_separator("/abs/path"), "/");
1462    }
1463
1464    #[test]
1465    fn a_token_with_no_separator_yet_gets_a_slash() {
1466        // Which is what the trailing directory marker has always used.
1467        assert_eq!(rendered_separator(""), "/");
1468        assert_eq!(rendered_separator("target"), "/");
1469    }
1470
1471    #[test]
1472    fn a_backslash_is_a_separator_only_on_windows() {
1473        // On Unix `a\b` is one filename, and reading the backslash as a separator would render a
1474        // completion nothing matches. `cfg!` rather than `#[cfg]` so both arms are type-checked
1475        // wherever this is compiled.
1476        let expected = if cfg!(windows) { "\\" } else { "/" };
1477        assert_eq!(rendered_separator(r"target\de"), expected);
1478        assert_eq!(rendered_separator(r"C:\Users\me"), expected);
1479    }
1480
1481    #[test]
1482    fn a_mixed_token_settles_on_the_slash() {
1483        // Deterministic rather than clever: one of them has to win, and `/` works in both the
1484        // shells that reach here on Windows and everywhere else.
1485        assert_eq!(rendered_separator(r"target\de/inc"), "/");
1486    }
1487
1488    #[test]
1489    fn windows_command_prefixes_ignore_ascii_case() {
1490        assert!(command_name_starts_with("Cargo.EXE", "car", true));
1491        assert!(!command_name_starts_with("Cargo.EXE", "car", false));
1492    }
1493
1494    #[test]
1495    fn completer_templates_can_shell_quote_typed_words() {
1496        let mut ctx = tera::Context::new();
1497        ctx.insert("word", "a'b; echo injected");
1498        let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1499            .expect("the filter should render");
1500        assert_eq!(rendered, "printf '%s\\n' 'a'\\''b; echo injected'");
1501    }
1502
1503    #[cfg(unix)]
1504    #[test]
1505    fn shell_quoted_template_values_remain_one_literal_argument() {
1506        let mut ctx = tera::Context::new();
1507        ctx.insert("word", "$(printf injected); a'b");
1508        let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1509            .expect("the filter should render");
1510        let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1511        assert_eq!(stdout, "$(printf injected); a'b\n");
1512    }
1513
1514    #[test]
1515    fn shell_quote_rejects_non_strings() {
1516        let mut ctx = tera::Context::new();
1517        ctx.insert("word", &42);
1518        let err = render_completer_run("{{ word | shell_quote }}", &ctx).unwrap_err();
1519        assert!(
1520            err.to_string().contains("shell_quote expects a string"),
1521            "{err}"
1522        );
1523    }
1524
1525    #[cfg(unix)]
1526    #[test]
1527    fn shell_join_preserves_the_argv_vector_when_forwarded_as_one_argument() {
1528        let expected = ["ex", "two words", "a'b"];
1529        let mut ctx = tera::Context::new();
1530        ctx.insert("words", &expected);
1531        let rendered = render_completer_run(
1532            "printf '%s\\n' {{ words | shell_join | shell_quote }}",
1533            &ctx,
1534        )
1535        .expect("the filters should render");
1536        let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1537        let reparsed = shell_words::split(stdout.trim()).expect("the joined value should parse");
1538        assert_eq!(reparsed, expected);
1539    }
1540}