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 pub fn from_env() -> Result<Self> {
114 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 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
179pub 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 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 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", &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 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 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}