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