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