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