Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions crates/vite_global_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1644,7 +1644,11 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command {
}

/// Parse CLI arguments from a custom args iterator with custom help formatting.
pub fn parse_args_from(args: impl IntoIterator<Item = String>) -> Args {
/// Returns `Err` with the clap error if parsing fails (e.g., unknown command).
pub fn try_parse_args_from(
args: impl IntoIterator<Item = String>,
) -> Result<Args, clap::error::Error> {
let cmd = apply_custom_help(Args::command());
Args::from_arg_matches(&cmd.get_matches_from(args)).expect("Failed to parse CLI arguments")
let matches = cmd.try_get_matches_from(args)?;
Args::from_arg_matches(&matches).map_err(|e| e.into())
}
63 changes: 49 additions & 14 deletions crates/vite_global_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ mod commands;
mod error;
mod js_executor;
mod shim;
mod tips;

use std::process::ExitCode;

use crate::cli::{parse_args_from, run_command};
use owo_colors::OwoColorize;

use crate::cli::run_command;
pub use crate::cli::try_parse_args_from;

/// Normalize CLI arguments:
/// - `vp list ...` / `vp ls ...` → `vp pm list ...`
Expand Down Expand Up @@ -73,29 +77,60 @@ async fn main() -> ExitCode {
}
};

let mut tip_context = tips::TipContext {
// Capture user args (excluding argv0) before normalization
raw_args: args[1..].to_vec(),
..Default::default()
};

// Normalize arguments (list/ls aliases, help rewriting)
let normalized_args = normalize_args(args);

// Parse CLI arguments (using custom help formatting)
let args = parse_args_from(normalized_args);
let exit_code = match try_parse_args_from(normalized_args) {
Err(e) => {
use clap::error::ErrorKind;
// Print the clap error/help/version
e.print().ok();

match run_command(cwd, args).await {
Ok(exit_status) => {
if exit_status.success() {
// --help and --version are "errors" in clap but should exit successfully
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
ExitCode::SUCCESS
} else {
// Exit codes are typically 0-255 on Unix systems
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8))
let code = e.exit_code();
tip_context.clap_error = Some(e);
#[allow(clippy::cast_sign_loss)]
ExitCode::from(code as u8)
}
}
Err(e) => {
if matches!(&e, error::Error::UserMessage(_)) {
eprintln!("{e}");
} else {
eprintln!("Error: {e}");
Ok(args) => {
match run_command(cwd.clone(), args).await {
Ok(exit_status) => {
if exit_status.success() {
ExitCode::SUCCESS
} else {
// Exit codes are typically 0-255 on Unix systems
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8))
}
}
Err(e) => {
if matches!(&e, error::Error::UserMessage(_)) {
eprintln!("{e}");
} else {
eprintln!("Error: {e}");
}
ExitCode::FAILURE
}
}
ExitCode::FAILURE
}
};

tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 };

if let Some(tip) = tips::get_tip(&tip_context) {
eprintln!("\n{}", format!("Tip: {tip}").bright_black());
}

exit_code
}
121 changes: 121 additions & 0 deletions crates/vite_global_cli/src/tips/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! CLI tips system for providing helpful suggestions to users.
//!
//! Tips are shown after command execution to help users discover features
//! and shortcuts.

mod short_aliases;
mod use_vpx_or_run;

use clap::error::ErrorKind as ClapErrorKind;

use self::{short_aliases::ShortAliases, use_vpx_or_run::UseVpxOrRun};

/// Execution context passed in from the CLI entry point.
pub struct TipContext {
/// CLI arguments as typed by the user, excluding the program name (`vp`).
pub raw_args: Vec<String>,
/// The exit code of the command (0 = success, non-zero = failure).
pub exit_code: i32,
/// The clap error if parsing failed.
pub clap_error: Option<clap::Error>,
}

impl Default for TipContext {
fn default() -> Self {
TipContext { raw_args: Vec::new(), exit_code: 0, clap_error: None }
}
}

impl TipContext {
/// Whether the command completed successfully.
#[expect(dead_code)]
pub fn success(&self) -> bool {
self.exit_code == 0
}

#[expect(dead_code)]
pub fn is_unknown_command_error(&self) -> bool {
if let Some(err) = &self.clap_error {
matches!(err.kind(), ClapErrorKind::InvalidSubcommand)
} else {
false
}
}

/// Iterate positional args (skipping flags starting with `-`).
fn positionals(&self) -> impl Iterator<Item = &str> {
self.raw_args.iter().map(String::as_str).filter(|a| !a.starts_with('-'))
}

/// The subcommand (first positional arg, e.g., "ls", "build").
pub fn subcommand(&self) -> Option<&str> {
self.positionals().next()
}

/// Whether the positional args start with the given command pattern.
/// Pattern is space-separated: "pm list" matches even if flags are interspersed.
#[expect(dead_code)]
pub fn is_subcommand(&self, pattern: &str) -> bool {
let mut positionals = self.positionals();
pattern.split_whitespace().all(|expected| positionals.next() == Some(expected))
}
}

/// A tip that can be shown to the user after command execution.
pub trait Tip {
/// Whether this tip is relevant given the current execution context.
fn matches(&self, ctx: &TipContext) -> bool;
/// The tip text shown to the user.
fn message(&self) -> &'static str;
}

/// Returns all registered tips.
fn all() -> &'static [&'static dyn Tip] {
&[&ShortAliases, &UseVpxOrRun]
}

/// Pick a random tip from those matching the current context.
///
/// Returns `None` if:
/// - The `VITE_PLUS_CLI_TEST` env var is set (test mode)
/// - No tips match the given context
pub fn get_tip(context: &TipContext) -> Option<&'static str> {
if std::env::var_os("VITE_PLUS_CLI_TEST").is_some() {
return None;
}

let now =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();

let all = all();
let matching: Vec<&&dyn Tip> = all.iter().filter(|t| t.matches(context)).collect();

if matching.is_empty() {
return None;
}

// Use subsec_nanos for random tip selection
let nanos = now.subsec_nanos() as usize;
Some(matching[nanos % matching.len()].message())
}

/// Create a `TipContext` from a command string using real clap parsing.
///
/// `command` is exactly what the user types in the terminal (e.g. `"vp list --flag"`).
/// The first arg is treated as the program name and excluded from `raw_args`,
/// matching how the real CLI uses `std::env::args()`.
#[cfg(test)]
pub fn tip_context_from_command(command: &str) -> TipContext {
// Split simulates what the OS does with command line args
let args: Vec<String> = command.split_whitespace().map(String::from).collect();

let (exit_code, clap_error) = match crate::try_parse_args_from(args.iter().cloned()) {
Ok(_) => (0, None),
Err(e) => (e.exit_code(), Some(e)),
};

// raw_args excludes program name (args[0]), same as real CLI: args[1..].to_vec()
let raw_args = args.get(1..).map(<[String]>::to_vec).unwrap_or_default();

TipContext { raw_args, exit_code, clap_error }
}
63 changes: 63 additions & 0 deletions crates/vite_global_cli/src/tips/short_aliases.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Tip suggesting short aliases for long-form commands.

use super::{Tip, TipContext};

/// Long-form commands that have short aliases.
const LONG_FORMS: &[&str] = &["install", "remove", "uninstall", "update", "list", "link"];

/// Suggest short aliases when user runs a long-form command.
pub struct ShortAliases;

impl Tip for ShortAliases {
fn matches(&self, ctx: &TipContext) -> bool {
ctx.subcommand().is_some_and(|cmd| LONG_FORMS.contains(&cmd))
}
Comment thread
hyf0 marked this conversation as resolved.

fn message(&self) -> &'static str {
"Available short aliases: i = install, rm = remove, un = uninstall, up = update, ls = list, ln = link"
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::tips::tip_context_from_command;

#[test]
fn matches_long_form_commands() {
for cmd in LONG_FORMS {
let ctx = tip_context_from_command(&format!("vp {cmd}"));
assert!(ShortAliases.matches(&ctx), "should match {cmd}");
}
}

#[test]
fn does_not_match_short_form_commands() {
let short_forms = ["i", "rm", "un", "up", "ln"];
for cmd in short_forms {
let ctx = tip_context_from_command(&format!("vp {cmd}"));
assert!(!ShortAliases.matches(&ctx), "should not match {cmd}");
}
}

#[test]
fn does_not_match_other_commands() {
let other_commands = ["build", "test", "lint", "run", "pack"];
for cmd in other_commands {
let ctx = tip_context_from_command(&format!("vp {cmd}"));
assert!(!ShortAliases.matches(&ctx), "should not match {cmd}");
}
}

#[test]
fn install_shows_short_alias_tip() {
let ctx = tip_context_from_command("vp install");
assert!(ShortAliases.matches(&ctx));
}

#[test]
fn short_form_does_not_show_tip() {
let ctx = tip_context_from_command("vp i");
assert!(!ShortAliases.matches(&ctx));
}
}
39 changes: 39 additions & 0 deletions crates/vite_global_cli/src/tips/use_vpx_or_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Tip suggesting vpx or vp run for unknown commands.

use super::{Tip, TipContext};

/// Suggest `vpx <bin>` or `vp run <script>` when an unknown command is used.
pub struct UseVpxOrRun;

impl Tip for UseVpxOrRun {
fn matches(&self, _ctx: &TipContext) -> bool {
// TODO: Enable when `vpx` is supported
// ctx.is_unknown_command_error()
false
Comment thread
hyf0 marked this conversation as resolved.
}

fn message(&self) -> &'static str {
"Run a local binary with `vpx <bin>`, or a script with `vp run <script>`"
}
}

// TODO: Re-enable tests when `vpx` is supported
// #[cfg(test)]
// mod tests {
// use super::*;
// use crate::tips::tip_context_from_command;
//
// #[test]
// fn matches_on_unknown_command() {
// let ctx = tip_context_from_command("vp typecheck");
// assert!(UseVpxOrRun.matches(&ctx));
// assert!(ctx.is_unknown_command_error());
// }
//
// #[test]
// fn does_not_match_on_known_command() {
// let ctx = tip_context_from_command("vp build");
// assert!(!UseVpxOrRun.matches(&ctx));
// assert!(!ctx.is_unknown_command_error());
// }
// }
1 change: 1 addition & 0 deletions packages/tools/src/snap-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string) {
const env: Record<string, string> = {
...passThroughEnvs,
// Indicate CLI is running in test mode, so that it prints more detailed outputs.
// Also disables tips for stable snapshots.
VITE_PLUS_CLI_TEST: '1',
NO_COLOR: 'true',
// set CI=true make sure snap-tests are stable on GitHub Actions
Expand Down
Loading
Loading