Skip to main content

appcore_args/
shell.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: shell.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/19 12:52:57 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/19 13:34:54 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11use 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}