Skip to main content

television/utils/
shell.rs

1use crate::{
2    cli::args::{Cli, Shell as CliShell},
3    config::shell_integration::ShellIntegrationConfig,
4};
5use anyhow::Result;
6use clap::CommandFactory;
7use std::fmt::Display;
8use tracing::{debug, warn};
9
10#[derive(
11    Debug, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize,
12)]
13#[serde(rename_all = "lowercase")]
14pub enum Shell {
15    Bash,
16    Zsh,
17    Fish,
18    #[serde(rename = "powershell")]
19    Psh,
20    Cmd,
21    Nu,
22}
23
24impl Default for Shell {
25    #[cfg(unix)]
26    fn default() -> Self {
27        Shell::Bash
28    }
29
30    #[cfg(windows)]
31    fn default() -> Self {
32        Shell::Psh
33    }
34}
35
36#[derive(Debug)]
37pub enum ShellError {
38    UnsupportedShell(String),
39}
40
41impl TryFrom<Shell> for clap_complete::Shell {
42    type Error = ShellError;
43
44    fn try_from(value: Shell) -> std::result::Result<Self, Self::Error> {
45        match value {
46            Shell::Bash => Ok(clap_complete::Shell::Bash),
47            Shell::Zsh => Ok(clap_complete::Shell::Zsh),
48            Shell::Fish => Ok(clap_complete::Shell::Fish),
49            Shell::Psh => Ok(clap_complete::Shell::PowerShell),
50            Shell::Cmd => Err(ShellError::UnsupportedShell(
51                "Cmd shell is not supported for completion scripts"
52                    .to_string(),
53            )),
54            Shell::Nu => Err(ShellError::UnsupportedShell(
55                "Nu shell is not supported for completion scripts".to_string(),
56            )),
57        }
58    }
59}
60
61impl Display for Shell {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Shell::Bash => write!(f, "bash"),
65            Shell::Zsh => write!(f, "zsh"),
66            Shell::Fish => write!(f, "fish"),
67            Shell::Psh => write!(f, "powershell"),
68            Shell::Cmd => write!(f, "cmd"),
69            Shell::Nu => write!(f, "nu"),
70        }
71    }
72}
73
74const SHELL_ENV_VAR: &str = "SHELL";
75
76impl TryFrom<&str> for Shell {
77    type Error = anyhow::Error;
78
79    fn try_from(value: &str) -> Result<Self> {
80        if value.contains("bash") {
81            Ok(Shell::Bash)
82        } else if value.contains("zsh") {
83            Ok(Shell::Zsh)
84        } else if value.contains("fish") {
85            Ok(Shell::Fish)
86        } else if value.contains("powershell") {
87            Ok(Shell::Psh)
88        } else if value.contains("cmd") {
89            Ok(Shell::Cmd)
90        } else if value.contains("nu") {
91            Ok(Shell::Nu)
92        } else {
93            Err(anyhow::anyhow!("Unsupported shell: {}", value))
94        }
95    }
96}
97
98impl Shell {
99    #[allow(clippy::borrow_interior_mutable_const)]
100    #[cfg(unix)]
101    pub fn from_env() -> Result<Self> {
102        if let Ok(shell) = std::env::var(SHELL_ENV_VAR) {
103            Shell::try_from(shell.as_str())
104        } else {
105            debug!("Environment variable {} not set", SHELL_ENV_VAR);
106            Ok(Shell::default())
107        }
108    }
109
110    #[cfg(windows)]
111    /// There isn't a straightforward way of doing this on Windows so we poke around
112    /// to make an educated guess.
113    pub fn from_env() -> Result<Self> {
114        // If SHELL is set, try to use it
115        if let Ok(shell) = std::env::var(SHELL_ENV_VAR) {
116            return Shell::try_from(shell.as_str());
117        }
118        if std::env::var("POWERSHELL_DISTRIBUTION_CHANNEL").is_ok()
119            || std::env::var("PSModulePath").is_ok()
120        {
121            return Ok(Shell::Psh);
122        }
123        // Otherwise, fall back to COMSPEC
124        if let Ok(shell) = std::env::var("COMSPEC") {
125            if shell.to_lowercase().contains("powershell") {
126                return Ok(Shell::Psh);
127            } else if shell.to_lowercase().contains("cmd") {
128                return Ok(Shell::Cmd);
129            }
130        }
131        debug!("Environment variable COMSPEC not set");
132        Ok(Shell::default())
133    }
134
135    pub fn executable(&self) -> &'static str {
136        match self {
137            Shell::Bash => "bash",
138            Shell::Zsh => "zsh",
139            Shell::Fish => "fish",
140            Shell::Psh => "powershell",
141            Shell::Cmd => "cmd",
142            Shell::Nu => "nu",
143        }
144    }
145}
146
147impl From<CliShell> for Shell {
148    fn from(val: CliShell) -> Self {
149        match val {
150            CliShell::Bash => Shell::Bash,
151            CliShell::Zsh => Shell::Zsh,
152            CliShell::Fish => Shell::Fish,
153            CliShell::PowerShell => Shell::Psh,
154            CliShell::Cmd => Shell::Cmd,
155            CliShell::Nu => Shell::Nu,
156        }
157    }
158}
159
160impl From<&CliShell> for Shell {
161    fn from(val: &CliShell) -> Self {
162        match val {
163            CliShell::Bash => Shell::Bash,
164            CliShell::Zsh => Shell::Zsh,
165            CliShell::Fish => Shell::Fish,
166            CliShell::PowerShell => Shell::Psh,
167            CliShell::Cmd => Shell::Cmd,
168            CliShell::Nu => Shell::Nu,
169        }
170    }
171}
172
173const COMPLETION_ZSH: &str = include_str!("shell/completion.zsh");
174const COMPLETION_BASH: &str = include_str!("shell/completion.bash");
175const COMPLETION_FISH: &str = include_str!("shell/completion.fish");
176const COMPLETION_NU: &str = include_str!("shell/completion.nu");
177const COMPLETION_POWERSHELL: &str = include_str!("shell/completion.ps1");
178
179// create the appropriate key binding for each supported shell
180pub fn ctrl_keybinding(shell: Shell, character: char) -> Result<String> {
181    match shell {
182        Shell::Bash => Ok(format!(r"\C-{character}")),
183        Shell::Zsh => Ok(format!(r"^{character}")),
184        Shell::Fish => {
185            if character == ' ' {
186                Ok(r"ctrl-space".to_string())
187            } else {
188                let lower_char = character.to_ascii_lowercase();
189                Ok(format!(r"ctrl-{lower_char}"))
190            }
191        }
192        Shell::Nu => Ok(format!(r"Ctrl-{character}")),
193        Shell::Psh => Ok(format!(r"Ctrl+{}", character.to_ascii_lowercase())),
194        Shell::Cmd => {
195            anyhow::bail!("This shell is not yet supported: {:?}", shell)
196        }
197    }
198}
199
200pub fn completion_script(shell: Shell) -> Result<&'static str> {
201    match shell {
202        Shell::Bash => Ok(COMPLETION_BASH),
203        Shell::Zsh => Ok(COMPLETION_ZSH),
204        Shell::Fish => Ok(COMPLETION_FISH),
205        Shell::Nu => Ok(COMPLETION_NU),
206        Shell::Psh => Ok(COMPLETION_POWERSHELL),
207        Shell::Cmd => {
208            anyhow::bail!("This shell is not yet supported: {:?}", shell)
209        }
210    }
211}
212
213pub fn render_autocomplete_script_template(
214    shell: Shell,
215    template: &str,
216    config: &ShellIntegrationConfig,
217) -> Result<String> {
218    // Custom autocomplete
219    let script = template
220        .replace(
221            "{tv_smart_autocomplete_keybinding}",
222            &ctrl_keybinding(
223                shell,
224                config.get_shell_autocomplete_keybinding_character(),
225            )?,
226        )
227        .replace(
228            "{tv_shell_history_keybinding}",
229            &ctrl_keybinding(
230                shell,
231                config.get_command_history_keybinding_character(),
232            )?,
233        );
234
235    let clap_autocomplete =
236        render_clap_autocomplete(shell).unwrap_or_default();
237
238    Ok(clap_autocomplete + &script)
239}
240
241pub fn render_clap_autocomplete(shell: Shell) -> Option<String> {
242    // Clap autocomplete
243    let mut clap_autocomplete = vec![];
244    let mut cmd = Cli::command();
245    let clap_shell: clap_complete::Shell = match shell.try_into() {
246        Ok(shell) => shell,
247        Err(err) => {
248            warn!("Failed to convert shell {:?}: {:?}", shell, err);
249            return None;
250        }
251    };
252
253    clap_complete::aot::generate(
254        clap_shell,
255        &mut cmd,
256        "tv", // the command defines the name as "television"
257        &mut clap_autocomplete,
258    );
259
260    String::from_utf8(clap_autocomplete).ok()
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_bash_ctrl_keybinding() {
269        let character = 's';
270        let shell = Shell::Bash;
271        let result = ctrl_keybinding(shell, character);
272        assert!(result.is_ok());
273        assert_eq!(result.unwrap(), "\\C-s");
274    }
275
276    #[test]
277    fn test_zsh_ctrl_keybinding() {
278        let character = 's';
279        let shell = Shell::Zsh;
280        let result = ctrl_keybinding(shell, character);
281        assert!(result.is_ok());
282        assert_eq!(result.unwrap(), "^s");
283    }
284
285    #[test]
286    fn test_fish_ctrl_keybinding() {
287        let character = 's';
288        let shell = Shell::Fish;
289        let result = ctrl_keybinding(shell, character);
290        assert!(result.is_ok());
291        assert_eq!(result.unwrap(), "ctrl-s");
292    }
293
294    #[test]
295    fn test_powershell_ctrl_keybinding() {
296        let character = 's';
297        let shell = Shell::Psh;
298        let result = ctrl_keybinding(shell, character);
299        assert!(result.is_ok());
300        assert_eq!(result.unwrap(), "Ctrl+s");
301    }
302
303    #[test]
304    fn test_powershell_ctrl_keybinding_uppercase_input() {
305        // PowerShell keybindings should be lowercase even if input is uppercase
306        let character = 'S';
307        let shell = Shell::Psh;
308        let result = ctrl_keybinding(shell, character);
309        assert!(result.is_ok());
310        assert_eq!(result.unwrap(), "Ctrl+s");
311    }
312
313    #[test]
314    fn test_nushell_ctrl_keybinding() {
315        let character = 's';
316        let shell = Shell::Nu;
317        let result = ctrl_keybinding(shell, character);
318        assert!(result.is_ok());
319        assert_eq!(result.unwrap(), "Ctrl-s");
320    }
321
322    #[test]
323    fn test_zsh_clap_completion() {
324        let shell = Shell::Zsh;
325        let result = render_autocomplete_script_template(
326            shell,
327            "",
328            &ShellIntegrationConfig::default(),
329        );
330        assert!(result.is_ok());
331        let result = result.unwrap();
332        assert!(result.contains("compdef _tv tv"));
333    }
334
335    #[test]
336    fn test_unsupported_clap_completion() {
337        let shell = Shell::Nu;
338        let result = render_autocomplete_script_template(
339            shell,
340            "",
341            &ShellIntegrationConfig::default(),
342        );
343        assert!(result.is_ok());
344        let result = result.unwrap();
345        assert!(result.is_empty());
346    }
347
348    #[test]
349    fn test_powershell_clap_completion() {
350        let shell = Shell::Psh;
351        let result = render_autocomplete_script_template(
352            shell,
353            "",
354            &ShellIntegrationConfig::default(),
355        );
356        assert!(result.is_ok());
357        let result = result.unwrap();
358        // PowerShell completion should contain the completion function
359        assert!(result.contains("Register-ArgumentCompleter"));
360    }
361
362    #[test]
363    fn test_powershell_completion_script() {
364        let shell = Shell::Psh;
365        let result = completion_script(shell);
366        assert!(result.is_ok());
367        let script = result.unwrap();
368        assert!(script.contains("Invoke-TvSmartAutocomplete"));
369        assert!(script.contains("Invoke-TvShellHistory"));
370        assert!(script.contains("Set-PSReadLineKeyHandler"));
371    }
372}