Add support for Nushell in shell builder (#33806)

Anthony Eid created

We also swap out env variables before sending them to shells now in the
task system. This fixed issues Fish and Nushell had where an empty
argument could be sent into a command when no argument should be sent.
This only happened from task's generated by Zed.

Closes #31297 #31240

Release Notes:

- Fix bug where spawning a Zed generated task or debug session with Fish
or Nushell failed

Change summary

crates/task/src/shell_builder.rs | 121 +++++++++++++++++++++++++++++++++
crates/task/src/task_template.rs |  10 +-
2 files changed, 123 insertions(+), 8 deletions(-)

Detailed changes

crates/task/src/shell_builder.rs 🔗

@@ -5,6 +5,7 @@ enum ShellKind {
     #[default]
     Posix,
     Powershell,
+    Nushell,
     Cmd,
 }
 
@@ -18,6 +19,8 @@ impl ShellKind {
             ShellKind::Powershell
         } else if program == "cmd" || program.ends_with("cmd.exe") {
             ShellKind::Cmd
+        } else if program == "nu" {
+            ShellKind::Nushell
         } else {
             // Someother shell detected, the user might install and use a
             // unix-like shell.
@@ -30,6 +33,7 @@ impl ShellKind {
             Self::Powershell => Self::to_powershell_variable(input),
             Self::Cmd => Self::to_cmd_variable(input),
             Self::Posix => input.to_owned(),
+            Self::Nushell => Self::to_nushell_variable(input),
         }
     }
 
@@ -70,11 +74,86 @@ impl ShellKind {
         }
     }
 
+    fn to_nushell_variable(input: &str) -> String {
+        let mut result = String::new();
+        let mut source = input;
+        let mut is_start = true;
+
+        loop {
+            match source.chars().next() {
+                None => return result,
+                Some('$') => {
+                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
+                    is_start = false;
+                }
+                Some(_) => {
+                    is_start = false;
+                    let chunk_end = source.find('$').unwrap_or(source.len());
+                    let (chunk, rest) = source.split_at(chunk_end);
+                    result.push_str(chunk);
+                    source = rest;
+                }
+            }
+        }
+    }
+
+    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
+        if source.starts_with("env.") {
+            text.push('$');
+            return source;
+        }
+
+        match source.chars().next() {
+            Some('{') => {
+                let source = &source[1..];
+                if let Some(end) = source.find('}') {
+                    let var_name = &source[..end];
+                    if !var_name.is_empty() {
+                        if !is_start {
+                            text.push_str("(");
+                        }
+                        text.push_str("$env.");
+                        text.push_str(var_name);
+                        if !is_start {
+                            text.push_str(")");
+                        }
+                        &source[end + 1..]
+                    } else {
+                        text.push_str("${}");
+                        &source[end + 1..]
+                    }
+                } else {
+                    text.push_str("${");
+                    source
+                }
+            }
+            Some(c) if c.is_alphabetic() || c == '_' => {
+                let end = source
+                    .find(|c: char| !c.is_alphanumeric() && c != '_')
+                    .unwrap_or(source.len());
+                let var_name = &source[..end];
+                if !is_start {
+                    text.push_str("(");
+                }
+                text.push_str("$env.");
+                text.push_str(var_name);
+                if !is_start {
+                    text.push_str(")");
+                }
+                &source[end..]
+            }
+            _ => {
+                text.push('$');
+                source
+            }
+        }
+    }
+
     fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
         match self {
             ShellKind::Powershell => vec!["-C".to_owned(), combined_command],
             ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
-            ShellKind::Posix => interactive
+            ShellKind::Posix | ShellKind::Nushell => interactive
                 .then(|| "-i".to_owned())
                 .into_iter()
                 .chain(["-c".to_owned(), combined_command])
@@ -142,9 +221,12 @@ impl ShellBuilder {
             ShellKind::Cmd => {
                 format!("{} /C '{}'", self.program, command_label)
             }
-            ShellKind::Posix => {
+            ShellKind::Posix | ShellKind::Nushell => {
                 let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
-                format!("{} {interactivity}-c '{}'", self.program, command_label)
+                format!(
+                    "{} {interactivity}-c '$\"{}\"'",
+                    self.program, command_label
+                )
             }
         }
     }
@@ -170,3 +252,36 @@ impl ShellBuilder {
         (self.program, self.args)
     }
 }
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn test_nu_shell_variable_substitution() {
+        let shell = Shell::Program("nu".to_owned());
+        let shell_builder = ShellBuilder::new(true, &shell);
+
+        let (program, args) = shell_builder.build(
+            Some("echo".into()),
+            &vec![
+                "${hello}".to_string(),
+                "$world".to_string(),
+                "nothing".to_string(),
+                "--$something".to_string(),
+                "$".to_string(),
+                "${test".to_string(),
+            ],
+        );
+
+        assert_eq!(program, "nu");
+        assert_eq!(
+            args,
+            vec![
+                "-i",
+                "-c",
+                "echo $env.hello $env.world nothing --($env.something) $ ${test"
+            ]
+        );
+    }
+}

crates/task/src/task_template.rs 🔗

@@ -256,7 +256,7 @@ impl TaskTemplate {
                     },
                 ),
                 command: Some(command),
-                args: self.args.clone(),
+                args: args_with_substitutions,
                 env,
                 use_new_terminal: self.use_new_terminal,
                 allow_concurrent_runs: self.allow_concurrent_runs,
@@ -642,11 +642,11 @@ mod tests {
             assert_eq!(
                 spawn_in_terminal.args,
                 &[
-                    "arg1 $ZED_SELECTED_TEXT",
-                    "arg2 $ZED_COLUMN",
-                    "arg3 $ZED_SYMBOL",
+                    "arg1 test_selected_text",
+                    "arg2 5678",
+                    "arg3 010101010101010101010101010101010101010101010101010101010101",
                 ],
-                "Args should not be substituted with variables"
+                "Args should be substituted with variables"
             );
             assert_eq!(
                 spawn_in_terminal.command_label,