1717
1818use std:: process:: ExitStatus ;
1919
20+ use clap:: CommandFactory ;
2021use owo_colors:: OwoColorize ;
2122
2223use 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)
2627const 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`)
379416async 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
452515end
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