From bf48a953448d5239404d01608be002af730ec58e Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 2 Oct 2025 22:10:55 +0200 Subject: [PATCH] acp_thread: Respect terminal settings shell for terminal tool environment (#39349) When sourcing the project environment for the terminal tool, we will now do so by spawning the shell specified by the users `terminal.shell` setting (or as usual fall back to the login shell). Closes #37687 Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 5 +- crates/assistant_tools/src/terminal_tool.rs | 6 +- crates/languages/src/python.rs | 8 +- crates/project/src/direnv.rs | 2 - crates/project/src/environment.rs | 168 +++++----- crates/project/src/project.rs | 4 +- crates/project/src/terminals.rs | 22 +- crates/task/src/shell_builder.rs | 217 +------------ crates/task/src/task.rs | 20 +- crates/terminal/src/terminal.rs | 2 +- crates/util/src/shell.rs | 340 ++++++++++++++++++++ crates/util/src/shell_env.rs | 195 +++++++---- crates/util/src/util.rs | 129 +------- 13 files changed, 617 insertions(+), 501 deletions(-) create mode 100644 crates/util/src/shell.rs diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 9cd277f62e6b38f85a8de73d278f8b09c9841819..e2c414985b99b3a939026103a004c2b10415fa91 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3,6 +3,7 @@ mod diff; mod mention; mod terminal; +use ::terminal::terminal_settings::TerminalSettings; use agent_settings::AgentSettings; use collections::HashSet; pub use connection::*; @@ -1961,11 +1962,11 @@ impl AcpThread { ) -> Task>> { let env = match &cwd { Some(dir) => self.project.update(cx, |project, cx| { - project.directory_environment(dir.as_path().into(), cx) + let shell = TerminalSettings::get_global(cx).shell.clone(); + project.directory_environment(&shell, dir.as_path().into(), cx) }), None => Task::ready(None).shared(), }; - let env = cx.spawn(async move |_, _| { let mut env = env.await.unwrap_or_default(); // Disables paging for `git` and hopefully other commands diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index b1b2313bcd1895f5c92cd71ad5291b4622a2e6d8..55c30539e96bf75d731231d2242d4ecb71bfdc8f 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -27,6 +27,7 @@ use std::{ time::{Duration, Instant}, }; use task::{Shell, ShellBuilder}; +use terminal::terminal_settings::TerminalSettings; use terminal_view::TerminalView; use theme::ThemeSettings; use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*}; @@ -119,9 +120,10 @@ impl Tool for TerminalTool { }; let cwd = working_dir.clone(); - let env = match &working_dir { + let env = match &cwd { Some(dir) => project.update(cx, |project, cx| { - project.directory_environment(dir.as_path().into(), cx) + let shell = TerminalSettings::get_global(cx).shell.clone(); + project.directory_environment(&shell, dir.as_path().into(), cx) }), None => Task::ready(None).shared(), }; diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index a1ca9a20d846c1c7f7dca309ccd7b0824ca0d42e..24f11945a92786b523f4b3f82613646552ac7ad9 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1187,11 +1187,13 @@ impl ToolchainLister for PythonToolchainProvider { ShellKind::PowerShell => ".", ShellKind::Fish => "source", ShellKind::Csh => "source", - ShellKind::Posix => "source", + ShellKind::Tcsh => "source", + ShellKind::Posix | ShellKind::Rc => "source", }; let activate_script_name = match shell { - ShellKind::Posix => "activate", + ShellKind::Posix | ShellKind::Rc => "activate", ShellKind::Csh => "activate.csh", + ShellKind::Tcsh => "activate.csh", ShellKind::Fish => "activate.fish", ShellKind::Nushell => "activate.nu", ShellKind::PowerShell => "activate.ps1", @@ -1220,7 +1222,9 @@ impl ToolchainLister for PythonToolchainProvider { ShellKind::Nushell => Some(format!("\"{pyenv}\" shell - nu {version}")), ShellKind::PowerShell => None, ShellKind::Csh => None, + ShellKind::Tcsh => None, ShellKind::Cmd => None, + ShellKind::Rc => None, }) } _ => {} diff --git a/crates/project/src/direnv.rs b/crates/project/src/direnv.rs index 32f4963fd19805e369c696cfd30c8ef599bd06f3..75c381dda96eb4f014310f9233c7557177f6eec9 100644 --- a/crates/project/src/direnv.rs +++ b/crates/project/src/direnv.rs @@ -1,7 +1,6 @@ use crate::environment::EnvironmentErrorMessage; use std::process::ExitStatus; -#[cfg(not(any(target_os = "windows", test, feature = "test-support")))] use {collections::HashMap, std::path::Path, util::ResultExt}; #[derive(Clone)] @@ -28,7 +27,6 @@ impl From for Option { } } -#[cfg(not(any(target_os = "windows", test, feature = "test-support")))] pub async fn load_direnv_environment( env: &HashMap, dir: &Path, diff --git a/crates/project/src/environment.rs b/crates/project/src/environment.rs index 7b016f05b6475d393095b6a576735d87cebf6203..fc86702901e1e4ad90acbe0504eaf2913d3c8326 100644 --- a/crates/project/src/environment.rs +++ b/crates/project/src/environment.rs @@ -1,6 +1,7 @@ use futures::{FutureExt, future::Shared}; use language::Buffer; use std::{path::Path, sync::Arc}; +use task::Shell; use util::ResultExt; use worktree::Worktree; @@ -16,6 +17,8 @@ use crate::{ pub struct ProjectEnvironment { cli_environment: Option>, environments: HashMap, Shared>>>>, + shell_based_environments: + HashMap<(Shell, Arc), Shared>>>>, environment_error_messages: HashMap, EnvironmentErrorMessage>, } @@ -30,6 +33,7 @@ impl ProjectEnvironment { Self { cli_environment, environments: Default::default(), + shell_based_environments: Default::default(), environment_error_messages: Default::default(), } } @@ -134,7 +138,22 @@ impl ProjectEnvironment { self.environments .entry(abs_path.clone()) - .or_insert_with(|| get_directory_env_impl(abs_path.clone(), cx).shared()) + .or_insert_with(|| { + get_directory_env_impl(&Shell::System, abs_path.clone(), cx).shared() + }) + .clone() + } + + /// Returns the project environment, if possible, with the given shell. + pub fn get_directory_environment_for_shell( + &mut self, + shell: &Shell, + abs_path: Arc, + cx: &mut Context, + ) -> Shared>>> { + self.shell_based_environments + .entry((shell.clone(), abs_path.clone())) + .or_insert_with(|| get_directory_env_impl(shell, abs_path.clone(), cx).shared()) .clone() } } @@ -176,6 +195,7 @@ impl EnvironmentErrorMessage { } async fn load_directory_shell_environment( + shell: &Shell, abs_path: &Path, load_direnv: &DirenvSettings, ) -> ( @@ -198,7 +218,7 @@ async fn load_directory_shell_environment( ); }; - load_shell_environment(dir, load_direnv).await + load_shell_environment(shell, dir, load_direnv).await } Err(err) => ( None, @@ -211,51 +231,8 @@ async fn load_directory_shell_environment( } } -#[cfg(any(test, feature = "test-support"))] -async fn load_shell_environment( - _dir: &Path, - _load_direnv: &DirenvSettings, -) -> ( - Option>, - Option, -) { - let fake_env = [("ZED_FAKE_TEST_ENV".into(), "true".into())] - .into_iter() - .collect(); - (Some(fake_env), None) -} - -#[cfg(all(target_os = "windows", not(any(test, feature = "test-support"))))] -async fn load_shell_environment( - dir: &Path, - _load_direnv: &DirenvSettings, -) -> ( - Option>, - Option, -) { - use util::shell_env; - - let envs = match shell_env::capture(dir).await { - Ok(envs) => envs, - Err(err) => { - util::log_err(&err); - return ( - None, - Some(EnvironmentErrorMessage(format!( - "Failed to load environment variables: {}", - err - ))), - ); - } - }; - - // Note: direnv is not available on Windows, so we skip direnv processing - // and just return the shell environment - (Some(envs), None) -} - -#[cfg(not(any(target_os = "windows", test, feature = "test-support")))] async fn load_shell_environment( + shell: &Shell, dir: &Path, load_direnv: &DirenvSettings, ) -> ( @@ -265,55 +242,86 @@ async fn load_shell_environment( use crate::direnv::load_direnv_environment; use util::shell_env; - let dir_ = dir.to_owned(); - let mut envs = match shell_env::capture(&dir_).await { - Ok(envs) => envs, - Err(err) => { - util::log_err(&err); - return ( - None, - Some(EnvironmentErrorMessage::from_str( - "Failed to load environment variables. See log for details", - )), - ); - } - }; - - // If the user selects `Direct` for direnv, it would set an environment - // variable that later uses to know that it should not run the hook. - // We would include in `.envs` call so it is okay to run the hook - // even if direnv direct mode is enabled. - let (direnv_environment, direnv_error) = match load_direnv { - DirenvSettings::ShellHook => (None, None), - DirenvSettings::Direct => match load_direnv_environment(&envs, dir).await { - Ok(env) => (Some(env), None), - Err(err) => (None, err.into()), - }, - }; - if let Some(direnv_environment) = direnv_environment { - for (key, value) in direnv_environment { - if let Some(value) = value { - envs.insert(key, value); - } else { - envs.remove(&key); + if cfg!(any(test, feature = "test-support")) { + let fake_env = [("ZED_FAKE_TEST_ENV".into(), "true".into())] + .into_iter() + .collect(); + (Some(fake_env), None) + } else if cfg!(target_os = "windows",) { + let (shell, args) = shell.program_and_args(); + let envs = match shell_env::capture(shell, args, dir).await { + Ok(envs) => envs, + Err(err) => { + util::log_err(&err); + return ( + None, + Some(EnvironmentErrorMessage(format!( + "Failed to load environment variables: {}", + err + ))), + ); + } + }; + + // Note: direnv is not available on Windows, so we skip direnv processing + // and just return the shell environment + (Some(envs), None) + } else { + let dir_ = dir.to_owned(); + let (shell, args) = shell.program_and_args(); + let mut envs = match shell_env::capture(shell, args, &dir_).await { + Ok(envs) => envs, + Err(err) => { + util::log_err(&err); + return ( + None, + Some(EnvironmentErrorMessage::from_str( + "Failed to load environment variables. See log for details", + )), + ); + } + }; + + // If the user selects `Direct` for direnv, it would set an environment + // variable that later uses to know that it should not run the hook. + // We would include in `.envs` call so it is okay to run the hook + // even if direnv direct mode is enabled. + let (direnv_environment, direnv_error) = match load_direnv { + DirenvSettings::ShellHook => (None, None), + DirenvSettings::Direct => match load_direnv_environment(&envs, dir).await { + Ok(env) => (Some(env), None), + Err(err) => (None, err.into()), + }, + }; + if let Some(direnv_environment) = direnv_environment { + for (key, value) in direnv_environment { + if let Some(value) = value { + envs.insert(key, value); + } else { + envs.remove(&key); + } } } - } - (Some(envs), direnv_error) + (Some(envs), direnv_error) + } } fn get_directory_env_impl( + shell: &Shell, abs_path: Arc, cx: &Context, ) -> Task>> { let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone(); + let shell = shell.clone(); cx.spawn(async move |this, cx| { let (mut shell_env, error_message) = cx .background_spawn({ let abs_path = abs_path.clone(); - async move { load_directory_shell_environment(&abs_path, &load_direnv).await } + async move { + load_directory_shell_environment(&shell, &abs_path, &load_direnv).await + } }) .await; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 4da841f3f47314c2bbc9526c2b5e9a69a0eb6e42..f98d12f7aca5cd0fe95f195f0f9920c607970cd7 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -33,6 +33,7 @@ pub mod search_history; mod yarn; use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope}; +use task::Shell; use crate::{ agent_server_store::{AgentServerStore, AllAgentServersSettings}, @@ -1894,11 +1895,12 @@ impl Project { pub fn directory_environment( &self, + shell: &Shell, abs_path: Arc, cx: &mut App, ) -> Shared>>> { self.environment.update(cx, |environment, cx| { - environment.get_directory_environment(abs_path, cx) + environment.get_directory_environment_for_shell(shell, abs_path, cx) }) } diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 45db5974594dfa35ba6c0a1c1b0f70a4d9afabea..af55fec786387ebce65b06a0e0fd5c953100aa4b 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -16,7 +16,7 @@ use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal}; use terminal::{ TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings, }; -use util::{get_default_system_shell, get_system_shell, maybe, rel_path::RelPath}; +use util::{get_default_system_shell, maybe, rel_path::RelPath}; use crate::{Project, ProjectPath}; @@ -98,15 +98,7 @@ impl Project { .read(cx) .shell() .unwrap_or_else(get_default_system_shell), - None => match &settings.shell { - Shell::Program(program) => program.clone(), - Shell::WithArguments { - program, - args: _, - title_override: _, - } => program.clone(), - Shell::System => get_system_shell(), - }, + None => settings.shell.program(), }; let project_path_contexts = self @@ -332,15 +324,7 @@ impl Project { .read(cx) .shell() .unwrap_or_else(get_default_system_shell), - None => match &settings.shell { - Shell::Program(program) => program.clone(), - Shell::WithArguments { - program, - args: _, - title_override: _, - } => program.clone(), - Shell::System => get_system_shell(), - }, + None => settings.shell.program(), }); let lang_registry = self.languages.clone(); diff --git a/crates/task/src/shell_builder.rs b/crates/task/src/shell_builder.rs index 5316154a82b063f8302a962e348c7567a9265634..c12683101863ba5806131cd455de64bf00b6c1f2 100644 --- a/crates/task/src/shell_builder.rs +++ b/crates/task/src/shell_builder.rs @@ -1,207 +1,8 @@ -use std::fmt; - -use util::get_system_shell; +use util::shell::get_system_shell; use crate::Shell; -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ShellKind { - #[default] - Posix, - Csh, - Fish, - PowerShell, - Nushell, - Cmd, -} - -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::Fish => write!(f, "fish"), - ShellKind::PowerShell => write!(f, "powershell"), - ShellKind::Nushell => write!(f, "nu"), - ShellKind::Cmd => write!(f, "cmd"), - } - } -} - -impl ShellKind { - pub fn system() -> Self { - Self::new(&get_system_shell()) - } - - pub fn new(program: &str) -> Self { - #[cfg(windows)] - let (_, program) = program.rsplit_once('\\').unwrap_or(("", program)); - #[cfg(not(windows))] - let (_, program) = program.rsplit_once('/').unwrap_or(("", program)); - if program == "powershell" - || program.ends_with("powershell.exe") - || program == "pwsh" - || program.ends_with("pwsh.exe") - { - ShellKind::PowerShell - } else if program == "cmd" || program.ends_with("cmd.exe") { - ShellKind::Cmd - } else if program == "nu" { - ShellKind::Nushell - } else if program == "fish" { - ShellKind::Fish - } else if program == "csh" { - ShellKind::Csh - } else { - // Some other shell detected, the user might install and use a - // unix-like shell. - ShellKind::Posix - } - } - - fn to_shell_variable(self, input: &str) -> String { - match self { - Self::PowerShell => 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::Nushell => Self::to_nushell_variable(input), - } - } - - fn to_cmd_variable(input: &str) -> String { - if let Some(var_str) = input.strip_prefix("${") { - if var_str.find(':').is_none() { - // If the input starts with "${", remove the trailing "}" - format!("%{}%", &var_str[..var_str.len() - 1]) - } else { - // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, - // which will result in the task failing to run in such cases. - input.into() - } - } else if let Some(var_str) = input.strip_prefix('$') { - // If the input starts with "$", directly append to "$env:" - format!("%{}%", var_str) - } else { - // If no prefix is found, return the input as is - input.into() - } - } - fn to_powershell_variable(input: &str) -> String { - if let Some(var_str) = input.strip_prefix("${") { - if var_str.find(':').is_none() { - // If the input starts with "${", remove the trailing "}" - format!("$env:{}", &var_str[..var_str.len() - 1]) - } else { - // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, - // which will result in the task failing to run in such cases. - input.into() - } - } else if let Some(var_str) = input.strip_prefix('$') { - // If the input starts with "$", directly append to "$env:" - format!("$env:{}", var_str) - } else { - // If no prefix is found, return the input as is - 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 - } - } - } - - fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec { - match self { - ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], - ShellKind::Cmd => vec!["/C".to_owned(), combined_command], - ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => interactive - .then(|| "-i".to_owned()) - .into_iter() - .chain(["-c".to_owned(), combined_command]) - .collect(), - } - } - - pub fn command_prefix(&self) -> Option { - match self { - ShellKind::PowerShell => Some('&'), - ShellKind::Nushell => Some('^'), - _ => None, - } - } -} +pub use util::shell::ShellKind; /// ShellBuilder is used to turn a user-requested task into a /// program that can be executed by the shell. @@ -253,7 +54,12 @@ impl ShellBuilder { ShellKind::Cmd => { format!("{} /C '{}'", self.program, command_to_use_in_label) } - ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => { + ShellKind::Posix + | ShellKind::Nushell + | ShellKind::Fish + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc => { let interactivity = self.interactive.then_some("-i ").unwrap_or_default(); format!( "{PROGRAM} {interactivity}-c '{command_to_use_in_label}'", @@ -283,7 +89,12 @@ impl ShellBuilder { }); if self.redirect_stdin { match self.kind { - ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => { + ShellKind::Posix + | ShellKind::Nushell + | ShellKind::Fish + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc => { combined_command.insert(0, '('); combined_command.push_str(") 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(), &[]), + } + } +} + type VsCodeEnvVariable = String; type ZedEnvVariable = String; diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index aa075154627e6e68f4068778b590943860d860e5..288916c775cf66b7a0f468ee48525df9b394515e 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -398,7 +398,7 @@ impl TerminalBuilder { #[cfg(target_os = "windows")] { Some(ShellParams::new( - util::get_windows_system_shell(), + util::shell::get_windows_system_shell(), None, None, )) diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs new file mode 100644 index 0000000000000000000000000000000000000000..640d191edf6da943c1298a97531020d3a8464962 --- /dev/null +++ b/crates/util/src/shell.rs @@ -0,0 +1,340 @@ +use std::{fmt, path::Path, sync::LazyLock}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShellKind { + #[default] + Posix, + Csh, + Tcsh, + Rc, + Fish, + PowerShell, + Nushell, + Cmd, +} + +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_windows_system_shell() -> String { + use std::path::PathBuf; + + fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option { + #[cfg(target_pointer_width = "64")] + let env_var = if find_alternate { + "ProgramFiles(x86)" + } else { + "ProgramFiles" + }; + + #[cfg(target_pointer_width = "32")] + let env_var = if find_alternate { + "ProgramW6432" + } else { + "ProgramFiles" + }; + + let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell"); + install_base_dir + .read_dir() + .ok()? + .filter_map(Result::ok) + .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir())) + .filter_map(|entry| { + let dir_name = entry.file_name(); + let dir_name = dir_name.to_string_lossy(); + + let version = if find_preview { + let dash_index = dir_name.find('-')?; + if &dir_name[dash_index + 1..] != "preview" { + return None; + }; + dir_name[..dash_index].parse::().ok()? + } else { + dir_name.parse::().ok()? + }; + + let exe_path = entry.path().join("pwsh.exe"); + if exe_path.exists() { + Some((version, exe_path)) + } else { + None + } + }) + .max_by_key(|(version, _)| *version) + .map(|(_, path)| path) + } + + fn find_pwsh_in_msix(find_preview: bool) -> Option { + let msix_app_dir = + PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps"); + if !msix_app_dir.exists() { + return None; + } + + let prefix = if find_preview { + "Microsoft.PowerShellPreview_" + } else { + "Microsoft.PowerShell_" + }; + msix_app_dir + .read_dir() + .ok()? + .filter_map(|entry| { + let entry = entry.ok()?; + if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) { + return None; + } + + if !entry.file_name().to_string_lossy().starts_with(prefix) { + return None; + } + + let exe_path = entry.path().join("pwsh.exe"); + exe_path.exists().then_some(exe_path) + }) + .next() + } + + fn find_pwsh_in_scoop() -> Option { + let pwsh_exe = + PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe"); + pwsh_exe.exists().then_some(pwsh_exe) + } + + static SYSTEM_SHELL: LazyLock = LazyLock::new(|| { + find_pwsh_in_programfiles(false, false) + .or_else(|| find_pwsh_in_programfiles(true, false)) + .or_else(|| find_pwsh_in_msix(false)) + .or_else(|| find_pwsh_in_programfiles(false, true)) + .or_else(|| find_pwsh_in_msix(true)) + .or_else(|| find_pwsh_in_programfiles(true, true)) + .or_else(find_pwsh_in_scoop) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or("powershell.exe".to_string()) + }); + + (*SYSTEM_SHELL).clone() +} + +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::Nushell => write!(f, "nu"), + ShellKind::Cmd => write!(f, "cmd"), + ShellKind::Rc => write!(f, "rc"), + } + } +} + +impl ShellKind { + pub fn system() -> Self { + Self::new(&get_system_shell()) + } + + pub fn new(program: impl AsRef) -> Self { + let program = program.as_ref(); + let Some(program) = program.file_name().and_then(|s| s.to_str()) else { + return if cfg!(windows) { + ShellKind::PowerShell + } else { + ShellKind::Posix + }; + }; + if program == "powershell" + || program.ends_with("powershell.exe") + || program == "pwsh" + || program.ends_with("pwsh.exe") + { + ShellKind::PowerShell + } else if program == "cmd" || program.ends_with("cmd.exe") { + ShellKind::Cmd + } else if program == "nu" { + ShellKind::Nushell + } else if program == "fish" { + ShellKind::Fish + } else if program == "csh" { + ShellKind::Csh + } else if program == "tcsh" { + ShellKind::Tcsh + } else if program == "rc" { + ShellKind::Rc + } else { + if cfg!(windows) { + ShellKind::PowerShell + } else { + // Some other shell detected, the user might install and use a + // unix-like shell. + ShellKind::Posix + } + } + } + + pub fn to_shell_variable(self, input: &str) -> String { + match self { + Self::PowerShell => 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), + } + } + + fn to_cmd_variable(input: &str) -> String { + if let Some(var_str) = input.strip_prefix("${") { + if var_str.find(':').is_none() { + // If the input starts with "${", remove the trailing "}" + format!("%{}%", &var_str[..var_str.len() - 1]) + } else { + // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, + // which will result in the task failing to run in such cases. + input.into() + } + } else if let Some(var_str) = input.strip_prefix('$') { + // If the input starts with "$", directly append to "$env:" + format!("%{}%", var_str) + } else { + // If no prefix is found, return the input as is + input.into() + } + } + fn to_powershell_variable(input: &str) -> String { + if let Some(var_str) = input.strip_prefix("${") { + if var_str.find(':').is_none() { + // If the input starts with "${", remove the trailing "}" + format!("$env:{}", &var_str[..var_str.len() - 1]) + } else { + // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, + // which will result in the task failing to run in such cases. + input.into() + } + } else if let Some(var_str) = input.strip_prefix('$') { + // If the input starts with "$", directly append to "$env:" + format!("$env:{}", var_str) + } else { + // If no prefix is found, return the input as is + 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 { + match self { + ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], + ShellKind::Cmd => vec!["/C".to_owned(), combined_command], + ShellKind::Posix + | ShellKind::Nushell + | ShellKind::Fish + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc => interactive + .then(|| "-i".to_owned()) + .into_iter() + .chain(["-c".to_owned(), combined_command]) + .collect(), + } + } + + pub fn command_prefix(&self) -> Option { + match self { + ShellKind::PowerShell => Some('&'), + ShellKind::Nushell => Some('^'), + _ => None, + } + } +} diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index 066d87ea5d4667b96bdda4d30bec4ec944bb4d39..3559bf2c78d48545afa1c299d43d220957349dc4 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -1,60 +1,81 @@ -#![cfg_attr(not(unix), allow(unused))] +use std::path::Path; use anyhow::{Context as _, Result}; use collections::HashMap; -/// Capture all environment variables from the login shell. +use crate::shell::ShellKind; + +pub fn print_env() { + let env_vars: HashMap = std::env::vars().collect(); + let json = serde_json::to_string_pretty(&env_vars).unwrap_or_else(|err| { + eprintln!("Error serializing environment variables: {}", err); + std::process::exit(1); + }); + println!("{}", json); +} + +/// Capture all environment variables from the login shell in the given directory. +pub async fn capture( + shell_path: impl AsRef, + args: &[String], + directory: impl AsRef, +) -> Result> { + #[cfg(windows)] + return capture_windows(shell_path.as_ref(), args, directory.as_ref()).await; + #[cfg(unix)] + return capture_unix(shell_path.as_ref(), args, directory.as_ref()).await; +} + #[cfg(unix)] -pub async fn capture(directory: &std::path::Path) -> Result> { +async fn capture_unix( + shell_path: &Path, + args: &[String], + directory: &Path, +) -> Result> { use std::os::unix::process::CommandExt; use std::process::Stdio; let zed_path = super::get_shell_safe_zed_path()?; - let shell_path = std::env::var("SHELL").map(std::path::PathBuf::from)?; - let shell_name = shell_path.file_name().and_then(std::ffi::OsStr::to_str); + let shell_kind = ShellKind::new(shell_path); let mut command_string = String::new(); - let mut command = std::process::Command::new(&shell_path); + let mut command = std::process::Command::new(shell_path); + command.args(args); // In some shells, file descriptors greater than 2 cannot be used in interactive mode, // so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others. // See: https://github.com/zed-industries/zed/pull/32136#issuecomment-2999645482 const FD_STDIN: std::os::fd::RawFd = 0; const FD_STDOUT: std::os::fd::RawFd = 1; - let (fd_num, redir) = match shell_name { - Some("rc") => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]` - Some("nu") | Some("tcsh") => (FD_STDOUT, "".to_string()), + let (fd_num, redir) = match shell_kind { + ShellKind::Rc => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]` + ShellKind::Nushell | ShellKind::Tcsh => (FD_STDOUT, "".to_string()), _ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0` }; command.stdin(Stdio::null()); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); - let mut command_prefix = String::new(); - match shell_name { - Some("tcsh" | "csh") => { + match shell_kind { + ShellKind::Csh | ShellKind::Tcsh => { // For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`) command.arg0("-"); } - Some("fish") => { + ShellKind::Fish => { // in fish, asdf, direnv attach to the `fish_prompt` event command_string.push_str("emit fish_prompt;"); command.arg("-l"); } - Some("nu") => { - // nu needs special handling for -- options. - command_prefix = String::from("^"); - } _ => { command.arg("-l"); } } // cd into the directory, triggering directory specific side-effects (asdf, direnv, etc) command_string.push_str(&format!("cd '{}';", directory.display())); - command_string.push_str(&format!( - "{}{} --printenv {}", - command_prefix, zed_path, redir - )); + if let Some(prefix) = shell_kind.command_prefix() { + command_string.push(prefix); + } + command_string.push_str(&format!("{} --printenv {}", zed_path, redir)); command.args(["-i", "-c", &command_string]); super::set_pre_exec_to_start_new_session(&mut command); @@ -99,54 +120,104 @@ async fn spawn_and_read_fd( Ok((buffer, process.output().await?)) } -/// Capture all environment variables from the shell on Windows. #[cfg(windows)] -pub async fn capture(directory: &std::path::Path) -> Result> { +async fn capture_windows( + shell_path: &Path, + _args: &[String], + directory: &Path, +) -> Result> { use std::process::Stdio; let zed_path = std::env::current_exe().context("Failed to determine current zed executable path.")?; - // Use PowerShell to get environment variables in the directory context - let output = crate::command::new_smol_command(crate::get_windows_system_shell()) - .args([ - "-NonInteractive", - "-NoProfile", - "-Command", - &format!( - "Set-Location '{}'; & '{}' --printenv", - directory.display(), - zed_path.display() - ), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "PowerShell command failed with {}. stdout: {:?}, stderr: {:?}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); + let shell_kind = ShellKind::new(shell_path); + let env_output = match shell_kind { + ShellKind::Posix | ShellKind::Csh | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish => { + return Err(anyhow::anyhow!("unsupported shell kind")); + } + ShellKind::PowerShell => { + let output = crate::command::new_smol_command(shell_path) + .args([ + "-NonInteractive", + "-NoProfile", + "-Command", + &format!( + "Set-Location '{}'; & '{}' --printenv", + directory.display(), + zed_path.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "PowerShell command failed with {}. stdout: {:?}, stderr: {:?}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output + } + ShellKind::Nushell => { + let output = crate::command::new_smol_command(shell_path) + .args([ + "-c", + &format!( + "cd '{}'; {} --printenv", + directory.display(), + zed_path.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "Nushell command failed with {}. stdout: {:?}, stderr: {:?}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output + } + ShellKind::Cmd => { + let output = crate::command::new_smol_command(shell_path) + .args([ + "/c", + &format!( + "cd '{}'; {} --printenv", + directory.display(), + zed_path.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "Cmd command failed with {}. stdout: {:?}, stderr: {:?}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output + } + }; - let env_output = String::from_utf8_lossy(&output.stdout); + let env_output = String::from_utf8_lossy(&env_output.stdout); // Parse the JSON output from zed --printenv - let env_map: collections::HashMap = serde_json::from_str(&env_output) - .with_context(|| "Failed to deserialize environment variables from json")?; - Ok(env_map) -} - -pub fn print_env() { - let env_vars: HashMap = std::env::vars().collect(); - let json = serde_json::to_string_pretty(&env_vars).unwrap_or_else(|err| { - eprintln!("Error serializing environment variables: {}", err); - std::process::exit(1); - }); - println!("{}", json); - std::process::exit(0); + serde_json::from_str(&env_output) + .with_context(|| "Failed to deserialize environment variables from json") } diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 732a07c982698d917dd80441c7766a769e31613b..7ec4552c779ac48f76012f47c3424ffbf89c9833 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -8,6 +8,7 @@ pub mod redact; pub mod rel_path; pub mod schemars; pub mod serde; +pub mod shell; pub mod shell_env; pub mod size; #[cfg(any(test, feature = "test-support"))] @@ -367,7 +368,7 @@ pub async fn load_login_shell_environment() -> Result<()> { // into shell's `cd` command (and hooks) to manipulate env. // We do this so that we get the env a user would have when spawning a shell // in home directory. - for (name, value) in shell_env::capture(paths::home_dir()).await? { + for (name, value) in shell_env::capture(get_system_shell(), &[], paths::home_dir()).await? { unsafe { env::set_var(&name, &value) }; } @@ -555,108 +556,6 @@ pub fn wrapped_usize_outward_from( }) } -#[cfg(target_os = "windows")] -pub fn get_windows_system_shell() -> String { - use std::path::PathBuf; - - fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option { - #[cfg(target_pointer_width = "64")] - let env_var = if find_alternate { - "ProgramFiles(x86)" - } else { - "ProgramFiles" - }; - - #[cfg(target_pointer_width = "32")] - let env_var = if find_alternate { - "ProgramW6432" - } else { - "ProgramFiles" - }; - - let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell"); - install_base_dir - .read_dir() - .ok()? - .filter_map(Result::ok) - .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir())) - .filter_map(|entry| { - let dir_name = entry.file_name(); - let dir_name = dir_name.to_string_lossy(); - - let version = if find_preview { - let dash_index = dir_name.find('-')?; - if &dir_name[dash_index + 1..] != "preview" { - return None; - }; - dir_name[..dash_index].parse::().ok()? - } else { - dir_name.parse::().ok()? - }; - - let exe_path = entry.path().join("pwsh.exe"); - if exe_path.exists() { - Some((version, exe_path)) - } else { - None - } - }) - .max_by_key(|(version, _)| *version) - .map(|(_, path)| path) - } - - fn find_pwsh_in_msix(find_preview: bool) -> Option { - let msix_app_dir = - PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps"); - if !msix_app_dir.exists() { - return None; - } - - let prefix = if find_preview { - "Microsoft.PowerShellPreview_" - } else { - "Microsoft.PowerShell_" - }; - msix_app_dir - .read_dir() - .ok()? - .filter_map(|entry| { - let entry = entry.ok()?; - if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) { - return None; - } - - if !entry.file_name().to_string_lossy().starts_with(prefix) { - return None; - } - - let exe_path = entry.path().join("pwsh.exe"); - exe_path.exists().then_some(exe_path) - }) - .next() - } - - fn find_pwsh_in_scoop() -> Option { - let pwsh_exe = - PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe"); - pwsh_exe.exists().then_some(pwsh_exe) - } - - static SYSTEM_SHELL: LazyLock = LazyLock::new(|| { - find_pwsh_in_programfiles(false, false) - .or_else(|| find_pwsh_in_programfiles(true, false)) - .or_else(|| find_pwsh_in_msix(false)) - .or_else(|| find_pwsh_in_programfiles(false, true)) - .or_else(|| find_pwsh_in_msix(true)) - .or_else(|| find_pwsh_in_programfiles(true, true)) - .or_else(find_pwsh_in_scoop) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or("powershell.exe".to_string()) - }); - - (*SYSTEM_SHELL).clone() -} - pub trait ResultExt { type Ok; @@ -1100,29 +999,7 @@ pub fn default() -> D { Default::default() } -pub fn get_system_shell() -> String { - #[cfg(target_os = "windows")] - { - get_windows_system_shell() - } - - #[cfg(not(target_os = "windows"))] - { - std::env::var("SHELL").unwrap_or("/bin/sh".to_string()) - } -} - -pub fn get_default_system_shell() -> String { - #[cfg(target_os = "windows")] - { - get_windows_system_shell() - } - - #[cfg(not(target_os = "windows"))] - { - "/bin/sh".to_string() - } -} +pub use self::shell::{get_default_system_shell, get_system_shell}; #[derive(Debug)] pub enum ConnectionResult {