Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
18e5d59
feat(env): add support for Nu shell version in EnvConfig
naokihaba Apr 5, 2026
fc9d457
feat(shell): add support for NuShell in shell detection and command f…
naokihaba Apr 5, 2026
e17d514
feat(env): add Nushell support with completion and environment setup
naokihaba Apr 5, 2026
f339b45
feat(nushell): add Nushell completion support in main function
naokihaba Apr 5, 2026
babc76d
feat(nushell): add clap_complete_nushell dependency for Nushell support
naokihaba Apr 5, 2026
840bf1e
feat(nushell): add clap_complete_nushell dependency for Nushell compl…
naokihaba Apr 5, 2026
4e4d495
chore: remove clap_complete_nushell dependency from Cargo.toml files
naokihaba Apr 5, 2026
d55632e
feat(nushell): update completion generation to use Fish format for Nu…
naokihaba Apr 5, 2026
c060095
refactor(nushell): remove direct Nushell completion handling from mai…
naokihaba Apr 5, 2026
d39efa0
Merge branch 'main' into feat/nushell-support
naokihaba Apr 5, 2026
aa170c4
chore: remove unused import of CompleteEnv from main.rs
naokihaba Apr 5, 2026
5e0bf75
Merge branch 'feat/nushell-support' of github.com:naokihaba/vite-plus…
naokihaba Apr 5, 2026
f39e459
fix(nushell): ensure environment mutations are applied in emission order
naokihaba Apr 5, 2026
2e8fc7b
fix(nushell): ensure environment key is checked before hiding
naokihaba Apr 5, 2026
08a8e9d
fix(nushell): update environment variable assignment syntax in setup …
naokihaba Apr 5, 2026
371d6ef
feat(nushell): introduce VP_SHELL_NU for explicit Nu shell detection …
naokihaba Apr 5, 2026
6fede95
test(nushell): simplify assertion for load-env in nu_content check
naokihaba Apr 5, 2026
5938405
test(nushell): add windows shell detection assertion in tests
naokihaba Apr 5, 2026
3170641
Merge branch 'main' into feat/nushell-support
fengmk2 Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions crates/vite_global_cli/src/commands/env/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result<ExitStatus, Error>
// 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();
Copy link
Copy Markdown
Collaborator

@nekomoyi nekomoyi Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the script from this comment, and it works well for dynamic completions. We can use this to replace the static completion file here.

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.");
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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__")
Comment on lines +520 to +524
Copy link
Copy Markdown
Contributor Author

@naokihaba naokihaba Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ref: clap-rs/clap#5771

When a command has multiple Vec arguments (for example, when vp install has both packages and pass_through_args), clap_complete_nushell generates invalid syntax. This is because Nushell's extern only allows a single ...rest parameter

Error: nu::parser::multiple_rest_params
× Multiple rest params.
  ...packages: string
  ...pass_through_args: string  ← Nushell rejects this

As a workaround for this problem, completion processing is delegated to Fish. Running vp env setup generates vp.fish (which is shared with the Fish shell integration), so if Fish is installed, completions can be used without any additional configuration.


# 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 }
Copy link
Copy Markdown
Collaborator

@nekomoyi nekomoyi Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we delegate completion to Fish shell via static completion files, dynamic completion for vp run isn't available. We could remove completion support for vpr for now and implement it later.

"#
.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__"
Expand Down Expand Up @@ -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\"");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line outputs:

For Nushell, add to ~/.config/nushell/config.nu:

source "$HOME/.vite-plus/env.nu"

But Nushell seems to use $env.HOME instead, and source "$env.HOME/.vite-plus/env.nu" is also not a valid script because source in Nushell requires a static string literal.

Should we switch to using ~ for Nushell instead?

(BTW, I'm not familiar with nushell, so please correct me if I'm missing anything)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

println!();
println!(" For PowerShell, add to your $PROFILE:");
println!();
println!(" . \"{home_path}/env.ps1\"");
Expand Down Expand Up @@ -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();
Expand Down
55 changes: 55 additions & 0 deletions crates/vite_global_cli/src/commands/env/use.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@ enum Shell {
PowerShell,
/// Windows cmd.exe
Cmd,
/// Nushell
NuShell,
}

/// Detect the current shell from environment variables.
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) {
Expand All @@ -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}\""),
}
}

Expand All @@ -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}"),
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand Down Expand Up @@ -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");
}
}
17 changes: 17 additions & 0 deletions crates/vite_shared/src/env_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ pub struct EnvConfig {
///
/// Env: `PSModulePath`
pub ps_module_path: Option<String>,

/// Nu shell version (indicates running under Nu shell).
///
/// Env: `NU_VERSION`
pub nu_version: Option<String>,

/// 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 {
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -233,6 +248,8 @@ impl EnvConfig {
user_home: None,
fish_version: None,
ps_module_path: None,
nu_version: None,
vp_shell_nu: false,
}
}

Expand Down
7 changes: 7 additions & 0 deletions crates/vite_shared/src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading