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