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