1use std::fmt;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Shell {
15 Bash,
16 Zsh,
17 Fish,
18 PowerShell,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct ShellScriptError {
23 message: String,
24}
25
26impl Shell {
27 pub fn parse(value: &str) -> Option<Self> {
28 match value {
29 "bash" => Some(Self::Bash),
30 "zsh" => Some(Self::Zsh),
31 "fish" => Some(Self::Fish),
32 "powershell" | "pwsh" => Some(Self::PowerShell),
33 _ => None,
34 }
35 }
36}
37
38impl fmt::Display for Shell {
39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40 formatter.write_str(match self {
41 Self::Bash => "bash",
42 Self::Zsh => "zsh",
43 Self::Fish => "fish",
44 Self::PowerShell => "powershell",
45 })
46 }
47}
48
49pub fn render_dynamic_completion_script(
50 binary: &str,
51 completion_command: &[&str],
52 shell: Shell,
53) -> Result<String, ShellScriptError> {
54 validate_command_token("binary", binary)?;
55 for part in completion_command {
56 validate_command_token("completion command", part)?;
57 }
58 Ok(match shell {
59 Shell::Bash => bash_script(binary, completion_command),
60 Shell::Zsh => zsh_script(binary, completion_command),
61 Shell::Fish => fish_script(binary, completion_command),
62 Shell::PowerShell => powershell_script(binary, completion_command),
63 })
64}
65
66impl fmt::Display for ShellScriptError {
67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68 formatter.write_str(&self.message)
69 }
70}
71
72impl std::error::Error for ShellScriptError {}
73
74fn validate_command_token(kind: &str, value: &str) -> Result<(), ShellScriptError> {
75 let valid = !value.is_empty()
76 && value
77 .bytes()
78 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
79 if valid {
80 Ok(())
81 } else {
82 Err(ShellScriptError {
83 message: format!("unsafe {kind} token `{value}`"),
84 })
85 }
86}
87
88fn command_prefix(binary: &str, completion_command: &[&str]) -> String {
89 let mut parts = vec![binary.to_string()];
90 parts.extend(completion_command.iter().map(|part| part.to_string()));
91 parts.join(" ")
92}
93
94fn bash_script(binary: &str, completion_command: &[&str]) -> String {
95 let function = function_name(binary);
96 let prefix = command_prefix(binary, completion_command);
97 format!(
98 r#"{function}() {{
99 local IFS=$'\n'
100 COMPREPLY=( $({prefix} bash "$COMP_CWORD" "${{COMP_WORDS[@]}}") )
101}}
102complete -o bashdefault -o default -F {function} {binary}
103"#
104 )
105}
106
107fn zsh_script(binary: &str, completion_command: &[&str]) -> String {
108 let function = function_name(binary);
109 let prefix = command_prefix(binary, completion_command);
110 format!(
111 r#"{function}() {{
112 local -a completions
113 completions=("${{(@f)$({prefix} zsh "$((CURRENT - 1))" "${{words[@]}}")}}")
114 if (( ${{#completions}} )); then
115 compadd -- "${{completions[@]}}"
116 else
117 _files
118 fi
119}}
120compdef {function} {binary}
121"#
122 )
123}
124
125fn fish_script(binary: &str, completion_command: &[&str]) -> String {
126 let prefix = command_prefix(binary, completion_command);
127 format!(
128 "complete -c {binary} -a '(set -l words (commandline -opc); set -a words (commandline -ct); set -l cursor (math (count $words) - 1); {prefix} fish $cursor $words)'\n"
129 )
130}
131
132fn powershell_script(binary: &str, completion_command: &[&str]) -> String {
133 let prefix = command_prefix(binary, completion_command);
134 format!(
135 r#"Register-ArgumentCompleter -Native -CommandName '{binary}' -ScriptBlock {{
136 param($wordToComplete, $commandAst, $cursorPosition)
137 $words = @($commandAst.CommandElements | ForEach-Object {{ $_.ToString() }})
138 $cursorWord = [Math]::Max(0, $words.Count - 1)
139 $custom = @(& {prefix} powershell $cursorWord @words)
140 if ($custom.Count -gt 0) {{
141 $custom | ForEach-Object {{ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }}
142 }} else {{
143 Get-ChildItem -Name -Path "$wordToComplete*" -ErrorAction SilentlyContinue |
144 ForEach-Object {{ [System.Management.Automation.CompletionResult]::new($_, $_, 'ProviderItem', $_) }}
145 }}
146}}
147"#
148 )
149}
150
151fn function_name(binary: &str) -> String {
152 let normalized = binary
153 .chars()
154 .map(|character| {
155 if character.is_ascii_alphanumeric() {
156 character
157 } else {
158 '_'
159 }
160 })
161 .collect::<String>();
162 format!("_{normalized}_complete")
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{render_dynamic_completion_script, Shell};
168
169 #[test]
170 fn renders_bash_script_for_binary() {
171 let script =
172 render_dynamic_completion_script("appcore-dev", &["complete"], Shell::Bash).unwrap();
173
174 assert!(script
175 .contains("complete -o bashdefault -o default -F _appcore_dev_complete appcore-dev"));
176 }
177
178 #[test]
179 fn generated_scripts_preserve_native_file_completion() {
180 let zsh = render_dynamic_completion_script("demo", &["complete"], Shell::Zsh).unwrap();
181 let fish = render_dynamic_completion_script("demo", &["complete"], Shell::Fish).unwrap();
182 let powershell =
183 render_dynamic_completion_script("demo", &["complete"], Shell::PowerShell).unwrap();
184
185 assert!(zsh.contains("_files"));
186 assert!(!fish.contains(" -f "));
187 assert!(powershell.contains("Get-ChildItem"));
188 }
189
190 #[test]
191 fn rejects_shell_metacharacters_in_command_tokens() {
192 let error =
193 render_dynamic_completion_script("appcore-dev;echo", &["complete"], Shell::PowerShell)
194 .unwrap_err();
195
196 assert!(error.to_string().contains("unsafe binary token"));
197 }
198}