Skip to main content

ssg/cmd/
completions.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Shell-completion generation from the live clap definition.
5//!
6//! # Why this is written here rather than taken from a crate
7//!
8//! `clap_complete` is the obvious answer and was measured, not assumed:
9//! adding it reports `1 unvetted dependencies: clap_complete:4.6.9 missing
10//! ["safe-to-deploy"]`. This repository's `cargo vet` policy runs an
11//! exemption ratchet whose count may only decrease, so the crate cannot be
12//! added without either a real audit or breaking the gate. The same
13//! reasoning retired the `roff` crate in [`crate::cmd::man`], and the same
14//! trade applies: four narrow emitters cost less than the supply-chain
15//! review they avoid.
16//!
17//! # What cannot drift
18//!
19//! Every completion is walked out of [`crate::cmd::Cli`]'s own
20//! `clap::Command`. Nothing is transcribed, so a flag the parser gains
21//! appears in all four shells by construction, and `tests/completions.rs`
22//! asserts that — as well as feeding each script to the real shell's
23//! syntax checker, since a completion script that fails to parse is worse
24//! than none at all: it breaks the user's prompt on every tab.
25//!
26//! # Path arguments
27//!
28//! Which options complete filenames is taken from the argument's
29//! `value_parser` type — `PathBuf` means a path — rather than from its
30//! `value_name` reading `DIR` or `FILE`. A name is a display convention
31//! that nothing enforces; the parser type is the property that actually
32//! decides how the value is used.
33
34use clap::builder::ValueParser;
35use clap::{Arg, ArgAction, Command};
36use std::fmt::Write as _;
37
38/// A shell that completions can be generated for.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum Shell {
41    /// GNU Bash, via `complete -F`.
42    Bash,
43    /// Z shell, via an autoloaded `#compdef` function.
44    Zsh,
45    /// fish, via `complete -c`.
46    Fish,
47    /// PowerShell, via `Register-ArgumentCompleter -Native`.
48    PowerShell,
49}
50
51impl Shell {
52    /// Every supported shell, in a stable order.
53    pub const ALL: [Self; 4] =
54        [Self::Bash, Self::Zsh, Self::Fish, Self::PowerShell];
55
56    /// The lowercase name used on the command line and in file paths.
57    #[must_use]
58    pub const fn name(self) -> &'static str {
59        match self {
60            Self::Bash => "bash",
61            Self::Zsh => "zsh",
62            Self::Fish => "fish",
63            Self::PowerShell => "powershell",
64        }
65    }
66
67    /// The filename each shell expects to find in its completions
68    /// directory.
69    ///
70    /// These are not cosmetic. Bash looks for a file named exactly after
71    /// the command, zsh requires the leading underscore to autoload the
72    /// function, and fish requires the `.fish` extension.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use ssg::cmd::completions::Shell;
78    /// assert_eq!(Shell::Bash.file_name("ssg"), "ssg");
79    /// assert_eq!(Shell::Zsh.file_name("ssg"), "_ssg");
80    /// assert_eq!(Shell::Fish.file_name("ssg"), "ssg.fish");
81    /// ```
82    #[must_use]
83    pub fn file_name(self, bin: &str) -> String {
84        match self {
85            Self::Bash => bin.to_owned(),
86            Self::Zsh => format!("_{bin}"),
87            Self::Fish => format!("{bin}.fish"),
88            Self::PowerShell => format!("_{bin}.ps1"),
89        }
90    }
91
92    /// Parses a shell name, as accepted on the command line.
93    #[must_use]
94    pub fn parse(s: &str) -> Option<Self> {
95        Self::ALL
96            .into_iter()
97            .find(|sh| sh.name().eq_ignore_ascii_case(s))
98    }
99}
100
101/// True when the argument's value is a filesystem path.
102///
103/// Derived from the `value_parser`, not from the value name — see the
104/// module documentation.
105fn takes_path(arg: &Arg) -> bool {
106    arg.get_value_parser().type_id() == ValueParser::path_buf().type_id()
107}
108
109/// True when the argument consumes a value at all, as opposed to being a
110/// bare flag such as `--drafts` or `--help`.
111///
112/// The signal is the argument's [`ArgAction`], not `get_num_args`, which
113/// returns `None` for every argument that did not set it explicitly — none
114/// of them here. Reading it as "takes no value" marked even `--content` as
115/// a bare flag, which drops the `-r` from the fish spec and the
116/// `:DIR:_files` from the zsh one, so the shell offers the next option
117/// where it should offer a path.
118fn takes_value(arg: &Arg) -> bool {
119    matches!(arg.get_action(), ArgAction::Set | ArgAction::Append)
120}
121
122/// Every spelling of an argument: `-c` and `--content`.
123fn spellings(arg: &Arg) -> Vec<String> {
124    let mut out = Vec::new();
125    if let Some(short) = arg.get_short() {
126        out.push(format!("-{short}"));
127    }
128    if let Some(long) = arg.get_long() {
129        out.push(format!("--{long}"));
130    }
131    out
132}
133
134/// The one-line help for an argument, collapsed to a single line.
135fn help_of(arg: &Arg) -> String {
136    arg.get_help()
137        .map(|h| h.to_string().replace('\n', " "))
138        .unwrap_or_default()
139}
140
141/// Visible arguments only — a hidden flag should not be suggested.
142fn visible_args(cmd: &Command) -> impl Iterator<Item = &Arg> {
143    cmd.get_arguments().filter(|a| !a.is_hide_set())
144}
145
146/// Renders a completion script for `shell`.
147///
148/// # Examples
149///
150/// ```
151/// use ssg::cmd::completions::{render, Shell};
152/// use ssg::cmd::Cli;
153///
154/// let script = render(&Cli::subcommand_app(), Shell::Fish);
155/// assert!(script.contains("complete -c ssg"));
156/// ```
157#[must_use]
158pub fn render(app: &Command, shell: Shell) -> String {
159    match shell {
160        Shell::Bash => render_bash(app),
161        Shell::Zsh => render_zsh(app),
162        Shell::Fish => render_fish(app),
163        Shell::PowerShell => render_powershell(app),
164    }
165}
166
167// ---------------------------------------------------------------------------
168// bash
169// ---------------------------------------------------------------------------
170
171fn render_bash(app: &Command) -> String {
172    let bin = app.get_name();
173    let subs: Vec<&str> =
174        app.get_subcommands().map(Command::get_name).collect();
175
176    let mut out = String::new();
177    let _ =
178        writeln!(out, "# {bin} completion for bash. Generated — do not edit.");
179    let _ = writeln!(out, "_{bin}() {{");
180    let _ = writeln!(out, "    local cur prev cmd opts paths i");
181    let _ = writeln!(out, "    COMPREPLY=()");
182    let _ = writeln!(out, r#"    cur="${{COMP_WORDS[COMP_CWORD]}}""#);
183    let _ = writeln!(out, r#"    prev="${{COMP_WORDS[COMP_CWORD-1]}}""#);
184    let _ = writeln!(out, r#"    cmd="""#);
185    let _ = writeln!(out, "    for ((i = 1; i < COMP_CWORD; i++)); do");
186    let _ = writeln!(out, r#"        case "${{COMP_WORDS[i]}}" in"#);
187    let _ = writeln!(out, "            -*) ;;");
188    let _ = writeln!(
189        out,
190        r#"            {}) cmd="${{COMP_WORDS[i]}}"; break ;;"#,
191        subs.join("|")
192    );
193    let _ = writeln!(out, "        esac");
194    let _ = writeln!(out, "    done");
195    let _ = writeln!(out);
196    let _ = writeln!(out, r#"    paths="""#);
197    let _ = writeln!(out, r#"    case "$cmd" in"#);
198
199    for sub in app.get_subcommands() {
200        let opts = bash_word_list(sub, false);
201        let paths = bash_word_list(sub, true);
202        let _ = writeln!(out, r#"        {})"#, sub.get_name());
203        let _ = writeln!(out, r#"            opts="{opts}""#);
204        if !paths.is_empty() {
205            let _ = writeln!(out, r#"            paths="{paths}""#);
206        }
207        let _ = writeln!(out, "            ;;");
208    }
209
210    // No subcommand seen yet: offer the subcommands and the global flags.
211    let mut root = subs.join(" ");
212    let root_opts = bash_word_list(app, false);
213    if !root_opts.is_empty() {
214        root.push(' ');
215        root.push_str(&root_opts);
216    }
217    let root_paths = bash_word_list(app, true);
218    let _ = writeln!(out, "        *)");
219    let _ = writeln!(out, r#"            opts="{root}""#);
220    if !root_paths.is_empty() {
221        let _ = writeln!(out, r#"            paths="{root_paths}""#);
222    }
223    let _ = writeln!(out, "            ;;");
224    let _ = writeln!(out, "    esac");
225    let _ = writeln!(out);
226    // A path-taking option was the previous word, so complete filenames
227    // rather than repeating the option list.
228    let _ = writeln!(
229        out,
230        r#"    if [[ -n "$paths" && " $paths " == *" $prev "* ]]; then"#
231    );
232    let _ = writeln!(
233        out,
234        r#"        mapfile -t COMPREPLY < <(compgen -f -- "$cur")"#
235    );
236    let _ = writeln!(out, "        return 0");
237    let _ = writeln!(out, "    fi");
238    let _ = writeln!(
239        out,
240        r#"    mapfile -t COMPREPLY < <(compgen -W "$opts" -- "$cur")"#
241    );
242    let _ = writeln!(out, "    return 0");
243    let _ = writeln!(out, "}}");
244    let _ = writeln!(out, "complete -F _{bin} {bin}");
245    out
246}
247
248/// Space-separated flag spellings for `cmd`; `paths_only` restricts the
249/// list to options whose value is a filesystem path.
250fn bash_word_list(cmd: &Command, paths_only: bool) -> String {
251    let mut words = Vec::new();
252    for arg in visible_args(cmd) {
253        if paths_only && !takes_path(arg) {
254            continue;
255        }
256        words.extend(spellings(arg));
257    }
258    words.join(" ")
259}
260
261// ---------------------------------------------------------------------------
262// zsh
263// ---------------------------------------------------------------------------
264
265/// Escapes text for use inside a zsh `_arguments` specification.
266///
267/// `[`, `]` and `:` delimit the fields of a spec, so an unescaped one in a
268/// help string silently truncates the entry or corrupts the one after it.
269fn zsh_escape(s: &str) -> String {
270    s.replace('\\', r"\\")
271        .replace('\'', r"'\''")
272        .replace('[', r"\[")
273        .replace(']', r"\]")
274        .replace(':', r"\:")
275}
276
277fn render_zsh(app: &Command) -> String {
278    let bin = app.get_name();
279    let mut out = String::new();
280    let _ = writeln!(out, "#compdef {bin}");
281    let _ =
282        writeln!(out, "# {bin} completion for zsh. Generated — do not edit.");
283    let _ = writeln!(out);
284    let _ = writeln!(out, "_{bin}() {{");
285    let _ = writeln!(out, "    local curcontext=\"$curcontext\" state line");
286    let _ = writeln!(out, "    local -a commands");
287    let _ = writeln!(out, "    commands=(");
288    for sub in app.get_subcommands() {
289        let about = sub
290            .get_about()
291            .map(|a| a.to_string().replace('\n', " "))
292            .unwrap_or_default();
293        let _ = writeln!(
294            out,
295            "        '{}:{}'",
296            sub.get_name(),
297            zsh_escape(&about)
298        );
299    }
300    let _ = writeln!(out, "    )");
301    let _ = writeln!(out);
302    let _ = writeln!(out, "    _arguments -C \\");
303    for arg in visible_args(app) {
304        let _ = writeln!(out, "        {} \\", zsh_arg_spec(arg));
305    }
306    let _ = writeln!(out, "        '1: :->command' \\");
307    let _ = writeln!(out, "        '*:: :->args' && return 0");
308    let _ = writeln!(out);
309    let _ = writeln!(out, "    case $state in");
310    let _ = writeln!(out, "        command)");
311    let _ = writeln!(
312        out,
313        "            _describe -t commands '{bin} command' commands && return 0"
314    );
315    let _ = writeln!(out, "            ;;");
316    let _ = writeln!(out, "        args)");
317    let _ = writeln!(out, "            case $words[1] in");
318    for sub in app.get_subcommands() {
319        let _ = writeln!(out, "                {})", sub.get_name());
320        let _ = writeln!(out, "                    _arguments \\");
321        for arg in visible_args(sub) {
322            let _ = writeln!(
323                out,
324                "                        {} \\",
325                zsh_arg_spec(arg)
326            );
327        }
328        let _ = writeln!(out, "                        && return 0");
329        let _ = writeln!(out, "                    ;;");
330    }
331    let _ = writeln!(out, "            esac");
332    let _ = writeln!(out, "            ;;");
333    let _ = writeln!(out, "    esac");
334    let _ = writeln!(out, "    return 1");
335    let _ = writeln!(out, "}}");
336    let _ = writeln!(out);
337    let _ = writeln!(out, "_{bin} \"$@\"");
338    out
339}
340
341/// One `_arguments` spec line for a single argument.
342fn zsh_arg_spec(arg: &Arg) -> String {
343    let names = spellings(arg);
344    let help = zsh_escape(&help_of(arg));
345
346    // The exclusion list stops zsh offering `--content` once `-c` is typed.
347    let exclusion = if names.len() > 1 {
348        format!("({})", names.join(" "))
349    } else {
350        String::new()
351    };
352
353    let action = if takes_value(arg) {
354        let value = arg
355            .get_value_names()
356            .and_then(|n| n.first())
357            .map_or_else(|| "VALUE".to_owned(), ToString::to_string);
358        let completer = if takes_path(arg) { "_files" } else { " " };
359        format!(":{}:{completer}", zsh_escape(&value))
360    } else {
361        String::new()
362    };
363
364    if names.len() > 1 {
365        // `'(-c --content)'{-c,--content}'[help]:DIR:_files'`
366        format!("'{exclusion}'{{{}}}'[{help}]{action}'", names.join(","))
367    } else {
368        format!("'{}[{help}]{action}'", names.join(""))
369    }
370}
371
372// ---------------------------------------------------------------------------
373// fish
374// ---------------------------------------------------------------------------
375
376/// Escapes text for a single-quoted fish string, where only `\` and `'`
377/// are special.
378fn fish_escape(s: &str) -> String {
379    s.replace('\\', r"\\").replace('\'', r"\'")
380}
381
382fn render_fish(app: &Command) -> String {
383    let bin = app.get_name();
384    let mut out = String::new();
385    let _ =
386        writeln!(out, "# {bin} completion for fish. Generated — do not edit.");
387    // Disable the default filename fallback; path options opt back in with
388    // `-F` below, so a flag-only position never suggests the whole cwd.
389    let _ = writeln!(out, "complete -c {bin} -f");
390    let _ = writeln!(out);
391
392    for arg in visible_args(app) {
393        let _ = writeln!(
394            out,
395            "complete -c {bin} -n '__fish_use_subcommand' {}",
396            fish_arg_spec(arg)
397        );
398    }
399    for sub in app.get_subcommands() {
400        let about = sub
401            .get_about()
402            .map(|a| a.to_string().replace('\n', " "))
403            .unwrap_or_default();
404        let _ = writeln!(
405            out,
406            "complete -c {bin} -n '__fish_use_subcommand' -a '{}' -d '{}'",
407            sub.get_name(),
408            fish_escape(&about)
409        );
410    }
411    let _ = writeln!(out);
412    for sub in app.get_subcommands() {
413        let name = sub.get_name();
414        for arg in visible_args(sub) {
415            let _ = writeln!(
416                out,
417                "complete -c {bin} -n '__fish_seen_subcommand_from {name}' {}",
418                fish_arg_spec(arg)
419            );
420        }
421    }
422    out
423}
424
425fn fish_arg_spec(arg: &Arg) -> String {
426    let mut parts = Vec::new();
427    if let Some(short) = arg.get_short() {
428        parts.push(format!("-s {short}"));
429    }
430    if let Some(long) = arg.get_long() {
431        parts.push(format!("-l {long}"));
432    }
433    if takes_value(arg) {
434        // `-r` marks the option as requiring an argument; `-F` re-enables
435        // the filename completion turned off by `complete -c ssg -f`.
436        parts.push(if takes_path(arg) {
437            "-r -F".to_owned()
438        } else {
439            "-r".to_owned()
440        });
441    }
442    let help = help_of(arg);
443    if !help.is_empty() {
444        parts.push(format!("-d '{}'", fish_escape(&help)));
445    }
446    parts.join(" ")
447}
448
449// ---------------------------------------------------------------------------
450// powershell
451// ---------------------------------------------------------------------------
452
453/// Escapes text for a single-quoted PowerShell string, where a literal
454/// quote is written by doubling it.
455fn ps_escape(s: &str) -> String {
456    s.replace('\'', "''")
457}
458
459fn render_powershell(app: &Command) -> String {
460    let bin = app.get_name();
461    let mut out = String::new();
462    let _ = writeln!(
463        out,
464        "# {bin} completion for PowerShell. Generated — do not edit."
465    );
466    let _ = writeln!(out, "using namespace System.Management.Automation");
467    let _ =
468        writeln!(out, "using namespace System.Management.Automation.Language");
469    let _ = writeln!(out);
470    let _ = writeln!(
471        out,
472        "Register-ArgumentCompleter -Native -CommandName '{bin}' -ScriptBlock {{"
473    );
474    let _ = writeln!(
475        out,
476        "    param($wordToComplete, $commandAst, $cursorPosition)"
477    );
478    let _ = writeln!(out);
479    let _ = writeln!(out, "    $commandElements = $commandAst.CommandElements");
480    let _ = writeln!(out, "    $command = @(");
481    let _ = writeln!(out, "        '{bin}'");
482    let _ = writeln!(
483        out,
484        "        for ($i = 1; $i -lt $commandElements.Count; $i++) {{"
485    );
486    let _ = writeln!(out, "            $element = $commandElements[$i]");
487    let _ = writeln!(
488        out,
489        "            if ($element -isnot [StringConstantExpressionAst] -or"
490    );
491    let _ = writeln!(out, "                $element.StringConstantType -ne [StringConstantType]::BareWord -or");
492    let _ = writeln!(out, "                $element.Value.StartsWith('-')) {{");
493    let _ = writeln!(out, "                break");
494    let _ = writeln!(out, "            }}");
495    let _ = writeln!(out, "            $element.Value");
496    let _ = writeln!(out, "        }}) -join ';'");
497    let _ = writeln!(out);
498    let _ = writeln!(out, "    $completions = @(switch ($command) {{");
499
500    let _ = writeln!(out, "        '{bin}' {{");
501    for arg in visible_args(app) {
502        for line in ps_completion_results(arg) {
503            let _ = writeln!(out, "            {line}");
504        }
505    }
506    for sub in app.get_subcommands() {
507        let about = sub
508            .get_about()
509            .map(|a| a.to_string().replace('\n', " "))
510            .unwrap_or_default();
511        let name = sub.get_name();
512        let _ = writeln!(
513            out,
514            "            [CompletionResult]::new('{name}', '{name}', \
515             [CompletionResultType]::ParameterValue, '{}')",
516            ps_escape(&about)
517        );
518    }
519    let _ = writeln!(out, "            break");
520    let _ = writeln!(out, "        }}");
521
522    for sub in app.get_subcommands() {
523        let _ = writeln!(out, "        '{bin};{}' {{", sub.get_name());
524        for arg in visible_args(sub) {
525            for line in ps_completion_results(arg) {
526                let _ = writeln!(out, "            {line}");
527            }
528        }
529        let _ = writeln!(out, "            break");
530        let _ = writeln!(out, "        }}");
531    }
532
533    let _ = writeln!(out, "    }})");
534    let _ = writeln!(out);
535    let _ = writeln!(
536        out,
537        "    $completions.Where{{ $_.CompletionText -like \"$wordToComplete*\" }} |"
538    );
539    let _ = writeln!(out, "        Sort-Object -Property ListItemText");
540    let _ = writeln!(out, "}}");
541    out
542}
543
544fn ps_completion_results(arg: &Arg) -> Vec<String> {
545    let help = ps_escape(&help_of(arg));
546    spellings(arg)
547        .into_iter()
548        .map(|name| {
549            format!(
550                "[CompletionResult]::new('{name}', '{name}', \
551                 [CompletionResultType]::ParameterName, '{help}')"
552            )
553        })
554        .collect()
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::cmd::Cli;
561
562    fn app() -> Command {
563        Cli::subcommand_app()
564    }
565
566    #[test]
567    fn shell_names_round_trip() {
568        for sh in Shell::ALL {
569            assert_eq!(Shell::parse(sh.name()), Some(sh));
570        }
571        assert_eq!(Shell::parse("BASH"), Some(Shell::Bash));
572        assert_eq!(Shell::parse("tcsh"), None);
573    }
574
575    #[test]
576    fn file_names_match_what_each_shell_looks_for() {
577        assert_eq!(Shell::Bash.file_name("ssg"), "ssg");
578        assert_eq!(Shell::Zsh.file_name("ssg"), "_ssg");
579        assert_eq!(Shell::Fish.file_name("ssg"), "ssg.fish");
580        assert_eq!(Shell::PowerShell.file_name("ssg"), "_ssg.ps1");
581    }
582
583    #[test]
584    fn every_shell_produces_a_non_empty_script() {
585        for sh in Shell::ALL {
586            let script = render(&app(), sh);
587            assert!(
588                script.len() > 200,
589                "{} produced a suspiciously short script",
590                sh.name()
591            );
592        }
593    }
594
595    #[test]
596    fn rendering_is_deterministic() {
597        for sh in Shell::ALL {
598            assert_eq!(
599                render(&app(), sh),
600                render(&app(), sh),
601                "{} output varies between runs",
602                sh.name()
603            );
604        }
605    }
606
607    #[test]
608    fn zsh_escape_neutralises_every_spec_delimiter() {
609        assert_eq!(zsh_escape("a[b]c:d"), r"a\[b\]c\:d");
610        assert_eq!(zsh_escape(r"back\slash"), r"back\\slash");
611        assert_eq!(zsh_escape("it's"), r"it'\''s");
612    }
613
614    #[test]
615    fn fish_escape_handles_quotes_and_backslashes() {
616        assert_eq!(fish_escape("it's"), r"it\'s");
617        assert_eq!(fish_escape(r"a\b"), r"a\\b");
618    }
619
620    #[test]
621    fn powershell_escape_doubles_quotes() {
622        assert_eq!(ps_escape("it's"), "it''s");
623    }
624
625    #[test]
626    fn path_arguments_are_detected_from_the_value_parser() {
627        let app = app();
628        let build = app
629            .get_subcommands()
630            .find(|c| c.get_name() == "build")
631            .expect("build subcommand");
632        let content = build
633            .get_arguments()
634            .find(|a| a.get_id() == "content")
635            .expect("--content");
636        assert!(
637            takes_path(content),
638            "--content takes a PathBuf and must complete filenames"
639        );
640    }
641
642    /// The regression this pins: `get_num_args()` is `None` for every
643    /// argument in this parser, so a `takes_values()` reading marked
644    /// `--content` — an obvious value-taking option — as a bare flag.
645    #[test]
646    fn value_taking_and_bare_flags_are_told_apart() {
647        let app = app();
648        let build = app
649            .get_subcommands()
650            .find(|c| c.get_name() == "build")
651            .expect("build subcommand");
652        let arg = |id: &str| {
653            build
654                .get_arguments()
655                .find(|a| a.get_id() == id)
656                .unwrap_or_else(|| panic!("--{id}"))
657        };
658        assert!(takes_value(arg("content")), "--content takes a directory");
659        assert!(takes_value(arg("output")), "--output takes a directory");
660        assert!(!takes_value(arg("drafts")), "--drafts is a bare flag");
661        assert!(!takes_value(arg("quiet")), "--quiet is a bare flag");
662    }
663}