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    // The cache filename is version-keyed and the cache dir is now persistent
41    // (unlike the reboot-cleared temp dir), so old versions would accumulate
42    // forever. On a cache miss, reap this bin's spec files not regenerated in
43    // the last 30 days. Age-based (not "delete all other versions") so that
44    // running two versions of the same tool concurrently doesn't thrash — each
45    // live version's spec stays recent and survives.
46    let prune_stale = format!(
47        r#"find "$spec_dir" -maxdepth 1 -name 'usage__usage_spec_{bin_snake}_*.spec' -type f -mtime +30 -delete 2>/dev/null"#
48    );
49
50    // Build logic to write spec directly to file without storing in shell variables
51    let file_write_logic = if let Some(usage_cmd) = &opts.usage_cmd {
52        if opts.cache_key.is_some() {
53            format!(
54                r#"if not test -f "$spec_file"
55    {prune_stale}
56    {usage_cmd} | string collect > "$spec_file"
57end"#
58            )
59        } else {
60            format!(r#"{usage_cmd} | string collect > "$spec_file""#)
61        }
62    } else if let Some(_spec) = &opts.spec {
63        if opts.cache_key.is_some() {
64            format!(
65                r#"if not test -f "$spec_file"
66    {prune_stale}
67    echo ${spec_variable} > "$spec_file"
68end"#
69            )
70        } else {
71            format!(r#"echo ${spec_variable} > "$spec_file""#)
72        }
73    } else {
74        String::new()
75    };
76
77    out.push(format!(
78        r#"
79set -l spec_dir (if set -q XDG_CACHE_HOME; echo $XDG_CACHE_HOME; else; echo $HOME/.cache; end)/usage
80test -d "$spec_dir"; or mkdir -p -m 700 "$spec_dir"
81set -l spec_file "$spec_dir/usage_{spec_variable}.spec"
82{file_write_logic}
83
84set -l tokens
85if commandline -x >/dev/null 2>&1
86    complete -xc {bin} -a "(command {usage_bin} complete-word --shell fish -f \"$spec_file\" -- (commandline -xpc) (commandline -t))"
87else
88    complete -xc {bin} -a "(command {usage_bin} complete-word --shell fish -f \"$spec_file\" -- (commandline -opc) (commandline -t))"
89end
90"#
91    ).trim().to_string());
92
93    out.join("\n")
94}
95
96/// Generates a fish "init" script that wires up tab-completion for any command
97/// on `$PATH` whose first line is a `usage` shebang. The user sources this once
98/// from `~/.config/fish/conf.d/`; per-script
99/// `usage g completion fish <bin> -f <script>` generation is no longer required.
100///
101/// Mechanism: fish has no default-completer fallback equivalent to bash's
102/// `complete -D` or zsh's `compdef -default-`. Instead, the init script scans
103/// `$PATH` once at shell startup, identifies executables whose first line
104/// contains `usage`, and registers `complete -c <name>` for each. This is a
105/// one-time cost on shell startup proportional to the size of `$PATH`.
106pub fn complete_fish_init(usage_bin: &str) -> String {
107    format!(
108        r##"# @generated by usage-cli — auto-completion for usage shebang scripts
109# Source this file from ~/.config/fish/conf.d/ (or your fish config) to enable
110# <Tab> completion for any command on $PATH whose first line is a `usage`
111# shebang.
112
113function __usage_register_shebang_completions
114    # `type -P` (not `-q`/`-p`) so a shell function named `{usage_bin}` can't
115    # satisfy the check — only a real executable on $PATH does.
116    if not type -P {usage_bin} &> /dev/null
117        return 0
118    end
119    # `commandline -x` (fish 3.4+) tokenizes quoted/complex arguments more
120    # accurately than `-o`. Mirror the per-binary `complete_fish` detection so
121    # init-registered completions behave identically on modern fish.
122    set -l cmdline_pre_cmd 'commandline -opc'
123    if commandline -x >/dev/null 2>&1
124        set cmdline_pre_cmd 'commandline -xpc'
125    end
126    set -l registered
127    for dir in $PATH
128        test -d $dir; or continue
129        for file in $dir/*
130            test -f $file -a -x $file; or continue
131            # Skip files we can't read (e.g. setuid/execute-only binaries like
132            # macOS's sudo/visudo) — reading them below would make fish print
133            # a redirection warning on every shell startup.
134            test -r $file; or continue
135            # `string replace` works on every supported fish version; `path
136            # basename` requires fish 3.5+ which excludes Ubuntu 22.04 LTS.
137            set -l name (string replace -r '^.*/' '' -- $file)
138            contains -- $name $registered; and continue
139            # Cap the read at 128 chars so we don't buffer entire binary
140            # executables that have no newline.
141            set -l first
142            read -l -n 128 first <$file 2>/dev/null
143            if string match -q -- '#!*usage*' "$first"
144                complete -c $name -x -a "(command {usage_bin} complete-word --shell fish -f \"$file\" -- ($cmdline_pre_cmd) (commandline -t))"
145                set -a registered $name
146            end
147        end
148    end
149end
150
151__usage_register_shebang_completions
152# vim: noet ci pi sts=0 sw=4 ts=4
153"##
154    )
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::test::SPEC_KITCHEN_SINK;
161    use insta::assert_snapshot;
162
163    #[test]
164    fn test_complete_fish_init() {
165        assert_snapshot!(complete_fish_init("usage"));
166    }
167
168    #[test]
169    fn test_complete_fish() {
170        assert_snapshot!(complete_fish(&CompleteOptions {
171            usage_bin: "usage".to_string(),
172            shell: "fish".to_string(),
173            bin: "mycli".to_string(),
174            cache_key: None,
175            spec: None,
176            usage_cmd: Some("mycli complete --usage".to_string()),
177            include_bash_completion_lib: false,
178            source_file: None,
179        }));
180        assert_snapshot!(complete_fish(&CompleteOptions {
181            usage_bin: "usage".to_string(),
182            shell: "fish".to_string(),
183            bin: "mycli".to_string(),
184            cache_key: Some("1.2.3".to_string()),
185            spec: None,
186            usage_cmd: Some("mycli complete --usage".to_string()),
187            include_bash_completion_lib: false,
188            source_file: None,
189        }));
190        assert_snapshot!(complete_fish(&CompleteOptions {
191            usage_bin: "usage".to_string(),
192            shell: "fish".to_string(),
193            bin: "mycli".to_string(),
194            cache_key: None,
195            spec: Some(SPEC_KITCHEN_SINK.clone()),
196            usage_cmd: None,
197            include_bash_completion_lib: false,
198            source_file: None,
199        }));
200    }
201}