diff --git a/Cargo.lock b/Cargo.lock index 6c95f5a1eb..611f193c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,6 +801,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecount" @@ -2271,8 +2274,9 @@ dependencies = [ [[package]] name = "fspy" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ + "allocator-api2", "anyhow", "bstr", "bumpalo", @@ -2305,52 +2309,93 @@ dependencies = [ [[package]] name = "fspy_client_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "allocator-api2", "anyhow", "bstr", + "fspy_nostd", + "fspy_nostd_alloc", "fspy_shared", "fspy_shared_unix", "itoa", "libc", "nix 0.31.3", - "sigsafe", - "sigsafe_alloc", - "wincode", ] [[package]] name = "fspy_detours_sys" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "cc", "winapi", ] +[[package]] +name = "fspy_ipc_str" +version = "0.0.0" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" +dependencies = [ + "allocator-api2", + "bumpalo", + "bytemuck", + "fspy_nostd", + "fspy_nostd_alloc", + "wincode", +] + +[[package]] +name = "fspy_nostd" +version = "0.0.0" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" +dependencies = [ + "atoi", + "bitflags 2.13.1", + "bstr", + "libc", + "linux-raw-sys", + "syscalls", + "windows-sys 0.61.2", +] + +[[package]] +name = "fspy_nostd_alloc" +version = "0.0.0" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" +dependencies = [ + "allocator-api2", + "bump-scope", + "fspy_nostd", +] + [[package]] name = "fspy_preload_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ + "allocator-api2", "ctor", "fspy_client_unix", + "fspy_nostd", + "fspy_nostd_alloc", "fspy_shared", "fspy_shared_unix", "libc", "nix 0.31.3", - "sigsafe", - "sigsafe_alloc", + "static_cell", ] [[package]] name = "fspy_preload_windows" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ + "allocator-api2", "constcat", "fspy_detours_sys", + "fspy_nostd", + "fspy_nostd_alloc", "fspy_shared", "ntapi", "smallvec 2.0.0-alpha.12", @@ -2364,7 +2409,7 @@ dependencies = [ [[package]] name = "fspy_seccomp_unotify" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "futures-util", "libc", @@ -2381,16 +2426,19 @@ dependencies = [ [[package]] name = "fspy_shared" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ + "allocator-api2", "bitflags 2.13.1", "bstr", "bumpalo", "bytemuck", + "fspy_ipc_str", + "fspy_nostd", + "fspy_nostd_alloc", "fspy_shm", - "native_str", + "omnipath", "thiserror 2.0.19", - "tracing", "uuid", "vt_path", "winapi", @@ -2400,18 +2448,19 @@ dependencies = [ [[package]] name = "fspy_shared_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ + "allocator-api2", "anyhow", "base64 0.22.1", "bstr", "elf", + "fspy_nostd", "fspy_seccomp_unotify", "fspy_shared", "memmap2", "nix 0.31.3", "phf 0.13.1", - "sigsafe", "stackalloc", "wincode", ] @@ -2419,10 +2468,9 @@ dependencies = [ [[package]] name = "fspy_shm" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ - "memmap2", - "uuid", + "fspy_nostd", "windows-sys 0.61.2", ] @@ -3583,7 +3631,7 @@ dependencies = [ [[package]] name = "materialized_artifact" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "materialized_artifact_macros", "tempfile", @@ -3592,7 +3640,7 @@ dependencies = [ [[package]] name = "materialized_artifact_macros" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "proc-macro2", "quote", @@ -3816,16 +3864,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "native_str" -version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" -dependencies = [ - "bumpalo", - "bytemuck", - "wincode", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -4141,6 +4179,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "omnipath" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80adb31078122c880307e9cdfd4e3361e6545c319f9b9dcafcb03acd3b51a575" + [[package]] name = "once_cell" version = "1.21.4" @@ -5525,7 +5569,7 @@ dependencies = [ [[package]] name = "pty_terminal" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "anyhow", "portable-pty", @@ -5535,7 +5579,7 @@ dependencies = [ [[package]] name = "pty_terminal_test" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "anyhow", "portable-pty", @@ -5546,7 +5590,7 @@ dependencies = [ [[package]] name = "pty_terminal_test_client" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "base64 0.22.1", "getrandom 0.4.3", @@ -7302,28 +7346,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "sigsafe" -version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" -dependencies = [ - "atoi", - "bstr", - "libc", - "rustix", - "syscalls", -] - -[[package]] -name = "sigsafe_alloc" -version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" -dependencies = [ - "allocator-api2", - "bump-scope", - "sigsafe", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -7430,7 +7452,7 @@ dependencies = [ [[package]] name = "snapshot_test" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "serde", "serde_json", @@ -7460,7 +7482,7 @@ dependencies = [ [[package]] name = "socket_ipc" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "nix 0.31.3", "tempfile", @@ -7514,6 +7536,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + [[package]] name = "str_indices" version = "0.4.4" @@ -8304,6 +8335,39 @@ dependencies = [ "serde", ] +[[package]] +name = "usage-argv" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" + +[[package]] +name = "usage-derive" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "usage-rs" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" +dependencies = [ + "usage-argv", + "usage-derive", + "usage-test", +] + +[[package]] +name = "usage-test" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" +dependencies = [ + "usage-argv", +] + [[package]] name = "utf8-chars" version = "3.0.7" @@ -8422,7 +8486,6 @@ dependencies = [ "anyhow", "async-trait", "base64-simd", - "clap", "cow-utils", "fspy", "napi", @@ -8438,6 +8501,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "usage-rs", "uuid", "vp_cli_help", "vp_command", @@ -8461,6 +8525,7 @@ dependencies = [ "clap", "owo-colors", "terminal_size", + "usage-rs", "vp_shared", ] @@ -8553,6 +8618,7 @@ dependencies = [ "thiserror 2.0.19", "tokio", "tracing", + "usage-rs", "uuid", "vp_cli_help", "vp_command", @@ -8657,6 +8723,7 @@ dependencies = [ "thiserror 2.0.19", "tokio", "tracing", + "usage-rs", "vp_command", "vp_error", "vp_pm_cli_macros", @@ -8765,12 +8832,11 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vt" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "anstream", "anyhow", "async-trait", - "clap", "ctrlc", "derive_more", "fspy", @@ -8793,6 +8859,7 @@ dependencies = [ "tokio-util", "tracing", "twox-hash", + "usage-rs", "uuid", "vt_client_napi", "vt_glob", @@ -8824,9 +8891,9 @@ dependencies = [ [[package]] name = "vt_client" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ - "native_str", + "fspy_ipc_str", "rustc-hash", "socket_ipc", "vt_ipc_shared", @@ -8837,7 +8904,7 @@ dependencies = [ [[package]] name = "vt_client_napi" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "napi", "napi-build", @@ -8849,7 +8916,7 @@ dependencies = [ [[package]] name = "vt_glob" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "globset", "thiserror 2.0.19", @@ -8859,7 +8926,7 @@ dependencies = [ [[package]] name = "vt_graph" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "anyhow", "async-trait", @@ -8881,7 +8948,7 @@ dependencies = [ [[package]] name = "vt_graph_ser" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "petgraph 0.8.3", "serde", @@ -8890,9 +8957,9 @@ dependencies = [ [[package]] name = "vt_ipc_shared" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ - "native_str", + "fspy_ipc_str", "rustc-hash", "wincode", ] @@ -8900,7 +8967,7 @@ dependencies = [ [[package]] name = "vt_path" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "diff-struct", "os_str_bytes", @@ -8915,7 +8982,7 @@ dependencies = [ [[package]] name = "vt_plan" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "anyhow", "async-trait", @@ -8943,7 +9010,7 @@ dependencies = [ [[package]] name = "vt_powershell" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "vt_path", "which", @@ -8952,7 +9019,7 @@ dependencies = [ [[package]] name = "vt_select" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "anyhow", "crossterm", @@ -8963,10 +9030,10 @@ dependencies = [ [[package]] name = "vt_server" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ + "fspy_ipc_str", "futures", - "native_str", "rustc-hash", "socket_ipc", "thiserror 2.0.19", @@ -8982,7 +9049,7 @@ dependencies = [ [[package]] name = "vt_shell" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "brush-parser 0.4.0", "diff-struct", @@ -8995,7 +9062,7 @@ dependencies = [ [[package]] name = "vt_str" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ "compact_str 0.9.1", "diff-struct", @@ -9006,9 +9073,8 @@ dependencies = [ [[package]] name = "vt_workspace" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d05b1dcdbaabaa69643ee0b89cebe3cd390957e9#d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=b6222447b2b71d903ff5a092ab59ce881be19f51#b6222447b2b71d903ff5a092ab59ce881be19f51" dependencies = [ - "clap", "petgraph 0.8.3", "rustc-hash", "serde", @@ -9016,6 +9082,7 @@ dependencies = [ "serde_norway", "thiserror 2.0.19", "tracing", + "usage-rs", "vec1", "vt_glob", "vt_path", diff --git a/Cargo.toml b/Cargo.toml index 91d2a65d81..7a2ec21b55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,7 +184,7 @@ dunce = "1.0.5" fast-glob = "1.0.0" flate2 = { version = "=1.1.9", features = ["zlib-rs"] } form_urlencoded = "1.2.1" -fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } +fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } futures = "0.3.31" futures-util = "0.3.31" glob = "0.3.2" @@ -241,8 +241,8 @@ pretty_assertions = "1.4.1" phf = "0.14.0" prettyplease = "0.2.32" proc-macro2 = "1" -pty_terminal_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } -pty_terminal_test_client = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } +pty_terminal_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } +pty_terminal_test_client = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } quote = "1" rayon = "1.10.0" regex = "1.11.1" @@ -266,7 +266,7 @@ sha2 = "0.10.9" shell-escape = "0.1.5" simdutf8 = "0.1.5" smallvec = { version = "1.15.1", features = ["union"] } -snapshot_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } +snapshot_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } string_cache = "0.9.0" sugar_path = { version = "3", features = ["cached_current_dir"] } supports-color = "3" @@ -287,10 +287,13 @@ tracing-subscriber = { version = "0.3.19", default-features = false, features = ts-rs = "12.0" typedmap = "0.6.0" url = "2.5.4" +usage-rs = { git = "https://github.com/fengmk2/usage", rev = "25e76738edda9b21c5b2268c1274a9fb63cb4aad", default-features = false, features = [ + "spec", +] } uuid = "1.17.0" vfs = "0.13.0" vp_command = { path = "crates/vp_command" } -vp_cli_help = { path = "crates/vp_cli_help" } +vp_cli_help = { path = "crates/vp_cli_help", default-features = false } vp_error = { path = "crates/vp_error" } vp_js_runtime = { path = "crates/vp_js_runtime" } vp_migration = { path = "crates/vp_migration" } @@ -300,12 +303,12 @@ vp_setup = { path = "crates/vp_setup" } vp_shared = { path = "crates/vp_shared" } vp_static_config = { path = "crates/vp_static_config" } vp_toolchain = { path = "crates/vp_toolchain" } -vt = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } -vt_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } -vt_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } -vt_select = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } -vt_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } -vt_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } +vt = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } +vt_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } +vt_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } +vt_select = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } +vt_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } +vt_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "b6222447b2b71d903ff5a092ab59ce881be19f51" } walkdir = "2.5.0" webpki-root-certs = "1.0.9" which = "8.0.0" diff --git a/crates/vp_cli_help/Cargo.toml b/crates/vp_cli_help/Cargo.toml index 86dfb739e9..1bb9652ed0 100644 --- a/crates/vp_cli_help/Cargo.toml +++ b/crates/vp_cli_help/Cargo.toml @@ -7,10 +7,16 @@ license.workspace = true publish = false rust-version.workspace = true +[features] +default = ["clap"] +clap = ["dep:clap"] +usage = ["dep:usage-rs"] + [dependencies] -clap = { workspace = true } +clap = { workspace = true, optional = true } owo-colors = { workspace = true } terminal_size = { workspace = true } +usage-rs = { workspace = true, optional = true } vp_shared = { workspace = true } [lints] diff --git a/crates/vp_cli_help/src/lib.rs b/crates/vp_cli_help/src/lib.rs index c4595ec270..e951d4b1e7 100644 --- a/crates/vp_cli_help/src/lib.rs +++ b/crates/vp_cli_help/src/lib.rs @@ -8,9 +8,12 @@ use std::{borrow::Cow, fmt::Write as _, io::Write as _}; +#[cfg(feature = "clap")] use clap::{Arg, Command}; use owo_colors::OwoColorize; use terminal_size::{Width, terminal_size_of}; +#[cfg(feature = "usage")] +use usage_rs::{Command as UsageCommand, spec::CommandMeta}; const HELP_RIGHT_MARGIN: usize = 4; const ROW_LABEL_INDENT: &str = " "; @@ -38,6 +41,7 @@ pub struct HelpRow { } /// Build a help document from public `clap` command metadata. +#[cfg(feature = "clap")] #[must_use] pub fn help_doc_from_command( mut command: Command, @@ -81,6 +85,167 @@ pub fn help_doc_from_command( HelpDoc { usage: usage.into(), summary, sections, documentation_url } } +#[cfg(feature = "usage")] +#[must_use] +pub fn help_doc_from_usage( + spec: &'static usage_rs::spec::Spec<'static>, + argv: &[String], + command: &UsageCommand<'_>, + documentation_url: Option>, +) -> Option { + let argv = argv.iter().map(std::ffi::OsStr::new).collect::>(); + let route = usage_rs::help::route_to(spec.root.cmd, &argv, command)?; + let root_name = spec.bin.unwrap_or(spec.name); + let (path, metadata) = usage_route(spec.root, &route, root_name)?; + let usage = if path.len() == 1 { + spec.usage + .map(str::trim) + .and_then(|usage| usage.strip_prefix("Usage: ")) + .map_or_else(|| usage_line(&path, metadata), ToOwned::to_owned) + } else { + usage_line(&path, metadata) + }; + let summary = metadata + .long_about + .or(metadata.about) + .or_else(|| (path.len() == 1).then_some(spec.long_about.or(spec.about)).flatten()) + .map(str::to_owned) + .map(Into::into) + .into_iter() + .collect(); + let mut sections = Vec::new(); + + for argument in metadata.args.iter().filter(|argument| !argument.hide) { + let title = argument.help_heading.unwrap_or("Arguments"); + let description = argument.long_help.or(argument.help).unwrap_or_default(); + push_help_row( + &mut sections, + title, + HelpRow { + label: usage_argument_label(argument).into(), + description: vec![description.to_owned().into()], + }, + ); + } + + for flag in metadata.flags.iter().filter(|flag| !flag.hide) { + let title = flag.help_heading.unwrap_or("Options"); + let description = flag.long_help.or(flag.help).unwrap_or_default(); + push_help_row( + &mut sections, + title, + HelpRow { + label: usage_flag_label(flag).into(), + description: vec![description.to_owned().into()], + }, + ); + } + push_help_row( + &mut sections, + "Options", + HelpRow { label: "-h, --help".into(), description: vec!["Show this help message".into()] }, + ); + + let subcommand_title = metadata.subcommand_help_heading.unwrap_or("Commands"); + for subcommand in metadata.subcommands.iter().filter(|subcommand| !subcommand.hide) { + let visible_aliases = subcommand + .cmd + .aliases + .iter() + .filter(|alias| !subcommand.hidden_aliases.contains(alias)); + let label = std::iter::once(subcommand.cmd.name) + .chain(visible_aliases.copied()) + .collect::>() + .join(", "); + let description = subcommand.long_about.or(subcommand.about).unwrap_or_default(); + push_help_row( + &mut sections, + subcommand_title, + HelpRow { label: label.into(), description: vec![description.to_owned().into()] }, + ); + } + + Some(HelpDoc { usage: usage.into(), summary, sections, documentation_url }) +} + +#[cfg(feature = "usage")] +fn usage_route<'a>( + root: &'a CommandMeta<'a>, + route: &[&UsageCommand<'_>], + root_name: &'a str, +) -> Option<(Vec<&'a str>, &'a CommandMeta<'a>)> { + let mut path = vec![root_name]; + let mut metadata = root; + for command in route.iter().skip(1) { + metadata = metadata + .subcommands + .iter() + .find(|subcommand| std::ptr::eq(subcommand.cmd, *command))?; + path.push(metadata.cmd.name); + } + Some((path, metadata)) +} + +#[cfg(feature = "usage")] +fn usage_line(path: &[&str], metadata: &CommandMeta<'_>) -> String { + let mut usage = path.join(" "); + for argument in metadata.args.iter().filter(|argument| !argument.hide) { + let _ = write!(usage, " {}", usage_argument_label(argument)); + } + if metadata.flags.iter().any(|flag| !flag.hide) { + usage.push_str(" [OPTIONS]"); + } + if !metadata.cmd.subcommands.is_empty() { + let value_name = metadata.subcommand_value_name.unwrap_or("COMMAND"); + let _ = write!(usage, " <{value_name}>"); + } + usage +} + +#[cfg(feature = "usage")] +fn usage_argument_label(argument: &usage_rs::spec::ArgMeta<'_>) -> String { + let value_name = argument.value_names.first().copied().unwrap_or(argument.arg.name); + let mut label = + if argument.required { format!("<{value_name}>") } else { format!("[{value_name}]") }; + if argument.arg.var { + label.push_str("..."); + } + label +} + +#[cfg(feature = "usage")] +fn usage_flag_label(flag: &usage_rs::spec::FlagMeta<'_>) -> String { + let short = flag + .flag + .shorts + .iter() + .find(|short| !flag.hidden_shorts.contains(short)) + .map(|short| format!("-{}", *short as char)); + let long = flag + .flag + .longs + .iter() + .find(|long| !flag.hidden_longs.contains(long)) + .map(|long| format!("--{long}")); + let mut label = match (short, long) { + (Some(short), Some(long)) => format!("{short}, {long}"), + (Some(short), None) => short, + (None, Some(long)) => long, + (None, None) => flag.flag.name.to_owned(), + }; + + if flag.flag.takes_value { + let value_name = flag.value_names.first().copied().or(flag.value_name).unwrap_or("VALUE"); + if flag.flag.value_optional { + let _ = write!(label, " [<{value_name}>]"); + } else { + let _ = write!(label, " <{value_name}>"); + } + } + label +} + +#[cfg(feature = "clap")] fn push_argument_rows<'a>( sections: &mut Vec, default_title: &str, @@ -104,6 +269,7 @@ fn push_argument_rows<'a>( } } +#[cfg(feature = "clap")] fn arg_label(arg: &Arg) -> String { let label = arg.to_string(); match (arg.get_short(), arg.get_long()) { @@ -387,13 +553,19 @@ pub fn print_help_doc(doc: &HelpDoc) { #[cfg(test)] mod tests { + #[cfg(feature = "clap")] use clap::{ArgAction, Command}; + #[cfg(feature = "clap")] + use super::help_doc_from_command; + #[cfg(feature = "usage")] + use super::help_doc_from_usage; use super::{ - HelpDoc, HelpRow, HelpSection, ROW_DESCRIPTION_INDENT, help_doc_from_command, - render_help_doc_with_width, render_rows, visible_length, + HelpDoc, HelpRow, HelpSection, ROW_DESCRIPTION_INDENT, render_help_doc_with_width, + render_rows, visible_length, }; + #[cfg(feature = "clap")] #[test] fn builds_help_from_command_metadata() { let command = Command::new("vp example") @@ -460,6 +632,57 @@ mod tests { assert_eq!(rows[0].label, "inspect, show"); } + #[cfg(feature = "usage")] + #[test] + fn builds_help_from_usage_metadata() { + #[derive(Debug, usage_rs::Cli)] + #[usage( + bin = "vp example", + about = "Run an example command", + usage = "Usage: vp example [input] [OPTIONS]", + unknown_flags = "error", + args_override_self = false + )] + struct Example { + #[usage(value_name = "input", help = "Input path")] + input: Option, + #[usage(short = 'v', long, help = "Show more output")] + verbose: bool, + } + + let argv = vec!["--help".to_owned()]; + let words = argv.iter().map(std::ffi::OsStr::new).collect::>(); + let Err(usage_rs::Error::Help { cmd, .. }) = Example::parse_from(&words) else { + panic!("--help must return a help request"); + }; + let doc = help_doc_from_usage( + Example::spec(), + &argv, + cmd, + Some("https://viteplus.dev/example".into()), + ) + .expect("help command must belong to the example parser"); + + assert_eq!(doc.usage, "vp example [input] [OPTIONS]"); + assert_eq!(doc.summary, ["Run an example command"]); + assert_eq!(doc.documentation_url.as_deref(), Some("https://viteplus.dev/example")); + assert_eq!(doc.sections.len(), 2); + + let HelpSection::Rows { title, rows } = &doc.sections[0] else { + panic!("Arguments must contain rows"); + }; + assert_eq!(title, "Arguments"); + assert_eq!(rows[0].label, "[input]"); + + let HelpSection::Rows { title, rows } = &doc.sections[1] else { + panic!("Options must contain rows"); + }; + assert_eq!(title, "Options"); + assert_eq!(rows[0].label, "-v, --verbose"); + assert_eq!(rows[0].description, ["Show more output"]); + assert_eq!(rows[1].label, "-h, --help"); + } + #[test] fn wraps_help_within_the_terminal_width() { let doc = HelpDoc { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/package.json new file mode 100644 index 0000000000..6291b45176 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/package.json @@ -0,0 +1,8 @@ +{ + "name": "completion-fixture", + "private": true, + "scripts": { + "build": "vpt print build", + "check": "vpt print check" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots.toml new file mode 100644 index 0000000000..18d86b5d5c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "command_completion" +vp = ["local", "global"] +comment = "The hidden completion protocol returns local commands, options, values, tasks, packages, and the vpr view." +steps = [ + { argv = ["vp", "__complete_word__", "--shell", "nu", "--line", "vp cr"], comment = "completes a local command" }, + { argv = ["vp", "__complete_word__", "--shell", "nu", "--line", "vp staged --di"], comment = "completes JavaScript command options" }, + { argv = ["vp", "__complete_word__", "--shell", "nu", "--line", "vp create --package-manager p"], comment = "completes an option value" }, + { argv = ["vp", "__complete_word__", "--shell", "nu", "--line", "vp run bu"], comment = "completes a package script" }, + { argv = ["vp", "__complete_word__", "--shell", "nu", "--line", "vp run --filter completion"], comment = "completes a workspace package" }, + { argv = ["vp", "__complete_word__", "--shell", "nu", "--line", "vpr --lo"], comment = "completes the vpr executable view" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots/command_completion.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots/command_completion.global.md new file mode 100644 index 0000000000..086bb8f150 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots/command_completion.global.md @@ -0,0 +1,52 @@ +# command_completion + +The hidden completion protocol returns local commands, options, values, tasks, packages, and the vpr view. + +## `vp __complete_word__ --shell nu --line 'vp cr'` + +completes a local command + +``` +create +``` + +## `vp __complete_word__ --shell nu --line 'vp staged --di'` + +completes JavaScript command options + +``` +--diff Override the default --staged flag of git diff +--diff-filter Override the default --diff-filter=ACMR flag of git diff +``` + +## `vp __complete_word__ --shell nu --line 'vp create --package-manager p'` + +completes an option value + +``` +pnpm +``` + +## `vp __complete_word__ --shell nu --line 'vp run bu'` + +completes a package script + +``` +build +``` + +## `vp __complete_word__ --shell nu --line 'vp run --filter completion'` + +completes a workspace package + +``` +completion-fixture +``` + +## `vp __complete_word__ --shell nu --line 'vpr --lo'` + +completes the vpr executable view + +``` +--log How task output is displayed. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots/command_completion.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots/command_completion.local.md new file mode 100644 index 0000000000..086bb8f150 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_completion/snapshots/command_completion.local.md @@ -0,0 +1,52 @@ +# command_completion + +The hidden completion protocol returns local commands, options, values, tasks, packages, and the vpr view. + +## `vp __complete_word__ --shell nu --line 'vp cr'` + +completes a local command + +``` +create +``` + +## `vp __complete_word__ --shell nu --line 'vp staged --di'` + +completes JavaScript command options + +``` +--diff Override the default --staged flag of git diff +--diff-filter Override the default --diff-filter=ACMR flag of git diff +``` + +## `vp __complete_word__ --shell nu --line 'vp create --package-manager p'` + +completes an option value + +``` +pnpm +``` + +## `vp __complete_word__ --shell nu --line 'vp run bu'` + +completes a package script + +``` +build +``` + +## `vp __complete_word__ --shell nu --line 'vp run --filter completion'` + +completes a workspace package + +``` +completion-fixture +``` + +## `vp __complete_word__ --shell nu --line 'vpr --lo'` + +completes the vpr executable view + +``` +--log How task output is displayed. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/assert_posix.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/assert_posix.sh index d00ecec5f2..fbdd5fc2cb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/assert_posix.sh +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/assert_posix.sh @@ -93,12 +93,17 @@ if [ "${VP_NODE_VERSION:-}" != "22.18.0" ]; then fi if [ -n "${BASH_VERSION:-}" ]; then - complete -p vp >/dev/null || { - echo "env did not register Bash completions" - exit 1 - } + case ":${SHELLOPTS:-}:" in + *:posix:*) ;; + *) + complete -p vp >/dev/null || { + echo "env did not register Bash completions" + exit 1 + } + ;; + esac elif [ -n "${ZSH_VERSION:-}" ]; then - whence -w _vpr_complete >/dev/null || { + whence -w _vpr >/dev/null || { echo "env did not register Zsh completions" exit 1 } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md index cdaaf2eba4..add95e7b9a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md @@ -54,40 +54,17 @@ vp() { # Dynamic shell completion for bash/zsh if [ -n "${BASH_VERSION-}" ] && type complete >/dev/null 2>&1; then - eval "$(VP_COMPLETE=bash command vp)" + # Bash invoked as `sh` rejects process substitution in the generated script. + case ":${SHELLOPTS-}:" in + *:posix:*) ;; + *) + eval "$(command vp __complete_script__ --shell bash)" + eval "$(command vp __complete_script__ --shell bash --alias vpr)" + ;; + esac elif [ -n "${ZSH_VERSION-}" ] && type compdef >/dev/null 2>&1; then - eval "$(VP_COMPLETE=zsh command vp)" - eval ' - _vpr_complete() { - local -a orig=("${words[@]}") - if [[ "${orig[2]-}" == "-C" ]]; then - if (( ${#orig[@]} >= 4 )); then - words=("vp" "-C" "${orig[3]}" "run" "${orig[@]:3}") - if (( CURRENT >= 4 )); then - CURRENT=$((CURRENT + 1)) - fi - else - words=("vp" "${orig[@]:1}") - fi - elif [[ "${orig[2]-}" == -C?* ]]; then - if (( ${#orig[@]} >= 3 )); then - words=("vp" "${orig[2]}" "run" "${orig[@]:2}") - if (( CURRENT >= 3 )); then - CURRENT=$((CURRENT + 1)) - fi - else - words=("vp" "${orig[@]:1}") - fi - else - words=("vp" "run" "${orig[@]:1}") - if (( CURRENT >= 2 )); then - CURRENT=$((CURRENT + 1)) - fi - fi - ${=_comps[vp]} - } - compdef _vpr_complete vpr - ' + eval "$(command vp __complete_script__ --shell zsh)" + eval "$(command vp __complete_script__ --shell zsh --alias vpr)" fi ``` @@ -133,28 +110,8 @@ function vp end # Dynamic shell completion for fish -VP_COMPLETE=fish command vp | source - -function __vpr_complete - set -l tokens (commandline --current-process --tokenize --cut-at-cursor) - set -l current (commandline --current-token) - set -l args $tokens[2..] - set -l translated vp - if test (count $args) -eq 0; and string match -qr '^-C' -- "$current" - # Keep completing the global -C option until its value is finished. - else if test (count $args) -ge 1; and test "$args[1]" = "-C" - set -a translated -C - if test (count $args) -ge 2 - set -a translated "$args[2]" run $args[3..] - end - else if test (count $args) -ge 1; and string match -qr '^-C.+' -- "$args[1]" - set -a translated "$args[1]" run $args[2..] - else - set -a translated run $args - end - VP_COMPLETE=fish command vp -- $translated $current -end -complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" +command vp __complete_script__ --shell fish | source +command vp __complete_script__ --shell fish --alias vpr | source ``` ## `vpt print-file home/env.nu` @@ -202,38 +159,22 @@ def --env --wrapped vp [...args: string@"nu-complete vp"] { } } -# Shell completion for nushell (delegates to fish completions dynamically) +# Shell completion for nushell def "nu-complete vp" [context: string] { - let fish_cmd = $"VP_COMPLETE=fish command vp | source; 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} - } + let out = (^vp __complete_word__ --shell nu --line $context | complete) + if $out.exit_code != 0 { return null } + let lines = ($out.stdout | lines | where {|line| $line != "" }) + let marker = "\u{1}" + let wants_files = ($lines | any {|line| $line == $marker + "files" or $line == $marker + "dirs" or $line == $marker + "executables" or $line == $marker + "commands" }) + let candidates = ($lines | where {|line| not ($line | str starts-with $marker) } | each {|line| + let parts = ($line | split row (char tab)) + { value: ($parts | get 0), description: (if ($parts | length) > 1 { $parts | get 1 } else { "" }) } + }) + if ($candidates | is-empty) and $wants_files { null } else { $candidates } } -# Completion logic for vpr (translates context to 'vp run ...') +# vpr uses the Vite Task executable view. def "nu-complete vpr" [context: string] { - let modified_context = if ($context =~ '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' - } else if ($context =~ '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' - } else if ($context =~ '^vpr\s+-C') { - $context | str replace -r '^vpr' 'vp' - } else { - $context | str replace -r '^vpr' 'vp run' - } - let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($modified_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} - } + nu-complete vp $context } export extern "vpr" [...args: string@"nu-complete vpr"] ``` @@ -285,36 +226,6 @@ function vp { } # Dynamic shell completion for PowerShell -$env:VP_COMPLETE = "powershell" -& (Join-Path $__vp_bin "vp") | Out-String | Invoke-Expression -Remove-Item Env:\VP_COMPLETE -ErrorAction SilentlyContinue - -$__vpr_comp = { - param($wordToComplete, $commandAst, $cursorPosition) - $prev = $env:VP_COMPLETE - $env:VP_COMPLETE = "powershell" - $commandLine = $commandAst.Extent.Text - $args = $commandLine.Substring(0, [math]::Min($cursorPosition, $commandLine.Length)) - if ($args -match '^(vpr\.exe|vpr)\b(\s+-C\s+(?:"[^"]*"|''[^'']*''|\S+))\s') { - $args = $args -replace '^(vpr\.exe|vpr)\b(\s+-C\s+(?:"[^"]*"|''[^'']*''|\S+))\s', 'vp$2 run ' - } elseif ($args -match '^(vpr\.exe|vpr)\b(\s+-C=?(?:"[^"]*"|''[^'']*''|\S+))\s') { - $args = $args -replace '^(vpr\.exe|vpr)\b(\s+-C=?(?:"[^"]*"|''[^'']*''|\S+))\s', 'vp$2 run ' - } elseif ($args -match '^(vpr\.exe|vpr)\b\s+-C') { - $args = $args -replace '^(vpr\.exe|vpr)\b', 'vp' - } else { - $args = $args -replace '^(vpr\.exe|vpr)\b', 'vp run' - } - if ($wordToComplete -eq "") { $args += " ''" } - $results = Invoke-Expression @" -& (Join-Path $__vp_bin 'vp') -- $args -"@; - if ($prev) { $env:VP_COMPLETE = $prev } else { Remove-Item Env:\VP_COMPLETE } - $results | ForEach-Object { - $split = $_.Split("`t") - $cmd = $split[0]; - if ($split.Length -eq 2) { $help = $split[1] } else { $help = $split[0] } - [System.Management.Automation.CompletionResult]::new($cmd, $cmd, 'ParameterValue', $help) - } -} -Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp +& (Join-Path $__vp_bin "vp") __complete_script__ --shell powershell | Out-String | Invoke-Expression +& (Join-Path $__vp_bin "vp") __complete_script__ --shell powershell --alias vpr | Out-String | Invoke-Expression ``` diff --git a/crates/vp_command/src/lib.rs b/crates/vp_command/src/lib.rs index a8bcd09d72..7cafbd0db8 100644 --- a/crates/vp_command/src/lib.rs +++ b/crates/vp_command/src/lib.rs @@ -320,9 +320,10 @@ where let child = cmd.spawn(CancellationToken::new()).await.map_err(|e| Error::Anyhow(e.into()))?; let termination = child.wait_handle.await?; + let accesses = termination.path_accesses.map_err(|error| Error::Anyhow(error.into()))?; let mut path_accesses = HashMap::::new(); - for access in termination.path_accesses.iter() { + for access in accesses.iter() { tracing::debug!("Path access: {:?}", access); let relative_path = access .path diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index e3d30598e3..4ef1503d3c 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -27,6 +27,7 @@ tar = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } +usage-rs = { workspace = true, features = ["completions"] } owo-colors = { workspace = true } same-file = { workspace = true } oxc_resolver = { workspace = true } @@ -40,7 +41,7 @@ vp_js_runtime = { workspace = true } vp_pm_cli = { workspace = true } vt_path = { workspace = true } vp_command = { workspace = true } -vp_cli_help = { workspace = true } +vp_cli_help = { workspace = true, features = ["clap"] } vp_setup = { workspace = true } vp_shared = { workspace = true } vp_toolchain = { workspace = true } diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index f2b5b77b20..57664480dc 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -3,13 +3,11 @@ //! This module defines the CLI structure using clap and routes commands //! to their appropriate handlers. -use std::{collections::HashSet, ffi::OsStr, process::ExitStatus}; +use std::{collections::HashSet, process::ExitStatus}; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; -use clap_complete::ArgValueCompleter; use dialoguer::{Confirm, theme::ColorfulTheme}; use owo_colors::OwoColorize; -use tokio::runtime::Runtime; use vp_pm_cli::{ManagedGlobalCommand, PackageManagerCommand}; use vp_shared::output; use vt_path::AbsolutePathBuf; @@ -174,7 +172,7 @@ pub enum Commands { #[command(disable_help_flag = true)] Run { /// Additional arguments - #[arg(trailing_var_arg = true, allow_hyphen_values = true, add = ArgValueCompleter::new(run_tasks_completions))] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, @@ -545,42 +543,6 @@ fn should_suppress_header_for_subcommand(command: &str, args: &[String]) -> bool } } -/// Get available tasks for shell completion. -/// -/// Delegates to the local vite-plus CLI to run `vp run` without arguments, -/// which returns a list of available tasks in the format "task_name: description". -fn run_tasks_completions(current: &OsStr) -> Vec { - let Ok(cwd) = vt_path::current_dir() else { - return vec![]; - }; - - // Unescape hashtag and trim quotes for better matching - let current = current - .to_string_lossy() - .replace("\\#", "#") - .trim_matches(|c| c == '"' || c == '\'') - .to_string(); - - let output = tokio::task::block_in_place(|| { - Runtime::new().ok().and_then(|rt| { - rt.block_on(async { commands::delegate::execute_output(cwd, "run", &[]).await.ok() }) - }) - }); - - output - .filter(|o| o.status.success()) - .map(|output| { - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| line.split_once(": ").map(|(name, _)| name.trim())) - .filter(|name| !name.is_empty()) - .filter(|name| name.starts_with(¤t) || current.is_empty()) - .map(|name| clap_complete::CompletionCandidate::new(name.to_string())) - .collect() - }) - .unwrap_or_default() -} - /// Handle a parsed package-manager command. /// /// Commands projected by [`PackageManagerCommand::managed_global_command`] are diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index abce0f2696..8b6b487d11 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -572,40 +572,17 @@ vp() { # Dynamic shell completion for bash/zsh if [ -n "${BASH_VERSION-}" ] && type complete >/dev/null 2>&1; then - eval "$(VP_COMPLETE=bash command vp)" + # Bash invoked as `sh` rejects process substitution in the generated script. + case ":${SHELLOPTS-}:" in + *:posix:*) ;; + *) + eval "$(command vp __complete_script__ --shell bash)" + eval "$(command vp __complete_script__ --shell bash --alias vpr)" + ;; + esac elif [ -n "${ZSH_VERSION-}" ] && type compdef >/dev/null 2>&1; then - eval "$(VP_COMPLETE=zsh command vp)" - eval ' - _vpr_complete() { - local -a orig=("${words[@]}") - if [[ "${orig[2]-}" == "-C" ]]; then - if (( ${#orig[@]} >= 4 )); then - words=("vp" "-C" "${orig[3]}" "run" "${orig[@]:3}") - if (( CURRENT >= 4 )); then - CURRENT=$((CURRENT + 1)) - fi - else - words=("vp" "${orig[@]:1}") - fi - elif [[ "${orig[2]-}" == -C?* ]]; then - if (( ${#orig[@]} >= 3 )); then - words=("vp" "${orig[2]}" "run" "${orig[@]:2}") - if (( CURRENT >= 3 )); then - CURRENT=$((CURRENT + 1)) - fi - else - words=("vp" "${orig[@]:1}") - fi - else - words=("vp" "run" "${orig[@]:1}") - if (( CURRENT >= 2 )); then - CURRENT=$((CURRENT + 1)) - fi - fi - ${=_comps[vp]} - } - compdef _vpr_complete vpr - ' + eval "$(command vp __complete_script__ --shell zsh)" + eval "$(command vp __complete_script__ --shell zsh --alias vpr)" fi "#; @@ -645,33 +622,11 @@ function vp end # Dynamic shell completion for fish -VP_COMPLETE=fish command vp | source - -function __vpr_complete - set -l tokens (commandline --current-process --tokenize --cut-at-cursor) - set -l current (commandline --current-token) - set -l args $tokens[2..] - set -l translated vp - if test (count $args) -eq 0; and string match -qr '^-C' -- "$current" - # Keep completing the global -C option until its value is finished. - else if test (count $args) -ge 1; and test "$args[1]" = "-C" - set -a translated -C - if test (count $args) -ge 2 - set -a translated "$args[2]" run $args[3..] - end - else if test (count $args) -ge 1; and string match -qr '^-C.+' -- "$args[1]" - set -a translated "$args[1]" run $args[2..] - else - set -a translated run $args - end - VP_COMPLETE=fish command vp -- $translated $current -end -complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" +command vp __complete_script__ --shell fish | source +command vp __complete_script__ --shell fish --alias vpr | source "#; -// Nushell env file with vp wrapper function. -// Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell -// generates multiple rest params (e.g. for `vp install`), which Nushell does not support. +// Nushell env file with vp wrapper function and dynamic completion. const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev) __ENV_EXPORTS__$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") @@ -711,38 +666,22 @@ def --env --wrapped vp [...args: string@"nu-complete vp"] { } } -# Shell completion for nushell (delegates to fish completions dynamically) +# Shell completion for nushell def "nu-complete vp" [context: string] { - let fish_cmd = $"VP_COMPLETE=fish command vp | source; 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} - } + let out = (^vp __complete_word__ --shell nu --line $context | complete) + if $out.exit_code != 0 { return null } + let lines = ($out.stdout | lines | where {|line| $line != "" }) + let marker = "\u{1}" + let wants_files = ($lines | any {|line| $line == $marker + "files" or $line == $marker + "dirs" or $line == $marker + "executables" or $line == $marker + "commands" }) + let candidates = ($lines | where {|line| not ($line | str starts-with $marker) } | each {|line| + let parts = ($line | split row (char tab)) + { value: ($parts | get 0), description: (if ($parts | length) > 1 { $parts | get 1 } else { "" }) } + }) + if ($candidates | is-empty) and $wants_files { null } else { $candidates } } -# Completion logic for vpr (translates context to 'vp run ...') +# vpr uses the Vite Task executable view. def "nu-complete vpr" [context: string] { - let modified_context = if ($context =~ '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' - } else if ($context =~ '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' - } else if ($context =~ '^vpr\s+-C') { - $context | str replace -r '^vpr' 'vp' - } else { - $context | str replace -r '^vpr' 'vp run' - } - let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($modified_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} - } + nu-complete vp $context } export extern "vpr" [...args: string@"nu-complete vpr"] "#; @@ -788,38 +727,8 @@ function vp { } # Dynamic shell completion for PowerShell -$env:VP_COMPLETE = "powershell" -& (Join-Path $__vp_bin "vp") | Out-String | Invoke-Expression -Remove-Item Env:\VP_COMPLETE -ErrorAction SilentlyContinue - -$__vpr_comp = { - param($wordToComplete, $commandAst, $cursorPosition) - $prev = $env:VP_COMPLETE - $env:VP_COMPLETE = "powershell" - $commandLine = $commandAst.Extent.Text - $args = $commandLine.Substring(0, [math]::Min($cursorPosition, $commandLine.Length)) - if ($args -match '^(vpr\.exe|vpr)\b(\s+-C\s+(?:"[^"]*"|''[^'']*''|\S+))\s') { - $args = $args -replace '^(vpr\.exe|vpr)\b(\s+-C\s+(?:"[^"]*"|''[^'']*''|\S+))\s', 'vp$2 run ' - } elseif ($args -match '^(vpr\.exe|vpr)\b(\s+-C=?(?:"[^"]*"|''[^'']*''|\S+))\s') { - $args = $args -replace '^(vpr\.exe|vpr)\b(\s+-C=?(?:"[^"]*"|''[^'']*''|\S+))\s', 'vp$2 run ' - } elseif ($args -match '^(vpr\.exe|vpr)\b\s+-C') { - $args = $args -replace '^(vpr\.exe|vpr)\b', 'vp' - } else { - $args = $args -replace '^(vpr\.exe|vpr)\b', 'vp run' - } - if ($wordToComplete -eq "") { $args += " ''" } - $results = Invoke-Expression @" -& (Join-Path $__vp_bin 'vp') -- $args -"@; - if ($prev) { $env:VP_COMPLETE = $prev } else { Remove-Item Env:\VP_COMPLETE } - $results | ForEach-Object { - $split = $_.Split("`t") - $cmd = $split[0]; - if ($split.Length -eq 2) { $help = $split[1] } else { $help = $split[0] } - [System.Management.Automation.CompletionResult]::new($cmd, $cmd, 'ParameterValue', $help) - } -} -Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp +& (Join-Path $__vp_bin "vp") __complete_script__ --shell powershell | Out-String | Invoke-Expression +& (Join-Path $__vp_bin "vp") __complete_script__ --shell powershell --alias vpr | Out-String | Invoke-Expression "#; // cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions). @@ -1382,8 +1291,8 @@ mod tests { "env.nu should set VP_ENV_USE_EVAL_ENABLE" ); assert!( - nu_content.contains("VP_COMPLETE=fish"), - "env.nu should use dynamic Fish completion delegation" + nu_content.contains("__complete_word__ --shell nu"), + "env.nu should use the native completion protocol" ); assert!(nu_content.contains("load-env"), "env.nu should use load-env to apply exports"); }) @@ -2007,34 +1916,30 @@ mod tests { let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); assert!( - env_content.contains("VP_COMPLETE=bash") - && env_content.contains("VP_COMPLETE=zsh"), + env_content.contains("__complete_script__ --shell bash") + && env_content.contains("__complete_script__ --shell zsh"), "env file should contain completion for bash and zsh" ); assert!( - fish_content.contains("VP_COMPLETE=fish"), + fish_content.contains("__complete_script__ --shell fish"), "env.fish file should contain completion for fish" ); assert!( - ps1_content.contains("VP_COMPLETE = \"powershell\""), + ps1_content.contains("__complete_script__ --shell powershell"), "env.ps1 file should contain completion for PowerShell" ); assert!( - env_content.contains("compdef _vpr_complete vpr"), - "env should have vpr completion for zsh" - ); - assert!( - env_content.contains("eval '") && env_content.contains("_vpr_complete() {"), - "env should wrap zsh-specific code in eval" + env_content.matches("--alias vpr").count() == 2, + "env should request vpr completion for bash and zsh" ); assert!( - fish_content.contains("complete -c vpr"), - "env.fish should have vpr completion" + fish_content.contains("--alias vpr"), + "env.fish should request vpr completion" ); assert!( - ps1_content.contains("Register-ArgumentCompleter -Native -CommandName vpr"), - "env.ps1 should have vpr completion" + ps1_content.contains("--alias vpr"), + "env.ps1 should request vpr completion" ); }, ) @@ -2042,7 +1947,7 @@ mod tests { } #[test] - fn test_render_env_content_cwd_completion_regressions() { + fn test_render_env_content_uses_native_completion_protocol() { let temp_dir = TempDir::new().unwrap(); vp_shared::EnvConfig::with_vars(test_env_vars(temp_dir.path(), temp_dir.path()), |_| { let config = vp_shared::EnvConfig::get(); @@ -2050,12 +1955,10 @@ mod tests { let nu_content = render_env_content(EnvShell::Nu, &config); let ps1_content = render_env_content(EnvShell::Powershell, &config); - assert!(posix_content.contains("if (( CURRENT >= 4 )); then")); - assert!(posix_content.contains("if (( CURRENT >= 3 )); then")); - assert!(nu_content.contains(r#"-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+)"#)); - assert!(ps1_content.contains(r#""$($args[0])" -like "-C?*""#)); - assert!(!ps1_content.contains("$args[0].StartsWith")); - assert!(ps1_content.contains(r#"-C=?(?:"[^"]*"|''[^'']*''|\S+)"#)); + assert!(posix_content.contains("__complete_script__ --shell bash --alias vpr")); + assert!(posix_content.contains("__complete_script__ --shell zsh --alias vpr")); + assert!(nu_content.contains("__complete_word__ --shell nu")); + assert!(ps1_content.contains("__complete_script__ --shell powershell --alias vpr")); }); } @@ -2121,71 +2024,4 @@ vp ) .await; } - - #[test] - #[cfg(unix)] - fn test_zsh_vpr_completion_preserves_cursor_before_inserted_run() { - use std::{os::unix::fs::PermissionsExt, process::Command}; - - if Command::new("zsh").arg("-c").arg("exit 0").status().is_err() { - return; - } - - let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let bin_dir = home.join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - let fake_vp = bin_dir.join("vp"); - std::fs::write( - &fake_vp, - "#!/bin/sh\nif [ -n \"$VP_COMPLETE\" ]; then exit 0; fi\nexit 88\n", - ) - .unwrap(); - let mut permissions = std::fs::metadata(&fake_vp).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&fake_vp, permissions).unwrap(); - - vp_shared::EnvConfig::with_vars(test_env_vars(temp_dir.path(), temp_dir.path()), |_| { - let env_file = home.join("env"); - std::fs::write( - &env_file, - render_env_content(EnvShell::Posix, &vp_shared::EnvConfig::get()), - ) - .unwrap(); - - let output = Command::new("zsh") - .arg("-c") - .arg( - r#"compdef() { : } -typeset -A _comps -capture() { print -r -- "$CURRENT|${words[1]}|${words[2]-}|${words[3]-}|${words[4]-}|${words[5]-}" } -_comps[vp]=capture -. "$1" -words=(vpr -C "" build) -CURRENT=3 -_vpr_complete -words=(vpr -C dir build) -CURRENT=4 -_vpr_complete -words=(vpr -Cdir build) -CURRENT=2 -_vpr_complete -setopt no_unset -words=(vpr) -CURRENT=1 -_vpr_complete -"#, - ) - .arg("test-zsh-vpr-completion") - .arg(env_file.as_path()) - .env("HOME", temp_dir.path()) - .output() - .unwrap(); - assert!(output.status.success(), "zsh completion script should run"); - assert_eq!( - String::from_utf8(output.stdout).unwrap(), - "3|vp|-C||run|build\n5|vp|-C|dir|run|build\n2|vp|-Cdir|run|build|\n1|vp|run|||\n" - ); - }); - } } diff --git a/crates/vp_global_cli/src/completion.rs b/crates/vp_global_cli/src/completion.rs new file mode 100644 index 0000000000..529355aa29 --- /dev/null +++ b/crates/vp_global_cli/src/completion.rs @@ -0,0 +1,177 @@ +use std::ffi::OsString; + +use usage_rs::complete::{Candidate, Completions, Files, Request, Shell}; + +use crate::{apply_chdir, cli::command_with_help, commands}; + +fn script_request(args: &[String]) -> Option { + if args.first().map(String::as_str) != Some("__complete_script__") { + return None; + } + let shell = args + .windows(2) + .find_map(|pair| (pair[0] == "--shell").then(|| Shell::from_name(&pair[1])).flatten()) + .unwrap_or(Shell::Bash); + let alias = args.windows(2).find_map(|pair| (pair[0] == "--alias").then_some(pair[1].as_str())); + Some(alias.map_or_else( + || usage_rs::script::script("vp", shell), + |alias| usage_rs::script::script_for("vp", alias, shell), + )) +} + +fn local_request_args(args: &[String]) -> Vec { + let mut args = args.to_vec(); + if let Some(index) = args.iter().position(|arg| arg == "--shell") { + if let Some(shell) = args.get_mut(index + 1) { + *shell = "nu".to_owned(); + } + } else { + args.extend(["--shell".to_owned(), "nu".to_owned()]); + } + args +} + +fn local_completions(output: &[u8]) -> Completions<'static> { + let mut answer = Completions::default(); + for line in String::from_utf8_lossy(output).lines() { + match line { + usage_rs::complete::FILES_MARKER => answer.files = Some(Files::Any), + usage_rs::complete::DIRS_MARKER => answer.files = Some(Files::Dirs), + usage_rs::complete::EXECUTABLE_PATHS_MARKER => { + answer.files = Some(Files::ExecutablePaths); + } + usage_rs::complete::COMMANDS_MARKER => answer.files = Some(Files::Commands), + "" => {} + value => { + let (value, description) = value + .split_once('\t') + .map_or((value, None), |(value, description)| (value, Some(description))); + answer.candidates.push(Candidate { + value: value.to_owned(), + description: description + .filter(|value| !value.is_empty()) + .map(|value| value.to_owned().into()), + }); + } + } + } + answer +} + +fn global_completions(request: &Request) -> Completions<'static> { + let args = request.split.words.iter().map(OsString::from).collect::>(); + let current_dir = std::env::current_dir().ok(); + let candidates = clap_complete::engine::complete( + &mut command_with_help(), + args, + request.split.cword, + current_dir.as_deref(), + ) + .unwrap_or_default() + .into_iter() + .map(|candidate| Candidate { + value: candidate.get_value().to_string_lossy().into_owned(), + description: candidate.get_help().map(|help| help.to_string().into()), + }) + .collect(); + Completions { candidates, files: None } +} + +fn apply_request_chdir(request: &Request) { + let Some(words) = request.split.words.get(1..) else { + return; + }; + let Some((dir, _)) = crate::parse_leading_chdir(words) else { + return; + }; + if let Ok(cwd) = vt_path::current_dir() { + let _ = apply_chdir(&cwd, &dir); + } +} + +pub(crate) async fn request(args: &[String]) -> Option { + if let Some(script) = script_request(args) { + return Some(script); + } + let argv = args.iter().map(OsString::from).collect::>(); + let request = Request::parse(&argv)?; + apply_request_chdir(&request); + + let mut answer = global_completions(&request); + if let Ok(cwd) = vt_path::current_dir() + && let Ok(output) = commands::delegate::execute_output( + cwd, + "__complete_word__", + &local_request_args(&args[1..]), + ) + .await + && output.status.success() + { + let local = local_completions(&output.stdout); + answer.candidates.extend(local.candidates); + answer.files = answer.files.or(local.files); + } + answer.candidates.sort(); + answer.candidates.dedup_by(|left, right| left.value == right.value); + Some(usage_rs::complete::render(&answer, request.shell)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_local_candidates_descriptions_and_markers() { + let answer = local_completions( + format!("run\tRun tasks\n--help\n{}\n", usage_rs::complete::DIRS_MARKER).as_bytes(), + ); + assert_eq!( + answer.candidates.iter().map(|candidate| candidate.value.as_str()).collect::>(), + ["run", "--help"] + ); + assert_eq!(answer.candidates[0].description.as_deref(), Some("Run tasks")); + assert_eq!(answer.candidates[1].description, None); + assert_eq!(answer.files, Some(Files::Dirs)); + } + + #[test] + fn generates_all_supported_scripts() { + for shell in ["bash", "zsh", "fish", "nu", "powershell"] { + let script = script_request(&[ + "__complete_script__".to_owned(), + "--shell".to_owned(), + shell.to_owned(), + ]) + .expect("script request"); + assert!(script.contains("__complete_word__"), "{shell}"); + + let alias = script_request(&[ + "__complete_script__".to_owned(), + "--shell".to_owned(), + shell.to_owned(), + "--alias".to_owned(), + "vpr".to_owned(), + ]) + .expect("alias script request"); + assert!(alias.contains("vpr"), "{shell}"); + } + } + + #[test] + fn completes_global_commands_without_the_local_package() { + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--shell"), + OsString::from("bash"), + OsString::from("--line"), + OsString::from("vp en"), + ]; + let request = Request::parse(&argv).expect("completion request"); + assert!( + global_completions(&request) + .candidates + .iter() + .any(|candidate| candidate.value == "env") + ); + } +} diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index bad8e805a7..c5620da9ad 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -16,6 +16,7 @@ mod cli; mod command_picker; mod commands; +mod completion; mod error; mod help; mod js_executor; @@ -380,6 +381,11 @@ async fn main() -> ExitCode { let mut args: Vec = std::env::args().collect(); + if let Some(answer) = completion::request(&args[1..]).await { + print!("{answer}"); + return ExitCode::SUCCESS; + } + // Replace bash completion script to fix completion for items containing ':' if env::var_os("VP_COMPLETE").is_some_and(|shell| shell == "bash") && args.len() == 1 { print!("{}", include_str!("../completion-register.bash")); diff --git a/crates/vp_pm_cli/Cargo.toml b/crates/vp_pm_cli/Cargo.toml index 2a6994f065..b9f71eda9a 100644 --- a/crates/vp_pm_cli/Cargo.toml +++ b/crates/vp_pm_cli/Cargo.toml @@ -7,10 +7,15 @@ license.workspace = true publish = false rust-version.workspace = true +[features] +default = ["clap-parser"] +clap-parser = ["dep:clap"] +usage-parser = ["dep:usage-rs", "usage-rs/clap-coexistence"] + [dependencies] backon = { workspace = true } base64-simd = { workspace = true } -clap = { workspace = true, features = ["derive"] } +clap = { workspace = true, features = ["derive"], optional = true } cow-utils = { workspace = true } crossterm = { workspace = true } flate2 = { workspace = true } @@ -30,6 +35,7 @@ tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } +usage-rs = { workspace = true, optional = true } vp_command = { workspace = true } vp_error = { workspace = true } vt_path = { workspace = true } diff --git a/crates/vp_pm_cli/src/cli.rs b/crates/vp_pm_cli/src/cli.rs index a895edc99b..49baea6636 100644 --- a/crates/vp_pm_cli/src/cli.rs +++ b/crates/vp_pm_cli/src/cli.rs @@ -1,11 +1,9 @@ -//! Typed clap surface for every package-manager command. +//! Typed parser surface for every package-manager command. //! //! [`PackageManagerCommand`] is flattened into both the global CLI and the //! local NAPI CLI. Each variant owns the same typed argument value that is //! later diagnosed and resolved for the detected package manager. There is no -//! parser-to-options compatibility layer between clap and dispatch. - -use clap::Subcommand; +//! parser-to-options compatibility layer between parsing and dispatch. use crate::{ Error, PackageManager, PackageManagerType, @@ -23,21 +21,32 @@ use crate::{ /// /// The variants intentionally hold the production resolver argument types /// directly. Aliases match the existing public `vp` command surface. -#[derive(Subcommand, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] +#[derive(Debug, PartialEq, Eq)] pub enum PackageManagerCommand { /// Install all dependencies, or add packages if package names are provided - #[command(visible_alias = "i")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "i"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "i"))] Install(InstallArgs), /// Add packages to dependencies Add(AddArgs), /// Remove packages from dependencies - #[command(visible_alias = "rm", visible_alias = "un", visible_alias = "uninstall")] + #[cfg_attr( + feature = "clap-parser", + command(visible_alias = "rm", visible_alias = "un", visible_alias = "uninstall") + )] + #[cfg_attr( + feature = "usage-parser", + usage(visible_alias = "rm", visible_alias = "un", visible_alias = "uninstall") + )] Remove(RemoveArgs), /// Update packages to their latest versions - #[command(visible_alias = "up")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "up"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "up"))] Update(UpdateArgs), /// Deduplicate dependencies @@ -47,15 +56,18 @@ pub enum PackageManagerCommand { Outdated(OutdatedArgs), /// Show why a package is installed - #[command(visible_alias = "explain")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "explain"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "explain"))] Why(WhyArgs), /// View package information from the registry - #[command(visible_alias = "view", visible_alias = "show")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "view", visible_alias = "show"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "view", visible_alias = "show"))] Info(ViewArgs), /// Link packages for local development - #[command(visible_alias = "ln")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "ln"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "ln"))] Link(LinkArgs), /// Unlink packages @@ -65,18 +77,38 @@ pub enum PackageManagerCommand { Dlx(DlxArgs), /// Forward a command to the package manager - #[command(subcommand)] - Pm(PmCommand), + Pm { + #[cfg_attr(feature = "clap-parser", command(subcommand))] + #[cfg_attr(feature = "usage-parser", usage(subcommand))] + command: PmCommand, + }, +} + +/// The package-manager parser used by the local NAPI CLI. +#[cfg(feature = "usage-parser")] +#[derive(Debug, usage_rs::Cli)] +#[usage( + bin = "vp", + unknown_flags = "error", + args_override_self = false, + disable_help_subcommand = true +)] +pub struct PackageManagerCli { + #[usage(subcommand)] + pub command: PackageManagerCommand, } /// Commands nested below `vp pm`. -#[derive(Subcommand, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] +#[derive(Debug, PartialEq, Eq)] pub enum PmCommand { /// Clean install dependencies for CI environments Ci(CiArgs), /// Approve dependency lifecycle scripts (install/postinstall) to run - #[command(name = "approve-builds")] + #[cfg_attr(feature = "clap-parser", command(name = "approve-builds"))] + #[cfg_attr(feature = "usage-parser", usage(name = "approve-builds"))] ApproveBuilds(ApproveBuildsArgs), /// Remove unnecessary packages @@ -86,18 +118,21 @@ pub enum PmCommand { Patch(PatchArgs), /// Commit a prepared package patch - #[command(name = "patch-commit")] + #[cfg_attr(feature = "clap-parser", command(name = "patch-commit"))] + #[cfg_attr(feature = "usage-parser", usage(name = "patch-commit"))] PatchCommit(PatchCommitArgs), /// Create a tarball of the package Pack(PackArgs), /// List installed packages - #[command(visible_alias = "ls")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "ls"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "ls"))] List(ListArgs), /// View package information from the registry - #[command(visible_alias = "info", visible_alias = "show")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "info", visible_alias = "show"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "info", visible_alias = "show"))] View(ViewArgs), /// Forward the native package version command @@ -107,22 +142,36 @@ pub enum PmCommand { Publish(PublishArgs), /// Stage a package for publishing (npm staged publishing workflow) - #[command(subcommand)] - Stage(StageCommand), + Stage { + #[cfg_attr(feature = "clap-parser", command(subcommand))] + #[cfg_attr(feature = "usage-parser", usage(subcommand))] + command: StageCommand, + }, /// Manage package owners - #[command(subcommand, visible_alias = "author")] - Owner(OwnerCommand), + #[cfg_attr(feature = "clap-parser", command(visible_alias = "author"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "author"))] + Owner { + #[cfg_attr(feature = "clap-parser", command(subcommand))] + #[cfg_attr(feature = "usage-parser", usage(subcommand))] + command: OwnerCommand, + }, /// Manage package cache Cache(CacheArgs), /// Manage package manager configuration - #[command(subcommand, visible_alias = "c")] - Config(ConfigCommand), + #[cfg_attr(feature = "clap-parser", command(visible_alias = "c"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "c"))] + Config { + #[cfg_attr(feature = "clap-parser", command(subcommand))] + #[cfg_attr(feature = "usage-parser", usage(subcommand))] + command: ConfigCommand, + }, /// Log in to a registry - #[command(visible_alias = "adduser")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "adduser"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "adduser"))] Login(LoginArgs), /// Log out from a registry @@ -132,15 +181,23 @@ pub enum PmCommand { Whoami(WhoamiArgs), /// Manage authentication tokens - #[command(subcommand)] - Token(TokenCommand), + Token { + #[cfg_attr(feature = "clap-parser", command(subcommand))] + #[cfg_attr(feature = "usage-parser", usage(subcommand))] + command: TokenCommand, + }, /// Run a security audit Audit(AuditArgs), /// Manage distribution tags - #[command(name = "dist-tag", subcommand)] - DistTag(DistTagCommand), + #[cfg_attr(feature = "clap-parser", command(name = "dist-tag"))] + #[cfg_attr(feature = "usage-parser", usage(name = "dist-tag"))] + DistTag { + #[cfg_attr(feature = "clap-parser", command(subcommand))] + #[cfg_attr(feature = "usage-parser", usage(subcommand))] + command: DistTagCommand, + }, /// Deprecate a package version Deprecate(DeprecateArgs), @@ -149,7 +206,8 @@ pub enum PmCommand { Search(SearchArgs), /// Rebuild native modules - #[command(visible_alias = "rb")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "rb"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "rb"))] Rebuild(RebuildArgs), /// Show funding information for installed packages @@ -161,7 +219,7 @@ pub enum PmCommand { /// A package-manager command handled by Vite+'s managed global-package store. /// -/// This borrowed projection keeps the clap argument layout private while +/// This borrowed projection keeps the parser argument layout private while /// exposing the small set of values required by the global CLI dispatcher. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ManagedGlobalCommand<'a> { @@ -194,7 +252,7 @@ pub enum ManagedGlobalCommand<'a> { } impl PackageManagerCommand { - /// Build a `dlx` command for callers that do not use the clap parser. + /// Build a `dlx` command for callers that do not use a CLI parser. #[must_use] pub fn dlx(package: Vec, shell_mode: bool, silent: bool, args: Vec) -> Self { Self::Dlx(DlxArgs { package, shell_mode, silent, args }) @@ -203,7 +261,7 @@ impl PackageManagerCommand { /// Resolve this parsed command for a detected package manager. /// /// `install ` normalizes directly into [`AddArgs`]. This is the - /// only command whose typed clap shape selects between two resolvers. + /// only command whose typed parser shape selects between two resolvers. pub(crate) fn resolve_for_manager(self, manager: &PackageManager) -> Result { match self { Self::Install(args) if !args.packages.is_empty() => { @@ -220,7 +278,7 @@ impl PackageManagerCommand { Self::Link(args) => resolve_args_for_manager(manager, args), Self::Unlink(args) => resolve_args_for_manager(manager, args), Self::Dlx(args) => resolve_args_for_manager(manager, args), - Self::Pm(command) => command.resolve_for_manager(manager), + Self::Pm { command } => command.resolve_for_manager(manager), } } @@ -241,13 +299,13 @@ impl PackageManagerCommand { packages: &args.packages, node: args.node.as_deref(), force: args.force, - concurrency: args.concurrency, + concurrency: args.concurrency.map(Into::into), }), Self::Add(args) if args.global => Some(ManagedGlobalCommand::Install { packages: &args.packages, node: args.node.as_deref(), force: false, - concurrency: args.concurrency, + concurrency: args.concurrency.map(Into::into), }), Self::Remove(args) if args.global => Some(ManagedGlobalCommand::Remove { packages: &args.packages, @@ -256,7 +314,7 @@ impl PackageManagerCommand { Self::Update(args) if args.global => Some(ManagedGlobalCommand::Update { packages: &args.packages, latest: args.latest, - concurrency: args.concurrency, + concurrency: args.concurrency.map(Into::into), reinstall_node_mismatch: args.reinstall_node_mismatch, ignore_node_mismatch: args.ignore_node_mismatch, }), @@ -264,12 +322,14 @@ impl PackageManagerCommand { packages: &args.packages, long: args.long, format: args.format, - concurrency: args.concurrency, - }), - Self::Pm(PmCommand::List(args)) if args.global => Some(ManagedGlobalCommand::List { - json: args.json, - pattern: args.pattern.as_deref(), + concurrency: args.concurrency.map(Into::into), }), + Self::Pm { command: PmCommand::List(args) } if args.global => { + Some(ManagedGlobalCommand::List { + json: args.json, + pattern: args.pattern.as_deref(), + }) + } _ => None, } } @@ -286,7 +346,7 @@ impl PackageManagerCommand { } Self::Why(args) => args.is_machine_readable(), Self::Info(args) => args.json, - Self::Pm(command) => command.is_quiet_or_machine_readable(), + Self::Pm { command } => command.is_quiet_or_machine_readable(), _ => false, } } @@ -346,16 +406,16 @@ impl PmCommand { Self::View(args) => resolve_args_for_manager(manager, args), Self::Version(args) => resolve_args_for_manager(manager, args), Self::Publish(args) => resolve_args_for_manager(manager, args), - Self::Stage(args) => resolve_args_for_manager(manager, args), - Self::Owner(args) => resolve_args_for_manager(manager, args), + Self::Stage { command } => resolve_args_for_manager(manager, command), + Self::Owner { command } => resolve_args_for_manager(manager, command), Self::Cache(args) => resolve_args_for_manager(manager, args), - Self::Config(args) => resolve_args_for_manager(manager, args), + Self::Config { command } => resolve_args_for_manager(manager, command), Self::Login(args) => resolve_args_for_manager(manager, args), Self::Logout(args) => resolve_args_for_manager(manager, args), Self::Whoami(args) => resolve_args_for_manager(manager, args), - Self::Token(args) => resolve_args_for_manager(manager, args), + Self::Token { command } => resolve_args_for_manager(manager, command), Self::Audit(args) => resolve_args_for_manager(manager, args), - Self::DistTag(args) => resolve_args_for_manager(manager, args), + Self::DistTag { command } => resolve_args_for_manager(manager, command), Self::Deprecate(args) => resolve_args_for_manager(manager, args), Self::Search(args) => resolve_args_for_manager(manager, args), Self::Rebuild(args) => resolve_args_for_manager(manager, args), @@ -374,17 +434,17 @@ impl PmCommand { Self::Audit(args) => args.json, Self::Search(args) => args.json, Self::Fund(args) => args.json, - Self::Config(args) => match args { + Self::Config { command } => match command { ConfigCommand::List { json, .. } | ConfigCommand::Get { json, .. } | ConfigCommand::Set { json, .. } => *json, ConfigCommand::Delete { .. } => false, }, - Self::Token(args) => match args { + Self::Token { command } => match command { TokenCommand::List { json, .. } | TokenCommand::Create { json, .. } => *json, TokenCommand::Revoke { .. } => false, }, - Self::Stage(args) => match args { + Self::Stage { command } => match command { StageCommand::Publish { json, .. } | StageCommand::List { json, .. } | StageCommand::View { json, .. } => *json, @@ -399,11 +459,13 @@ impl PmCommand { #[cfg(test)] mod tests { + #[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] use clap::{FromArgMatches, Subcommand}; use super::*; use crate::{PackageManagerType, resolution::CommandResolution}; + #[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] fn parse(args: &[&str]) -> Result { let command = PackageManagerCommand::augment_subcommands(clap::Command::new("vp")); let matches = @@ -411,6 +473,12 @@ mod tests { PackageManagerCommand::from_arg_matches(&matches) } + #[cfg(feature = "usage-parser")] + fn parse(args: &[&str]) -> Result { + let argv = args.iter().map(std::ffi::OsStr::new).collect::>(); + PackageManagerCli::parse_from(&argv).map(|cli| cli.command).map_err(|_| ()) + } + fn package_manager(client: PackageManagerType, version: &str) -> PackageManager { let workspace_root = vt_path::current_dir().unwrap(); PackageManager { @@ -420,6 +488,16 @@ mod tests { } } + #[cfg(all(feature = "clap-parser", feature = "usage-parser"))] + #[test] + fn parser_features_can_coexist_in_a_workspace_build() { + fn has_clap_parser() {} + fn has_usage_parser() {} + + has_clap_parser::(); + has_usage_parser::(); + } + #[test] fn parses_top_level_aliases() { assert!(matches!(parse(&["i"]).unwrap(), PackageManagerCommand::Install(_))); @@ -438,41 +516,51 @@ mod tests { fn parses_pm_aliases_and_nested_commands() { assert!(matches!( parse(&["pm", "ls"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::List(_)) + PackageManagerCommand::Pm { command: PmCommand::List(_) } )); for alias in ["info", "show"] { assert!(matches!( parse(&["pm", alias, "react"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::View(_)) + PackageManagerCommand::Pm { command: PmCommand::View(_) } )); } assert!(matches!( parse(&["pm", "author", "ls", "react"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::Owner(OwnerCommand::List { .. })) + PackageManagerCommand::Pm { + command: PmCommand::Owner { command: OwnerCommand::List { .. } } + } )); assert!(matches!( parse(&["pm", "c", "list"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::Config(ConfigCommand::List { .. })) + PackageManagerCommand::Pm { + command: PmCommand::Config { command: ConfigCommand::List { .. } } + } )); assert!(matches!( parse(&["pm", "adduser"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::Login(_)) + PackageManagerCommand::Pm { command: PmCommand::Login(_) } )); assert!(matches!( parse(&["pm", "rb"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::Rebuild(_)) + PackageManagerCommand::Pm { command: PmCommand::Rebuild(_) } )); assert!(matches!( parse(&["pm", "token", "ls"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::Token(TokenCommand::List { .. })) + PackageManagerCommand::Pm { + command: PmCommand::Token { command: TokenCommand::List { .. } } + } )); assert!(matches!( parse(&["pm", "dist-tag", "ls"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::DistTag(DistTagCommand::List { .. })) + PackageManagerCommand::Pm { + command: PmCommand::DistTag { command: DistTagCommand::List { .. } } + } )); assert!(matches!( parse(&["pm", "stage", "ls"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::Stage(StageCommand::List { .. })) + PackageManagerCommand::Pm { + command: PmCommand::Stage { command: StageCommand::List { .. } } + } )); } @@ -480,10 +568,10 @@ mod tests { fn ci_parses_and_captures_pass_through_args() { assert!(matches!( parse(&["pm", "ci"]).unwrap(), - PackageManagerCommand::Pm(PmCommand::Ci(_)) + PackageManagerCommand::Pm { command: PmCommand::Ci(_) } )); - let PackageManagerCommand::Pm(PmCommand::Ci(args)) = + let PackageManagerCommand::Pm { command: PmCommand::Ci(args) } = parse(&["pm", "ci", "--", "--ignore-scripts"]).unwrap() else { panic!("expected ci command"); @@ -723,7 +811,7 @@ mod tests { parse(&["pm", "version", "prerelease", "--json", "--", "--preid", "beta"]).unwrap(); assert!(command.is_quiet_or_machine_readable()); - let PackageManagerCommand::Pm(PmCommand::Version(args)) = command else { + let PackageManagerCommand::Pm { command: PmCommand::Version(args) } = command else { panic!("expected version command"); }; assert_eq!(args.new_version.as_deref(), Some("prerelease")); diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 3e1413df6a..4eed7b876d 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -99,7 +99,7 @@ fn manager_policy(command: &PackageManagerCommand) -> ManagerPolicy { PackageManagerCommand::Dlx(_) => { unreachable!("dlx commands are dispatched before manager policy selection") } - PackageManagerCommand::Pm(command) => pm_manager_policy(command), + PackageManagerCommand::Pm { command } => pm_manager_policy(command), } } @@ -117,17 +117,19 @@ fn pm_manager_policy(command: &PmCommand) -> ManagerPolicy { | PmCommand::Rebuild(_) | PmCommand::Fund(_) | PmCommand::Audit(_) - | PmCommand::Stage(StageCommand::Publish { .. }) => ManagerPolicy::RequireProject, + | PmCommand::Stage { command: StageCommand::Publish { .. } } => { + ManagerPolicy::RequireProject + } PmCommand::View(_) - | PmCommand::Stage(_) - | PmCommand::Owner(_) + | PmCommand::Stage { .. } + | PmCommand::Owner { .. } | PmCommand::Cache(_) - | PmCommand::Config(_) + | PmCommand::Config { .. } | PmCommand::Login(_) | PmCommand::Logout(_) | PmCommand::Whoami(_) - | PmCommand::Token(_) - | PmCommand::DistTag(_) + | PmCommand::Token { .. } + | PmCommand::DistTag { .. } | PmCommand::Deprecate(_) | PmCommand::Search(_) | PmCommand::Ping(_) => ManagerPolicy::AllowNpmFallback, @@ -136,16 +138,26 @@ fn pm_manager_policy(command: &PmCommand) -> ManagerPolicy { #[cfg(test)] mod tests { + #[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] use clap::{FromArgMatches, Subcommand}; use super::*; + #[cfg(feature = "usage-parser")] + use crate::PackageManagerCli; + #[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] fn parse_command(args: &[&str]) -> PackageManagerCommand { let mut command = PackageManagerCommand::augment_subcommands(clap::Command::new("vp")); let matches = command.try_get_matches_from_mut(args).unwrap(); PackageManagerCommand::from_arg_matches(&matches).unwrap() } + #[cfg(feature = "usage-parser")] + fn parse_command(args: &[&str]) -> PackageManagerCommand { + let argv = args[1..].iter().map(std::ffi::OsStr::new).collect::>(); + PackageManagerCli::parse_from(&argv).expect("command must parse").command + } + #[test] fn manager_policy_covers_project_creation_and_requirement() { assert_eq!( diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 98b025aac8..9204f28446 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -1,7 +1,7 @@ //! Package-manager infrastructure for `vp`. //! //! [`PackageManager`] detects and downloads the selected package manager. -//! [`PackageManagerCommand`] provides the shared clap surface, and [`dispatch`] +//! [`PackageManagerCommand`] provides the shared typed parser surface, and [`dispatch`] //! resolves and executes it. Managed Node.js runtimes and managed global //! packages remain owned by the global CLI. @@ -17,6 +17,8 @@ mod request; pub(crate) mod resolution; mod shim; +#[cfg(feature = "usage-parser")] +pub use cli::PackageManagerCli; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; pub use config::npm_registry; pub use dispatch::{DispatchResult, dispatch, dispatch_with_metadata}; diff --git a/crates/vp_pm_cli/src/resolution/commands/add.rs b/crates/vp_pm_cli/src/resolution/commands/add.rs index 4ca5265f45..3c8d819cd6 100644 --- a/crates/vp_pm_cli/src/resolution/commands/add.rs +++ b/crates/vp_pm_cli/src/resolution/commands/add.rs @@ -1,14 +1,19 @@ use vp_pm_cli_macros::pm_args; +use super::PositiveUsize; +#[cfg(feature = "clap-parser")] use super::parse_positive_usize; use crate::resolution::{ Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AddArgs { - #[command(flatten)] + #[cfg_attr(feature = "clap-parser", command(flatten))] + #[cfg_attr(feature = "usage-parser", usage(flatten))] pub(crate) save_dependency: SaveDependencyArgs, /// Save exact version rather than semver range @@ -49,7 +54,7 @@ pub struct AddArgs { /// Number of global package installs to run in parallel (only with -g) #[arg(long, requires = "global", value_parser = parse_positive_usize)] - pub(crate) concurrency: Option, + pub(crate) concurrency: Option, /// Packages to add #[arg(required = true)] @@ -68,23 +73,31 @@ pub(crate) enum SaveDependencyTarget { Optional, } -#[derive(clap::Args, Clone, Copy, Debug, Default, PartialEq, Eq)] -#[group(id = "save_dependency_target", multiple = false)] +#[pm_args] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", group(id = "save_dependency_target", multiple = false))] +#[cfg_attr(feature = "usage-parser", usage(group("save_dependency_target")))] pub(crate) struct SaveDependencyArgs { /// Save to `dependencies` (default) #[arg(short = 'P', long)] + #[cfg_attr(feature = "usage-parser", usage(group = "save_dependency_target"))] pub(crate) save_prod: bool, /// Save to `devDependencies` #[arg(short = 'D', long)] + #[cfg_attr(feature = "usage-parser", usage(group = "save_dependency_target"))] pub(crate) save_dev: bool, /// Save to `peerDependencies` and `devDependencies` #[arg(long)] + #[cfg_attr(feature = "usage-parser", usage(group = "save_dependency_target"))] pub(crate) save_peer: bool, /// Save to `optionalDependencies` #[arg(short = 'O', long)] + #[cfg_attr(feature = "usage-parser", usage(group = "save_dependency_target"))] pub(crate) save_optional: bool, } @@ -256,7 +269,7 @@ mod tests { use super::*; use crate::resolution::{ resolve, - test_utils::{bun, expect_run, npm, parse_args, pnpm, yarn}, + test_utils::{ParseErrorKind, bun, expect_run, npm, parse_args, pnpm, yarn}, }; fn add_args(packages: &[&str]) -> AddArgs { @@ -381,7 +394,7 @@ mod tests { fn save_dependency_flags_are_mutually_exclusive() { let error = parse_args::(["--save-dev", "--save-optional", "react"]).unwrap_err(); - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + assert_eq!(error.kind(), ParseErrorKind::ArgumentConflict); } #[test] diff --git a/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs b/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs index 62ea3082dd..1a1537a7ae 100644 --- a/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs +++ b/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs @@ -10,7 +10,9 @@ const NPM_ADVISORY_NOTE: &str = "npm's allowScripts policy is advisory in npm 11 const NPM_ENFORCED_NOTE: &str = "npm records the approval in the `allowScripts` field of package.json but does not run scripts a previous install skipped. Run `vp pm rebuild ` to execute them."; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ApproveBuildsArgs { /// Packages to approve. Prefix with `!` to deny (pnpm >= 11.0.0, npm >= 11.16.0). /// Omit to launch interactive mode (pnpm) or list pending packages (npm >= 11.16.0). @@ -230,7 +232,7 @@ mod tests { use super::*; use crate::resolution::{ resolve, - test_utils::{bun, expect_run, npm, parse_args, pnpm, yarn}, + test_utils::{ParseErrorKind, bun, expect_run, npm, parse_args, pnpm, yarn}, }; #[test] @@ -246,7 +248,7 @@ mod tests { let error = parse_args::(["--all", "esbuild"]) .expect_err("expected clap conflict"); - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + assert_eq!(error.kind(), ParseErrorKind::ArgumentConflict); } #[test] diff --git a/crates/vp_pm_cli/src/resolution/commands/audit.rs b/crates/vp_pm_cli/src/resolution/commands/audit.rs index 19e52f31b4..0fd319dad6 100644 --- a/crates/vp_pm_cli/src/resolution/commands/audit.rs +++ b/crates/vp_pm_cli/src/resolution/commands/audit.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AuditArgs { /// Automatically fix vulnerabilities #[arg(long)] diff --git a/crates/vp_pm_cli/src/resolution/commands/cache.rs b/crates/vp_pm_cli/src/resolution/commands/cache.rs index 52799d3c24..322bd42fa5 100644 --- a/crates/vp_pm_cli/src/resolution/commands/cache.rs +++ b/crates/vp_pm_cli/src/resolution/commands/cache.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CacheArgs { /// Subcommand: dir, path, clean #[arg(required = true)] diff --git a/crates/vp_pm_cli/src/resolution/commands/ci.rs b/crates/vp_pm_cli/src/resolution/commands/ci.rs index 686d7532d0..d89880fc9a 100644 --- a/crates/vp_pm_cli/src/resolution/commands/ci.rs +++ b/crates/vp_pm_cli/src/resolution/commands/ci.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CiArgs { /// Additional arguments to pass through to the package manager #[arg(last = true, allow_hyphen_values = true)] diff --git a/crates/vp_pm_cli/src/resolution/commands/config.rs b/crates/vp_pm_cli/src/resolution/commands/config.rs index ce9fe1a314..5ed07a1694 100644 --- a/crates/vp_pm_cli/src/resolution/commands/config.rs +++ b/crates/vp_pm_cli/src/resolution/commands/config.rs @@ -6,7 +6,9 @@ use crate::resolution::{ /// Configuration subcommands. #[pm_args] -#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum ConfigCommand { /// List all configuration List { diff --git a/crates/vp_pm_cli/src/resolution/commands/dedupe.rs b/crates/vp_pm_cli/src/resolution/commands/dedupe.rs index 4d31b3104e..a6700a10cf 100644 --- a/crates/vp_pm_cli/src/resolution/commands/dedupe.rs +++ b/crates/vp_pm_cli/src/resolution/commands/dedupe.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DedupeArgs { /// Check if deduplication would make changes #[arg(long)] diff --git a/crates/vp_pm_cli/src/resolution/commands/deprecate.rs b/crates/vp_pm_cli/src/resolution/commands/deprecate.rs index 81a4326e7b..ab86e814b7 100644 --- a/crates/vp_pm_cli/src/resolution/commands/deprecate.rs +++ b/crates/vp_pm_cli/src/resolution/commands/deprecate.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DeprecateArgs { /// Package name with version (e.g., "my-pkg@1.0.0") pub(crate) package: String, diff --git a/crates/vp_pm_cli/src/resolution/commands/dist_tag.rs b/crates/vp_pm_cli/src/resolution/commands/dist_tag.rs index 1403281638..7241efdc48 100644 --- a/crates/vp_pm_cli/src/resolution/commands/dist_tag.rs +++ b/crates/vp_pm_cli/src/resolution/commands/dist_tag.rs @@ -5,10 +5,13 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum DistTagCommand { /// List distribution tags for a package - #[command(visible_alias = "ls")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "ls"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "ls"))] List { /// Package name package: Option, diff --git a/crates/vp_pm_cli/src/resolution/commands/dlx.rs b/crates/vp_pm_cli/src/resolution/commands/dlx.rs index 5e8ed4537c..2e6d8fb5bd 100644 --- a/crates/vp_pm_cli/src/resolution/commands/dlx.rs +++ b/crates/vp_pm_cli/src/resolution/commands/dlx.rs @@ -6,7 +6,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DlxArgs { /// Package(s) to install before running #[arg(long, short = 'p', value_name = "NAME")] diff --git a/crates/vp_pm_cli/src/resolution/commands/fund.rs b/crates/vp_pm_cli/src/resolution/commands/fund.rs index f9e81a42f9..f6c4c5a100 100644 --- a/crates/vp_pm_cli/src/resolution/commands/fund.rs +++ b/crates/vp_pm_cli/src/resolution/commands/fund.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FundArgs { /// Output in JSON format #[arg(long)] diff --git a/crates/vp_pm_cli/src/resolution/commands/install.rs b/crates/vp_pm_cli/src/resolution/commands/install.rs index 93d176e297..0daf69fb1e 100644 --- a/crates/vp_pm_cli/src/resolution/commands/install.rs +++ b/crates/vp_pm_cli/src/resolution/commands/install.rs @@ -1,5 +1,7 @@ use vp_pm_cli_macros::pm_args; +use super::PositiveUsize; +#[cfg(feature = "clap-parser")] use super::parse_positive_usize; use crate::resolution::{ AddArgs, Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, @@ -7,7 +9,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct InstallArgs { /// Do not install devDependencies #[arg(short = 'P', long)] @@ -103,7 +107,7 @@ pub struct InstallArgs { /// Number of global package installs to run in parallel (only with -g) #[arg(long, requires = "global", value_parser = parse_positive_usize)] - pub(crate) concurrency: Option, + pub(crate) concurrency: Option, /// Packages to add (if provided, acts as `vp add`) pub(crate) packages: Vec, diff --git a/crates/vp_pm_cli/src/resolution/commands/link.rs b/crates/vp_pm_cli/src/resolution/commands/link.rs index feea29e298..5a7704b5d8 100644 --- a/crates/vp_pm_cli/src/resolution/commands/link.rs +++ b/crates/vp_pm_cli/src/resolution/commands/link.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LinkArgs { /// Package name or directory to link #[arg(value_name = "PACKAGE|DIR")] diff --git a/crates/vp_pm_cli/src/resolution/commands/list.rs b/crates/vp_pm_cli/src/resolution/commands/list.rs index dd79198ae0..43557cb1c4 100644 --- a/crates/vp_pm_cli/src/resolution/commands/list.rs +++ b/crates/vp_pm_cli/src/resolution/commands/list.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ListArgs { /// Package pattern to filter pub(crate) pattern: Option, diff --git a/crates/vp_pm_cli/src/resolution/commands/login.rs b/crates/vp_pm_cli/src/resolution/commands/login.rs index 7130aac34f..07f22e5e5c 100644 --- a/crates/vp_pm_cli/src/resolution/commands/login.rs +++ b/crates/vp_pm_cli/src/resolution/commands/login.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LoginArgs { /// Registry URL #[arg(long, value_name = "URL")] diff --git a/crates/vp_pm_cli/src/resolution/commands/logout.rs b/crates/vp_pm_cli/src/resolution/commands/logout.rs index 622e3a36d1..5a2a662b54 100644 --- a/crates/vp_pm_cli/src/resolution/commands/logout.rs +++ b/crates/vp_pm_cli/src/resolution/commands/logout.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LogoutArgs { /// Registry URL #[arg(long, value_name = "URL")] diff --git a/crates/vp_pm_cli/src/resolution/commands/mod.rs b/crates/vp_pm_cli/src/resolution/commands/mod.rs index 54310a4c07..eaeaf18c63 100644 --- a/crates/vp_pm_cli/src/resolution/commands/mod.rs +++ b/crates/vp_pm_cli/src/resolution/commands/mod.rs @@ -71,10 +71,28 @@ pub use view::ViewArgs; pub use whoami::WhoamiArgs; pub use why::WhyArgs; -fn parse_positive_usize(value: &str) -> Result { - match value.parse::() { - Ok(value) if value > 0 => Ok(value), - Ok(_) => Err("value must be at least 1".to_string()), - Err(error) => Err(error.to_string()), +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PositiveUsize(usize); + +impl std::str::FromStr for PositiveUsize { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.parse::() { + Ok(value) if value > 0 => Ok(Self(value)), + Ok(_) => Err("value must be at least 1".to_string()), + Err(error) => Err(error.to_string()), + } } } + +impl From for usize { + fn from(value: PositiveUsize) -> Self { + value.0 + } +} + +#[cfg(feature = "clap-parser")] +fn parse_positive_usize(value: &str) -> Result { + value.parse() +} diff --git a/crates/vp_pm_cli/src/resolution/commands/outdated.rs b/crates/vp_pm_cli/src/resolution/commands/outdated.rs index 0854cfcc3b..25aca14086 100644 --- a/crates/vp_pm_cli/src/resolution/commands/outdated.rs +++ b/crates/vp_pm_cli/src/resolution/commands/outdated.rs @@ -3,6 +3,8 @@ use std::str::FromStr; use cow_utils::CowUtils as _; use vp_pm_cli_macros::pm_args; +use super::PositiveUsize; +#[cfg(feature = "clap-parser")] use super::parse_positive_usize; use crate::resolution::{ Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, @@ -46,7 +48,9 @@ impl std::fmt::Display for OutdatedFormat { } #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OutdatedArgs { /// Package name(s) to check pub(crate) packages: Vec, @@ -97,7 +101,7 @@ pub struct OutdatedArgs { /// Number of global package checks to run in parallel (only with -g) #[arg(long, requires = "global", value_parser = parse_positive_usize)] - pub(crate) concurrency: Option, + pub(crate) concurrency: Option, /// Additional arguments to pass through to the package manager #[arg(last = true, allow_hyphen_values = true)] @@ -233,7 +237,7 @@ mod tests { use super::*; use crate::resolution::{ resolve, - test_utils::{bun, expect_run, npm, parse_args, pnpm, yarn}, + test_utils::{ParseErrorKind, bun, expect_run, npm, parse_args, pnpm, yarn}, }; fn outdated_args(packages: &[&str]) -> OutdatedArgs { @@ -254,21 +258,21 @@ mod tests { fn format_parser_rejects_unknown_value() { let error = parse_args::(["--format", "yaml"]).unwrap_err(); - assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + assert_eq!(error.kind(), ParseErrorKind::ValueValidation); } #[test] fn concurrency_requires_global() { let error = parse_args::(["--concurrency", "2"]).unwrap_err(); - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!(error.kind(), ParseErrorKind::MissingRequiredArgument); } #[test] fn concurrency_rejects_zero() { let error = parse_args::(["-g", "--concurrency", "0"]).unwrap_err(); - assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + assert_eq!(error.kind(), ParseErrorKind::ValueValidation); } #[test] diff --git a/crates/vp_pm_cli/src/resolution/commands/owner.rs b/crates/vp_pm_cli/src/resolution/commands/owner.rs index e9a6120c7e..0c14a22726 100644 --- a/crates/vp_pm_cli/src/resolution/commands/owner.rs +++ b/crates/vp_pm_cli/src/resolution/commands/owner.rs @@ -6,10 +6,13 @@ use crate::resolution::{ /// Owner subcommands. #[pm_args] -#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum OwnerCommand { /// List package owners - #[command(visible_alias = "ls")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "ls"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "ls"))] List { /// Package name package: String, diff --git a/crates/vp_pm_cli/src/resolution/commands/pack.rs b/crates/vp_pm_cli/src/resolution/commands/pack.rs index e4dfcd49ec..083969126b 100644 --- a/crates/vp_pm_cli/src/resolution/commands/pack.rs +++ b/crates/vp_pm_cli/src/resolution/commands/pack.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PackArgs { /// Pack all workspace packages #[arg(short = 'r', long, not_supported(yarn < "2", bun))] diff --git a/crates/vp_pm_cli/src/resolution/commands/patch.rs b/crates/vp_pm_cli/src/resolution/commands/patch.rs index 277bb9e3b4..61b6be83db 100644 --- a/crates/vp_pm_cli/src/resolution/commands/patch.rs +++ b/crates/vp_pm_cli/src/resolution/commands/patch.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PatchArgs { /// Package to patch pub(crate) package: String, diff --git a/crates/vp_pm_cli/src/resolution/commands/patch_commit.rs b/crates/vp_pm_cli/src/resolution/commands/patch_commit.rs index 2bfd748caf..45402b74fb 100644 --- a/crates/vp_pm_cli/src/resolution/commands/patch_commit.rs +++ b/crates/vp_pm_cli/src/resolution/commands/patch_commit.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PatchCommitArgs { /// Patch directory to commit pub(crate) patch_dir: String, diff --git a/crates/vp_pm_cli/src/resolution/commands/ping.rs b/crates/vp_pm_cli/src/resolution/commands/ping.rs index 89d5a9d044..9765f0afbf 100644 --- a/crates/vp_pm_cli/src/resolution/commands/ping.rs +++ b/crates/vp_pm_cli/src/resolution/commands/ping.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PingArgs { /// Registry URL #[arg(long, value_name = "URL")] diff --git a/crates/vp_pm_cli/src/resolution/commands/prune.rs b/crates/vp_pm_cli/src/resolution/commands/prune.rs index ea25e6cee2..a7d049295a 100644 --- a/crates/vp_pm_cli/src/resolution/commands/prune.rs +++ b/crates/vp_pm_cli/src/resolution/commands/prune.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PruneArgs { /// Remove devDependencies #[arg(long)] diff --git a/crates/vp_pm_cli/src/resolution/commands/publish.rs b/crates/vp_pm_cli/src/resolution/commands/publish.rs index 00be623c38..63b8519371 100644 --- a/crates/vp_pm_cli/src/resolution/commands/publish.rs +++ b/crates/vp_pm_cli/src/resolution/commands/publish.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PublishArgs { /// Tarball or folder to publish #[arg(value_name = "TARBALL|FOLDER")] diff --git a/crates/vp_pm_cli/src/resolution/commands/rebuild.rs b/crates/vp_pm_cli/src/resolution/commands/rebuild.rs index 4ec5c57aa4..f2c004d652 100644 --- a/crates/vp_pm_cli/src/resolution/commands/rebuild.rs +++ b/crates/vp_pm_cli/src/resolution/commands/rebuild.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RebuildArgs { /// Packages to rebuild (rebuilds all if omitted) pub(crate) packages: Vec, diff --git a/crates/vp_pm_cli/src/resolution/commands/remove.rs b/crates/vp_pm_cli/src/resolution/commands/remove.rs index 4635f9637e..402b8ff3ba 100644 --- a/crates/vp_pm_cli/src/resolution/commands/remove.rs +++ b/crates/vp_pm_cli/src/resolution/commands/remove.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RemoveArgs { /// Only remove from `devDependencies` (pnpm-specific) #[arg(short = 'D', long)] @@ -135,7 +137,7 @@ mod tests { use super::*; use crate::resolution::{ resolve, - test_utils::{bun, expect_run, npm, parse_args, pnpm, yarn}, + test_utils::{ParseErrorKind, bun, expect_run, npm, parse_args, pnpm, yarn}, }; fn remove_args(packages: &[&str]) -> RemoveArgs { @@ -149,7 +151,7 @@ mod tests { fn dry_run_requires_global() { let error = parse_args::(["--dry-run", "lodash"]).unwrap_err(); - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!(error.kind(), ParseErrorKind::MissingRequiredArgument); } #[test] diff --git a/crates/vp_pm_cli/src/resolution/commands/search.rs b/crates/vp_pm_cli/src/resolution/commands/search.rs index cd6a33feb9..56c5e630d4 100644 --- a/crates/vp_pm_cli/src/resolution/commands/search.rs +++ b/crates/vp_pm_cli/src/resolution/commands/search.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SearchArgs { /// Search terms #[arg(required = true, num_args = 1..)] diff --git a/crates/vp_pm_cli/src/resolution/commands/stage.rs b/crates/vp_pm_cli/src/resolution/commands/stage.rs index 532d76b9b0..634aad27fa 100644 --- a/crates/vp_pm_cli/src/resolution/commands/stage.rs +++ b/crates/vp_pm_cli/src/resolution/commands/stage.rs @@ -10,7 +10,9 @@ use crate::resolution::{ /// (`yarn npm publish --staged`, `yarn npm stage ...`). Note: this is unrelated /// to yarn's own `yarn stage` command, which stages files for a VCS commit. #[pm_args] -#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum StageCommand { /// Stage a package for publishing (no 2FA required) Publish { @@ -60,7 +62,8 @@ pub enum StageCommand { }, /// List staged versions - #[command(visible_alias = "ls")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "ls"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "ls"))] List { /// Package spec to filter by package: Option, diff --git a/crates/vp_pm_cli/src/resolution/commands/token.rs b/crates/vp_pm_cli/src/resolution/commands/token.rs index 764a47b4e3..cb470deb49 100644 --- a/crates/vp_pm_cli/src/resolution/commands/token.rs +++ b/crates/vp_pm_cli/src/resolution/commands/token.rs @@ -6,10 +6,13 @@ use crate::resolution::{ /// Token subcommands. #[pm_args] -#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum TokenCommand { /// List all known tokens - #[command(visible_alias = "ls")] + #[cfg_attr(feature = "clap-parser", command(visible_alias = "ls"))] + #[cfg_attr(feature = "usage-parser", usage(visible_alias = "ls"))] List { /// Output in JSON format #[arg(long)] diff --git a/crates/vp_pm_cli/src/resolution/commands/unlink.rs b/crates/vp_pm_cli/src/resolution/commands/unlink.rs index 164b5823d0..caabd917f1 100644 --- a/crates/vp_pm_cli/src/resolution/commands/unlink.rs +++ b/crates/vp_pm_cli/src/resolution/commands/unlink.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct UnlinkArgs { /// Package name to unlink #[arg(value_name = "PACKAGE|DIR")] diff --git a/crates/vp_pm_cli/src/resolution/commands/update.rs b/crates/vp_pm_cli/src/resolution/commands/update.rs index f09ee4546b..e041175867 100644 --- a/crates/vp_pm_cli/src/resolution/commands/update.rs +++ b/crates/vp_pm_cli/src/resolution/commands/update.rs @@ -1,12 +1,16 @@ use vp_pm_cli_macros::pm_args; +use super::PositiveUsize; +#[cfg(feature = "clap-parser")] use super::parse_positive_usize; use crate::resolution::{ Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct UpdateArgs { /// Update to latest version (ignore semver range) #[arg(short = 'L', long, not_supported(npm))] @@ -18,7 +22,7 @@ pub struct UpdateArgs { /// Number of global package updates to run in parallel (only with -g) #[arg(long, requires = "global", value_parser = parse_positive_usize)] - pub(crate) concurrency: Option, + pub(crate) concurrency: Option, /// Reinstall up-to-date global packages installed with a different Node.js version #[arg(long, requires = "global")] @@ -172,7 +176,7 @@ mod tests { use super::*; use crate::resolution::{ resolve, - test_utils::{bun, expect_run, npm, parse_args, pnpm, yarn}, + test_utils::{ParseErrorKind, bun, expect_run, npm, parse_args, pnpm, yarn}, }; fn update_args(packages: &[&str]) -> UpdateArgs { @@ -186,14 +190,14 @@ mod tests { fn concurrency_requires_global() { let error = parse_args::(["--concurrency", "2", "typescript"]).unwrap_err(); - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!(error.kind(), ParseErrorKind::MissingRequiredArgument); } #[test] fn concurrency_rejects_zero() { let error = parse_args::(["-g", "--concurrency", "0"]).unwrap_err(); - assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + assert_eq!(error.kind(), ParseErrorKind::ValueValidation); } #[test] @@ -208,7 +212,7 @@ mod tests { .unwrap(); assert!(args.global); - assert_eq!(args.concurrency, Some(2)); + assert_eq!(args.concurrency.map(Into::into), Some(2)); assert!(args.reinstall_node_mismatch); assert_eq!(args.packages, vec!["typescript"]); } diff --git a/crates/vp_pm_cli/src/resolution/commands/version.rs b/crates/vp_pm_cli/src/resolution/commands/version.rs index 0ef22de85f..06afe4d64a 100644 --- a/crates/vp_pm_cli/src/resolution/commands/version.rs +++ b/crates/vp_pm_cli/src/resolution/commands/version.rs @@ -7,7 +7,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct VersionArgs { /// Version number or increment strategy pub(crate) new_version: Option, diff --git a/crates/vp_pm_cli/src/resolution/commands/view.rs b/crates/vp_pm_cli/src/resolution/commands/view.rs index f2f81799c5..7e3d2a5308 100644 --- a/crates/vp_pm_cli/src/resolution/commands/view.rs +++ b/crates/vp_pm_cli/src/resolution/commands/view.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ViewArgs { /// Package name with optional version #[arg(required = true)] diff --git a/crates/vp_pm_cli/src/resolution/commands/whoami.rs b/crates/vp_pm_cli/src/resolution/commands/whoami.rs index d834889f65..d0eeac67c9 100644 --- a/crates/vp_pm_cli/src/resolution/commands/whoami.rs +++ b/crates/vp_pm_cli/src/resolution/commands/whoami.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct WhoamiArgs { /// Registry URL #[arg(long, value_name = "URL")] diff --git a/crates/vp_pm_cli/src/resolution/commands/why.rs b/crates/vp_pm_cli/src/resolution/commands/why.rs index b92132829b..917c980ff6 100644 --- a/crates/vp_pm_cli/src/resolution/commands/why.rs +++ b/crates/vp_pm_cli/src/resolution/commands/why.rs @@ -5,7 +5,9 @@ use crate::resolution::{ }; #[pm_args] -#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "clap-parser", derive(clap::Args))] +#[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct WhyArgs { /// Package(s) to check #[arg(required = true)] diff --git a/crates/vp_pm_cli/src/resolution/mod.rs b/crates/vp_pm_cli/src/resolution/mod.rs index 9a354370ec..a6a903292e 100644 --- a/crates/vp_pm_cli/src/resolution/mod.rs +++ b/crates/vp_pm_cli/src/resolution/mod.rs @@ -1,7 +1,7 @@ //! Typed package-manager command resolution. //! //! The production-facing seam is [`resolve_for_manager`]. It accepts the -//! detected [`crate::PackageManager`] and one typed clap argument value. +//! detected [`crate::PackageManager`] and one typed argument value. //! The resolver then: //! //! 1. runs the diagnosis generated by `#[pm_args]` and removes unsupported arguments; @@ -41,7 +41,7 @@ use command::CommandBuilder; /// Control-flow outcome produced by [`resolve_for_manager`]. pub(crate) use command::CommandResolution; pub(crate) use commands::SaveDependencyArgs; -/// Typed clap argument values accepted by [`resolve_for_manager`]. +/// Typed argument values accepted by [`resolve_for_manager`]. #[expect(unused_imports, reason = "typed command arguments form the production CLI surface")] pub use commands::{ AddArgs, ApproveBuildsArgs, AuditArgs, CacheArgs, CiArgs, ConfigCommand, DedupeArgs, diff --git a/crates/vp_pm_cli/src/resolution/support.rs b/crates/vp_pm_cli/src/resolution/support.rs index 567d7fab6e..805a02fc43 100644 --- a/crates/vp_pm_cli/src/resolution/support.rs +++ b/crates/vp_pm_cli/src/resolution/support.rs @@ -143,7 +143,9 @@ mod tests { #[test] fn active_detection_filters_bool_option_vec_and_option_vec() { #[pm_args] - #[derive(clap::Args, Clone, Debug, Default)] + #[cfg_attr(feature = "clap-parser", derive(clap::Args))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] + #[derive(Clone, Debug, Default)] struct ShapeArgs { #[arg(long, not_supported(bun))] bool_field: bool, @@ -177,7 +179,9 @@ mod tests { #[test] fn enum_diagnosis_filters_inline_variant_fields() { #[pm_args] - #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] + #[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] + #[derive(Clone, Debug, PartialEq, Eq)] enum ShapeArgs { List { #[arg(long, not_supported(bun))] @@ -196,7 +200,9 @@ mod tests { #[test] fn enum_diagnosis_binding_names_are_hygienic() { #[pm_args] - #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] + #[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] + #[derive(Clone, Debug, PartialEq, Eq)] enum ShadowingArgs { Check { #[arg(long, not_supported(bun))] @@ -251,7 +257,9 @@ mod tests { #[test] fn diagnosis_preserves_conditional_fields_and_variants() { #[pm_args] - #[derive(clap::Args, Clone, Debug, PartialEq, Eq)] + #[cfg_attr(feature = "clap-parser", derive(clap::Args))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] + #[derive(Clone, Debug, PartialEq, Eq)] struct ConditionalStruct { #[cfg(any())] #[arg(long, not_supported(bun))] @@ -262,7 +270,9 @@ mod tests { } #[pm_args] - #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] + #[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] + #[derive(Clone, Debug, PartialEq, Eq)] enum ConditionalEnum { #[cfg_attr(all(), cfg(any()))] Hidden { @@ -293,7 +303,9 @@ mod tests { #[test] fn unknown_version_skips_version_qualified_rules() { #[pm_args] - #[derive(clap::Args, Clone, Debug, PartialEq, Eq)] + #[cfg_attr(feature = "clap-parser", derive(clap::Args))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] + #[derive(Clone, Debug, PartialEq, Eq)] struct ShapeArgs { #[arg(long, not_supported(npm < "99"))] future: bool, diff --git a/crates/vp_pm_cli/src/resolution/test_utils.rs b/crates/vp_pm_cli/src/resolution/test_utils.rs index d5bfb701ff..001075fd10 100644 --- a/crates/vp_pm_cli/src/resolution/test_utils.rs +++ b/crates/vp_pm_cli/src/resolution/test_utils.rs @@ -28,6 +28,7 @@ pub(crate) fn bun(version: &str) -> Bun { Bun::new(parse_version(version)) } +#[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] pub(crate) fn parse_args( args: impl IntoIterator>, ) -> Result @@ -39,6 +40,7 @@ where A::from_arg_matches(&matches) } +#[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] pub(crate) fn parse_subcommand( args: impl IntoIterator>, ) -> Result @@ -54,23 +56,129 @@ fn parse_version(value: &str) -> Version { Version::parse(value).expect("test package manager version must be valid semantic version") } +#[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] fn test_argv( args: impl IntoIterator>, ) -> impl Iterator { std::iter::once(OsString::from("test")).chain(args.into_iter().map(Into::into)) } +#[cfg(all(feature = "clap-parser", not(feature = "usage-parser")))] +pub(crate) use clap::error::ErrorKind as ParseErrorKind; + +#[cfg(feature = "usage-parser")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ParseErrorKind { + ArgumentConflict, + MissingRequiredArgument, + ValueValidation, + Other, +} + +#[cfg(feature = "usage-parser")] +#[derive(Debug)] +pub(crate) struct ParseError { + kind: ParseErrorKind, +} + +#[cfg(feature = "usage-parser")] +impl ParseError { + pub(crate) const fn kind(&self) -> ParseErrorKind { + self.kind + } +} + +#[cfg(feature = "usage-parser")] +fn usage_error(error: usage_rs::Error<'_, '_>) -> ParseError { + use usage_rs::Error; + + let kind = match error { + Error::ConflictingFlags { .. } | Error::DuplicateFlag { .. } => { + ParseErrorKind::ArgumentConflict + } + Error::MissingRequired { .. } | Error::MissingSubcommand => { + ParseErrorKind::MissingRequiredArgument + } + Error::InvalidChoice { .. } | Error::InvalidValue(_) => ParseErrorKind::ValueValidation, + _ => ParseErrorKind::Other, + }; + ParseError { kind } +} + +#[cfg(feature = "usage-parser")] +pub(crate) fn parse_args( + args: impl IntoIterator>, +) -> Result +where + A: usage_rs::spec::CommandArgs, +{ + let args = args.into_iter().map(Into::into).collect::>(); + let argv = args.iter().map(OsString::as_os_str).collect::>(); + let mut partial = A::start(); + let mut parser = usage_rs::Parser::new(A::COMMAND, &argv); + while let Some(event) = parser.next_event() { + let event = event.map_err(usage_error)?; + A::apply(&mut partial, &event); + } + A::apply_env(&mut partial); + A::check_with_args_override_self(&mut partial, false).map_err(usage_error)?; + A::build(partial).map_err(usage_error) +} + +#[cfg(feature = "usage-parser")] +pub(crate) fn parse_subcommand( + args: impl IntoIterator>, +) -> Result +where + A: usage_rs::spec::Subcommands, +{ + let root = Box::leak(Box::new(usage_rs::Command { + name: "test", + subcommands: A::COMMANDS, + unknown_flags: Some(usage_rs::UnknownFlags::Error), + ..usage_rs::Command::EMPTY + })); + let args = args.into_iter().map(Into::into).collect::>(); + let argv = args.iter().map(OsString::as_os_str).collect::>(); + let mut partial = A::Partial::default(); + let mut selected = None; + let mut parser = usage_rs::Parser::new(root, &argv); + while let Some(event) = parser.next_event() { + let event = event.map_err(usage_error)?; + if let usage_rs::Event::Command(command) = event + && let Some(named) = + A::COMMANDS.iter().position(|candidate| std::ptr::eq(*candidate, command)) + { + let at = if A::HAS_EXTERNAL { A::VARIANT_OF[named] } else { named }; + selected = Some(at); + A::begin(&mut partial, at); + } + A::apply(&mut partial, selected, &event); + } + let selected = selected.ok_or_else(|| usage_error(usage_rs::Error::MissingSubcommand))?; + A::apply_env(&mut partial, Some(selected)); + A::check(&mut partial, selected).map_err(usage_error)?; + A::select(partial, selected) + .map_err(usage_error)? + .ok_or_else(|| usage_error(usage_rs::Error::MissingSubcommand)) +} + #[cfg(test)] mod tests { use super::*; - #[derive(clap::Args, Debug, PartialEq, Eq)] + #[cfg_attr(feature = "clap-parser", derive(clap::Args))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Args))] + #[derive(Debug, PartialEq, Eq)] struct TestArgs { - #[arg(long)] + #[cfg_attr(feature = "clap-parser", arg(long))] + #[cfg_attr(feature = "usage-parser", usage(long))] value: String, } - #[derive(clap::Subcommand, Debug, PartialEq, Eq)] + #[cfg_attr(feature = "clap-parser", derive(clap::Subcommand))] + #[cfg_attr(feature = "usage-parser", derive(usage_rs::Subcommands))] + #[derive(Debug, PartialEq, Eq)] enum TestSubcommand { Get { key: String }, } @@ -82,6 +190,14 @@ mod tests { assert_eq!(args, TestArgs { value: "hello".to_string() }); } + #[test] + fn rejects_repeated_scalar_args() { + let error = parse_args::(["--value", "first", "--value", "second"]) + .expect_err("a scalar argument must not be repeated"); + + assert_eq!(error.kind(), ParseErrorKind::ArgumentConflict); + } + #[test] fn parses_subcommands_without_a_program_name() { let args = parse_subcommand::(["get", "registry"]).unwrap(); diff --git a/crates/vp_pm_cli_macros/src/lib.rs b/crates/vp_pm_cli_macros/src/lib.rs index 07e8ec753f..6514f99ad2 100644 --- a/crates/vp_pm_cli_macros/src/lib.rs +++ b/crates/vp_pm_cli_macros/src/lib.rs @@ -180,8 +180,8 @@ fn process_field(field: &mut Field) -> Result> { continue; } - let processed = process_arg_attr(&attr, &field_ident)?; - new_attrs.push(processed.attr); + let processed = process_arg_attr(&attr, &field_ident, &field.ty)?; + new_attrs.extend(processed.attrs); if let Some(attr_support) = processed.support { if support.is_some() { return Err(Error::new( @@ -230,7 +230,7 @@ fn conditional_attrs(attrs: &[Attribute]) -> Result> { } struct ProcessedArgAttr { - attr: Attribute, + attrs: [Attribute; 2], support: Option, } @@ -239,7 +239,11 @@ struct AttrSupport { clauses: Vec, } -fn process_arg_attr(attr: &Attribute, field_ident: &Ident) -> Result { +fn process_arg_attr( + attr: &Attribute, + field_ident: &Ident, + field_type: &Type, +) -> Result { let metas = attr.parse_args_with(Punctuated::::parse_terminated)?; let mut kept = Vec::new(); let mut clauses = None; @@ -273,7 +277,12 @@ fn process_arg_attr(attr: &Attribute, field_ident: &Ident) -> Result>(); + let usage_attr: Attribute = + syn::parse_quote!(#[cfg_attr(feature = "usage-parser", usage(#(#usage_metas),*))]); let support = if let Some(clauses) = clauses { let display_name = long_display.or(short_display).or(value_display).ok_or_else(|| { Error::new( @@ -286,7 +295,36 @@ fn process_arg_attr(attr: &Attribute, field_ident: &Ident) -> Result Option { + let name = meta.path().get_ident()?.to_string(); + match name.as_str() { + "value_parser" => None, + "required" if !is_vec(field_type) && !is_option_vec(field_type) => None, + "last" => Some(syn::parse_quote!(double_dash = "required")), + "overrides_with" | "overrides_with_all" => Some(rename_meta(meta, "overrides")), + "conflicts_with" | "conflicts_with_all" => Some(rename_meta(meta, "conflicts")), + _ => Some(meta.clone()), + } +} + +fn rename_meta(meta: &Meta, name: &str) -> Meta { + let path = Ident::new(name, meta.path().span()).into(); + match meta { + Meta::Path(_) => Meta::Path(path), + Meta::List(list) => { + let mut list = list.clone(); + list.path = path; + Meta::List(list) + } + Meta::NameValue(value) => { + let mut value = value.clone(); + value.path = path; + Meta::NameValue(value) + } + } } fn is_not_supported_meta(meta: &Meta) -> bool { diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 202152f68a..08113cd8a7 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -14,7 +14,6 @@ rolldown = ["dep:rolldown_binding"] anyhow = { workspace = true } async-trait = { workspace = true } base64-simd = { workspace = true } -clap = { workspace = true, features = ["derive"] } cow-utils = { workspace = true } fspy = { workspace = true } rustc-hash = { workspace = true } @@ -26,12 +25,15 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["fs"] } tracing = { workspace = true } +usage-rs = { workspace = true, features = ["completions", "diagnostics"] } uuid = { workspace = true, features = ["v4"] } vp_command = { workspace = true } -vp_cli_help = { workspace = true } +vp_cli_help = { workspace = true, features = ["usage"] } vp_error = { workspace = true } vp_migration = { workspace = true } -vp_pm_cli = { workspace = true } +vp_pm_cli = { path = "../../../crates/vp_pm_cli", default-features = false, features = [ + "usage-parser", +] } vt_path = { workspace = true } vt_select = { workspace = true } vp_shared = { workspace = true } @@ -48,6 +50,7 @@ napi-build = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } tempfile = { workspace = true } +usage-rs = { workspace = true, features = ["test"] } vp_shared = { workspace = true, features = ["test-utils"] } [lib] diff --git a/packages/cli/binding/src/cli/completion.rs b/packages/cli/binding/src/cli/completion.rs new file mode 100644 index 0000000000..92a4beb9b1 --- /dev/null +++ b/packages/cli/binding/src/cli/completion.rs @@ -0,0 +1,192 @@ +use std::{ffi::OsString, sync::Arc}; + +use rustc_hash::FxHashMap; +use usage_rs::complete::{Candidate, Completions, Request, Shell, Split}; +use vt::{CommandHandler, CompletionData, HandledCommand, ScriptCommand, Session, SessionConfig}; +use vt_path::{AbsolutePath, AbsolutePathBuf}; +use vt_str::Str; + +use super::{ + handler::VitePlusConfigLoader, + types::{CliOptions, LocalCli}, +}; + +#[derive(Debug)] +struct CompletionCommandHandler; + +#[async_trait::async_trait(?Send)] +impl CommandHandler for CompletionCommandHandler { + async fn handle_command( + &mut self, + _command: &mut ScriptCommand, + ) -> anyhow::Result { + Ok(HandledCommand::Verbatim) + } +} + +fn command_name<'a>(spec: &'a usage_rs::spec::Spec<'a>) -> &'a str { + spec.bin.unwrap_or(spec.name).split_ascii_whitespace().next_back().unwrap_or(spec.name) +} + +fn remove_leading_chdir(split: &mut Split) { + if split.words.len() < 2 || split.cword <= 1 { + return; + } + let remove = if split.words[1] == "-C" && split.words.len() > 2 && split.cword > 2 { + 2 + } else if split.words[1].starts_with("-C") && split.words[1].len() > 2 { + 1 + } else { + 0 + }; + if remove > 0 { + split.words.drain(1..=remove); + split.cword -= remove; + } +} + +fn merge_root_candidates(split: &Split) -> Completions<'static> { + let mut answer = Completions::default(); + for spec in [vt::Cli::spec(), vp_pm_cli::PackageManagerCli::spec(), LocalCli::spec()] { + answer.candidates.extend(usage_rs::complete::complete(spec, split).candidates); + } + for spec in crate::js_command_args::command_specs() { + let name = command_name(spec); + if name.starts_with(&split.prefix) { + answer.candidates.push(Candidate { value: name.to_owned(), description: None }); + } + } + answer.candidates.sort(); + answer.candidates.dedup_by(|left, right| left.value == right.value); + answer +} + +fn is_vpr(split: &Split) -> bool { + split.words.first().is_some_and(|word| { + std::path::Path::new(word).file_stem().is_some_and(|name| name.eq_ignore_ascii_case("vpr")) + }) +} + +fn complete_selected(split: &Split, data: &CompletionData) -> Completions<'static> { + if is_vpr(split) { + return vt::complete(split, data); + } + + let Some(command) = split.words.get(1).map(String::as_str) else { + return merge_root_candidates(split); + }; + if super::command_matches(vt::Cli::spec(), command) { + return vt::complete(split, data); + } + for spec in [vp_pm_cli::PackageManagerCli::spec(), LocalCli::spec()] { + if super::command_matches(spec, command) { + return usage_rs::complete::complete(spec, split); + } + } + if let Some(spec) = crate::js_command_args::command_specs() + .into_iter() + .find(|spec| command_name(spec) == command) + { + let mut projected = split.clone(); + projected.words.remove(1); + projected.cword = projected.cword.saturating_sub(1); + return usage_rs::complete::complete(spec, &projected); + } + Completions::default() +} + +async fn completion_data(cwd: &AbsolutePathBuf, options: Option<&CliOptions>) -> CompletionData { + let Some(resolve) = options.map(|options| Arc::clone(&options.resolve_universal_vite_config)) + else { + return CompletionData::default(); + }; + let envs = std::env::vars_os() + .map(|(key, value)| (Arc::from(key.as_os_str()), Arc::from(value.as_os_str()))) + .collect::, Arc>>(); + let mut command_handler = CompletionCommandHandler; + let mut config_loader = VitePlusConfigLoader::new(resolve); + let config = SessionConfig { + command_handler: &mut command_handler, + user_config_loader: &mut config_loader, + program_name: Str::from("vp"), + }; + let Ok(mut session) = Session::init_with(envs, Arc::::from(cwd.clone()), config) + else { + return CompletionData::default(); + }; + session.completion_data().await.unwrap_or_default() +} + +pub(super) async fn request( + cwd: &AbsolutePathBuf, + options: Option<&CliOptions>, + args: &[String], +) -> Option { + let argv = args.iter().map(OsString::from).collect::>(); + let mut request = Request::parse(&argv)?; + remove_leading_chdir(&mut request.split); + let data = if vt::completion_uses_workspace_data(&request.split) { + completion_data(cwd, options).await + } else { + CompletionData::default() + }; + let answer = if request.split.cword <= 1 && !is_vpr(&request.split) { + merge_root_candidates(&request.split) + } else { + complete_selected(&request.split, &data) + }; + // The global binary parses this stable tab-delimited form and renders the user's shell. + Some(usage_rs::complete::render(&answer, Shell::Nu)) +} + +#[cfg(test)] +mod tests { + use usage_rs::complete::{Request, Shell}; + + use super::*; + + fn values(line: &str) -> Vec { + let argv = [ + OsString::from("__complete_word__"), + OsString::from("--shell"), + OsString::from("bash"), + OsString::from("--line"), + OsString::from(line), + ]; + let mut request = Request::parse(&argv).expect("completion request"); + remove_leading_chdir(&mut request.split); + let answer = if request.split.cword <= 1 && !is_vpr(&request.split) { + merge_root_candidates(&request.split) + } else { + complete_selected(&request.split, &CompletionData::default()) + }; + usage_rs::complete::render(&answer, Shell::Bash).lines().map(ToOwned::to_owned).collect() + } + + #[test] + fn completes_all_local_command_sources() { + assert!(values("vp cr").contains(&"create".to_owned())); + assert!(values("vp ru").contains(&"run".to_owned())); + assert!(values("vp in").contains(&"install".to_owned())); + assert!(values("vp li").contains(&"lint".to_owned())); + } + + #[test] + fn completes_javascript_command_options() { + assert!(values("vp staged --di").contains(&"--diff".to_owned())); + assert!(values("vp create --package-manager p").contains(&"pnpm".to_owned())); + } + + #[test] + fn completes_vite_task_options_and_vpr_view() { + assert!(values("vp run --lo").contains(&"--log".to_owned())); + assert!(values("vpr --lo").contains(&"--log".to_owned())); + assert!(!values("vp run build -- --lo").contains(&"--log".to_owned())); + } + + #[test] + fn removes_the_node_owned_chdir_option() { + assert!(values("vp -C workspace staged --di").contains(&"--diff".to_owned())); + assert!(values("vp -Cworkspace staged --di").contains(&"--diff".to_owned())); + } +} diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index 0811dd5091..d5b10fe3dc 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -1,6 +1,3 @@ -use std::iter; - -use clap::{Parser, error::ErrorKind}; use vt::{ CommandHandler, HandledCommand, ScriptCommand, config::user::{EnabledCacheConfig, UserCacheConfig, UserRunConfig}, @@ -10,6 +7,7 @@ use vt_path::AbsolutePath; use vt_str::Str; use super::{ + ParsedCli, is_known_command, parse_cli_args, resolver::{SubcommandResolver, check_cache_inputs}, types::{CLIArgs, ResolvedUniversalViteConfig, SynthesizableSubcommand, ViteConfigResolverFn}, }; @@ -63,18 +61,21 @@ impl CommandHandler for VitePlusCommandHandler { } // "vpr " is shorthand for "vp run ", so prepend "run" for parsing. let is_vpr = program == "vpr"; - let cli_args = match CLIArgs::try_parse_from( - iter::once("vp") - .chain(is_vpr.then_some("run")) - .chain(command.args.iter().map(Str::as_str)), - ) { - Ok(args) => args, - Err(err) if err.kind() == ErrorKind::InvalidSubcommand => { - return Ok(HandledCommand::Synthesized( - command.to_synthetic_plan_request(UserCacheConfig::disabled()), - )); + let mut args = Vec::with_capacity(command.args.len() + usize::from(is_vpr)); + if is_vpr { + args.push("run".to_owned()); + } + args.extend(command.args.iter().map(ToString::to_string)); + if args.first().is_none_or(|name| !is_known_command(name)) { + return Ok(HandledCommand::Synthesized( + command.to_synthetic_plan_request(UserCacheConfig::disabled()), + )); + } + let cli_args = match parse_cli_args(&args) { + ParsedCli::Command(args) => args, + ParsedCli::Exit(status) => { + return Err(anyhow::anyhow!("nested vp arguments exited with status {status:?}")); } - Err(err) => return Err(err.into()), }; match cli_args { CLIArgs::Synthesizable(SynthesizableSubcommand::Check { .. }) => { diff --git a/packages/cli/binding/src/cli/help.rs b/packages/cli/binding/src/cli/help.rs index daf2f765aa..e59ce15e26 100644 --- a/packages/cli/binding/src/cli/help.rs +++ b/packages/cli/binding/src/cli/help.rs @@ -1,8 +1,6 @@ -use clap::error::{ContextKind, ContextValue, ErrorKind}; use owo_colors::OwoColorize; -use vp_error::Error; +use vp_cli_help::{HelpDoc, HelpRow, HelpSection, print_help_doc}; use vp_shared::output; -use vt::ExitStatus; use super::types::SynthesizableSubcommand; @@ -12,19 +10,6 @@ use super::types::SynthesizableSubcommand; /// `crates/vp_global_cli/src/cli.rs`; the local CLI cannot run them and only /// needs the names to point users at the global installation. const GLOBAL_ONLY_SUBCOMMANDS: &[&str] = &["env", "upgrade", "implode"]; - -pub(super) fn handle_cli_parse_error(err: clap::Error) -> Result { - if matches!(err.kind(), ErrorKind::InvalidSubcommand) && print_invalid_subcommand_error(&err) { - return Ok(ExitStatus(err.exit_code() as u8)); - } - if matches!(err.kind(), ErrorKind::UnknownArgument) && print_unknown_argument_error(&err) { - return Ok(ExitStatus(err.exit_code() as u8)); - } - - err.print().map_err(|e| Error::Anyhow(e.into()))?; - Ok(ExitStatus(err.exit_code() as u8)) -} - pub(super) fn normalize_help_args(args: Vec) -> Vec { match args.as_slice() { [arg] if arg == "help" => vec!["--help".to_string()], @@ -97,151 +82,154 @@ pub(super) fn should_print_help(args: &[String]) -> bool { args.is_empty() || matches!(args, [arg] if arg == "-h" || arg == "--help") } -fn extract_invalid_subcommand_details(error: &clap::Error) -> Option<(String, Option)> { - let invalid_subcommand = match error.get(ContextKind::InvalidSubcommand) { - Some(ContextValue::String(value)) => value.as_str(), - _ => return None, - }; - - let suggestion = match error.get(ContextKind::SuggestedSubcommand) { - Some(ContextValue::String(value)) => Some(value.to_owned()), - Some(ContextValue::Strings(values)) => { - vp_shared::string_similarity::pick_best_suggestion(invalid_subcommand, values) - } - _ => None, - }; - - Some((invalid_subcommand.to_owned(), suggestion)) -} - -fn print_invalid_subcommand_error(error: &clap::Error) -> bool { - let Some((invalid_subcommand, suggestion)) = extract_invalid_subcommand_details(error) else { - return false; - }; - - if GLOBAL_ONLY_SUBCOMMANDS.contains(&invalid_subcommand.as_str()) { +pub(super) fn print_invalid_subcommand_error(invalid_subcommand: &str) { + if GLOBAL_ONLY_SUBCOMMANDS.contains(&invalid_subcommand) { let command = format!("`{invalid_subcommand}`").bright_blue().to_string(); output::error(&format!( "The {command} command is only available in the global `vp` CLI. See https://viteplus.dev/guide/ to install it, then run the same command via the global `vp` binary." )); - return true; + return; } let highlighted_subcommand = invalid_subcommand.bright_blue().to_string(); output::error(&format!("Command '{highlighted_subcommand}' not found")); - if let Some(suggestion) = suggestion { + let commands = super::local_command_names(); + if let Some(suggestion) = + vp_shared::string_similarity::pick_best_suggestion(invalid_subcommand, &commands) + { eprintln!(); let highlighted_suggestion = format!("`vp {suggestion}`").bright_blue().to_string(); eprintln!("Did you mean {highlighted_suggestion}?"); } - - true } -fn extract_unknown_argument(error: &clap::Error) -> Option { - match error.get(ContextKind::InvalidArg) { - Some(ContextValue::String(value)) => Some(value.to_owned()), - _ => None, - } +pub(super) fn print_help() { + let mut core = command_rows(vt::Cli::spec()); + core.extend(command_rows(super::types::LocalCli::spec())); + core.extend(crate::js_command_args::command_specs().into_iter().map(root_command_row)); + core.sort_unstable_by(|left, right| left.label.cmp(&right.label)); + + let mut package_manager = command_rows(vp_pm_cli::PackageManagerCli::spec()); + package_manager.sort_unstable_by(|left, right| left.label.cmp(&right.label)); + + print_help_doc(&HelpDoc { + usage: "vp ".into(), + summary: Vec::new(), + sections: vec![ + HelpSection::Rows { title: "Core Commands".into(), rows: core }, + HelpSection::Rows { + title: "Package Manager Commands".into(), + rows: package_manager, + }, + HelpSection::Rows { + title: "Options".into(), + rows: vec![ + HelpRow { + label: "-C ".into(), + description: vec![ + "Run as if vp was started in instead of the current working directory" + .into(), + ], + }, + HelpRow { + label: "-h, --help".into(), + description: vec!["Show this help message".into()], + }, + ], + }, + ], + documentation_url: None, + }); } -fn has_pass_as_value_suggestion(error: &clap::Error) -> bool { - let contains_pass_as_value = |suggestion: &str| suggestion.contains("as a value"); - - match error.get(ContextKind::Suggested) { - Some(ContextValue::String(suggestion)) => contains_pass_as_value(suggestion), - Some(ContextValue::Strings(suggestions)) => { - suggestions.iter().any(|suggestion| contains_pass_as_value(suggestion)) - } - Some(ContextValue::StyledStr(suggestion)) => { - contains_pass_as_value(&suggestion.to_string()) - } - Some(ContextValue::StyledStrs(suggestions)) => { - suggestions.iter().any(|suggestion| contains_pass_as_value(&suggestion.to_string())) - } - _ => false, - } +fn command_rows(spec: &'static usage_rs::spec::Spec<'static>) -> Vec { + spec.root + .subcommands + .iter() + .filter(|command| !command.hide) + .map(|command| { + let aliases = + command.cmd.aliases.iter().filter(|alias| !command.hidden_aliases.contains(alias)); + HelpRow { + label: std::iter::once(command.cmd.name) + .chain(aliases.copied()) + .collect::>() + .join(", ") + .into(), + description: vec![command.long_about.or(command.about).unwrap_or_default().into()], + } + }) + .collect() } -fn print_unknown_argument_error(error: &clap::Error) -> bool { - let Some(invalid_argument) = extract_unknown_argument(error) else { - return false; - }; - - let highlighted_argument = invalid_argument.bright_blue().to_string(); - output::error(&format!("Unexpected argument '{highlighted_argument}'")); - - if has_pass_as_value_suggestion(error) { - eprintln!(); - let pass_through_argument = format!("-- {invalid_argument}"); - let highlighted_pass_through_argument = - format!("`{}`", pass_through_argument.bright_blue()); - eprintln!("Use {highlighted_pass_through_argument} to pass the argument as a value"); +fn root_command_row(spec: &'static usage_rs::spec::Spec<'static>) -> HelpRow { + HelpRow { + label: spec + .bin + .unwrap_or(spec.name) + .split_ascii_whitespace() + .next_back() + .unwrap_or(spec.name) + .into(), + description: vec![ + spec.long_about + .or(spec.about) + .or(spec.root.long_about) + .or(spec.root.about) + .unwrap_or_default() + .into(), + ], } - - true -} - -pub(super) fn print_help() { - let header = if vp_shared::header::should_print_header() { - format!("{}\n\n", vp_shared::header::vite_plus_header()) - } else { - String::new() - }; - let bold = "\x1b[1m"; - let bold_underline = "\x1b[1;4m"; - let reset = "\x1b[0m"; - println!( - "{header}{bold_underline}Usage:{reset} {bold}vp{reset} - -{bold_underline}Core Commands:{reset} - {bold}create{reset} Create a new project from a template - {bold}migrate{reset} Migrate an existing project to Vite+ - {bold}dev{reset} Run the development server - {bold}build{reset} Build for production - {bold}test{reset} Run tests - {bold}lint{reset} Lint code - {bold}fmt, format{reset} Format code - {bold}check{reset} Run format, lint, and type checks - {bold}pack{reset} Build library - {bold}run{reset} Run tasks - {bold}exec{reset} Execute a command from local node_modules/.bin - {bold}preview{reset} Preview production build - {bold}cache{reset} Manage the task cache - {bold}config{reset} Configure hooks and agent integration - {bold}hooks{reset} Manage the Git hook dispatcher - {bold}staged{reset} Run linters on staged files - {bold}toolchain{reset} Show Vite+ tool versions and relationships - -{bold_underline}Package Manager Commands:{reset} - {bold}install{reset} Install all dependencies, or add packages if package names are provided - -Options: - -C Run as if vp was started in instead of the current working directory - -h, --help Print help" - ); } #[cfg(test)] mod tests { - use clap::Parser; + use usage_rs::test::{self, Outcome, Page}; use vt::Command; - use super::{super::types::CLIArgs, *}; + use super::{ + super::{ + ParsedCli, parse_cli_args, + types::{CLIArgs, LocalCli}, + }, + *, + }; #[test] fn unknown_argument_detected_without_pass_as_value_hint() { - let error = CLIArgs::try_parse_from(["vp", "--cache"]).expect_err("Expected parse error"); - assert_eq!(extract_unknown_argument(&error).as_deref(), Some("--cache")); - assert!(!has_pass_as_value_suggestion(&error)); + let argv = test::argv(["--cache"]); + let Outcome::Failed(error) = + test::outcome(LocalCli::spec(), &argv.words(), LocalCli::parse_from) + else { + panic!("expected an argument error"); + }; + assert!(error.text.contains("--cache"), "{}", error.text); + assert!(!error.text.contains("pass the argument as a value"), "{}", error.text); } #[test] - fn run_accepts_unknown_flags_as_task_args() { - // After trailing_var_arg change, unknown flags like --yolo are - // accepted as task arguments instead of producing a parse error. - let args = CLIArgs::try_parse_from(["vp", "run", "--yolo"]).unwrap(); + fn help_trees_use_the_parser_metadata() { + let local = test::help_tree(LocalCli::spec(), Page::Long); + assert!(local.contains("=== vp check ==="), "{local}"); + assert!(local.contains("--no-error-on-unmatched-pattern"), "{local}"); + + let tasks = test::help_tree(vt::Cli::spec(), Page::Long); + assert!(tasks.contains("=== vt run ==="), "{tasks}"); + assert!(tasks.contains("--concurrency-limit"), "{tasks}"); + + let package_manager = test::help_tree(vp_pm_cli::PackageManagerCli::spec(), Page::Long); + assert!(package_manager.contains("=== vp install ==="), "{package_manager}"); + assert!(package_manager.contains("=== vp pm approve-builds ==="), "{package_manager}"); + } + + #[test] + fn run_forwards_unknown_flags_after_the_task_name() { + let ParsedCli::Command(args) = + parse_cli_args(&["run".into(), "build".into(), "--yolo".into()]) + else { + panic!("run arguments must parse"); + }; let debug = vt_str::format!("{args:?}"); assert!(debug.contains("\"--yolo\""), "Expected --yolo in task args, got: {debug}"); assert!(matches!(args, CLIArgs::ViteTask(Command::Run(_)))); @@ -319,16 +307,10 @@ mod tests { #[test] fn global_subcommands_produce_invalid_subcommand_error() { - use clap::error::ErrorKind; - for subcommand in ["config", "create", "env", "hooks", "implode", "migrate", "upgrade"] { - let error = CLIArgs::try_parse_from(["vp", subcommand]) - .expect_err(&format!("expected error for global subcommand '{subcommand}'")); - assert_eq!( - error.kind(), - ErrorKind::InvalidSubcommand, - "expected InvalidSubcommand for '{subcommand}', got {:?}", - error.kind() + assert!( + matches!(parse_cli_args(&[subcommand.into()]), ParsedCli::Exit(status) if status.0 == 2), + "expected an invalid-subcommand exit for '{subcommand}'" ); } } diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index e370d1eaea..89dace6eab 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -4,6 +4,7 @@ //! It handles argument parsing, command dispatching, and orchestration of the task execution. mod app_target; +mod completion; mod execution; mod handler; mod help; @@ -13,7 +14,6 @@ mod types; use std::{borrow::Cow, env, ffi::OsStr, sync::Arc}; -use clap::Parser; use cow_utils::CowUtils; pub(crate) use execution::resolve_and_capture_output; // Re-exports for lib.rs and check/mod.rs @@ -35,12 +35,106 @@ use self::{ execution::{FilterStream, resolve_and_execute, resolve_and_execute_with_filter}, handler::{VitePlusCommandHandler, VitePlusConfigLoader}, help::{ - handle_cli_parse_error, normalize_help_args, print_help, should_print_help, + normalize_help_args, print_help, print_invalid_subcommand_error, should_print_help, should_suppress_subcommand_stdout, }, - types::CLIArgs, + types::{CLIArgs, LocalCli}, }; +enum ParsedCli { + Command(CLIArgs), + Exit(ExitStatus), +} + +fn command_matches(spec: &'static usage_rs::spec::Spec<'static>, name: &str) -> bool { + spec.root + .subcommands + .iter() + .any(|subcommand| subcommand.cmd.name == name || subcommand.cmd.aliases.contains(&name)) +} + +fn is_known_command(name: &str) -> bool { + command_matches(vt::Cli::spec(), name) + || command_matches(vp_pm_cli::PackageManagerCli::spec(), name) + || command_matches(LocalCli::spec(), name) +} + +fn local_command_names() -> Vec { + let mut names = [vt::Cli::spec(), vp_pm_cli::PackageManagerCli::spec(), LocalCli::spec()] + .into_iter() + .flat_map(|spec| spec.root.subcommands) + .flat_map(|command| { + std::iter::once(command.cmd.name).chain(command.cmd.aliases.iter().copied()) + }) + .map(ToOwned::to_owned) + .collect::>(); + names.extend(crate::js_command_args::command_specs().into_iter().map(|spec| { + spec.bin + .unwrap_or(spec.name) + .split_ascii_whitespace() + .next_back() + .unwrap_or(spec.name) + .to_owned() + })); + names.sort_unstable(); + names.dedup(); + names +} + +fn finish_usage_parse<'value, T>( + spec: &'static usage_rs::spec::Spec<'static>, + args: &[String], + argv: &[&'value OsStr], + result: Result>, + into_command: impl FnOnce(T) -> CLIArgs, +) -> ParsedCli { + match result { + Ok(value) => ParsedCli::Command(into_command(value)), + Err(usage_rs::Error::Help { cmd, .. } | usage_rs::Error::MissingArgsHelp { cmd }) => { + let doc = vp_cli_help::help_doc_from_usage(spec, args, cmd, None) + .expect("help command must belong to its parser"); + vp_cli_help::print_help_doc(&doc); + ParsedCli::Exit(ExitStatus::SUCCESS) + } + Err(error) => { + vp_shared::output::raw_stderr(usage_rs::render_failure(spec, argv, &error).trim_end()); + ParsedCli::Exit(ExitStatus(2)) + } + } +} + +fn parse_cli_args(args: &[String]) -> ParsedCli { + let Some(command_name) = args.first().map(String::as_str) else { + return ParsedCli::Exit(ExitStatus::SUCCESS); + }; + let argv = args.iter().map(OsStr::new).collect::>(); + + if command_matches(vt::Cli::spec(), command_name) { + return finish_usage_parse( + vt::Cli::spec(), + args, + &argv, + vt::Cli::parse_from(&argv), + |cli| CLIArgs::ViteTask(cli.command), + ); + } + if command_matches(vp_pm_cli::PackageManagerCli::spec(), command_name) { + return finish_usage_parse( + vp_pm_cli::PackageManagerCli::spec(), + args, + &argv, + vp_pm_cli::PackageManagerCli::parse_from(&argv), + |cli| CLIArgs::PackageManager(cli.command), + ); + } + if !is_known_command(command_name) { + print_invalid_subcommand_error(command_name); + return ParsedCli::Exit(ExitStatus(2)); + } + + finish_usage_parse(LocalCli::spec(), args, &argv, LocalCli::parse_from(&argv), Into::into) +} + /// Execute a synthesizable subcommand directly (not through vite-task Session). /// No caching, no task graph, no dependency resolution. async fn execute_direct_subcommand( @@ -371,6 +465,10 @@ pub async fn main( explicit_chdir: bool, ) -> Result { let raw_args: Vec = args.unwrap_or_else(|| env::args().skip(1).collect()); + if let Some(answer) = completion::request(&cwd, options.as_ref(), &raw_args).await { + vp_shared::output::raw_inline(&answer); + return Ok(ExitStatus::SUCCESS); + } // The global CLI resolves aliases to their canonical names before // delegating, so prefer the original spelling it forwards. A direct local // invocation can use its first, still-unnormalized argument. @@ -382,10 +480,9 @@ pub async fn main( return Ok(ExitStatus::SUCCESS); } - let args_with_program = std::iter::once("vp".to_string()).chain(args_vec.iter().cloned()); - let cli_args = match CLIArgs::try_parse_from(args_with_program) { - Ok(args) => args, - Err(err) => return handle_cli_parse_error(err), + let cli_args = match parse_cli_args(&args_vec) { + ParsedCli::Command(args) => args, + ParsedCli::Exit(status) => return Ok(status), }; match cli_args { diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs index c2fda6c821..55c81987aa 100644 --- a/packages/cli/binding/src/cli/types.rs +++ b/packages/cli/binding/src/cli/types.rs @@ -1,9 +1,8 @@ use std::{ffi::OsStr, future::Future, pin::Pin, sync::Arc}; -use clap::{Parser, Subcommand}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; -use vt::{Command, ExitStatus, config::user::UserCacheConfig, plan_request::SyntheticPlanRequest}; +use vt::{ExitStatus, config::user::UserCacheConfig, plan_request::SyntheticPlanRequest}; use vt_str::Str; /// Resolved configuration from vite.config.ts @@ -25,114 +24,157 @@ pub struct ResolveCommandResult { } /// Built-in subcommands that resolve to a concrete tool (oxlint, vitest, vite, etc.) -#[derive(Debug, Clone, Subcommand)] +#[derive(Debug, Clone)] pub enum SynthesizableSubcommand { /// Lint code - #[command(disable_help_flag = true)] - Lint { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Lint { args: Vec }, /// Format code - #[command(disable_help_flag = true, visible_alias = "format")] - Fmt { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Fmt { args: Vec }, /// Build for production - #[command(disable_help_flag = true)] - Build { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Build { args: Vec }, /// Run tests - #[command(disable_help_flag = true)] - Test { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Test { args: Vec }, /// Build library - #[command(disable_help_flag = true)] - Pack { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Pack { args: Vec }, /// Run the development server - #[command(disable_help_flag = true)] - Dev { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Dev { args: Vec }, /// Preview production build - #[command(disable_help_flag = true)] - Preview { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Preview { args: Vec }, /// Build documentation - #[command(disable_help_flag = true, hide = true)] - Doc { - #[clap(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Doc { args: Vec }, /// Run format, lint, and type checks Check { /// Auto-fix format and lint issues - #[arg(long)] fix: bool, /// Skip format check - #[arg(long = "no-fmt")] no_fmt: bool, /// Skip lint rules; type-check still runs when `lint.options.typeCheck` is true - #[arg(long = "no-lint")] no_lint: bool, /// Do not exit with error when pattern is unmatched - #[arg(long = "no-error-on-unmatched-pattern")] no_error_on_unmatched_pattern: bool, /// File paths to check (passed through to fmt and lint) - #[arg(trailing_var_arg = true)] paths: Vec, }, } -#[derive(Debug, clap::Args)] +#[derive(Debug, usage_rs::Args)] +#[usage(args_override_self = false)] pub struct ToolchainArgs { /// Tool or package names to show - #[arg(value_name = "TOOLS")] + #[usage(value_name = "TOOLS")] pub tools: Vec, /// Print the graph as JSON - #[arg(long)] + #[usage(long)] pub json: bool, /// Use the global Vite+ toolchain - #[arg(long)] + #[usage(long)] pub global: bool, } -/// Top-level CLI argument parser for vite-plus. -#[derive(Debug, Parser)] -#[command(name = "vp", disable_help_subcommand = true)] -pub(super) enum CLIArgs { - /// vite-task commands (run, cache) - #[command(flatten)] - ViteTask(Command), - - /// Built-in subcommands (lint, build, test, etc.) - #[command(flatten)] - Synthesizable(SynthesizableSubcommand), +#[derive(Debug, usage_rs::Args)] +#[usage(args_override_self = false, disable_help_flag = true)] +struct PassthroughArgs { + #[usage(double_dash = "automatic", value_name = "ARGS")] + args: Vec, +} - /// Package manager commands (install, add, remove, update, dedupe, …) - #[command(flatten)] - PackageManager(vp_pm_cli::PackageManagerCommand), +#[derive(Debug, usage_rs::Args)] +#[usage(args_override_self = false)] +struct CheckArgs { + /// Auto-fix format and lint issues + #[usage(long)] + fix: bool, + /// Skip format check + #[usage(long = "no-fmt")] + no_fmt: bool, + /// Skip lint rules; type-check still runs when `lint.options.typeCheck` is true + #[usage(long = "no-lint")] + no_lint: bool, + /// Do not exit with error when pattern is unmatched + #[usage(long = "no-error-on-unmatched-pattern")] + no_error_on_unmatched_pattern: bool, + /// File paths to check (passed through to fmt and lint) + #[usage(double_dash = "automatic", value_name = "PATH")] + paths: Vec, +} +#[derive(Debug, usage_rs::Subcommands)] +enum LocalCommand { + /// Lint code + Lint(PassthroughArgs), + /// Format code + #[usage(visible_alias = "format")] + Fmt(PassthroughArgs), + /// Build for production + Build(PassthroughArgs), + /// Run tests + Test(PassthroughArgs), + /// Build library + Pack(PassthroughArgs), + /// Run the development server + Dev(PassthroughArgs), + /// Preview production build + Preview(PassthroughArgs), + /// Build documentation + #[usage(hide)] + Doc(PassthroughArgs), + /// Run format, lint, and type checks + Check(CheckArgs), /// Execute a command from local node_modules/.bin Exec(crate::exec::ExecArgs), - /// Show active Vite+ tools, versions, and relationships Toolchain(ToolchainArgs), } +#[derive(Debug, usage_rs::Cli)] +#[usage( + bin = "vp", + unknown_flags = "error", + args_override_self = false, + disable_help_subcommand = true +)] +pub(super) struct LocalCli { + #[usage(subcommand)] + command: LocalCommand, +} + +/// Parsed command from one of the local CLI parser trees. +#[derive(Debug)] +pub(super) enum CLIArgs { + ViteTask(vt::Command), + Synthesizable(SynthesizableSubcommand), + PackageManager(vp_pm_cli::PackageManagerCommand), + Exec(crate::exec::ExecArgs), + Toolchain(ToolchainArgs), +} + +impl From for CLIArgs { + fn from(cli: LocalCli) -> Self { + let command = match cli.command { + LocalCommand::Lint(args) => SynthesizableSubcommand::Lint { args: args.args }, + LocalCommand::Fmt(args) => SynthesizableSubcommand::Fmt { args: args.args }, + LocalCommand::Build(args) => SynthesizableSubcommand::Build { args: args.args }, + LocalCommand::Test(args) => SynthesizableSubcommand::Test { args: args.args }, + LocalCommand::Pack(args) => SynthesizableSubcommand::Pack { args: args.args }, + LocalCommand::Dev(args) => SynthesizableSubcommand::Dev { args: args.args }, + LocalCommand::Preview(args) => SynthesizableSubcommand::Preview { args: args.args }, + LocalCommand::Doc(args) => SynthesizableSubcommand::Doc { args: args.args }, + LocalCommand::Check(args) => SynthesizableSubcommand::Check { + fix: args.fix, + no_fmt: args.no_fmt, + no_lint: args.no_lint, + no_error_on_unmatched_pattern: args.no_error_on_unmatched_pattern, + paths: args.paths, + }, + LocalCommand::Exec(args) => return Self::Exec(args), + LocalCommand::Toolchain(args) => return Self::Toolchain(args), + }; + Self::Synthesizable(command) + } +} + /// Type alias for boxed async resolver function /// NOTE: Uses anyhow::Error to avoid NAPI type inference issues pub type BoxedResolverFn = diff --git a/packages/cli/binding/src/exec/args.rs b/packages/cli/binding/src/exec/args.rs index 57ede5dc56..6a02c1b173 100644 --- a/packages/cli/binding/src/exec/args.rs +++ b/packages/cli/binding/src/exec/args.rs @@ -1,8 +1,9 @@ -use vt_workspace::package_filter::PackageQueryArgs; +use vt_workspace::package_filter::PackageQueryCliArgs; -/// Parsed exec arguments (clap-derived). -#[derive(Debug, clap::Args)] -#[command( +/// Parsed exec arguments. +#[derive(Debug, usage_rs::Args)] +#[usage( + args_override_self = false, about = "Execute a command from local node_modules/.bin", after_help = "\ Examples: @@ -13,30 +14,30 @@ Examples: vp exec --filter 'app...' -- tsc # Run in filtered packages" )] pub(crate) struct ExecArgs { - #[clap(flatten)] - pub packages: PackageQueryArgs, + #[usage(flatten)] + pub packages: PackageQueryCliArgs, /// Execute the command within a shell environment - #[clap(short = 'c', long = "shell-mode")] + #[usage(short = 'c', long = "shell-mode")] pub shell_mode: bool, /// Run concurrently without topological ordering - #[clap(long)] + #[usage(long)] pub parallel: bool, /// Reverse execution order - #[clap(long)] + #[usage(long)] pub reverse: bool, /// Resume from a specific package - #[clap(long = "resume-from")] + #[usage(long = "resume-from")] pub resume_from: Option, /// Save results to vp-exec-summary.json - #[clap(long = "report-summary")] + #[usage(long = "report-summary")] pub report_summary: bool, /// Command and arguments to execute - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + #[usage(double_dash = "automatic", value_name = "COMMAND")] pub command: Vec, } diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs index 01aa5bc752..a154cba727 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -29,7 +29,8 @@ pub(super) async fn execute_exec_workspace( // Build the query from exec flags let fail_if_no_match = args.packages.fail_if_no_match; let cwd_arc: Arc = cwd.clone().into(); - let (query, is_cwd_only) = match args.packages.into_package_query(None, &cwd_arc) { + let package_query = vt_workspace::package_filter::PackageQueryArgs::from(args.packages); + let (query, is_cwd_only) = match package_query.into_package_query(None, &cwd_arc) { Ok(result) => result, Err(e) => { vp_shared::output::error(&vt_str::format!("{e}")); diff --git a/packages/cli/binding/src/js_command_args/config.rs b/packages/cli/binding/src/js_command_args/config.rs index f28540a182..fabb2ea185 100644 --- a/packages/cli/binding/src/js_command_args/config.rs +++ b/packages/cli/binding/src/js_command_args/config.rs @@ -1,60 +1,64 @@ -use clap::{ArgAction, Args, Command}; use napi_derive::napi; -use vp_cli_help::{HelpRow, HelpSection, help_doc_from_command, print_help_doc}; +use usage_rs::Cli; +use vp_cli_help::{HelpRow, HelpSection, help_doc_from_usage, print_help_doc}; -use super::parse::{CliParseError, ParseResult, boolean_option, help_arg, parse_args}; +use super::parse::{CliParseError, CliParser, ParseResult, boolean_option, parse_args}; const DOCUMENTATION_URL: &str = "https://viteplus.dev/guide/commit-hooks"; -#[derive(Debug, Args)] +#[derive(Debug, Cli)] +#[usage( + bin = "vp config", + about = "Configure Vite+ for the current project (hook dispatcher + agent integration).", + usage = "Usage: vp config [OPTIONS]", + unknown_flags = "error", + args_override_self = false +)] struct ConfigCliArgs { - #[arg( + #[usage( long, value_name = "path", help = "Custom hooks directory (default: .vite-hooks, or last used in this clone)" )] hooks_dir: Option, - #[arg( - long, - action = ArgAction::SetTrue, - overrides_with_all = ["hooks", "no_hooks"], - help = "Install the hook dispatcher" - )] + #[usage(long, var, overrides("no_hooks"), help = "Install the hook dispatcher")] hooks: bool, - #[arg( + #[usage( long = "no-hooks", - action = ArgAction::SetTrue, - overrides_with_all = ["hooks", "no_hooks"], + var, + overrides("hooks"), help = "Skip hook dispatcher installation" )] no_hooks: bool, - #[arg( - long, - action = ArgAction::SetTrue, - overrides_with_all = ["agent", "no_agent"], - help = "Update coding agent instructions" - )] + #[usage(long, var, overrides("no_agent"), help = "Update coding agent instructions")] agent: bool, - #[arg( + #[usage( long = "no-agent", - action = ArgAction::SetTrue, - overrides_with_all = ["agent", "no_agent"], + var, + overrides("agent"), help = "Skip updating coding agent instructions" )] no_agent: bool, } -fn config_command() -> Command { - ConfigCliArgs::augment_args( - Command::new("vp config") - .about("Configure Vite+ for the current project (hook dispatcher + agent integration).") - .disable_help_flag(true), - ) - .arg(help_arg()) +impl CliParser for ConfigCliArgs { + fn parse_from<'value>( + argv: &'value [&'value std::ffi::OsStr], + ) -> Result> { + Self::parse_from(argv) + } + + fn spec() -> &'static usage_rs::spec::Spec<'static> { + Self::spec() + } +} + +pub(super) fn spec() -> &'static usage_rs::spec::Spec<'static> { + ConfigCliArgs::spec() } #[napi(object, object_from_js = false)] @@ -83,10 +87,16 @@ pub enum ParseConfigArgsOutcome { #[napi] pub fn parse_config_args(argv: Vec) -> ParseConfigArgsOutcome { - match parse_args::(config_command(), argv) { + match parse_args::(&argv) { ParseResult::Ok(value) => ParseConfigArgsOutcome::Ok { value: value.into() }, ParseResult::Help(command) => { - let mut doc = help_doc_from_command(*command, Some(DOCUMENTATION_URL.into())); + let mut doc = help_doc_from_usage( + ConfigCliArgs::spec(), + &argv, + command, + Some(DOCUMENTATION_URL.into()), + ) + .expect("help command must belong to the config parser"); doc.sections.push(HelpSection::Rows { title: "Environment".into(), rows: vec![HelpRow { @@ -106,7 +116,8 @@ mod tests { use super::*; fn parse(argv: &[&str]) -> ParseResult { - parse_args(config_command(), argv.iter().map(|value| (*value).to_owned()).collect()) + let argv = argv.iter().map(|value| (*value).to_owned()).collect::>(); + parse_args(&argv) } fn parsed(argv: &[&str]) -> ConfigCliArgs { @@ -132,6 +143,13 @@ mod tests { assert_eq!(args.agent, Some(false)); } + #[test] + fn accepts_repeated_boolean_overrides() { + let args = ConfigArgs::from(parsed(&["--hooks", "--hooks", "--no-agent", "--no-agent"])); + assert_eq!(args.hooks, Some(true)); + assert_eq!(args.agent, Some(false)); + } + #[test] fn rejects_invalid_arguments() { assert_eq!(parse_error(&["--hooks-dir"]).kind, "invalid-value"); diff --git a/packages/cli/binding/src/js_command_args/create.rs b/packages/cli/binding/src/js_command_args/create.rs index 3b637e0ee9..534bfe0a9c 100644 --- a/packages/cli/binding/src/js_command_args/create.rs +++ b/packages/cli/binding/src/js_command_args/create.rs @@ -1,156 +1,143 @@ -use clap::{ArgAction, Args, Command}; +use std::str::FromStr; + use napi::bindgen_prelude::{Either, Either3}; use napi_derive::napi; -use vp_cli_help::{HelpSection, help_doc_from_command, print_help_doc}; +use usage_rs::Cli; +use vp_cli_help::{HelpSection, help_doc_from_usage, print_help_doc}; use vp_pm_cli::PackageManagerType; use super::parse::{ - CliParseError, ParseResult, agent_option, boolean_option, editor_option, help_arg, parse_args, + CliParseError, CliParser, ParseResult, agent_option, boolean_option, editor_option, parse_args, }; const DOCUMENTATION_URL: &str = "https://viteplus.dev/guide/create"; const PACKAGE_MANAGER_ERROR: &str = "use pnpm, npm, yarn, or bun"; -fn parse_package_manager(value: &str) -> Result { - PackageManagerType::from_name(value).ok_or(PACKAGE_MANAGER_ERROR) +#[derive(Debug)] +struct PackageManager(PackageManagerType); + +impl FromStr for PackageManager { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + PackageManagerType::from_name(value).map(Self).ok_or(PACKAGE_MANAGER_ERROR) + } } -#[derive(Debug, Args)] +#[derive(Debug, Cli)] +#[usage( + bin = "vp create", + about = "Use any builtin, local or remote template with Vite+.", + usage = "Usage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]", + unknown_flags = "error", + args_override_self = false +)] struct CreateCliArgs { - #[arg(value_name = "TEMPLATE", help = "Builtin, local, or remote template name")] + #[usage(value_name = "TEMPLATE", help = "Builtin, local, or remote template name")] template: Option, - #[arg(long, value_name = "DIR", help = "Target directory for the generated project")] + #[usage(long, value_name = "DIR", help = "Target directory for the generated project")] directory: Option, - #[arg( + #[usage( long, value_name = "NAME", - action = ArgAction::Append, - overrides_with = "no_agent", + overrides("no_agent"), help = "Write coding agent instructions to AGENTS.md, CLAUDE.md, etc." )] agent: Vec, - #[arg( + #[usage( long = "no-agent", - action = ArgAction::SetTrue, - overrides_with_all = ["agent", "no_agent"], + var, + overrides("agent"), help = "Skip writing coding agent instructions" )] no_agent: bool, - #[arg( + #[usage( long, value_name = "NAME", - action = ArgAction::Append, - overrides_with = "no_editor", + overrides("no_editor"), help = "Write editor config files for the specified editor" )] editor: Vec, - #[arg( + #[usage( long = "no-editor", - action = ArgAction::SetTrue, - overrides_with_all = ["editor", "no_editor"], + var, + overrides("editor"), help = "Skip writing editor config files" )] no_editor: bool, - #[arg( - long, - action = ArgAction::SetTrue, - overrides_with_all = ["git", "no_git"], - help = "Initialize a git repository" - )] + #[usage(long, var, overrides("no_git"), help = "Initialize a git repository")] git: bool, - #[arg( - long = "no-git", - action = ArgAction::SetTrue, - overrides_with_all = ["git", "no_git"], - help = "Skip git repository initialization" - )] + #[usage(long = "no-git", var, overrides("git"), help = "Skip git repository initialization")] no_git: bool, - #[arg( + #[usage( long, - action = ArgAction::SetTrue, - overrides_with_all = ["hooks", "no_hooks"], + var, + overrides("no_hooks"), help = "Set up pre-commit hooks (default in non-interactive mode)" )] hooks: bool, - #[arg( - long = "no-hooks", - action = ArgAction::SetTrue, - overrides_with_all = ["hooks", "no_hooks"], - help = "Skip pre-commit hooks setup" - )] + #[usage(long = "no-hooks", var, overrides("hooks"), help = "Skip pre-commit hooks setup")] no_hooks: bool, - #[arg( + #[usage( long, value_name = "pnpm|npm|yarn|bun", - value_parser = parse_package_manager, + choices("pnpm", "npm", "yarn", "bun"), help = "Use the specified package manager" )] - package_manager: Option, + package_manager: Option, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Approve and run gated dependency build scripts without prompting" - )] + #[usage(long, help = "Approve and run gated dependency build scripts without prompting")] approve_builds: bool, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Show detailed scaffolding output" - )] + #[usage(long, help = "Show detailed scaffolding output")] verbose: bool, - #[arg( - long, - action = ArgAction::SetTrue, - overrides_with_all = ["interactive", "no_interactive"], - help = "Enable interactive prompts" - )] + #[usage(long, var, overrides("no_interactive"), help = "Enable interactive prompts")] interactive: bool, - #[arg( + #[usage( long = "no-interactive", - action = ArgAction::SetTrue, - overrides_with_all = ["interactive", "no_interactive"], + var, + overrides("interactive"), help = "Run in non-interactive mode" )] no_interactive: bool, - #[arg( - long, - action = ArgAction::SetTrue, - help = "List all available templates" - )] + #[usage(long, help = "List all available templates")] list: bool, - #[arg( - last = true, - allow_hyphen_values = true, + #[usage( + double_dash = "required", value_name = "TEMPLATE_OPTIONS", help = "Arguments passed to the template without changes" )] template_args: Vec, } -fn create_command() -> Command { - CreateCliArgs::augment_args( - Command::new("vp create") - .about("Use any builtin, local or remote template with Vite+.") - .disable_help_flag(true) - .override_usage("vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]"), - ) - .arg(help_arg()) +pub(super) fn spec() -> &'static usage_rs::spec::Spec<'static> { + CreateCliArgs::spec() +} + +impl CliParser for CreateCliArgs { + fn parse_from<'value>( + argv: &'value [&'value std::ffi::OsStr], + ) -> Result> { + Self::parse_from(argv) + } + + fn spec() -> &'static usage_rs::spec::Spec<'static> { + Self::spec() + } } #[napi(object, object_from_js = false)] @@ -183,7 +170,7 @@ impl From for CreateArgs { hooks: boolean_option(value.hooks, value.no_hooks), package_manager: value .package_manager - .map(|package_manager| package_manager.to_string()), + .map(|package_manager| package_manager.0.to_string()), approve_builds: value.approve_builds.then_some(true), verbose: value.verbose.then_some(true), interactive: boolean_option(value.interactive, value.no_interactive), @@ -202,10 +189,16 @@ pub enum ParseCreateArgsOutcome { #[napi] pub fn parse_create_args(argv: Vec) -> ParseCreateArgsOutcome { - match parse_args::(create_command(), argv) { + match parse_args::(&argv) { ParseResult::Ok(value) => ParseCreateArgsOutcome::Ok { value: value.into() }, ParseResult::Help(command) => { - let mut doc = help_doc_from_command(*command, Some(DOCUMENTATION_URL.into())); + let mut doc = help_doc_from_usage( + CreateCliArgs::spec(), + &argv, + command, + Some(DOCUMENTATION_URL.into()), + ) + .expect("help command must belong to the create parser"); doc.sections.push(HelpSection::Lines { title: "Examples".into(), lines: vec![ @@ -233,7 +226,8 @@ mod tests { use super::*; fn parse(argv: &[&str]) -> ParseResult { - parse_args(create_command(), argv.iter().map(|value| (*value).to_owned()).collect()) + let argv = argv.iter().map(|value| (*value).to_owned()).collect::>(); + parse_args(&argv) } fn parsed(argv: &[&str]) -> CreateArgs { @@ -287,6 +281,27 @@ mod tests { assert_eq!(args.interactive, Some(true)); } + #[test] + fn accepts_repeated_boolean_overrides() { + let args = parsed(&[ + "--git", + "--git", + "--no-hooks", + "--no-hooks", + "--interactive", + "--interactive", + "--no-agent", + "--no-agent", + "--no-editor", + "--no-editor", + ]); + assert_eq!(args.git, Some(true)); + assert_eq!(args.hooks, Some(false)); + assert_eq!(args.interactive, Some(true)); + assert!(matches!(args.agent, Some(Either3::A(false)))); + assert!(matches!(args.editor, Some(Either::A(false)))); + } + #[test] fn parses_other_supported_options() { let args = parsed(&[ @@ -313,7 +328,9 @@ mod tests { let error = parse_error(&["--package-manager", "deno"]); assert_eq!(error.kind, "invalid-value"); - assert!(error.message.contains(PACKAGE_MANAGER_ERROR)); + for package_manager in ["pnpm", "npm", "yarn", "bun"] { + assert!(error.message.contains(package_manager), "{}", error.message); + } } #[test] diff --git a/packages/cli/binding/src/js_command_args/hooks.rs b/packages/cli/binding/src/js_command_args/hooks.rs index 81585fe228..2789d24abc 100644 --- a/packages/cli/binding/src/js_command_args/hooks.rs +++ b/packages/cli/binding/src/js_command_args/hooks.rs @@ -1,53 +1,61 @@ -use clap::{ArgAction, Args, Command, Subcommand}; use napi_derive::napi; -use vp_cli_help::{HelpRow, HelpSection, help_doc_from_command, print_help_doc}; +use usage_rs::{Args, Cli, Subcommands}; +use vp_cli_help::{HelpRow, HelpSection, help_doc_from_usage, print_help_doc}; -use super::parse::{CliParseError, ParseResult, help_arg, parse_args}; +use super::parse::{CliParseError, CliParser, ParseResult, parse_args}; const DOCUMENTATION_URL: &str = "https://viteplus.dev/guide/commit-hooks"; -#[derive(Debug, Args)] +#[derive(Debug, Cli)] +#[usage( + bin = "vp hooks", + about = "Manage the Vite+ Git hook dispatcher for this repository.", + usage = "Usage: vp hooks [OPTIONS]", + unknown_flags = "error", + args_override_self = false, + arg_required_else_help, + disable_help_subcommand +)] struct HooksCliArgs { - #[command(subcommand)] + #[usage(subcommand)] command: HooksSubcommand, } -#[derive(Debug, Subcommand)] +#[derive(Debug, Subcommands)] enum HooksSubcommand { /// Install or refresh the hook dispatcher (sets core.hooksPath) - #[command(disable_help_flag = true)] Enable(HooksActionArgs), /// Disable hooks: unset core.hooksPath, remove /_, persist preference - #[command(disable_help_flag = true)] Disable(HooksActionArgs), /// Show preference, core.hooksPath, and dispatcher state - #[command(disable_help_flag = true)] Status(HooksActionArgs), } #[derive(Debug, Args)] +#[usage(args_override_self = false)] struct HooksActionArgs { - #[arg( + #[usage( long, value_name = "path", help = "Custom hooks directory (default: .vite-hooks, or last used)" )] hooks_dir: Option, +} + +impl CliParser for HooksCliArgs { + fn parse_from<'value>( + argv: &'value [&'value std::ffi::OsStr], + ) -> Result> { + Self::parse_from(argv) + } - #[arg(short = 'h', long, action = ArgAction::Help, help = "Show this help message")] - help: Option, + fn spec() -> &'static usage_rs::spec::Spec<'static> { + Self::spec() + } } -fn hooks_command() -> Command { - HooksCliArgs::augment_args( - Command::new("vp hooks") - .about("Manage the Vite+ Git hook dispatcher for this repository.") - .disable_help_flag(true) - .disable_help_subcommand(true) - .override_usage("vp hooks [OPTIONS]") - .arg_required_else_help(true), - ) - .arg(help_arg()) +pub(super) fn spec() -> &'static usage_rs::spec::Spec<'static> { + HooksCliArgs::spec() } #[napi(object, object_from_js = false)] @@ -75,25 +83,19 @@ pub enum ParseHooksArgsOutcome { Error { error: CliParseError }, } -fn command_for_help(mut command: Command, argv: &[String]) -> Command { - command.build(); - if let Some(name) = argv.first() - && let Some(subcommand) = command.find_subcommand(name) - { - return subcommand.clone(); - } - command -} - #[napi] pub fn parse_hooks_args(argv: Vec) -> ParseHooksArgsOutcome { - let help_argv = argv.clone(); - match parse_args::(hooks_command(), argv) { + match parse_args::(&argv) { ParseResult::Ok(value) => ParseHooksArgsOutcome::Ok { value: value.into() }, ParseResult::Help(command) => { - let command = command_for_help(*command, &help_argv); - let is_top_level = command.get_name() == "vp hooks"; - let mut doc = help_doc_from_command(command, Some(DOCUMENTATION_URL.into())); + let is_top_level = std::ptr::eq(command, HooksCliArgs::command()); + let mut doc = help_doc_from_usage( + HooksCliArgs::spec(), + &argv, + command, + Some(DOCUMENTATION_URL.into()), + ) + .expect("help command must belong to the hooks parser"); if is_top_level { doc.sections.push(HelpSection::Rows { title: "Environment".into(), @@ -127,7 +129,8 @@ mod tests { use super::*; fn parse(argv: &[&str]) -> ParseResult { - parse_args(hooks_command(), argv.iter().map(|value| (*value).to_owned()).collect()) + let argv = argv.iter().map(|value| (*value).to_owned()).collect::>(); + parse_args(&argv) } fn parsed(argv: &[&str]) -> HooksArgs { diff --git a/packages/cli/binding/src/js_command_args/migrate.rs b/packages/cli/binding/src/js_command_args/migrate.rs index 1367ced085..1fcf031bb2 100644 --- a/packages/cli/binding/src/js_command_args/migrate.rs +++ b/packages/cli/binding/src/js_command_args/migrate.rs @@ -1,103 +1,101 @@ -use clap::{ArgAction, Args, Command}; use napi::bindgen_prelude::{Either, Either3}; use napi_derive::napi; -use vp_cli_help::{HelpSection, help_doc_from_command, print_help_doc}; +use usage_rs::Cli; +use vp_cli_help::{HelpSection, help_doc_from_usage, print_help_doc}; use super::parse::{ - CliParseError, ParseResult, agent_option, boolean_option, editor_option, help_arg, parse_args, + CliParseError, CliParser, ParseResult, agent_option, boolean_option, editor_option, parse_args, }; const DOCUMENTATION_URL: &str = "https://viteplus.dev/guide/migrate"; -#[derive(Debug, Args)] +#[derive(Debug, Cli)] +#[usage( + bin = "vp migrate", + about = "Migrate standalone Vite, Vitest, Oxlint, Oxfmt, and Prettier projects to unified Vite+.", + usage = "Usage: vp migrate [PATH] [OPTIONS]", + unknown_flags = "error", + args_override_self = false +)] struct MigrateCliArgs { - #[arg(value_name = "PATH", help = "Target directory to migrate (default: current directory)")] + #[usage( + value_name = "PATH", + help = "Target directory to migrate (default: current directory)" + )] path: Option, - #[arg( + #[usage( long, value_name = "NAME", - action = ArgAction::Append, - overrides_with = "no_agent", + overrides("no_agent"), help = "Write coding agent instructions to AGENTS.md, CLAUDE.md, etc." )] agent: Vec, - #[arg( + #[usage( long = "no-agent", - action = ArgAction::SetTrue, - overrides_with_all = ["agent", "no_agent"], + var, + overrides("agent"), help = "Skip writing coding agent instructions" )] no_agent: bool, - #[arg( + #[usage( long, value_name = "NAME", - action = ArgAction::Append, - overrides_with = "no_editor", + overrides("no_editor"), help = "Write editor config files into the project" )] editor: Vec, - #[arg( + #[usage( long = "no-editor", - action = ArgAction::SetTrue, - overrides_with_all = ["editor", "no_editor"], + var, + overrides("editor"), help = "Skip writing editor config files" )] no_editor: bool, - #[arg( + #[usage( long, - action = ArgAction::SetTrue, - overrides_with_all = ["hooks", "no_hooks"], + var, + overrides("no_hooks"), help = "Set up pre-commit hooks (default in non-interactive mode)" )] hooks: bool, - #[arg( - long = "no-hooks", - action = ArgAction::SetTrue, - overrides_with_all = ["hooks", "no_hooks"], - help = "Skip pre-commit hooks setup" - )] + #[usage(long = "no-hooks", var, overrides("hooks"), help = "Skip pre-commit hooks setup")] no_hooks: bool, - #[arg( - long, - action = ArgAction::SetTrue, - overrides_with_all = ["interactive", "no_interactive"], - help = "Enable interactive prompts" - )] + #[usage(long, var, overrides("no_interactive"), help = "Enable interactive prompts")] interactive: bool, - #[arg( + #[usage( long = "no-interactive", - action = ArgAction::SetTrue, - overrides_with_all = ["interactive", "no_interactive"], + var, + overrides("interactive"), help = "Run in non-interactive mode (skip prompts and use defaults)" )] no_interactive: bool, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Also run the full setup for an existing Vite+ project" - )] + #[usage(long, help = "Also run the full setup for an existing Vite+ project")] full: bool, } -fn migrate_command() -> Command { - MigrateCliArgs::augment_args( - Command::new("vp migrate") - .about( - "Migrate standalone Vite, Vitest, Oxlint, Oxfmt, and Prettier projects to unified Vite+.", - ) - .disable_help_flag(true) - .override_usage("vp migrate [PATH] [OPTIONS]"), - ) - .arg(help_arg()) +impl CliParser for MigrateCliArgs { + fn parse_from<'value>( + argv: &'value [&'value std::ffi::OsStr], + ) -> Result> { + Self::parse_from(argv) + } + + fn spec() -> &'static usage_rs::spec::Spec<'static> { + Self::spec() + } +} + +pub(super) fn spec() -> &'static usage_rs::spec::Spec<'static> { + MigrateCliArgs::spec() } #[napi(object, object_from_js = false)] @@ -134,10 +132,16 @@ pub enum ParseMigrateArgsOutcome { #[napi] pub fn parse_migrate_args(argv: Vec) -> ParseMigrateArgsOutcome { - match parse_args::(migrate_command(), argv) { + match parse_args::(&argv) { ParseResult::Ok(value) => ParseMigrateArgsOutcome::Ok { value: value.into() }, ParseResult::Help(command) => { - let mut doc = help_doc_from_command(*command, Some(DOCUMENTATION_URL.into())); + let mut doc = help_doc_from_usage( + MigrateCliArgs::spec(), + &argv, + command, + Some(DOCUMENTATION_URL.into()), + ) + .expect("help command must belong to the migrate parser"); doc.sections.push(HelpSection::Lines { title: "Examples".into(), lines: vec![ @@ -177,7 +181,8 @@ mod tests { use super::*; fn parse(argv: &[&str]) -> ParseResult { - parse_args(migrate_command(), argv.iter().map(|value| (*value).to_owned()).collect()) + let argv = argv.iter().map(|value| (*value).to_owned()).collect::>(); + parse_args(&argv) } fn parsed(argv: &[&str]) -> MigrateArgs { @@ -233,6 +238,24 @@ mod tests { assert_eq!(args.interactive, Some(false)); } + #[test] + fn accepts_repeated_boolean_overrides() { + let args = parsed(&[ + "--hooks", + "--hooks", + "--no-interactive", + "--no-interactive", + "--no-agent", + "--no-agent", + "--no-editor", + "--no-editor", + ]); + assert_eq!(args.hooks, Some(true)); + assert_eq!(args.interactive, Some(false)); + assert!(matches!(args.agent, Some(Either3::A(false)))); + assert!(matches!(args.editor, Some(Either::A(false)))); + } + #[test] fn rejects_invalid_arguments() { assert_eq!(parse_error(&["one", "two"]).kind, "unknown-argument"); diff --git a/packages/cli/binding/src/js_command_args/mod.rs b/packages/cli/binding/src/js_command_args/mod.rs index 65fe0b2433..e8f1b741e1 100644 --- a/packages/cli/binding/src/js_command_args/mod.rs +++ b/packages/cli/binding/src/js_command_args/mod.rs @@ -4,3 +4,7 @@ mod hooks; mod migrate; mod parse; mod staged; + +pub(crate) fn command_specs() -> [&'static usage_rs::spec::Spec<'static>; 5] { + [config::spec(), create::spec(), hooks::spec(), migrate::spec(), staged::spec()] +} diff --git a/packages/cli/binding/src/js_command_args/parse.rs b/packages/cli/binding/src/js_command_args/parse.rs index 7ff077cb94..1830d6e718 100644 --- a/packages/cli/binding/src/js_command_args/parse.rs +++ b/packages/cli/binding/src/js_command_args/parse.rs @@ -1,8 +1,8 @@ -use std::iter; +use std::ffi::OsStr; -use clap::{Arg, ArgAction, Args, Command, FromArgMatches, error::ErrorKind}; use napi::bindgen_prelude::{Either, Either3}; use napi_derive::napi; +use usage_rs::{Command, Error, spec::Spec}; #[napi(object, object_from_js = false)] pub struct CliParseError { @@ -12,7 +12,7 @@ pub struct CliParseError { pub(super) enum ParseResult { Ok(T), - Help(Box), + Help(&'static Command<'static>), Error(CliParseError), } @@ -35,34 +35,26 @@ impl ParseResult { } } -pub(super) fn parse_args(mut command: Command, argv: Vec) -> ParseResult -where - T: Args + FromArgMatches, -{ - let bin_name = command.get_name().to_owned(); - let parsed = command - .try_get_matches_from_mut(iter::once(bin_name).chain(argv)) - .and_then(|mut matches| T::from_arg_matches_mut(&mut matches)); +pub(super) trait CliParser: Sized { + fn parse_from<'value>(argv: &'value [&'value OsStr]) -> Result>; - match parsed { + fn spec() -> &'static Spec<'static>; +} + +pub(super) fn parse_args(argv: &[String]) -> ParseResult { + let argv = argv.iter().map(OsStr::new).collect::>(); + match T::parse_from(&argv) { Ok(value) => ParseResult::Ok(value), - Err(error) - if matches!( - error.kind(), - ErrorKind::DisplayHelp | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - ) => - { - ParseResult::Help(Box::new(command)) - } + Err(Error::Help { cmd, .. } | Error::MissingArgsHelp { cmd }) => ParseResult::Help(cmd), Err(error) => ParseResult::Error(CliParseError { - kind: error_kind_name(error.kind()).to_owned(), - message: error.to_string().trim_end().to_owned(), + kind: error_kind_name(&error).to_owned(), + message: render_error(T::spec(), &argv, &error), }), } } -pub(super) fn help_arg() -> Arg { - Arg::new("help").short('h').long("help").action(ArgAction::Help).help("Show this help message") +fn render_error(spec: &'static Spec<'static>, argv: &[&OsStr], error: &Error<'_, '_>) -> String { + usage_rs::render_failure_plain(spec, argv, error).trim_end().to_owned() } pub(super) fn agent_option( @@ -91,17 +83,18 @@ pub(super) fn boolean_option(enabled: bool, disabled: bool) -> Option { if disabled { Some(false) } else { enabled.then_some(true) } } -fn error_kind_name(kind: ErrorKind) -> &'static str { - match kind { - ErrorKind::InvalidValue - | ErrorKind::NoEquals - | ErrorKind::ValueValidation - | ErrorKind::TooManyValues - | ErrorKind::TooFewValues - | ErrorKind::WrongNumberOfValues => "invalid-value", - ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand => "unknown-argument", - ErrorKind::ArgumentConflict => "argument-conflict", - ErrorKind::MissingRequiredArgument | ErrorKind::MissingSubcommand => "missing-argument", +fn error_kind_name(error: &Error<'_, '_>) -> &'static str { + match error { + Error::InvalidChoice { .. } + | Error::InvalidValue(_) + | Error::MissingFlagValue { .. } + | Error::VarTooFew { .. } + | Error::VarTooMany { .. } => "invalid-value", + Error::ArgRequiresDoubleDash { .. } + | Error::UnknownFlag { .. } + | Error::UnexpectedArg { .. } => "unknown-argument", + Error::ConflictingFlags { .. } | Error::DuplicateFlag { .. } => "argument-conflict", + Error::MissingRequired { .. } | Error::MissingSubcommand => "missing-argument", _ => "invalid-arguments", } } diff --git a/packages/cli/binding/src/js_command_args/staged.rs b/packages/cli/binding/src/js_command_args/staged.rs index a7096b8574..d7047eaa0d 100644 --- a/packages/cli/binding/src/js_command_args/staged.rs +++ b/packages/cli/binding/src/js_command_args/staged.rs @@ -1,14 +1,34 @@ -use std::{num::NonZeroU32, str::FromStr}; +use std::{num::NonZeroU32, ops::Deref, str::FromStr}; -use clap::{ArgAction, Args, Command, builder::NonEmptyStringValueParser}; use napi::bindgen_prelude::Either; use napi_derive::napi; -use vp_cli_help::{help_doc_from_command, print_help_doc}; +use usage_rs::Cli; +use vp_cli_help::{help_doc_from_usage, print_help_doc}; -use super::parse::{CliParseError, ParseResult, help_arg, parse_args}; +use super::parse::{CliParseError, CliParser, ParseResult, parse_args}; const CONCURRENT_VALUE_ERROR: &str = "use true, false, or an integer from 1 through 4294967295"; const DOCUMENTATION_URL: &str = "https://viteplus.dev/guide/commit-hooks"; +const NON_EMPTY_VALUE_ERROR: &str = "a value cannot be empty"; + +#[derive(Debug)] +struct NonEmptyString(String); + +impl Deref for NonEmptyString { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromStr for NonEmptyString { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + if value.is_empty() { Err(NON_EMPTY_VALUE_ERROR) } else { Ok(Self(value.to_owned())) } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Concurrent { @@ -31,122 +51,90 @@ impl FromStr for Concurrent { } } -#[derive(Debug, Args)] +#[derive(Debug, Cli)] +#[usage( + bin = "vp staged", + about = "Run linters on staged files using staged config from vite.config.ts.", + usage = "Usage: vp staged [OPTIONS]", + unknown_flags = "error", + args_override_self = false +)] struct StagedCliArgs { - #[arg( - long, - action = ArgAction::SetTrue, - help = "Allow empty commits when tasks revert all staged changes" - )] + #[usage(long, help = "Allow empty commits when tasks revert all staged changes")] allow_empty: bool, - #[arg( + #[usage( short = 'p', long, value_name = "number|boolean", - num_args = 0..=1, - default_missing_value = "true", + default_missing = "true", allow_negative_numbers = true, - overrides_with = "no_concurrent", + overrides("no_concurrent"), help = "Run tasks at the same time. Use false to run one task at a time" )] concurrent: Option, - #[arg(long = "no-concurrent", overrides_with = "concurrent", help = "Run one task at a time")] + #[usage(long = "no-concurrent", overrides("concurrent"), help = "Run one task at a time")] no_concurrent: bool, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Run all tasks to completion even if one fails" - )] + #[usage(long, help = "Run all tasks to completion even if one fails")] continue_on_error: bool, - #[arg( - long, - value_name = "path", - value_parser = NonEmptyStringValueParser::new(), - help = "Working directory to run all tasks in" - )] - cwd: Option, + #[usage(long, value_name = "path", help = "Working directory to run all tasks in")] + cwd: Option, - #[arg(short = 'd', long, action = ArgAction::SetTrue, help = "Enable debug output")] + #[usage(short = 'd', long, help = "Enable debug output")] debug: bool, - #[arg( - long, - value_name = "string", - value_parser = NonEmptyStringValueParser::new(), - help = "Override the default --staged flag of git diff" - )] - diff: Option, + #[usage(long, value_name = "string", help = "Override the default --staged flag of git diff")] + diff: Option, - #[arg( + #[usage( long, value_name = "string", - value_parser = NonEmptyStringValueParser::new(), help = "Override the default --diff-filter=ACMR flag of git diff" )] - diff_filter: Option, + diff_filter: Option, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Fail with exit code 1 when tasks modify tracked files" - )] + #[usage(long, help = "Fail with exit code 1 when tasks modify tracked files")] fail_on_changes: bool, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Hide unstaged changes from partially staged files" - )] + #[usage(long, help = "Hide unstaged changes from partially staged files")] hide_partially_staged: bool, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Hide all unstaged changes before running tasks" - )] + #[usage(long, help = "Hide all unstaged changes before running tasks")] hide_unstaged: bool, - #[arg(long = "no-stash", help = "Disable the backup stash")] + #[usage(long = "no-stash", help = "Disable the backup stash")] no_stash: bool, - #[arg(short = 'q', long, action = ArgAction::SetTrue, help = "Disable console output")] + #[usage(short = 'q', long, help = "Disable console output")] quiet: bool, - #[arg( - short = 'r', - long, - action = ArgAction::SetTrue, - help = "Pass filepaths relative to cwd to tasks" - )] + #[usage(short = 'r', long, help = "Pass filepaths relative to cwd to tasks")] relative: bool, - #[arg( - long, - action = ArgAction::SetTrue, - help = "Revert to original state in case of errors" - )] + #[usage(long, help = "Revert to original state in case of errors")] revert: bool, - #[arg( - short = 'v', - long, - action = ArgAction::SetTrue, - help = "Show task output even when tasks succeed" - )] + #[usage(short = 'v', long, help = "Show task output even when tasks succeed")] verbose: bool, } -fn staged_command() -> Command { - StagedCliArgs::augment_args( - Command::new("vp staged") - .about("Run linters on staged files using staged config from vite.config.ts.") - .disable_help_flag(true), - ) - .arg(help_arg()) +pub(super) fn spec() -> &'static usage_rs::spec::Spec<'static> { + StagedCliArgs::spec() +} + +impl CliParser for StagedCliArgs { + fn parse_from<'value>( + argv: &'value [&'value std::ffi::OsStr], + ) -> Result> { + Self::parse_from(argv) + } + + fn spec() -> &'static usage_rs::spec::Spec<'static> { + Self::spec() + } } #[napi(object, object_from_js = false)] @@ -184,10 +172,10 @@ impl From for StagedArgs { allow_empty: value.allow_empty.then_some(true), concurrent, continue_on_error: value.continue_on_error.then_some(true), - cwd: value.cwd, + cwd: value.cwd.map(|value| value.0), debug: value.debug.then_some(true), - diff: value.diff, - diff_filter: value.diff_filter, + diff: value.diff.map(|value| value.0), + diff_filter: value.diff_filter.map(|value| value.0), fail_on_changes: value.fail_on_changes.then_some(true), hide_partially_staged: value.hide_partially_staged.then_some(true), hide_unstaged: value.hide_unstaged.then_some(true), @@ -209,10 +197,16 @@ pub enum ParseStagedArgsOutcome { #[napi] pub fn parse_staged_args(argv: Vec) -> ParseStagedArgsOutcome { - match parse_args::(staged_command(), argv) { + match parse_args::(&argv) { ParseResult::Ok(value) => ParseStagedArgsOutcome::Ok { value: value.into() }, ParseResult::Help(command) => { - let doc = help_doc_from_command(*command, Some(DOCUMENTATION_URL.into())); + let doc = help_doc_from_usage( + StagedCliArgs::spec(), + &argv, + command, + Some(DOCUMENTATION_URL.into()), + ) + .expect("help command must belong to the staged parser"); print_help_doc(&doc); ParseStagedArgsOutcome::Exit { code: 0 } } @@ -225,7 +219,8 @@ mod tests { use super::*; fn parse(argv: &[&str]) -> ParseResult { - parse_args(staged_command(), argv.iter().map(|value| (*value).to_owned()).collect()) + let argv = argv.iter().map(|value| (*value).to_owned()).collect::>(); + parse_args(&argv) } fn parsed(argv: &[&str]) -> StagedCliArgs { diff --git a/rfcs/usage-rs-local-cli.md b/rfcs/usage-rs-local-cli.md new file mode 100644 index 0000000000..469efadfea --- /dev/null +++ b/rfcs/usage-rs-local-cli.md @@ -0,0 +1,563 @@ +# RFC: Use usage-rs for the Local Rust CLI + +Use [`usage-rs`](https://usage.jdx.dev/rust/) for argument parsing in the local +Vite+ CLI and in Vite Task. Remove `clap` from the NAPI binary dependency graph. + +Keep `clap` in the separate global `vp` binary. The global binary must continue +to select a local Vite+ package and forward its arguments without parsing the +local command grammar. + +This RFC replaces the parser-library decision in +[`napi-clap-cli-args.md`](./napi-clap-cli-args.md). It does not change that RFC's +command ownership or NAPI result types. + +## Motivation + +PR #2523 moves five JavaScript command grammars from `mri` to Rust `clap`. This +gives Vite+ strict parsing and one source for parser data and help data. It also +adds `clap` to the NAPI binary. + +PR #2534 tests `usage-rs` with the same architecture. `usage-rs` generates +static parser tables at compile time. A successful parse does not construct a +parser graph at run time. + +The first local measurements show this result for the `staged` parser: + +| Parser | Successful parse | +| ------------------------ | ---------------: | +| `mri` on the main design | 1.20 us | +| `clap` in PR #2523 | 6.36 us | +| `usage-rs` in PR #2534 | 1.02 us | + +The full CLI takes approximately 130 to 154 ms in the same test. Thus, the +parser improvement does not make process startup measurably faster. + +The first `usage-rs` change did not reduce the uncompressed NAPI binary. It was +880 bytes larger than PR #2523. It was 8,074 bytes smaller after `gzip -9`. +`clap` stayed in that dependency graph for these reasons: + +- the error path builds a `clap` command to keep the old diagnostics; +- the NAPI command router uses `clap` types; +- `vp_pm_cli` uses `clap` types; +- Vite Task exports `clap` parser types; +- `vt_workspace::PackageQueryArgs` derives `clap::Args`. + +A partial parser change cannot test the main size hypothesis. Vite+ must remove +`clap` from the complete NAPI dependency graph before it compares the result. + +The complete implementation removes that dependency path. A paired macOS ARM64 +build is 94,832 bytes smaller than PR #2523 before compression and 74,392 bytes +smaller after `gzip -9`. Thus, the complete implementation passes the native +size gate. + +## Goals + +1. Use `usage-rs` for all argument parsing in the local NAPI binary. +2. Remove `clap` from the normal dependency graph of `vite-plus-cli`. +3. Use one typed Rust grammar for parsing, help, diagnostics, and completion. +4. Preserve the strict argument rules from PR #2523. +5. Preserve the NAPI boundary and the JavaScript command behavior. +6. Add direct Rust tests for command grammars, help, errors, and completion. +7. Measure parser speed, full CLI speed, and all distributed artifact sizes. +8. Keep the selected local Vite+ package responsible for its command grammar. + +## Non-goals + +This RFC does not: + +- move JavaScript command work to Rust; +- make the global `vp` binary parse local command options; +- replace `clap` in the global `vp` binary or installer; +- change command names, option names, aliases, defaults, or exit codes; +- install shell completion without an explicit user command; +- require exact diagnostic text from `clap`; +- adopt `usage-rs` if the complete result does not improve the size and + maintenance trade-off. + +## Decision + +Use a reviewed `usage-rs` revision based on 6.1.0 for this change. Pin the exact +revision in Vite+ and Vite Task. Both repositories must use the same revision. + +`usage-rs` is still experimental. A later update must be an explicit dependency +change with parser, help, completion, performance, and size checks. + +Use these features only where they are necessary: + +| Feature | Consumer | Purpose | +| ------------------ | ----------------------------------------------------- | ------------------------------------------------------- | +| `spec` | production parser crates | Read static command metadata for help | +| `help` | standalone CLI entry points | Render built-in help and version output | +| `diagnostics` | NAPI parser and `vt` binary | Render user-facing parse errors | +| `completions` | global and local completion providers and `vt` binary | Parse requests, generate scripts, and return candidates | +| `clap-coexistence` | shared `vp_pm_cli` declarations during migration | Let the same type use gated clap and usage derives | +| `test` | development dependencies only | Test parse outcomes, help, and completion | + +Do not enable all default features without checking their binary-size cost. + +The reviewed usage-rs revision also stores common flag metadata separately from +rare value, help, and relationship metadata. It uses small cold-path sorts for +help and diagnostics. These changes reduce generated code and static table cost. +They do not change the parser result. + +## Ownership + +| Layer | Responsibility | +| ------------------- | ---------------------------------------------------------------------------------- | +| Global `vp` binary | Select the local package, merge completion candidates, and forward local arguments | +| Local Node.js CLI | Apply `-C` and `vpr`, select the local command, and call NAPI | +| NAPI command router | Parse local Rust commands and return typed results | +| `js_command_args` | Parse the five JavaScript command grammars | +| `vp_cli_help` | Convert static metadata to the shared Vite+ help document | +| `vp_pm_cli` | Parse package-manager command arguments | +| Vite Task | Parse `run` and `cache` arguments and provide task completion | +| JavaScript commands | Apply defaults and run JavaScript operations | + +## Architecture + +The command path stays the same: + +```text +Node.js local CLI + -> raw argv + -> NAPI parser + -> usage-rs static typed parser + -> typed NAPI result + -> JavaScript or Rust command operation +``` + +The local dependency path changes: + +```text +vite-plus-cli NAPI binary + +-> js_command_args -> usage-rs + +-> local command router -> usage-rs + +-> vp_pm_cli -> usage-rs + +-> vt -> usage-rs + +-> vt_workspace -> parser-neutral package query data + +-> vp_cli_help -> usage-rs spec + +clap: no normal dependency path +``` + +The global path stays separate: + +```text +global vp binary + -> clap parses global options + -> select local vite-plus package + -> forward raw local argv + -> local Node.js and NAPI parser +``` + +This split prevents a new global binary from rejecting options that belong to +an older local package, or the reverse. + +## Strict parser contract + +Each root `usage-rs` grammar must set these policies: + +```rust +#[usage( + unknown_flags = "error", + args_override_self = false +)] +``` + +`usage-rs` accepts unknown flags and repeated scalar flags by default. These +attributes are necessary to preserve the strict contract from PR #2523. + +Keep these rules: + +- reject unknown options; +- reject missing values; +- reject invalid positional arguments; +- reject repeated scalar options unless the command defines an override; +- preserve all values after `--` where the command forwards them; +- keep documented short and long aliases; +- accept only explicit negative flags; +- let the last positive or negative Boolean form take effect where specified; +- reject empty values when the previous JavaScript parser rejected them. + +Use `FromStr`, `ValueEnum`, `choices`, and validation functions for typed values. +Do not parse a value again in JavaScript. + +## Diagnostics + +Remove the cold `clap` diagnostic adapter from +`packages/cli/binding/src/js_command_args/parse.rs`. + +Use `usage-rs` diagnostics and `render_failure` for all structural and value +errors. Convert the structured error to the existing `CliParseError` kind. Keep +the existing NAPI result union: + +```text +ok -> typed command value +exit -> help or another successful early exit +error -> error kind and rendered diagnostic +``` + +Rust prints help. JavaScript keeps process lifetime and error exit ownership. +The Vite+ header and final exit codes must not change. + +Diagnostic wording can change from `clap` wording. Snapshot changes must be +reviewed. Tests must assert the error category, the invalid token, the relevant +option, and the help hint. They must not depend on unrelated punctuation. + +## Help + +Keep the shared `vp_cli_help` renderer. Build its document from the +`usage-rs` static `Spec` and `CommandMeta` values. + +One grammar must define these values: + +- option and subcommand names; +- aliases; +- value names; +- short and long help; +- groups and headings; +- choices and defaults; +- visibility; +- command usage. + +Do not keep a second help-only option table. A command can still add examples +and documentation links because those values do not change the grammar. + +The help output must still use a stacked layout when an option label consumes +the available terminal width. + +## Completion + +Enable the `completions` feature and add `#[usage(completion)]` to the local root +grammar. The completion implementation must use the same static parser tables +as normal parsing. + +When an embedded caller uses `parse_from`, it must call `completion_request` +before normal parsing. The hidden `__complete_word__` request is a protocol +message, not a user command. + +The completion call graph is: + +```text +shell completion script + -> global vp completion request parser + +-> clap_complete candidates for global-only options + +-> select the same local vite-plus package as command execution + -> local Node.js completion entry + -> NAPI completion request + -> usage-rs completion engine + -> neutral tab-delimited candidates and file markers + -> merge and de-duplicate candidates + -> usage-rs shell renderer + -> shell +``` + +Keep the current global completion setup command and startup-file behavior. The +global binary can keep `clap_complete` for global-only options. It must delegate +local command candidates to the selected local package. + +Generate scripts for Bash, Zsh, Fish, Nu, and PowerShell. Use +`completion_script_for_alias` for `vpr`, or provide an equivalent alias view +that inserts the `run` command before completion. + +The generated POSIX startup file must not evaluate the Bash completion script +when Bash runs in POSIX mode as `sh`. Bash process substitution is not valid in +that mode. + +Use static completion for command names, flags, aliases, and value choices. Use +custom completers for data that is available only at run time: + +- configured Vite Task names; +- package names and package filters; +- file or directory values when the grammar has a path hint. + +Completion must not run a task or change a project. A dynamic completer can read +the same configuration that the current `run_tasks_completions` function reads. + +## Vite Task changes + +Vite Task must replace `clap` in its production CLI crates before Vite+ updates +the pinned git revision. + +The draft implementation is [Vite Task PR #694](https://github.com/voidzero-dev/vite-task/pull/694). + +The upstream change must: + +1. derive `Cli`, `Args`, `Subcommands`, and `ValueEnum` on the Vite Task parser + types; +2. parse the standalone `vt` binary with `usage-rs`; +3. parse intercepted `vt`, `vp`, and `vpr` commands with the same grammar; +4. keep `RunCommand`, `RunFlags`, `CacheSubcommand`, and `LogMode` as typed public + API values; +5. keep the rule that arguments after a task name pass to the task unchanged; +6. keep `--cache` and `--no-cache` mutually exclusive; +7. expose completion for `run`, `cache`, log modes, task names, and package + selectors; +8. remove production `clap` dependencies from `vt` and `vt_workspace`; +9. migrate the `vt_plan` snapshot-test argument parser, or keep `clap` only as a + test dependency with a documented reason. + +`PackageQueryArgs` is domain input as well as parser input. Its fields must not +force downstream crates to depend on one parser framework. Prefer a public +constructor or a parser-neutral input type. The parser can convert its parsed +values to that type. + +The upstream PR must land before Vite+ points its git dependencies at the new +revision. During review, PR #2534 can use the pushed upstream commit by exact +revision. + +## Vite+ command router and package manager + +Replace the remaining NAPI `clap` roots in `binding/src/cli/` and +`binding/src/exec/args.rs` with `usage-rs` types. + +`vp_pm_cli` is shared by the global and local binaries. Keep its command action +types parser-neutral. Put parser-specific derives and entry points behind +features if the two artifacts need different parsers: + +```text +vp_pm_cli + +-> clap-parser feature -> global vp binary + +-> usage-parser feature -> local NAPI binary +``` + +Do not enable both parser features in the NAPI artifact. Cargo unifies features +for one build graph. Artifact checks must use the same package and feature set +as the distributed NAPI build. + +## Unit tests + +Use `usage-rs` with the `test` feature in development dependencies. Prefer its +process-free test helpers for one grammar. A composite completion provider can +test `Request`, `complete`, and `render` directly because it merges more than +one grammar. + +Each parser module must directly test: + +- successful values; +- each documented short option; +- each documented long option; +- aliases; +- unknown options; +- missing and empty values; +- repeated scalar values; +- allowed repeated values; +- positive and negative Boolean order; +- `--` forwarding; +- help and missing-subcommand outcomes; +- error kinds and stable diagnostic content. + +Use `usage::test::parse` or `usage::test::outcome` for parser tests. Use help-page +and `help_tree` snapshots for metadata and help coverage. + +Completion tests must cover: + +- root command and subcommand candidates; +- short and long options; +- value choices; +- a partial task name; +- a package-filter value; +- `vpr` alias behavior; +- tokens before and after `--`; +- all supported shells at script-generation level. + +During migration, run one table of representative argument vectors through the +old `clap` parser and the new `usage-rs` parser. Compare the typed values or the +error category. Remove this dual-parser test when `clap` leaves the production +and test dependency graphs. + +Keep the NAPI contract tests and PTY snapshots. These tests cover the language +boundary, output streams, terminal width, exit codes, and selected local-version +behavior. + +Vite Task must test its parser without starting a process. It must also keep its +existing end-to-end snapshots for task argument forwarding and cache commands. + +## Dependency checks + +Add or document these checks for the distributed NAPI configuration: + +```bash +cargo tree -p vite-plus-cli -i clap --edges normal +cargo tree -p vite-plus-cli -i usage-rs --edges normal +``` + +The first command must report that `clap` does not match a package in the normal +dependency graph. The second command must show only expected parser and help +consumers. + +In Vite Task, check the production crates separately from test-only dependencies. +No production path from `vt` or `vt_workspace` can require `clap`. + +## Performance and size checks + +The parser benchmarks ran on macOS ARM64 with Node.js 22.22.0. The successful +case used 100,000 warm-up calls and ten batches of 200,000 calls. The error, +help, and completion cases used at least 20,000 warm-up calls and ten batches of +50,000 calls. The full CLI cases used 25 separate processes after four warm-up +runs. + +The successful parser input was +`--allow-empty --concurrent=2 --diff-filter ACMR --no-stash`. Help generation +includes parsing `--help` and building the shared help document. It does not +include terminal output. Static completion includes request parsing, candidate +selection, and rendering. + +| Metric | main / `mri` | PR #2523 / `clap` | complete `usage-rs` change | +| ------------------------------- | -------------: | ----------------: | -------------------------: | +| Successful parser call | 1.20 us | 6.36 us | 1.007 us | +| Unknown option | Not comparable | 4.42 us | 2.158 us | +| Parse and build staged help | Not available | 21.572 us | 1.977 us | +| Static staged completion | Not available | Not available | 1.571 us | +| `vp --version` | 130.76 ms | 130.38 ms | 123.8 ms | +| `vp staged --help` | 140.27 ms | 132.42 ms | 124.4 ms | +| `vp staged --unknown` | Not comparable | Not measured | 125.1 ms | +| Static completion process | Not available | Not available | 122.9 ms | +| Dynamic task completion process | Not available | Not available | 125.3 ms | + +The complete usage-rs parser is 84% faster than the Clap parser for successful +input. It is 51% faster for an unknown option. Parse and help-document creation +is 10.9 times faster. The complete CLI stays within process-startup noise. + +The final CLI measurements ran in a later local session than the earlier paired +`mri` and Clap measurements. Use them to confirm the startup-scale result, not +as a claim that this change saves 6 to 17 ms. + +The native size gate used paired release builds with the same source checkout, +toolchain, target, profile, and machine: + +| macOS ARM64 NAPI library | PR #2523 / `clap` | Complete `usage-rs` | Change | +| ------------------------ | ----------------: | ------------------: | ---------: | +| Binary | 41,869,648 B | 41,774,816 B | -94,832 B | +| `gzip -9` | 17,221,832 B | 17,147,440 B | -74,392 B | +| Mach-O `__text` | 19,438,000 B | 19,207,460 B | -230,540 B | + +The raw binary is 0.23% smaller. The compressed binary is 0.43% smaller. The +machine code is 1.19% smaller. This result passes the directional size gate, +but it is not a material package-size improvement. Binary size alone does not +justify the migration. + +The package JavaScript does not change after PR #2523. The canonical Linux +artifact workflow for that PR supplies the exact dist totals. The complete +usage-rs change keeps those files unchanged: + +| Distributed JavaScript | main / `mri` | PR #2523 / `clap` | Complete `usage-rs` | +| --------------------------------------- | -----------: | ----------------: | ------------------: | +| `packages/cli/dist` | 1,685,719 B | 1,666,423 B | 1,666,423 B | +| `packages/core/dist`, excluding `.node` | 4,097,460 B | 4,097,460 B | 4,097,460 B | +| Combined dist | 5,783,179 B | 5,763,883 B | 5,763,883 B | + +The complete artifact is smaller than PR #2523, and all compatibility tests +pass. Therefore, the result meets the directional adoption condition. The main +reasons to adopt this change are the unified grammar, completion support, and +lower parser cost. + +## Migration order + +1. Add this RFC to PR #2534. +2. Review [Vite Task PR #694](https://github.com/voidzero-dev/vite-task/pull/694), + which replaces its production `clap` parsers. +3. Add Vite Task parser, help, completion, and equivalence tests. +4. Point PR #2534 at the pushed Vite Task commit. +5. Migrate the local Vite+ command router and `vp_pm_cli` usage path. +6. Remove the cold `clap` diagnostic adapter and direct NAPI dependency. +7. Run dependency, unit, NAPI, snapshot, completion, performance, and size checks. +8. Update the PR #2534 description with the complete results. + +Each step must keep typed command operations separate from parser-framework +types. A temporary commit can contain both parsers for equivalence tests. The +final NAPI build cannot contain both. + +## Risks + +### Experimental dependency + +`usage-rs` can change while its API develops. Exact revision pins and focused +upgrade checks limit this risk. + +The compact metadata work changes the public `FlagMeta` struct layout while it +keeps field reads compatible through `Deref`. External struct literals are not +source-compatible. The upstream project must either publish this layout in a +breaking release or provide a compatible construction API before Vite+ updates +to a published crate. Vite+ and Vite Task must pin the reviewed revision until +that decision is complete. + +### Diagnostic differences + +The native renderer does not produce byte-for-byte `clap` messages. Stable +semantic assertions and reviewed PTY snapshots protect the user contract. + +### Argument forwarding + +Vite Task forwards all tokens after the task name. A parser can accidentally +consume a task option such as `-v`. Direct parser tests and end-to-end task +snapshots must cover this boundary. + +### Completion version skew + +The global binary can differ from the selected local package. Delegating local +completion to that package keeps its candidates consistent with its parser. + +### Cargo feature unification + +Shared crates can enable both parser features in one graph. Package-specific +artifact builds and `cargo tree` checks must verify the actual distributed +binary. + +### Size does not improve + +Diagnostics and completion add code. The complete binary can stay the same size +or grow after `clap` is removed. In that case, keep PR #2523 as the baseline and +do not adopt this RFC without a new trade-off decision. + +## Alternatives + +### Keep PR #2523 + +This is the fallback. `clap` is mature, complete, and already used by the global +binary. It has a larger parser-only cost in the current measurement. + +### Keep the partial adapter in PR #2534 + +This gives fast successful parses, but it keeps two parser libraries in the NAPI +graph. It does not test the expected size benefit and has extra adapter code. + +### Write a custom parser + +A custom parser can be small and fast. Vite+ would then own strict parsing, +diagnostics, help metadata, completion metadata, and all edge cases. That cost is +larger than the current command-parser scope. + +### Generate parsers from a neutral schema + +A neutral schema can feed both `clap` and `usage-rs`. It adds a generator and a +new schema language. `usage-rs` already exposes static metadata from typed Rust +definitions, so a second schema is not necessary for this experiment. + +## Acceptance criteria + +The implementation is complete when: + +- all local command grammars use `usage-rs`; +- Vite Task production CLI crates use `usage-rs`; +- `clap` has no normal path to the distributed NAPI binary; +- the global `vp` binary still forwards local arguments without parsing them; +- help, diagnostics, aliases, strict parsing, negation, repetition, and `--` + behavior pass focused and PTY tests; +- static and dynamic completion tests pass; +- Vite Task task-argument forwarding tests pass on Unix and Windows; +- the PR reports parser, CLI, NAPI, gzip, and dist measurements; +- the complete artifact result is better than the PR #2523 baseline; +- PR #2534 links the Vite Task PR and pins a reviewed upstream commit. + +## References + +- [`usage-rs` Rust quickstart](https://usage.jdx.dev/rust/quickstart) +- [`usage-rs` migration from clap](https://usage.jdx.dev/rust/migrating-from-clap) +- [`usage-rs` help and diagnostics](https://usage.jdx.dev/rust/help) +- [`usage-rs` completion](https://usage.jdx.dev/rust/completions) +- [`usage-rs` testing](https://usage.jdx.dev/rust/testing) +- [`usage-rs` validation](https://usage.jdx.dev/rust/validation) +- [`usage-rs` parser specification](https://usage.jdx.dev/rust/spec) +- [`usage-rs` performance notes](https://usage.jdx.dev/rust/performance) +- [RFC: Use clap to Parse Arguments for JavaScript Commands](./napi-clap-cli-args.md)