1use crate::complete::CompleteOptions;
2use heck::ToSnakeCase;
3
4pub fn complete_bash(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 mut out = vec![];
14 let generated_comment = if let Some(source_file) = &opts.source_file {
15 format!("# @generated by usage-cli from {source_file}")
16 } else {
17 "# @generated by usage-cli from usage spec".to_string()
18 };
19 out.push(generated_comment);
20 if opts.include_bash_completion_lib {
21 out.push(include_str!("../../bash-completion/bash_completion").to_string());
22 out.push("\n".to_string());
23 };
24 out.push(format!(
25 r#"_{bin_snake}() {{
26 if ! type -p {usage_bin} &> /dev/null; then
27 echo >&2
28 echo "Error: {usage_bin} CLI not found. This is required for completions to work in {bin}." >&2
29 echo "See https://usage.jdx.dev for more information." >&2
30 return 1
31 fi"#));
32
33 let prune_stale = format!(
40 r#"find "$spec_dir" -maxdepth 1 -name 'usage__usage_spec_{bin_snake}_*.spec' -type f -mtime +30 -delete 2>/dev/null"#
41 );
42
43 let file_write_logic = if let Some(usage_cmd) = &opts.usage_cmd {
45 if opts.cache_key.is_some() {
46 format!(
47 r#"if [[ ! -f "$spec_file" ]]; then
48 {prune_stale}
49 {usage_cmd} >| "$spec_file"
50 fi"#
51 )
52 } else {
53 format!(r#"{usage_cmd} >| "$spec_file""#)
54 }
55 } else if let Some(spec) = &opts.spec {
56 let heredoc = format!(
57 r#"cat >| "$spec_file" <<'__USAGE_EOF__'
58{spec}
59__USAGE_EOF__"#,
60 spec = spec.to_string().trim()
61 );
62 if opts.cache_key.is_some() {
63 format!(
64 r#"if [[ ! -f "$spec_file" ]]; then
65 {prune_stale}
66 {heredoc}
67fi"#
68 )
69 } else {
70 heredoc.to_string()
71 }
72 } else {
73 String::new()
74 };
75
76 out.push(format!(
77 r#"
78 local cur prev words cword was_split comp_args
79 _comp_initialize -n : -- "$@" || return
80 local spec_dir="${{XDG_CACHE_HOME:-$HOME/.cache}}/usage"
81 [[ -d "$spec_dir" ]] || mkdir -p -m 700 "$spec_dir"
82 local spec_file="$spec_dir/usage_{spec_variable}.spec"
83 {file_write_logic}
84 # shellcheck disable=SC2207
85 _comp_compgen -- -W "$(command {usage_bin} complete-word --shell bash -f "$spec_file" --cword="$cword" -- "${{words[@]}}")"
86 _comp_ltrim_colon_completions "$cur"
87 # shellcheck disable=SC2181
88 if [[ $? -ne 0 ]]; then
89 unset COMPREPLY
90 fi
91 return 0
92}}
93
94if [[ "${{BASH_VERSINFO[0]}}" -eq 4 && "${{BASH_VERSINFO[1]}}" -ge 4 || "${{BASH_VERSINFO[0]}}" -gt 4 ]]; then
95 shopt -u hostcomplete && complete -o nospace -o bashdefault -o nosort -F _{bin_snake} {bin}
96else
97 shopt -u hostcomplete && complete -o nospace -o bashdefault -F _{bin_snake} {bin}
98fi
99# vim: noet ci pi sts=0 sw=4 ts=4 ft=sh
100"#
101 ));
102
103 out.join("\n")
104}
105
106pub fn complete_bash_init(usage_bin: &str) -> String {
116 format!(
117 r##"# @generated by usage-cli — auto-completion for usage shebang scripts
118# Source this file from your bashrc to enable <Tab> completion for any command
119# on $PATH whose first line is a `usage` shebang.
120#
121# Sourcing order: this script registers a `complete -D` default handler. Bash
122# only allows one such handler — whichever script registers last wins. Source
123# this AFTER bash-completion (e.g. `/etc/bash_completion` or
124# `/usr/share/bash-completion/bash_completion`) so that bash-completion's own
125# default handler is captured below and chained to for non-usage commands.
126
127# Capture any pre-existing `complete -D` handler so we can chain to it for
128# commands that aren't usage shebang scripts (e.g. bash-completion's loader).
129# `complete -p -D` exits non-zero if no -D handler is set; tolerate that under
130# `set -e`.
131_usage_chained_default_complete=""
132{{
133 _usage_existing_d="$(complete -p -D 2>/dev/null || true)"
134 if [[ "$_usage_existing_d" =~ -F[[:space:]]+([^[:space:]]+) ]]; then
135 _usage_chained_default_complete="${{BASH_REMATCH[1]}}"
136 fi
137 unset _usage_existing_d
138}}
139
140_usage_default_complete() {{
141 local cmd="${{COMP_WORDS[0]}}"
142 local cmdpath
143 if [[ "$cmd" == */* ]]; then
144 cmdpath="$cmd"
145 else
146 cmdpath="$(type -P "$cmd" 2>/dev/null)"
147 fi
148
149 if [[ -n "$cmdpath" && -f "$cmdpath" ]]; then
150 local first
151 if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then
152 if type -p {usage_bin} &> /dev/null; then
153 local IFS=$'\n'
154 # shellcheck disable=SC2207
155 COMPREPLY=( $(command {usage_bin} complete-word --shell bash -f "$cmdpath" --cword="$COMP_CWORD" -- "${{COMP_WORDS[@]}}") )
156 return 0
157 fi
158 fi
159 fi
160
161 # Chain to whatever default handler was registered before us, if any.
162 if [[ -n "$_usage_chained_default_complete" ]] \
163 && [[ "$_usage_chained_default_complete" != "_usage_default_complete" ]] \
164 && declare -F "$_usage_chained_default_complete" >/dev/null 2>&1; then
165 "$_usage_chained_default_complete" "$@"
166 return $?
167 fi
168 if declare -F _completion_loader >/dev/null 2>&1; then
169 _completion_loader "$@"
170 return $?
171 fi
172 return 1
173}}
174
175complete -D -F _usage_default_complete -o bashdefault -o default
176# vim: noet ci pi sts=0 sw=4 ts=4 ft=sh
177"##
178 )
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::test::SPEC_KITCHEN_SINK;
185 use insta::assert_snapshot;
186
187 #[test]
188 fn test_complete_bash_init() {
189 assert_snapshot!(complete_bash_init("usage"));
190 }
191
192 #[test]
193 fn test_complete_bash() {
194 assert_snapshot!(complete_bash(&CompleteOptions {
195 usage_bin: "usage".to_string(),
196 shell: "bash".to_string(),
197 bin: "mycli".to_string(),
198 cache_key: None,
199 spec: None,
200 usage_cmd: Some("mycli complete --usage".to_string()),
201 include_bash_completion_lib: false,
202 source_file: None,
203 }));
204 assert_snapshot!(complete_bash(&CompleteOptions {
205 usage_bin: "usage".to_string(),
206 shell: "bash".to_string(),
207 bin: "mycli".to_string(),
208 cache_key: Some("1.2.3".to_string()),
209 spec: None,
210 usage_cmd: Some("mycli complete --usage".to_string()),
211 include_bash_completion_lib: false,
212 source_file: None,
213 }));
214 assert_snapshot!(complete_bash(&CompleteOptions {
215 usage_bin: "usage".to_string(),
216 shell: "bash".to_string(),
217 bin: "mycli".to_string(),
218 cache_key: None,
219 spec: Some(SPEC_KITCHEN_SINK.clone()),
220 usage_cmd: None,
221 include_bash_completion_lib: false,
222 source_file: None,
223 }));
224 }
225}