diff --git a/crates/nu-cli/src/completions/completion_common.rs b/crates/nu-cli/src/completions/completion_common.rs index 4b5ef857e1..d96ca365eb 100644 --- a/crates/nu-cli/src/completions/completion_common.rs +++ b/crates/nu-cli/src/completions/completion_common.rs @@ -174,6 +174,14 @@ pub fn complete_item( ) -> Vec { let cleaned_partial = surround_remove(partial); let isdir = cleaned_partial.ends_with(is_separator); + #[cfg(windows)] + let cleaned_partial = if let Some(absolute_partial) = + stack.pwd_per_drive.expand_pwd(Path::new(&cleaned_partial)) + { + absolute_partial.display().to_string() + } else { + cleaned_partial + }; let expanded_partial = expand_ndots(Path::new(&cleaned_partial)); let should_collapse_dots = expanded_partial != Path::new(&cleaned_partial); let mut partial = expanded_partial.to_string_lossy().to_string(); diff --git a/crates/nu-cli/src/repl.rs b/crates/nu-cli/src/repl.rs index 4bd86ddbf9..0476435aee 100644 --- a/crates/nu-cli/src/repl.rs +++ b/crates/nu-cli/src/repl.rs @@ -832,6 +832,12 @@ fn do_auto_cd( engine_state: &mut EngineState, span: Span, ) { + #[cfg(windows)] + let path = if let Some(abs_path) = stack.pwd_per_drive.expand_pwd(path.as_path()) { + abs_path + } else { + path + }; let path = { if !path.exists() { report_shell_error( diff --git a/crates/nu-command/src/filesystem/cd.rs b/crates/nu-command/src/filesystem/cd.rs index 2af932bcb4..3b5aa377fe 100644 --- a/crates/nu-command/src/filesystem/cd.rs +++ b/crates/nu-command/src/filesystem/cd.rs @@ -87,7 +87,7 @@ impl Command for Cd { }); } } else { - let path = nu_path::expand_path_with(path_no_whitespace, &cwd, true); + let path = stack.expand_path_with(path_no_whitespace, &cwd, true); if !path.exists() { return Err(ShellError::DirectoryNotFound { dir: path_no_whitespace.to_string(), diff --git a/crates/nu-engine/src/env.rs b/crates/nu-engine/src/env.rs index c4460f4695..f7aa3cf2e1 100644 --- a/crates/nu-engine/src/env.rs +++ b/crates/nu-engine/src/env.rs @@ -157,6 +157,9 @@ pub fn env_to_strings( } } + #[cfg(windows)] + stack.pwd_per_drive.get_env_vars(&mut env_vars_str); + Ok(env_vars_str) } diff --git a/crates/nu-engine/src/eval.rs b/crates/nu-engine/src/eval.rs index 0e3917290f..d41e5753bd 100644 --- a/crates/nu-engine/src/eval.rs +++ b/crates/nu-engine/src/eval.rs @@ -194,6 +194,11 @@ pub fn redirect_env(engine_state: &EngineState, caller_stack: &mut Stack, callee caller_stack.add_env_var(var, value); } + #[cfg(windows)] + { + caller_stack.pwd_per_drive = callee_stack.pwd_per_drive.clone(); + } + // set config to callee config, to capture any updates to that caller_stack.config.clone_from(&callee_stack.config); } diff --git a/crates/nu-engine/src/eval_ir.rs b/crates/nu-engine/src/eval_ir.rs index afcb58a90a..7b4c0a28f5 100644 --- a/crates/nu-engine/src/eval_ir.rs +++ b/crates/nu-engine/src/eval_ir.rs @@ -1,6 +1,6 @@ use std::{borrow::Cow, fs::File, sync::Arc}; -use nu_path::{expand_path_with, AbsolutePathBuf}; +use nu_path::AbsolutePathBuf; use nu_protocol::{ ast::{Bits, Block, Boolean, CellPath, Comparison, Math, Operator}, debugger::DebugContext, @@ -14,7 +14,7 @@ use nu_protocol::{ }; use nu_utils::IgnoreCaseExt; -use crate::{eval::is_automatic_env_var, eval_block_with_early_return}; +use crate::{eval::is_automatic_env_var, eval_block_with_early_return, redirect_env}; /// Evaluate the compiled representation of a [`Block`]. pub fn eval_ir_block( @@ -870,7 +870,7 @@ fn literal_value( Value::string(path, span) } else { let cwd = ctx.engine_state.cwd(Some(ctx.stack))?; - let path = expand_path_with(path, cwd, true); + let path = ctx.stack.expand_path_with(path, cwd, true); Value::string(path.to_string_lossy(), span) } @@ -890,7 +890,7 @@ fn literal_value( .cwd(Some(ctx.stack)) .map(AbsolutePathBuf::into_std_path_buf) .unwrap_or_default(); - let path = expand_path_with(path, cwd, true); + let path = ctx.stack.expand_path_with(path, cwd, true); Value::string(path.to_string_lossy(), span) } @@ -1405,7 +1405,8 @@ enum RedirectionStream { /// Open a file for redirection fn open_file(ctx: &EvalContext<'_>, path: &Value, append: bool) -> Result, ShellError> { let path_expanded = - expand_path_with(path.as_str()?, ctx.engine_state.cwd(Some(ctx.stack))?, true); + ctx.stack + .expand_path_with(path.as_str()?, ctx.engine_state.cwd(Some(ctx.stack))?, true); let mut options = File::options(); if append { options.append(true); @@ -1485,26 +1486,3 @@ fn eval_iterate( eval_iterate(ctx, dst, stream, end_index) } } - -/// Redirect environment from the callee stack to the caller stack -fn redirect_env(engine_state: &EngineState, caller_stack: &mut Stack, callee_stack: &Stack) { - // TODO: make this more efficient - // Grab all environment variables from the callee - let caller_env_vars = caller_stack.get_env_var_names(engine_state); - - // remove env vars that are present in the caller but not in the callee - // (the callee hid them) - for var in caller_env_vars.iter() { - if !callee_stack.has_env_var(engine_state, var) { - caller_stack.remove_env_var(engine_state, var); - } - } - - // add new env vars from callee to caller - for (var, value) in callee_stack.get_stack_env_vars() { - caller_stack.add_env_var(var, value); - } - - // set config to callee config, to capture any updates to that - caller_stack.config.clone_from(&callee_stack.config); -} diff --git a/crates/nu-path/src/lib.rs b/crates/nu-path/src/lib.rs index cf31a5789f..660fae4b22 100644 --- a/crates/nu-path/src/lib.rs +++ b/crates/nu-path/src/lib.rs @@ -6,6 +6,8 @@ pub mod expansions; pub mod form; mod helpers; mod path; +#[cfg(windows)] +pub mod pwd_per_drive; mod tilde; mod trailing_slash; @@ -13,5 +15,7 @@ pub use components::components; pub use expansions::{canonicalize_with, expand_path_with, expand_to_real_path, locate_in_dirs}; pub use helpers::{cache_dir, data_dir, home_dir, nu_config_dir}; pub use path::*; +#[cfg(windows)] +pub use pwd_per_drive::DriveToPwdMap; pub use tilde::expand_tilde; pub use trailing_slash::{has_trailing_slash, strip_trailing_slash}; diff --git a/crates/nu-path/src/pwd_per_drive.rs b/crates/nu-path/src/pwd_per_drive.rs new file mode 100644 index 0000000000..d7a395174f --- /dev/null +++ b/crates/nu-path/src/pwd_per_drive.rs @@ -0,0 +1,331 @@ +/// Usage for pwd_per_drive on windows +/// +/// let mut map = DriveToPwdMap::new(); +/// +/// Upon change PWD, call map.set_pwd() with absolute path +/// +/// Call map.expand_pwd() with relative path to get absolution path +/// +/// ``` +/// use std::path::{Path, PathBuf}; +/// use nu_path::DriveToPwdMap; +/// +/// let mut map = DriveToPwdMap::new(); +/// +/// // Set PWD for drive C +/// assert!(map.set_pwd(Path::new(r"C:\Users\Home")).is_ok()); +/// +/// // Expand a relative path +/// let expanded = map.expand_pwd(Path::new("c:test")); +/// assert_eq!(expanded, Some(PathBuf::from(r"C:\Users\Home\test"))); +/// +/// // Will NOT expand an absolute path +/// let expanded = map.expand_pwd(Path::new(r"C:\absolute\path")); +/// assert_eq!(expanded, None); +/// +/// // Expand with no drive letter +/// let expanded = map.expand_pwd(Path::new(r"\no_drive")); +/// assert_eq!(expanded, None); +/// +/// // Expand with no PWD set for the drive +/// let expanded = map.expand_pwd(Path::new("D:test")); +/// assert!(expanded.is_some()); +/// let abs_path = expanded.unwrap().as_path().to_str().expect("OK").to_string(); +/// assert!(abs_path.starts_with(r"D:\")); +/// assert!(abs_path.ends_with(r"\test")); +/// +/// // Get env vars for child process +/// use std::collections::HashMap; +/// let mut env = HashMap::::new(); +/// map.get_env_vars(&mut env); +/// assert_eq!(env.get("=C:").unwrap(), r"C:\Users\Home"); +/// ``` +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, PartialEq)] +pub enum PathError { + InvalidDriveLetter, + InvalidPath, +} + +/// Helper to check if input path is relative path +/// with drive letter, it can be expanded with PWD-per-drive. +fn need_expand(path: &Path) -> bool { + if let Some(path_str) = path.to_str() { + let chars: Vec = path_str.chars().collect(); + if chars.len() >= 2 { + return chars[1] == ':' && (chars.len() == 2 || (chars[2] != '/' && chars[2] != '\\')); + } + } + false +} + +#[derive(Clone, Debug)] +pub struct DriveToPwdMap { + map: [Option; 26], // Fixed-size array for A-Z +} + +impl Default for DriveToPwdMap { + fn default() -> Self { + Self::new() + } +} + +impl DriveToPwdMap { + pub fn new() -> Self { + Self { + map: Default::default(), + } + } + + pub fn env_var_for_drive(drive_letter: char) -> String { + let drive_letter = drive_letter.to_ascii_uppercase(); + format!("={}:", drive_letter) + } + + /// Collect PWD-per-drive as env vars (for child process) + pub fn get_env_vars(&self, env: &mut HashMap) { + for (drive_index, drive_letter) in ('A'..='Z').enumerate() { + if let Some(pwd) = self.map[drive_index].clone() { + if pwd.len() > 3 { + let env_var_for_drive = Self::env_var_for_drive(drive_letter); + env.insert(env_var_for_drive, pwd); + } + } + } + } + + /// Set the PWD for the drive letter in the absolute path. + /// Return PathError for error. + pub fn set_pwd(&mut self, path: &Path) -> Result<(), PathError> { + if let (Some(drive_letter), Some(path_str)) = + (Self::extract_drive_letter(path), path.to_str()) + { + if drive_letter.is_ascii_alphabetic() { + let drive_letter = drive_letter.to_ascii_uppercase(); + // Make sure saved drive letter is upper case + let mut c = path_str.chars(); + match c.next() { + None => Err(PathError::InvalidDriveLetter), + Some(_) => { + let drive_index = drive_letter as usize - 'A' as usize; + let normalized_pwd = drive_letter.to_string() + c.as_str(); + self.map[drive_index] = Some(normalized_pwd); + Ok(()) + } + } + } else { + Err(PathError::InvalidDriveLetter) + } + } else { + Err(PathError::InvalidPath) + } + } + + /// Get the PWD for drive, if not yet, ask GetFullPathNameW() or omnipath, + /// or else return default r"X:\". + fn get_pwd(&self, drive_letter: char) -> Result { + if drive_letter.is_ascii_alphabetic() { + let drive_letter = drive_letter.to_ascii_uppercase(); + let drive_index = drive_letter as usize - 'A' as usize; + Ok(self.map[drive_index].clone().unwrap_or_else(|| { + if let Some(sys_pwd) = get_full_path_name_w(&format!("{}:", drive_letter)) { + sys_pwd + } else { + format!(r"{}:\", drive_letter) + } + })) + } else { + Err(PathError::InvalidDriveLetter) + } + } + + /// Expand a relative path using the PWD-per-drive, return PathBuf + /// of absolute path. + /// Return None if path is not valid or can't get drive letter. + pub fn expand_pwd(&self, path: &Path) -> Option { + if need_expand(path) { + let path_str = path.to_str()?; + if let Some(drive_letter) = Self::extract_drive_letter(path) { + if let Ok(pwd) = self.get_pwd(drive_letter) { + // Combine current PWD with the relative path + let mut base = PathBuf::from(Self::ensure_trailing_delimiter(&pwd)); + // need_expand() and extract_drive_letter() all ensure path_str.len() >= 2 + base.push(&path_str[2..]); // Join PWD with path parts after "C:" + return Some(base); + } + } + } + None // Invalid path or has no drive letter + } + + /// Helper to extract the drive letter from a path, keep case + /// (e.g., `C:test` -> `C`, `d:\temp` -> `d`) + fn extract_drive_letter(path: &Path) -> Option { + path.to_str() + .and_then(|s| s.chars().next()) + .filter(|c| c.is_ascii_alphabetic()) + } + + /// Ensure a path has a trailing `\\` or '/' + fn ensure_trailing_delimiter(path: &str) -> String { + if !path.ends_with('\\') && !path.ends_with('/') { + format!(r"{}\", path) + } else { + path.to_string() + } + } +} + +fn get_full_path_name_w(path_str: &str) -> Option { + use omnipath::sys_absolute; + if let Ok(path_sys_abs) = sys_absolute(Path::new(path_str)) { + Some(path_sys_abs.to_str()?.to_string()) + } else { + None + } +} + +/// Test for Drive2PWD map +#[cfg(test)] +mod tests { + use super::*; + + /// Test or demo usage of PWD-per-drive + /// In doctest, there's no get_full_path_name_w available so can't foresee + /// possible result, here can have more accurate test assert + #[test] + fn test_usage_for_pwd_per_drive() { + let mut map = DriveToPwdMap::new(); + + // Set PWD for drive E + assert!(map.set_pwd(Path::new(r"E:\Users\Home")).is_ok()); + + // Expand a relative path + let expanded = map.expand_pwd(Path::new("e:test")); + assert_eq!(expanded, Some(PathBuf::from(r"E:\Users\Home\test"))); + + // Will NOT expand an absolute path + let expanded = map.expand_pwd(Path::new(r"E:\absolute\path")); + assert_eq!(expanded, None); + + // Expand with no drive letter + let expanded = map.expand_pwd(Path::new(r"\no_drive")); + assert_eq!(expanded, None); + + // Expand with no PWD set for the drive + let expanded = map.expand_pwd(Path::new("F:test")); + if let Some(sys_abs) = get_full_path_name_w("F:") { + assert_eq!( + expanded, + Some(PathBuf::from(format!( + "{}test", + DriveToPwdMap::ensure_trailing_delimiter(&sys_abs) + ))) + ); + } else { + assert_eq!(expanded, Some(PathBuf::from(r"F:\test"))); + } + } + + #[test] + fn test_get_env_vars() { + let mut map = DriveToPwdMap::new(); + map.set_pwd(Path::new(r"I:\Home")).unwrap(); + map.set_pwd(Path::new(r"j:\User")).unwrap(); + + let mut env = HashMap::::new(); + map.get_env_vars(&mut env); + assert_eq!( + env.get(&DriveToPwdMap::env_var_for_drive('I')).unwrap(), + r"I:\Home" + ); + assert_eq!( + env.get(&DriveToPwdMap::env_var_for_drive('J')).unwrap(), + r"J:\User" + ); + } + + #[test] + fn test_expand_pwd() { + let mut drive_map = DriveToPwdMap::new(); + + // Set PWD for drive 'M:' + assert_eq!(drive_map.set_pwd(Path::new(r"M:\Users")), Ok(())); + // or 'm:' + assert_eq!(drive_map.set_pwd(Path::new(r"m:\Users\Home")), Ok(())); + + // Expand a relative path on "M:" + let expanded = drive_map.expand_pwd(Path::new(r"M:test")); + assert_eq!(expanded, Some(PathBuf::from(r"M:\Users\Home\test"))); + // or on "m:" + let expanded = drive_map.expand_pwd(Path::new(r"m:test")); + assert_eq!(expanded, Some(PathBuf::from(r"M:\Users\Home\test"))); + + // Expand an absolute path + let expanded = drive_map.expand_pwd(Path::new(r"m:\absolute\path")); + assert_eq!(expanded, None); + + // Expand with no drive letter + let expanded = drive_map.expand_pwd(Path::new(r"\no_drive")); + assert_eq!(expanded, None); + + // Expand with no PWD set for the drive + let expanded = drive_map.expand_pwd(Path::new("N:test")); + if let Some(pwd_on_drive) = get_full_path_name_w("N:") { + assert_eq!( + expanded, + Some(PathBuf::from(format!( + r"{}test", + DriveToPwdMap::ensure_trailing_delimiter(&pwd_on_drive) + ))) + ); + } else { + assert_eq!(expanded, Some(PathBuf::from(r"N:\test"))); + } + } + + #[test] + fn test_set_and_get_pwd() { + let mut drive_map = DriveToPwdMap::new(); + + // Set PWD for drive 'O' + assert!(drive_map.set_pwd(Path::new(r"O:\Users")).is_ok()); + // Or for drive 'o' + assert!(drive_map.set_pwd(Path::new(r"o:\Users\Example")).is_ok()); + // Get PWD for drive 'O' + assert_eq!(drive_map.get_pwd('O'), Ok(r"O:\Users\Example".to_string())); + // or 'o' + assert_eq!(drive_map.get_pwd('o'), Ok(r"O:\Users\Example".to_string())); + + // Get PWD for drive P (not set yet, but system might already + // have PWD on this drive) + if let Some(pwd_on_drive) = get_full_path_name_w("P:") { + assert_eq!(drive_map.get_pwd('P'), Ok(pwd_on_drive)); + } else { + assert_eq!(drive_map.get_pwd('P'), Ok(r"P:\".to_string())); + } + } + + #[test] + fn test_set_pwd_invalid_path() { + let mut drive_map = DriveToPwdMap::new(); + + // Invalid path (no drive letter) + let result = drive_map.set_pwd(Path::new(r"\InvalidPath")); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), PathError::InvalidPath); + } + + #[test] + fn test_get_pwd_invalid_drive() { + let drive_map = DriveToPwdMap::new(); + + // Get PWD for a drive not set (e.g., Z) + assert_eq!(drive_map.get_pwd('Z'), Ok(r"Z:\".to_string())); + + // Invalid drive letter (non-alphabetic) + assert_eq!(drive_map.get_pwd('1'), Err(PathError::InvalidDriveLetter)); + } +} diff --git a/crates/nu-protocol/src/engine/stack.rs b/crates/nu-protocol/src/engine/stack.rs index ddd1169513..ba14e67817 100644 --- a/crates/nu-protocol/src/engine/stack.rs +++ b/crates/nu-protocol/src/engine/stack.rs @@ -8,6 +8,7 @@ use crate::{ use std::{ collections::{HashMap, HashSet}, fs::File, + path::{Path, PathBuf}, sync::Arc, }; @@ -52,6 +53,8 @@ pub struct Stack { /// Locally updated config. Use [`.get_config()`](Self::get_config) to access correctly. pub config: Option>, pub(crate) out_dest: StackOutDest, + #[cfg(windows)] + pub pwd_per_drive: nu_path::DriveToPwdMap, } impl Default for Stack { @@ -81,6 +84,8 @@ impl Stack { parent_deletions: vec![], config: None, out_dest: StackOutDest::new(), + #[cfg(windows)] + pwd_per_drive: nu_path::DriveToPwdMap::new(), } } @@ -101,6 +106,8 @@ impl Stack { parent_deletions: vec![], config: parent.config.clone(), out_dest: parent.out_dest.clone(), + #[cfg(windows)] + pwd_per_drive: parent.pwd_per_drive.clone(), parent_stack: Some(parent), } } @@ -127,6 +134,10 @@ impl Stack { unique_stack.env_hidden = child.env_hidden; unique_stack.active_overlays = child.active_overlays; unique_stack.config = child.config; + #[cfg(windows)] + { + unique_stack.pwd_per_drive = child.pwd_per_drive.clone(); + } unique_stack } @@ -318,6 +329,8 @@ impl Stack { parent_deletions: vec![], config: self.config.clone(), out_dest: self.out_dest.clone(), + #[cfg(windows)] + pwd_per_drive: self.pwd_per_drive.clone(), } } @@ -351,6 +364,8 @@ impl Stack { parent_deletions: vec![], config: self.config.clone(), out_dest: self.out_dest.clone(), + #[cfg(windows)] + pwd_per_drive: self.pwd_per_drive.clone(), } } @@ -726,9 +741,29 @@ impl Stack { let path = nu_path::strip_trailing_slash(path); let value = Value::string(path.to_string_lossy(), Span::unknown()); self.add_env_var("PWD".into(), value); + // Sync with PWD-per-drive + #[cfg(windows)] + { + let _ = self.pwd_per_drive.set_pwd(&path); + } Ok(()) } } + + // Helper stub/proxy for nu_path::expand_path_with::(path, relative_to, expand_tilde) + // Facilitates file system commands to easily gain the ability to expand PWD-per-drive + pub fn expand_path_with(&self, path: P, relative_to: Q, expand_tilde: bool) -> PathBuf + where + P: AsRef, + Q: AsRef, + { + #[cfg(windows)] + if let Some(absolute_path) = self.pwd_per_drive.expand_pwd(path.as_ref()) { + return absolute_path; + } + + nu_path::expand_path_with::(path, relative_to, expand_tilde) + } } #[cfg(test)] diff --git a/src/run.rs b/src/run.rs index 89bad3866f..eb2a9b2e98 100644 --- a/src/run.rs +++ b/src/run.rs @@ -12,6 +12,30 @@ use nu_protocol::{ }; use nu_utils::perf; +#[cfg(windows)] +fn init_pwd_per_drive(engine_state: &EngineState, stack: &mut Stack) { + use nu_path::DriveToPwdMap; + use std::path::Path; + + // Read environment for PWD-per-drive + for drive_letter in 'A'..='Z' { + let env_var = DriveToPwdMap::env_var_for_drive(drive_letter); + if let Some(env_pwd) = engine_state.get_env_var(&env_var) { + if let Ok(pwd_str) = nu_engine::env_to_string(&env_var, env_pwd, engine_state, stack) { + trace!("Get Env({}) {}", env_var, pwd_str); + let _ = stack.pwd_per_drive.set_pwd(Path::new(&pwd_str)); + stack.remove_env_var(engine_state, &env_var); + } + } + } + + if let Ok(abs_pwd) = engine_state.cwd(None) { + if let Some(abs_pwd_str) = abs_pwd.to_str() { + let _ = stack.pwd_per_drive.set_pwd(Path::new(abs_pwd_str)); + } + } +} + pub(crate) fn run_commands( engine_state: &mut EngineState, parsed_nu_cli_args: command::NushellCliArgs, @@ -26,6 +50,8 @@ pub(crate) fn run_commands( let create_scaffold = nu_path::nu_config_dir().map_or(false, |p| !p.exists()); let mut stack = Stack::new(); + #[cfg(windows)] + init_pwd_per_drive(engine_state, &mut stack); // if the --no-config-file(-n) option is NOT passed, load the plugin file, // load the default env file or custom (depending on parsed_nu_cli_args.env_file), @@ -115,6 +141,8 @@ pub(crate) fn run_file( ) { trace!("run_file"); let mut stack = Stack::new(); + #[cfg(windows)] + init_pwd_per_drive(engine_state, &mut stack); // if the --no-config-file(-n) option is NOT passed, load the plugin file, // load the default env file or custom (depending on parsed_nu_cli_args.env_file), @@ -182,6 +210,9 @@ pub(crate) fn run_repl( ) -> Result<(), miette::ErrReport> { trace!("run_repl"); let mut stack = Stack::new(); + #[cfg(windows)] + init_pwd_per_drive(engine_state, &mut stack); + let start_time = std::time::Instant::now(); if parsed_nu_cli_args.no_config_file.is_none() {