use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{borrow::Cow, fmt, path::Path};
#[cfg(windows)]
use std::{path::PathBuf, sync::LazyLock};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Shell {
#[default]
System,
Program(String),
WithArguments {
program: String,
args: Vec<String>,
title_override: Option<String>,
},
}
impl Shell {
pub fn program(&self) -> String {
match self {
Shell::Program(program) => program.clone(),
Shell::WithArguments { program, .. } => program.clone(),
Shell::System => get_system_shell(),
}
}
pub fn program_and_args(&self) -> (String, &[String]) {
match self {
Shell::Program(program) => (program.clone(), &[]),
Shell::WithArguments { program, args, .. } => (program.clone(), args),
Shell::System => (get_system_shell(), &[]),
}
}
pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
match self {
Shell::Program(program) => ShellKind::new(program, is_windows),
Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
Shell::System => ShellKind::system(),
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ShellKind {
#[default]
Posix,
Csh,
Tcsh,
Rc,
Fish,
PowerShell,
Pwsh,
Nushell,
Cmd,
Xonsh,
Elvish,
}
pub fn get_system_shell() -> String {
if cfg!(windows) {
get_windows_system_shell()
} else {
std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
}
}
pub fn get_default_system_shell() -> String {
if cfg!(windows) {
get_windows_system_shell()
} else {
"/bin/sh".to_string()
}
}
pub fn get_default_system_shell_preferring_bash() -> String {
#[cfg(windows)]
{
get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
}
#[cfg(not(windows))]
{
"/bin/sh".to_string()
}
}
#[cfg(windows)]
pub fn get_windows_bash() -> Option<String> {
fn find_bash_in_installation(install_root: &Path) -> Option<PathBuf> {
if !install_root.join("git-bash.exe").is_file() {
return None;
}
let bash = install_root.join("bin").join("bash.exe");
bash.is_file().then_some(bash)
}
fn find_bash_in_git() -> Option<PathBuf> {
if let Some(bash) = std::env::var_os("GIT_INSTALL_ROOT")
.map(PathBuf::from)
.and_then(|path| find_bash_in_installation(&path))
{
return Some(bash);
}
let git = which::which("git").ok()?;
let binary_directory = git.parent()?;
let parent = binary_directory.parent()?;
find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?))
}
static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned());
if let Some(ref path) = bash {
log::info!("Found bash at {}", path);
}
bash
});
(*BASH).clone()
}
pub fn get_windows_system_shell() -> String {
#[cfg(windows)]
return gpui_util::get_windows_system_shell();
#[cfg(not(windows))]
return "cmd.exe".to_string();
}
impl fmt::Display for ShellKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ShellKind::Posix => write!(f, "sh"),
ShellKind::Csh => write!(f, "csh"),
ShellKind::Tcsh => write!(f, "tcsh"),
ShellKind::Fish => write!(f, "fish"),
ShellKind::PowerShell => write!(f, "powershell"),
ShellKind::Pwsh => write!(f, "pwsh"),
ShellKind::Nushell => write!(f, "nu"),
ShellKind::Cmd => write!(f, "cmd"),
ShellKind::Rc => write!(f, "rc"),
ShellKind::Xonsh => write!(f, "xonsh"),
ShellKind::Elvish => write!(f, "elvish"),
}
}
}
impl ShellKind {
pub fn system() -> Self {
Self::new(&get_system_shell(), cfg!(windows))
}
pub fn supports_posix_chaining(&self) -> bool {
matches!(
self,
ShellKind::Posix
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Cmd
| ShellKind::Xonsh
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Nushell
| ShellKind::Elvish
| ShellKind::Rc
)
}
pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
let program = program.as_ref();
let program = program
.file_stem()
.unwrap_or_else(|| program.as_os_str())
.to_string_lossy();
match &*program {
"powershell" => ShellKind::PowerShell,
"pwsh" => ShellKind::Pwsh,
"cmd" => ShellKind::Cmd,
"nu" => ShellKind::Nushell,
"fish" => ShellKind::Fish,
"csh" => ShellKind::Csh,
"tcsh" => ShellKind::Tcsh,
"rc" => ShellKind::Rc,
"xonsh" => ShellKind::Xonsh,
"elvish" => ShellKind::Elvish,
"sh" | "bash" | "zsh" => ShellKind::Posix,
_ if is_windows => ShellKind::PowerShell,
_ => ShellKind::Posix,
}
}
pub fn to_shell_variable(self, input: &str) -> String {
match self {
Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
Self::Cmd => Self::to_cmd_variable(input),
Self::Posix => input.to_owned(),
Self::Fish => input.to_owned(),
Self::Csh => input.to_owned(),
Self::Tcsh => input.to_owned(),
Self::Rc => input.to_owned(),
Self::Nushell => Self::to_nushell_variable(input),
Self::Xonsh => input.to_owned(),
Self::Elvish => input.to_owned(),
}
}
fn to_cmd_variable(input: &str) -> String {
if let Some(var_str) = input.strip_prefix("${") {
match var_str.strip_suffix('}') {
Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
format!("%{var_name}%")
}
_ => input.into(),
}
} else if let Some(var_str) = input.strip_prefix('$') {
format!("%{}%", var_str)
} else {
input.into()
}
}
fn to_powershell_variable(input: &str) -> String {
if let Some(var_str) = input.strip_prefix("${") {
match var_str.strip_suffix('}') {
Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
format!("$env:{var_name}")
}
_ => input.into(),
}
} else if let Some(var_str) = input.strip_prefix('$') {
format!("$env:{}", var_str)
} else {
input.into()
}
}
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
}
}
}
pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
match self {
ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
ShellKind::Cmd => vec![
"/S".to_owned(),
"/C".to_owned(),
format!("\"{combined_command}\""),
],
ShellKind::Posix
| ShellKind::Nushell
| ShellKind::Fish
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Xonsh
| ShellKind::Elvish => interactive
.then(|| "-i".to_owned())
.into_iter()
.chain(["-c".to_owned(), combined_command])
.collect(),
}
}
pub const fn command_prefix(&self) -> Option<char> {
match self {
ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
ShellKind::Nushell => Some('^'),
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Cmd
| ShellKind::Xonsh
| ShellKind::Elvish => None,
}
}
pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
match self.command_prefix() {
Some(prefix) if !command.starts_with(prefix) => {
Cow::Owned(format!("{prefix}{command}"))
}
_ => Cow::Borrowed(command),
}
}
pub const fn sequential_commands_separator(&self) -> char {
match self {
ShellKind::Cmd => '&',
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => ';',
}
}
pub const fn sequential_and_commands_separator(&self) -> &'static str {
match self {
ShellKind::Cmd
| ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Pwsh
| ShellKind::Xonsh => "&&",
ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
}
}
pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
match self {
ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
ShellKind::Cmd => Some(Self::quote_cmd(arg)),
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => shlex::try_quote(arg).ok(),
}
}
fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
if arg.is_empty() {
return Cow::Borrowed("\"\"");
}
let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
if !needs_quoting {
return Cow::Borrowed(arg);
}
let mut result = String::with_capacity(arg.len() + 2);
if enclose {
result.push('"');
}
let chars: Vec<char> = arg.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '\\' {
let mut num_backslashes = 0;
while i < chars.len() && chars[i] == '\\' {
num_backslashes += 1;
i += 1;
}
if i < chars.len() && chars[i] == '"' {
for _ in 0..(num_backslashes * 2 + 1) {
result.push('\\');
}
result.push('"');
i += 1;
} else if i >= chars.len() {
for _ in 0..(num_backslashes * 2) {
result.push('\\');
}
} else {
for _ in 0..num_backslashes {
result.push('\\');
}
}
} else if chars[i] == '"' {
result.push('\\');
result.push('"');
i += 1;
} else {
result.push(chars[i]);
i += 1;
}
}
if enclose {
result.push('"');
}
Cow::Owned(result)
}
fn needs_quoting_powershell(s: &str) -> bool {
s.is_empty()
|| s.chars().any(|c| {
c.is_whitespace()
|| matches!(
c,
'"' | '`'
| '$'
| '&'
| '|'
| '<'
| '>'
| ';'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| ','
| '\''
| '@'
)
})
}
fn need_quotes_powershell(arg: &str) -> bool {
let mut quote_count = 0;
for c in arg.chars() {
if c == '"' {
quote_count += 1;
} else if c.is_whitespace() && (quote_count % 2 == 0) {
return true;
}
}
false
}
fn escape_powershell_quotes(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
result.push('\'');
for c in s.chars() {
if c == '\'' {
result.push('\'');
}
result.push(c);
}
result.push('\'');
result
}
pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
let ps_will_quote = Self::need_quotes_powershell(arg);
let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
if !Self::needs_quoting_powershell(arg) {
return crt_quoted;
}
Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
}
pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
if arg.is_empty() {
return Cow::Borrowed("''");
}
if !Self::needs_quoting_powershell(arg) {
return Cow::Borrowed(arg);
}
Cow::Owned(Self::escape_powershell_quotes(arg))
}
pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
let crt_quoted = Self::quote_windows(arg, true);
let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
if !needs_cmd_escaping {
return crt_quoted;
}
let mut result = String::with_capacity(crt_quoted.len() * 2);
for c in crt_quoted.chars() {
match c {
'^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
result.push('^');
result.push(c);
}
'%' => {
result.push_str("%%cd:~,%");
}
_ => result.push(c),
}
}
Cow::Owned(result)
}
pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
if let Some(char) = self.command_prefix() {
if let Some(arg) = arg.strip_prefix(char) {
for quote in ['\'', '"'] {
if let Some(arg) = arg
.strip_prefix(quote)
.and_then(|arg| arg.strip_suffix(quote))
{
let quoted = self.try_quote(arg)?;
return Some(if quoted.starts_with(['\'', '"']) {
Cow::Owned(self.prepend_command_prefix("ed).into_owned())
} else {
Cow::Owned(
self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
.into_owned(),
)
});
}
}
return self
.try_quote(arg)
.map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned()));
}
}
self.try_quote(arg).map(|quoted| match quoted {
unquoted @ Cow::Borrowed(_) => unquoted,
Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()),
})
}
pub fn split(&self, input: &str) -> Option<Vec<String>> {
shlex::split(input)
}
pub const fn activate_keyword(&self) -> &'static str {
match self {
ShellKind::Cmd => "",
ShellKind::Nushell => "overlay use",
ShellKind::PowerShell | ShellKind::Pwsh => ".",
ShellKind::Fish
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Posix
| ShellKind::Rc
| ShellKind::Xonsh
| ShellKind::Elvish => "source",
}
}
pub const fn clear_screen_command(&self) -> &'static str {
match self {
ShellKind::Cmd => "cls",
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => "clear",
}
}
#[cfg(windows)]
pub const fn tty_escape_args(&self) -> bool {
match self {
ShellKind::Cmd => false,
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_try_quote_powershell() {
let shell_kind = ShellKind::PowerShell;
assert_eq!(
shell_kind
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
.unwrap()
.into_owned(),
"'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
);
}
#[test]
fn test_try_quote_cmd() {
let shell_kind = ShellKind::Cmd;
assert_eq!(
shell_kind
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
.unwrap()
.into_owned(),
"^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
);
}
#[test]
fn test_try_quote_powershell_edge_cases() {
let shell_kind = ShellKind::PowerShell;
assert_eq!(
shell_kind.try_quote("").unwrap().into_owned(),
"'\"\"'".to_string()
);
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
assert_eq!(
shell_kind.try_quote("hello world").unwrap().into_owned(),
"'hello world'".to_string()
);
assert_eq!(
shell_kind.try_quote("$variable").unwrap().into_owned(),
"'$variable'".to_string()
);
assert_eq!(
shell_kind.try_quote("test`command").unwrap().into_owned(),
"'test`command'".to_string()
);
assert_eq!(
shell_kind
.try_quote("test `\"$var`\" end")
.unwrap()
.into_owned(),
"'test `\\\"$var`\\\" end'".to_string()
);
assert_eq!(
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
"C:\\path\\to\\file"
);
}
#[test]
fn test_try_quote_cmd_edge_cases() {
let shell_kind = ShellKind::Cmd;
assert_eq!(
shell_kind.try_quote("").unwrap().into_owned(),
"^\"^\"".to_string()
);
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
assert_eq!(
shell_kind.try_quote("hello world").unwrap().into_owned(),
"^\"hello world^\"".to_string()
);
assert_eq!(
shell_kind.try_quote("path\\ test").unwrap().into_owned(),
"^\"path\\ test^\"".to_string()
);
assert_eq!(
shell_kind.try_quote("test path\\").unwrap().into_owned(),
"^\"test path\\\\^\"".to_string()
);
assert_eq!(
shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
"^\"test path\\\\\\\\^\"".to_string()
);
assert_eq!(
shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
"^\"test\\\\\\^\"quote^\"".to_string()
);
assert_eq!(
shell_kind
.try_quote("test\\\\\"quote")
.unwrap()
.into_owned(),
"^\"test\\\\\\\\\\^\"quote^\"".to_string()
);
assert_eq!(
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
"C:\\path\\to\\file"
);
}
#[test]
fn test_try_quote_nu_command() {
let shell_kind = ShellKind::Nushell;
assert_eq!(
shell_kind.try_quote("'uname'").unwrap().into_owned(),
"\"'uname'\"".to_string()
);
assert_eq!(
shell_kind
.try_quote_prefix_aware("'uname'")
.unwrap()
.into_owned(),
"^\"'uname'\"".to_string()
);
assert_eq!(
shell_kind.try_quote("^uname").unwrap().into_owned(),
"'^uname'".to_string()
);
assert_eq!(
shell_kind
.try_quote_prefix_aware("^uname")
.unwrap()
.into_owned(),
"^uname".to_string()
);
assert_eq!(
shell_kind.try_quote("^'uname'").unwrap().into_owned(),
"'^'\"'uname\'\"".to_string()
);
assert_eq!(
shell_kind
.try_quote_prefix_aware("^'uname'")
.unwrap()
.into_owned(),
"^'uname'".to_string()
);
assert_eq!(
shell_kind.try_quote("'uname a'").unwrap().into_owned(),
"\"'uname a'\"".to_string()
);
assert_eq!(
shell_kind
.try_quote_prefix_aware("'uname a'")
.unwrap()
.into_owned(),
"^\"'uname a'\"".to_string()
);
assert_eq!(
shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
"'^'\"'uname a'\"".to_string()
);
assert_eq!(
shell_kind
.try_quote_prefix_aware("^'uname a'")
.unwrap()
.into_owned(),
"^'uname a'".to_string()
);
assert_eq!(
shell_kind.try_quote("uname").unwrap().into_owned(),
"uname".to_string()
);
assert_eq!(
shell_kind
.try_quote_prefix_aware("uname")
.unwrap()
.into_owned(),
"uname".to_string()
);
}
#[test]
fn test_try_quote_single_quote_paths() {
let path_with_quote = r"C:\Temp\O'Brien\repo";
let shlex_shells = [
ShellKind::Posix,
ShellKind::Fish,
ShellKind::Csh,
ShellKind::Tcsh,
ShellKind::Rc,
ShellKind::Xonsh,
ShellKind::Elvish,
ShellKind::Nushell,
];
for shell_kind in shlex_shells {
let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
assert_ne!(quoted, path_with_quote);
assert_eq!(
shlex::split("ed),
Some(vec![path_with_quote.to_string()])
);
if shell_kind == ShellKind::Nushell {
let prefixed = shell_kind.prepend_command_prefix("ed);
assert!(prefixed.starts_with('^'));
}
}
for shell_kind in [ShellKind::PowerShell, ShellKind::Pwsh] {
let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
assert!(quoted.starts_with('\''));
assert!(quoted.ends_with('\''));
assert!(quoted.contains("O''Brien"));
}
}
#[test]
fn test_to_shell_variable() {
assert_eq!(
ShellKind::PowerShell.to_shell_variable("${FOO}"),
"$env:FOO"
);
assert_eq!(ShellKind::Pwsh.to_shell_variable("${FOO}"), "$env:FOO");
assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%");
assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO");
assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}");
assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO");
assert_eq!(
ShellKind::PowerShell.to_shell_variable("${日本}"),
"$env:日本"
);
assert_eq!(
ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"),
"${FOO:-bar}"
);
}
#[test]
fn test_to_shell_variable_malformed_is_passed_through() {
for input in ["${", "${FOO", "${café", "${}", "${日本"] {
for shell_kind in [
ShellKind::PowerShell,
ShellKind::Pwsh,
ShellKind::Cmd,
ShellKind::Nushell,
] {
assert_eq!(shell_kind.to_shell_variable(input), input);
}
}
}
}