Skip to content

Commit 5122cc6

Browse files
nekomoyifengmk2
andauthored
feat(cli): add shell completion support (#974)
## Summary Implements shell completion for the vp CLI. ## Changes Completion scripts for bash, zsh, fish, and PowerShell are generated in `~/.vite-plus/completion/` when running `vp env setup`, and automatically sourced in the respective env files. ## Fixes Fixed mixed path separators in env files on Windows to use forward slashes consistently for `$HOME`-relative paths ```bash # Before __vp_bin="$HOME/.vite-plus\bin" # After __vp_bin="$HOME/.vite-plus/bin" ``` Increased stack size to 8MB to handle deep recursion in `clap_complete::generate()` which exceeds Windows' default stack size. Closes #950 --------- Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent f467510 commit 5122cc6

File tree

6 files changed

+189
-12
lines changed

6 files changed

+189
-12
lines changed

.cargo/config.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@ WORKSPACE_DIR = { value = "rolldown", relative = true }
44

55
[build]
66
rustflags = ["--cfg", "tokio_unstable"] # also update .github/workflows/ci.yml
7+
78
# fix sqlite build error on linux
89
[target.'cfg(target_os = "linux")']
910
rustflags = ["--cfg", "tokio_unstable", "-C", "link-args=-Wl,--warn-unresolved-symbols"]
1011

12+
# Increase stack size on Windows to avoid stack overflow
13+
[target.'cfg(all(windows, target_env = "msvc"))']
14+
rustflags = ["--cfg", "tokio_unstable", "-C", "link-arg=/STACK:8388608"]
15+
[target.'cfg(all(windows, target_env = "gnu"))']
16+
rustflags = ["--cfg", "tokio_unstable", "-C", "link-arg=-Wl,--stack,8388608"]
17+
1118
[unstable]
1219
bindeps = true
1320

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ jobs:
109109
# Test all crates/* packages. New crates are automatically included.
110110
# Also test vite-plus-cli (lives outside crates/) to catch type sync issues.
111111
- run: cargo test $(for d in crates/*/; do echo -n "-p $(basename $d) "; done) -p vite-plus-cli
112+
env:
113+
RUST_MIN_STACK: 8388608
112114

113115
lint:
114116
needs: detect-changes
@@ -399,6 +401,8 @@ jobs:
399401
git diff
400402
exit 1
401403
fi
404+
env:
405+
RUST_MIN_STACK: 8388608
402406

403407
# Upgrade tests (merged from separate job to avoid duplicate build)
404408
- name: Test upgrade (bash)

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ brush-parser = "0.3.0"
7171
blake3 = "1.8.2"
7272
chrono = { version = "0.4", features = ["serde"] }
7373
clap = "4.5.40"
74+
clap_complete = "4.6.0"
7475
commondir = "1.0.0"
7576
cow-utils = "0.1.3"
7677
criterion = { version = "0.7", features = ["html_reports"] }

crates/vite_global_cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ path = "src/main.rs"
1515
base64-simd = { workspace = true }
1616
chrono = { workspace = true }
1717
clap = { workspace = true, features = ["derive"] }
18+
clap_complete = { workspace = true }
1819
directories = { workspace = true }
1920
flate2 = { workspace = true }
2021
serde = { workspace = true }

crates/vite_global_cli/src/commands/env/setup.rs

Lines changed: 166 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717
1818
use std::process::ExitStatus;
1919

20+
use clap::CommandFactory;
2021
use owo_colors::OwoColorize;
2122

2223
use super::config::{get_bin_dir, get_vite_plus_home};
23-
use crate::{error::Error, help};
24+
use crate::{cli::Args, error::Error, help};
2425

2526
/// Tools to create shims for (node, npm, npx, vpx)
2627
const SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "vpx"];
@@ -40,6 +41,9 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result<ExitStatus, Error>
4041
// Ensure home directory exists (env files are written here)
4142
tokio::fs::create_dir_all(&vite_plus_home).await?;
4243

44+
// Generate completion scripts
45+
generate_completion_scripts(&vite_plus_home).await?;
46+
4347
// Create env files with PATH guard (prevents duplicate PATH entries)
4448
create_env_files(&vite_plus_home).await?;
4549

@@ -270,6 +274,39 @@ async fn create_windows_shim(
270274
Ok(())
271275
}
272276

277+
/// Creates completion scripts in `~/.vite-plus/completion/`:
278+
/// - `vp.bash` (bash)
279+
/// - `_vp` (zsh, following zsh convention)
280+
/// - `vp.fish` (fish shell)
281+
/// - `vp.ps1` (PowerShell)
282+
async fn generate_completion_scripts(
283+
vite_plus_home: &vite_path::AbsolutePath,
284+
) -> Result<(), Error> {
285+
let mut cmd = Args::command();
286+
287+
// Create completion directory
288+
let completion_dir = vite_plus_home.join("completion");
289+
tokio::fs::create_dir_all(&completion_dir).await?;
290+
291+
// Generate shell completion scripts
292+
let completions = [
293+
(clap_complete::Shell::Bash, "vp.bash"),
294+
(clap_complete::Shell::Zsh, "_vp"),
295+
(clap_complete::Shell::Fish, "vp.fish"),
296+
(clap_complete::Shell::PowerShell, "vp.ps1"),
297+
];
298+
299+
for (shell, filename) in completions {
300+
let path = completion_dir.join(filename);
301+
let mut file = std::fs::File::create(&path)?;
302+
clap_complete::generate(shell, &mut cmd, "vp", &mut file);
303+
}
304+
305+
tracing::debug!("Generated completion scripts in {:?}", completion_dir);
306+
307+
Ok(())
308+
}
309+
273310
/// Get the path to the trampoline template binary (vp-shim.exe).
274311
///
275312
/// The trampoline binary is distributed alongside vp.exe in the same directory.
@@ -378,23 +415,28 @@ pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vite_path::AbsolutePat
378415
/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`)
379416
async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> {
380417
let bin_path = vite_plus_home.join("bin");
418+
let completion_path = vite_plus_home.join("completion");
381419

382420
// Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env)
383421
// This makes the env file portable across sessions where HOME may differ
384-
let bin_path_ref = if let Some(home_dir) = vite_shared::EnvConfig::get().user_home {
385-
if let Ok(suffix) = bin_path.as_path().strip_prefix(&home_dir) {
386-
format!("$HOME/{}", suffix.display())
387-
} else {
388-
bin_path.as_path().display().to_string()
389-
}
390-
} else {
391-
bin_path.as_path().display().to_string()
422+
let home_dir = vite_shared::EnvConfig::get().user_home;
423+
let to_ref = |path: &vite_path::AbsolutePath| -> String {
424+
home_dir
425+
.as_ref()
426+
.and_then(|h| path.as_path().strip_prefix(h).ok())
427+
.map(|s| {
428+
// Normalize to forward slashes for $HOME/... paths (POSIX-style)
429+
format!("$HOME/{}", s.display().to_string().replace('\\', "/"))
430+
})
431+
.unwrap_or_else(|| path.as_path().display().to_string())
392432
};
433+
let bin_path_ref = to_ref(&bin_path);
393434

394435
// POSIX env file (bash/zsh)
395436
// When sourced multiple times, removes existing entry and re-prepends to front
396437
// Uses parameter expansion to split PATH around the bin entry in O(1) operations
397438
// Includes vp() shell function wrapper for `vp env use` (evals stdout)
439+
// Includes shell completion support
398440
let env_content = r#"#!/bin/sh
399441
# Vite+ environment setup (https://viteplus.dev)
400442
__vp_bin="__VP_BIN__"
@@ -425,8 +467,29 @@ vp() {
425467
command vp "$@"
426468
fi
427469
}
470+
471+
# Shell completion for bash/zsh
472+
# Source appropriate completion script based on current shell
473+
# Only load completion in interactive shells with required builtins
474+
if [ -n "$BASH_VERSION" ] && type complete >/dev/null 2>&1; then
475+
# Bash shell with completion support
476+
__vp_completion="__VP_COMPLETION_BASH__"
477+
if [ -f "$__vp_completion" ]; then
478+
. "$__vp_completion"
479+
fi
480+
unset __vp_completion
481+
elif [ -n "$ZSH_VERSION" ] && type compdef >/dev/null 2>&1; then
482+
# Zsh shell with completion support
483+
__vp_completion="__VP_COMPLETION_ZSH__"
484+
if [ -f "$__vp_completion" ]; then
485+
. "$__vp_completion"
486+
fi
487+
unset __vp_completion
488+
fi
428489
"#
429-
.replace("__VP_BIN__", &bin_path_ref);
490+
.replace("__VP_BIN__", &bin_path_ref)
491+
.replace("__VP_COMPLETION_BASH__", &to_ref(&completion_path.join("vp.bash")))
492+
.replace("__VP_COMPLETION_ZSH__", &to_ref(&completion_path.join("_vp")));
430493
let env_file = vite_plus_home.join("env");
431494
tokio::fs::write(&env_file, env_content).await?;
432495

@@ -450,8 +513,18 @@ function vp
450513
command vp $argv
451514
end
452515
end
516+
517+
# Shell completion for fish
518+
if not set -q __vp_completion_sourced
519+
set -l __vp_completion "__VP_COMPLETION_FISH__"
520+
if test -f "$__vp_completion"
521+
source "$__vp_completion"
522+
set -g __vp_completion_sourced 1
523+
end
524+
end
453525
"#
454-
.replace("__VP_BIN__", &bin_path_ref);
526+
.replace("__VP_BIN__", &bin_path_ref)
527+
.replace("__VP_COMPLETION_FISH__", &to_ref(&completion_path.join("vp.fish")));
455528
let env_fish_file = vite_plus_home.join("env.fish");
456529
tokio::fs::write(&env_fish_file, env_fish_content).await?;
457530

@@ -485,11 +558,20 @@ function vp {
485558
& (Join-Path $__vp_bin "vp.exe") @args
486559
}
487560
}
561+
562+
# Shell completion for PowerShell
563+
$__vp_completion = "__VP_COMPLETION_PS1__"
564+
if (Test-Path $__vp_completion) {
565+
. $__vp_completion
566+
}
488567
"#;
489568

490569
// For PowerShell, use the actual absolute path (not $HOME-relative)
491570
let bin_path_win = bin_path.as_path().display().to_string();
492-
let env_ps1_content = env_ps1_content.replace("__VP_BIN_WIN__", &bin_path_win);
571+
let completion_ps1_win = completion_path.join("vp.ps1").as_path().display().to_string();
572+
let env_ps1_content = env_ps1_content
573+
.replace("__VP_BIN_WIN__", &bin_path_win)
574+
.replace("__VP_COMPLETION_PS1__", &completion_ps1_win);
493575
let env_ps1_file = vite_plus_home.join("env.ps1");
494576
tokio::fs::write(&env_ps1_file, env_ps1_content).await?;
495577

@@ -820,4 +902,76 @@ mod tests {
820902
assert!(fresh_home.join("env.fish").exists(), "env.fish file should be created");
821903
assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created");
822904
}
905+
906+
#[tokio::test]
907+
async fn test_generate_completion_scripts_creates_all_files() {
908+
let temp_dir = TempDir::new().unwrap();
909+
let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
910+
911+
generate_completion_scripts(&home).await.unwrap();
912+
913+
let completion_dir = home.join("completion");
914+
915+
// Verify all completion scripts are created
916+
let bash_completion = completion_dir.join("vp.bash");
917+
let zsh_completion = completion_dir.join("_vp");
918+
let fish_completion = completion_dir.join("vp.fish");
919+
let ps1_completion = completion_dir.join("vp.ps1");
920+
921+
assert!(bash_completion.as_path().exists(), "bash completion (vp.bash) should be created");
922+
assert!(zsh_completion.as_path().exists(), "zsh completion (_vp) should be created");
923+
assert!(fish_completion.as_path().exists(), "fish completion (vp.fish) should be created");
924+
assert!(
925+
ps1_completion.as_path().exists(),
926+
"PowerShell completion (vp.ps1) should be created"
927+
);
928+
}
929+
930+
#[tokio::test]
931+
async fn test_create_env_files_contains_completion() {
932+
let temp_dir = TempDir::new().unwrap();
933+
let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
934+
let _guard = home_guard(temp_dir.path());
935+
936+
create_env_files(&home).await.unwrap();
937+
938+
let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap();
939+
let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap();
940+
let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap();
941+
942+
assert!(
943+
env_content.contains("Shell completion")
944+
&& env_content.contains("/completion/vp.bash\""),
945+
"env file should contain bash completion"
946+
);
947+
assert!(
948+
fish_content.contains("Shell completion")
949+
&& fish_content.contains("/completion/vp.fish\""),
950+
"env.fish file should contain fish completion"
951+
);
952+
assert!(
953+
ps1_content.contains("Shell completion")
954+
&& ps1_content.contains(&format!(
955+
"{}completion{}vp.ps1\"",
956+
std::path::MAIN_SEPARATOR_STR,
957+
std::path::MAIN_SEPARATOR_STR
958+
)),
959+
"env.ps1 file should contain PowerShell completion"
960+
);
961+
962+
// Verify placeholders are replaced
963+
assert!(
964+
!env_content.contains("__VP_COMPLETION_BASH__")
965+
&& !env_content.contains("__VP_COMPLETION_ZSH__"),
966+
"env file should not contain __VP_COMPLETION_* placeholders"
967+
);
968+
assert!(
969+
!fish_content.contains("__VP_COMPLETION_FISH__"),
970+
"env.fish file should not contain __VP_COMPLETION_FISH__ placeholder"
971+
);
972+
assert!(
973+
!ps1_content.contains("__VP_COMPLETION_PS1__"),
974+
"env.ps1 file should not contain __VP_COMPLETION_PS1__ placeholder"
975+
);
976+
}
823977
}

0 commit comments

Comments
 (0)