Skip to content
Closed
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
225 changes: 146 additions & 79 deletions Cargo.lock

Large diffs are not rendered by default.

25 changes: 14 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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" }
Expand All @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion crates/vp_cli_help/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
227 changes: 225 additions & 2 deletions crates/vp_cli_help/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = " ";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Cow<'static, str>>,
) -> Option<HelpDoc> {
let argv = argv.iter().map(std::ffi::OsStr::new).collect::<Vec<_>>();
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::<Vec<_>>()
.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<HelpSection>,
default_title: &str,
Expand All @@ -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()) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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<String>,
#[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::<Vec<_>>();
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "completion-fixture",
"private": true,
"scripts": {
"build": "vpt print build",
"check": "vpt print check"
}
}
Original file line number Diff line number Diff line change
@@ -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" },
]
Loading
Loading