diff --git a/crates/vite_global_cli/src/commands/env/setup.rs b/crates/vite_global_cli/src/commands/env/setup.rs index 229931775f..b41ebef6bd 100644 --- a/crates/vite_global_cli/src/commands/env/setup.rs +++ b/crates/vite_global_cli/src/commands/env/setup.rs @@ -46,6 +46,23 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result // Create env files with PATH guard (prevents duplicate PATH entries) create_env_files(&vite_plus_home).await?; + // Generate Fish completion file at ~/.vite-plus/vp.fish for use by the Nushell completer. + // Fish completions are generated statically so that Nushell can delegate to Fish at runtime. + // command_with_help() uses a deep call stack, so we spawn a thread with a larger stack. + let fish_completions_path = vite_plus_home.join("vp.fish").as_path().to_path_buf(); + std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(move || -> std::io::Result<()> { + let mut file = std::fs::File::create(&fish_completions_path)?; + let mut cmd = crate::cli::command_with_help(); + clap_complete::generate(clap_complete::Shell::Fish, &mut cmd, "vp", &mut file); + Ok(()) + }) + .map_err(Error::CommandExecution)? + .join() + .map_err(|_| Error::Other("Fish completion generation failed".into()))? + .map_err(Error::CommandExecution)?; + if env_only { println!("{}", help::render_heading("Setup")); println!(" Updated shell environment files."); @@ -388,6 +405,7 @@ async fn cleanup_legacy_completion_dir(vite_plus_home: &vite_path::AbsolutePath) /// Creates: /// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function /// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function +/// - `~/.vite-plus/env.nu` (Nushell) with `vp env use` wrapper function /// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function /// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`) async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> { @@ -499,6 +517,61 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" let env_fish_file = vite_plus_home.join("env.fish"); tokio::fs::write(&env_fish_file, env_fish_content).await?; + // Nushell env file with vp wrapper function. + // Completions delegate to Fish via vp.fish because clap_complete_nushell generates + // multiple rest params (e.g. for `vp install`), which Nushell does not support. + let env_nu_content = r#"# Vite+ environment setup (https://viteplus.dev) +$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") + +# Shell function wrapper: intercepts `vp env use` to parse its stdout, +# which sets/unsets VP_NODE_VERSION in the current shell session. +def --env --wrapped vp [...args: string@"nu-complete vp"] { + if ($args | length) >= 2 and $args.0 == "env" and $args.1 == "use" { + if ("-h" in $args) or ("--help" in $args) { + ^vp ...$args + return + } + let out = (with-env { VP_ENV_USE_EVAL_ENABLE: "1", VP_SHELL_NU: "1" } { + ^vp ...$args + }) + let lines = ($out | lines) + let exports = ($lines | where { $in =~ '^\$env\.' } | parse '$env.{key} = "{value}"') + let export_keys = ($exports | get key? | default []) + # Exclude keys that also appear in exports: when vp emits `hide-env X` then + # `$env.X = "v"` (e.g. `vp env use` with no args resolving from .node-version), + # the set should win. + let unsets = ($lines | where { $in =~ '^hide-env ' } | parse 'hide-env {key}' | get key? | default [] | where { $in not-in $export_keys }) + if ($exports | is-not-empty) { + load-env ($exports | reduce -f {} {|it, acc| $acc | insert $it.key $it.value}) + } + for key in $unsets { + if ($key in $env) { hide-env $key } + } + } else { + ^vp ...$args + } +} + +# Shell completion for nushell (delegates to fish completions) +def "nu-complete vp" [context: string] { + let fish_comp_path = "__VP_HOME__/vp.fish" + let fish_cmd = $"source '($fish_comp_path)'; complete '--do-complete=($context)'" + fish --command $fish_cmd | from tsv --flexible --noheaders --no-infer | rename value description | update value {|row| + let value = $row.value + let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | any {$in in $value} + if ($need_quote and ($value | path exists)) { + let expanded_path = if ($value starts-with ~) {$value | path expand --no-symlink} else {$value} + $'"($expanded_path | str replace --all "\"" "\\\"")"' + } else {$value} + } +} +export def --env --wrapped vpr [...args: string@"nu-complete vp"] { ^vp run ...$args } +"# + .replace("__VP_BIN__", &bin_path_ref) + .replace("__VP_HOME__", &vite_plus_home.as_path().display().to_string()); + let env_nu_file = vite_plus_home.join("env.nu"); + tokio::fs::write(&env_nu_file, env_nu_content).await?; + // PowerShell env file let env_ps1_content = r#"# Vite+ environment setup (https://viteplus.dev) $__vp_bin = "__VP_BIN_WIN__" @@ -601,6 +674,10 @@ fn print_path_instructions(bin_dir: &vite_path::AbsolutePath) { println!(); println!(" source \"{home_path}/env.fish\""); println!(); + println!(" For Nushell, add to ~/.config/nushell/config.nu:"); + println!(); + println!(" source \"{home_path}/env.nu\""); + println!(); println!(" For PowerShell, add to your $PROFILE:"); println!(); println!(" . \"{home_path}/env.ps1\""); @@ -654,12 +731,43 @@ mod tests { let env_path = home.join("env"); let env_fish_path = home.join("env.fish"); + let env_nu_path = home.join("env.nu"); let env_ps1_path = home.join("env.ps1"); assert!(env_path.as_path().exists(), "env file should be created"); assert!(env_fish_path.as_path().exists(), "env.fish file should be created"); + assert!(env_nu_path.as_path().exists(), "env.nu file should be created"); assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created"); } + #[tokio::test] + async fn test_create_env_files_nu_contains_path_guard() { + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = home_guard(temp_dir.path()); + + create_env_files(&home).await.unwrap(); + + let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); + assert!( + !nu_content.contains("__VP_BIN__"), + "env.nu should not contain __VP_BIN__ placeholder" + ); + assert!(nu_content.contains("$HOME/bin"), "env.nu should reference $HOME/bin"); + assert!( + nu_content.contains("VP_ENV_USE_EVAL_ENABLE"), + "env.nu should set VP_ENV_USE_EVAL_ENABLE" + ); + assert!( + nu_content.contains("vp.fish"), + "env.nu should reference the Fish completion file for Nushell delegation" + ); + assert!( + nu_content.contains("VP_SHELL_NU"), + "env.nu should use VP_SHELL_NU explicit marker instead of inherited NU_VERSION" + ); + assert!(nu_content.contains("load-env"), "env.nu should use load-env to apply exports"); + } + #[tokio::test] async fn test_create_env_files_replaces_placeholder_with_home_relative_path() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vite_global_cli/src/commands/env/use.rs b/crates/vite_global_cli/src/commands/env/use.rs index 398fb57379..27ee6dc819 100644 --- a/crates/vite_global_cli/src/commands/env/use.rs +++ b/crates/vite_global_cli/src/commands/env/use.rs @@ -25,6 +25,8 @@ enum Shell { PowerShell, /// Windows cmd.exe Cmd, + /// Nushell + NuShell, } /// Detect the current shell from environment variables. @@ -32,6 +34,8 @@ fn detect_shell() -> Shell { let config = vite_shared::EnvConfig::get(); if config.fish_version.is_some() { Shell::Fish + } else if config.vp_shell_nu { + Shell::NuShell } else if cfg!(windows) && config.ps_module_path.is_some() { Shell::PowerShell } else if cfg!(windows) { @@ -48,6 +52,7 @@ fn format_export(shell: &Shell, value: &str) -> String { Shell::Fish => format!("set -gx {VERSION_ENV_VAR} {value}"), Shell::PowerShell => format!("$env:{VERSION_ENV_VAR} = \"{value}\""), Shell::Cmd => format!("set {VERSION_ENV_VAR}={value}"), + Shell::NuShell => format!("$env.{VERSION_ENV_VAR} = \"{value}\""), } } @@ -60,6 +65,7 @@ fn format_unset(shell: &Shell) -> String { format!("Remove-Item Env:{VERSION_ENV_VAR} -ErrorAction SilentlyContinue") } Shell::Cmd => format!("set {VERSION_ENV_VAR}="), + Shell::NuShell => format!("hide-env {VERSION_ENV_VAR}"), } } @@ -194,6 +200,18 @@ mod tests { assert!(matches!(shell, Shell::Fish)); } + #[test] + fn test_detect_shell_fish_and_nushell() { + // Fish takes priority over Nu shell signal + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + fish_version: Some("3.7.0".into()), + vp_shell_nu: true, + ..vite_shared::EnvConfig::for_test() + }); + let shell = detect_shell(); + assert!(matches!(shell, Shell::Fish)); + } + #[test] fn test_detect_shell_posix_default() { // All shell detection fields None → defaults @@ -205,6 +223,32 @@ mod tests { assert!(matches!(shell, Shell::Cmd)); } + #[test] + fn test_detect_shell_nushell() { + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + vp_shell_nu: true, + ..vite_shared::EnvConfig::for_test() + }); + let shell = detect_shell(); + assert!(matches!(shell, Shell::NuShell)); + } + + #[test] + fn test_detect_shell_inherited_nu_version_is_posix() { + // NU_VERSION alone (inherited from parent Nushell) must NOT trigger Nu detection. + // Only the explicit VP_SHELL_NU marker set by env.nu wrapper counts. + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + nu_version: Some("0.111.0".into()), + vp_shell_nu: false, + ..vite_shared::EnvConfig::for_test() + }); + let shell = detect_shell(); + #[cfg(not(windows))] + assert!(matches!(shell, Shell::Posix)); + #[cfg(windows)] + let _ = shell; + } + #[test] fn test_format_export_posix() { let result = format_export(&Shell::Posix, "20.18.0"); @@ -252,4 +296,15 @@ mod tests { let result = format_unset(&Shell::Cmd); assert_eq!(result, "set VP_NODE_VERSION="); } + #[test] + fn test_format_export_nushell() { + let result = format_export(&Shell::NuShell, "20.18.0"); + assert_eq!(result, "$env.VP_NODE_VERSION = \"20.18.0\""); + } + + #[test] + fn test_format_unset_nushell() { + let result = format_unset(&Shell::NuShell); + assert_eq!(result, "hide-env VP_NODE_VERSION"); + } } diff --git a/crates/vite_shared/src/env_config.rs b/crates/vite_shared/src/env_config.rs index 95b198e1b2..6818058c5b 100644 --- a/crates/vite_shared/src/env_config.rs +++ b/crates/vite_shared/src/env_config.rs @@ -122,6 +122,19 @@ pub struct EnvConfig { /// /// Env: `PSModulePath` pub ps_module_path: Option, + + /// Nu shell version (indicates running under Nu shell). + /// + /// Env: `NU_VERSION` + pub nu_version: Option, + + /// Explicit Nu shell eval signal set by the `env.nu` wrapper. + /// + /// Unlike `NU_VERSION`, this is not inherited by child processes — it is only + /// present when the Nushell wrapper explicitly passes it via `with-env`. + /// + /// Env: `VP_SHELL_NU` + pub vp_shell_nu: bool, } impl EnvConfig { @@ -151,6 +164,8 @@ impl EnvConfig { .map(PathBuf::from), fish_version: std::env::var("FISH_VERSION").ok(), ps_module_path: std::env::var("PSModulePath").ok(), + nu_version: std::env::var("NU_VERSION").ok(), + vp_shell_nu: std::env::var(env_vars::VP_SHELL_NU).is_ok(), } } @@ -233,6 +248,8 @@ impl EnvConfig { user_home: None, fish_version: None, ps_module_path: None, + nu_version: None, + vp_shell_nu: false, } } diff --git a/crates/vite_shared/src/env_vars.rs b/crates/vite_shared/src/env_vars.rs index 6a3ecd6b49..5618e416fc 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -36,6 +36,13 @@ pub const VP_DEBUG_SHIM: &str = "VP_DEBUG_SHIM"; /// Enable eval mode for `vp env use`. pub const VP_ENV_USE_EVAL_ENABLE: &str = "VP_ENV_USE_EVAL_ENABLE"; +/// Explicit signal set by the Nushell wrapper to indicate Nu shell eval context. +/// +/// Unlike `NU_VERSION` (which is inherited by child processes), this is only set +/// by the `with-env` block in `env.nu`, so it cannot cause false detection when +/// bash/zsh is launched from a Nushell session. +pub const VP_SHELL_NU: &str = "VP_SHELL_NU"; + /// Filter for update task types. pub const VITE_UPDATE_TASK_TYPES: &str = "VITE_UPDATE_TASK_TYPES";