1use crate::Shell;
2
3#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
4pub enum ShellKind {
5 #[default]
6 Posix,
7 Csh,
8 Fish,
9 Powershell,
10 Nushell,
11 Cmd,
12}
13
14impl ShellKind {
15 pub fn system() -> Self {
16 Self::new(&system_shell())
17 }
18
19 pub fn new(program: &str) -> Self {
20 #[cfg(windows)]
21 let (_, program) = program.rsplit_once('\\').unwrap_or(("", program));
22 #[cfg(not(windows))]
23 let (_, program) = program.rsplit_once('/').unwrap_or(("", program));
24 if program == "powershell"
25 || program == "powershell.exe"
26 || program == "pwsh"
27 || program == "pwsh.exe"
28 {
29 ShellKind::Powershell
30 } else if program == "cmd" || program == "cmd.exe" {
31 ShellKind::Cmd
32 } else if program == "nu" {
33 ShellKind::Nushell
34 } else if program == "fish" {
35 ShellKind::Fish
36 } else if program == "csh" {
37 ShellKind::Csh
38 } else {
39 // Someother shell detected, the user might install and use a
40 // unix-like shell.
41 ShellKind::Posix
42 }
43 }
44
45 fn to_shell_variable(self, input: &str) -> String {
46 match self {
47 Self::Powershell => Self::to_powershell_variable(input),
48 Self::Cmd => Self::to_cmd_variable(input),
49 Self::Posix => input.to_owned(),
50 Self::Fish => input.to_owned(),
51 Self::Csh => input.to_owned(),
52 Self::Nushell => Self::to_nushell_variable(input),
53 }
54 }
55
56 fn to_cmd_variable(input: &str) -> String {
57 if let Some(var_str) = input.strip_prefix("${") {
58 if var_str.find(':').is_none() {
59 // If the input starts with "${", remove the trailing "}"
60 format!("%{}%", &var_str[..var_str.len() - 1])
61 } else {
62 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
63 // which will result in the task failing to run in such cases.
64 input.into()
65 }
66 } else if let Some(var_str) = input.strip_prefix('$') {
67 // If the input starts with "$", directly append to "$env:"
68 format!("%{}%", var_str)
69 } else {
70 // If no prefix is found, return the input as is
71 input.into()
72 }
73 }
74 fn to_powershell_variable(input: &str) -> String {
75 if let Some(var_str) = input.strip_prefix("${") {
76 if var_str.find(':').is_none() {
77 // If the input starts with "${", remove the trailing "}"
78 format!("$env:{}", &var_str[..var_str.len() - 1])
79 } else {
80 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
81 // which will result in the task failing to run in such cases.
82 input.into()
83 }
84 } else if let Some(var_str) = input.strip_prefix('$') {
85 // If the input starts with "$", directly append to "$env:"
86 format!("$env:{}", var_str)
87 } else {
88 // If no prefix is found, return the input as is
89 input.into()
90 }
91 }
92
93 fn to_nushell_variable(input: &str) -> String {
94 let mut result = String::new();
95 let mut source = input;
96 let mut is_start = true;
97
98 loop {
99 match source.chars().next() {
100 None => return result,
101 Some('$') => {
102 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
103 is_start = false;
104 }
105 Some(_) => {
106 is_start = false;
107 let chunk_end = source.find('$').unwrap_or(source.len());
108 let (chunk, rest) = source.split_at(chunk_end);
109 result.push_str(chunk);
110 source = rest;
111 }
112 }
113 }
114 }
115
116 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
117 if source.starts_with("env.") {
118 text.push('$');
119 return source;
120 }
121
122 match source.chars().next() {
123 Some('{') => {
124 let source = &source[1..];
125 if let Some(end) = source.find('}') {
126 let var_name = &source[..end];
127 if !var_name.is_empty() {
128 if !is_start {
129 text.push_str("(");
130 }
131 text.push_str("$env.");
132 text.push_str(var_name);
133 if !is_start {
134 text.push_str(")");
135 }
136 &source[end + 1..]
137 } else {
138 text.push_str("${}");
139 &source[end + 1..]
140 }
141 } else {
142 text.push_str("${");
143 source
144 }
145 }
146 Some(c) if c.is_alphabetic() || c == '_' => {
147 let end = source
148 .find(|c: char| !c.is_alphanumeric() && c != '_')
149 .unwrap_or(source.len());
150 let var_name = &source[..end];
151 if !is_start {
152 text.push_str("(");
153 }
154 text.push_str("$env.");
155 text.push_str(var_name);
156 if !is_start {
157 text.push_str(")");
158 }
159 &source[end..]
160 }
161 _ => {
162 text.push('$');
163 source
164 }
165 }
166 }
167
168 fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
169 match self {
170 ShellKind::Powershell => vec!["-C".to_owned(), combined_command],
171 ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
172 ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => interactive
173 .then(|| "-i".to_owned())
174 .into_iter()
175 .chain(["-c".to_owned(), combined_command])
176 .collect(),
177 }
178 }
179}
180
181fn system_shell() -> String {
182 if cfg!(target_os = "windows") {
183 // `alacritty_terminal` uses this as default on Windows. See:
184 // https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130
185 // We could use `util::get_windows_system_shell()` here, but we are running tasks here, so leave it to `powershell.exe`
186 // should be okay.
187 "powershell.exe".to_string()
188 } else {
189 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
190 }
191}
192
193/// ShellBuilder is used to turn a user-requested task into a
194/// program that can be executed by the shell.
195pub struct ShellBuilder {
196 /// The shell to run
197 program: String,
198 args: Vec<String>,
199 interactive: bool,
200 kind: ShellKind,
201}
202
203impl ShellBuilder {
204 /// Create a new ShellBuilder as configured.
205 pub fn new(remote_system_shell: Option<&str>, shell: &Shell) -> Self {
206 let (program, args) = match shell {
207 Shell::System => match remote_system_shell {
208 Some(remote_shell) => (remote_shell.to_string(), Vec::new()),
209 None => (system_shell(), Vec::new()),
210 },
211 Shell::Program(shell) => (shell.clone(), Vec::new()),
212 Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
213 };
214 let kind = ShellKind::new(&program);
215 Self {
216 program,
217 args,
218 interactive: true,
219 kind,
220 }
221 }
222 pub fn non_interactive(mut self) -> Self {
223 self.interactive = false;
224 self
225 }
226
227 /// Returns the label to show in the terminal tab
228 pub fn command_label(&self, command_label: &str) -> String {
229 match self.kind {
230 ShellKind::Powershell => {
231 format!("{} -C '{}'", self.program, command_label)
232 }
233 ShellKind::Cmd => {
234 format!("{} /C '{}'", self.program, command_label)
235 }
236 ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => {
237 let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
238 format!(
239 "{} {interactivity}-c '$\"{}\"'",
240 self.program, command_label
241 )
242 }
243 }
244 }
245 /// Returns the program and arguments to run this task in a shell.
246 pub fn build(
247 mut self,
248 task_command: Option<String>,
249 task_args: &[String],
250 ) -> (String, Vec<String>) {
251 if let Some(task_command) = task_command {
252 let combined_command = task_args.iter().fold(task_command, |mut command, arg| {
253 command.push(' ');
254 command.push_str(&self.kind.to_shell_variable(arg));
255 command
256 });
257
258 self.args
259 .extend(self.kind.args_for_shell(self.interactive, combined_command));
260 }
261
262 (self.program, self.args)
263 }
264}
265
266#[cfg(test)]
267mod test {
268 use super::*;
269
270 #[test]
271 fn test_nu_shell_variable_substitution() {
272 let shell = Shell::Program("nu".to_owned());
273 let shell_builder = ShellBuilder::new(None, &shell);
274
275 let (program, args) = shell_builder.build(
276 Some("echo".into()),
277 &[
278 "${hello}".to_string(),
279 "$world".to_string(),
280 "nothing".to_string(),
281 "--$something".to_string(),
282 "$".to_string(),
283 "${test".to_string(),
284 ],
285 );
286
287 assert_eq!(program, "nu");
288 assert_eq!(
289 args,
290 vec![
291 "-i",
292 "-c",
293 "echo $env.hello $env.world nothing --($env.something) $ ${test"
294 ]
295 );
296 }
297}