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 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 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
96pub 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}