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