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