Skip to main content

appcore_args/
shell.rs

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}