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