Skip to main content

usage/complete/
fish.rs

1use crate::complete::CompleteOptions;
2use heck::ToSnakeCase;
3
4pub fn complete_fish(opts: &CompleteOptions) -> String {
5    let usage_bin = &opts.usage_bin;
6    let bin = &opts.bin;
7    let bin_snake = bin.to_snake_case();
8    let spec_variable = if let Some(cache_key) = &opts.cache_key {
9        format!("_usage_spec_{bin_snake}_{}", cache_key.to_snake_case())
10    } else {
11        format!("_usage_spec_{bin_snake}")
12    };
13    let generated_comment = if let Some(source_file) = &opts.source_file {
14        format!("# @generated by usage-cli from {source_file}")
15    } else {
16        "# @generated by usage-cli from usage spec".to_string()
17    };
18    let mut out = vec![
19        generated_comment,
20        format!(
21            r#"
22# if "{usage_bin}" is not installed show an error
23if ! type -p {usage_bin} &> /dev/null
24    echo >&2
25    echo "Error: {usage_bin} CLI not found. This is required for completions to work in {bin}." >&2
26    echo "See https://usage.jdx.dev for more information." >&2
27    return 1
28end"#
29        ),
30    ];
31
32    if let Some(spec) = &opts.spec {
33        let spec_escaped = spec.to_string().replace("'", r"\'");
34        out.push(format!(
35            r#"
36set {spec_variable} '{spec_escaped}'"#
37        ));
38    }
39
40    // Build logic to write spec directly to file without storing in shell variables
41    let file_write_logic = if let Some(usage_cmd) = &opts.usage_cmd {
42        if opts.cache_key.is_some() {
43            format!(
44                r#"if not test -f "$spec_file"
45    {usage_cmd} | string collect > "$spec_file"
46end"#
47            )
48        } else {
49            format!(r#"{usage_cmd} | string collect > "$spec_file""#)
50        }
51    } else if let Some(_spec) = &opts.spec {
52        if opts.cache_key.is_some() {
53            format!(
54                r#"if not test -f "$spec_file"
55    echo ${spec_variable} > "$spec_file"
56end"#
57            )
58        } else {
59            format!(r#"echo ${spec_variable} > "$spec_file""#)
60        }
61    } else {
62        String::new()
63    };
64
65    out.push(format!(
66        r#"
67set -l tmpdir (if set -q TMPDIR; echo $TMPDIR; else; echo /tmp; end)
68set -l spec_file "$tmpdir/usage_{spec_variable}.spec"
69{file_write_logic}
70
71set -l tokens
72if commandline -x >/dev/null 2>&1
73    complete -xc {bin} -a "(command {usage_bin} complete-word --shell fish -f \"$spec_file\" -- (commandline -xpc) (commandline -t))"
74else
75    complete -xc {bin} -a "(command {usage_bin} complete-word --shell fish -f \"$spec_file\" -- (commandline -opc) (commandline -t))"
76end
77"#
78    ).trim().to_string());
79
80    out.join("\n")
81}
82
83/// Generates a fish "init" script that wires up tab-completion for any command
84/// on `$PATH` whose first line is a `usage` shebang. The user sources this once
85/// from `~/.config/fish/conf.d/`; per-script
86/// `usage g completion fish <bin> -f <script>` generation is no longer required.
87///
88/// Mechanism: fish has no default-completer fallback equivalent to bash's
89/// `complete -D` or zsh's `compdef -default-`. Instead, the init script scans
90/// `$PATH` once at shell startup, identifies executables whose first line
91/// contains `usage`, and registers `complete -c <name>` for each. This is a
92/// one-time cost on shell startup proportional to the size of `$PATH`.
93pub fn complete_fish_init(usage_bin: &str) -> String {
94    format!(
95        r##"# @generated by usage-cli — auto-completion for usage shebang scripts
96# Source this file from ~/.config/fish/conf.d/ (or your fish config) to enable
97# <Tab> completion for any command on $PATH whose first line is a `usage`
98# shebang.
99
100function __usage_register_shebang_completions
101    if not type -q {usage_bin}
102        return 0
103    end
104    # `commandline -x` (fish 3.4+) tokenizes quoted/complex arguments more
105    # accurately than `-o`. Mirror the per-binary `complete_fish` detection so
106    # init-registered completions behave identically on modern fish.
107    set -l cmdline_pre_cmd 'commandline -opc'
108    if commandline -x >/dev/null 2>&1
109        set cmdline_pre_cmd 'commandline -xpc'
110    end
111    set -l registered
112    for dir in $PATH
113        test -d $dir; or continue
114        for file in $dir/*
115            test -f $file -a -x $file; or continue
116            # Skip files we can't read (e.g. setuid/execute-only binaries like
117            # macOS's sudo/visudo) — reading them below would make fish print
118            # a redirection warning on every shell startup.
119            test -r $file; or continue
120            # `string replace` works on every supported fish version; `path
121            # basename` requires fish 3.5+ which excludes Ubuntu 22.04 LTS.
122            set -l name (string replace -r '^.*/' '' -- $file)
123            contains -- $name $registered; and continue
124            # Cap the read at 128 chars so we don't buffer entire binary
125            # executables that have no newline.
126            set -l first
127            read -l -n 128 first <$file 2>/dev/null
128            if string match -q -- '#!*usage*' "$first"
129                complete -c $name -x -a "(command {usage_bin} complete-word --shell fish -f \"$file\" -- ($cmdline_pre_cmd) (commandline -t))"
130                set -a registered $name
131            end
132        end
133    end
134end
135
136__usage_register_shebang_completions
137# vim: noet ci pi sts=0 sw=4 ts=4
138"##
139    )
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::test::SPEC_KITCHEN_SINK;
146    use insta::assert_snapshot;
147
148    #[test]
149    fn test_complete_fish_init() {
150        assert_snapshot!(complete_fish_init("usage"));
151    }
152
153    #[test]
154    fn test_complete_fish() {
155        assert_snapshot!(complete_fish(&CompleteOptions {
156            usage_bin: "usage".to_string(),
157            shell: "fish".to_string(),
158            bin: "mycli".to_string(),
159            cache_key: None,
160            spec: None,
161            usage_cmd: Some("mycli complete --usage".to_string()),
162            include_bash_completion_lib: false,
163            source_file: None,
164        }));
165        assert_snapshot!(complete_fish(&CompleteOptions {
166            usage_bin: "usage".to_string(),
167            shell: "fish".to_string(),
168            bin: "mycli".to_string(),
169            cache_key: Some("1.2.3".to_string()),
170            spec: None,
171            usage_cmd: Some("mycli complete --usage".to_string()),
172            include_bash_completion_lib: false,
173            source_file: None,
174        }));
175        assert_snapshot!(complete_fish(&CompleteOptions {
176            usage_bin: "usage".to_string(),
177            shell: "fish".to_string(),
178            bin: "mycli".to_string(),
179            cache_key: None,
180            spec: Some(SPEC_KITCHEN_SINK.clone()),
181            usage_cmd: None,
182            include_bash_completion_lib: false,
183            source_file: None,
184        }));
185    }
186}