Generate CLI reference for documentation (#5685)

Loosely based on [Cargo's
format](https://github.com/rust-lang/cargo/blob/master/src/doc/src/commands/cargo-build.md)

<img width="896" alt="Screenshot 2024-08-01 at 9 44 03 AM"
src="https://github.com/user-attachments/assets/7c016bb3-2b54-46af-8ea8-ce82e07a0e30">

Future work includes:

- Grouping options
- Enforcing some sort of specific command ordering
- Showing possible values for enums
- Adding "long_about" to commands for more context
This commit is contained in:
Zanie Blue 2024-08-01 11:04:16 -05:00 committed by GitHub
parent 9788496c51
commit f107406727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1889 additions and 2 deletions

21
Cargo.lock generated
View file

@ -2062,6 +2062,17 @@ dependencies = [
"quoted_printable",
]
[[package]]
name = "markdown"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef3aab6a1d529b112695f72beec5ee80e729cb45af58663ec902c8fac764ecdd"
dependencies = [
"lazy_static",
"pipeline",
"regex",
]
[[package]]
name = "matchers"
version = "0.1.0"
@ -2563,6 +2574,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pipeline"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d15b6607fa632996eb8a17c9041cb6071cb75ac057abd45dece578723ea8c7c0"
[[package]]
name = "pkg-config"
version = "0.3.30"
@ -2711,7 +2728,7 @@ dependencies = [
"indexmap",
"log",
"priority-queue",
"rustc-hash 1.1.0",
"rustc-hash 2.0.0",
"thiserror",
]
@ -4689,6 +4706,7 @@ dependencies = [
"fs-err",
"install-wheel-rs",
"itertools 0.13.0",
"markdown",
"mimalloc",
"owo-colors",
"pep508_rs",
@ -4709,6 +4727,7 @@ dependencies = [
"tracing-subscriber",
"uv-build",
"uv-cache",
"uv-cli",
"uv-client",
"uv-configuration",
"uv-dispatch",

View file

@ -29,7 +29,7 @@ uv-warnings = { workspace = true }
anstream = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
clap = { workspace = true, features = ["derive", "string"] }
clap_complete_command = { workspace = true }
serde = { workspace = true }
url = { workspace = true }

View file

@ -23,6 +23,7 @@ pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-build = { workspace = true }
uv-cache = { workspace = true, features = ["clap"] }
uv-cli = { workspace = true }
uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-dispatch = { workspace = true }
@ -43,6 +44,7 @@ anyhow = { workspace = true }
clap = { workspace = true, features = ["derive", "wrap_help"] }
fs-err = { workspace = true, features = ["tokio"] }
itertools = { workspace = true }
markdown = "0.3.0"
owo-colors = { workspace = true }
poloto = { version = "19.1.2", optional = true }
pretty_assertions = { version = "1.4.0" }

View file

@ -0,0 +1,262 @@
//! Generate a Markdown-compatible reference for the uv command-line interface.
use std::cmp::max;
use std::path::PathBuf;
use anstream::println;
use anyhow::{bail, Result};
use clap::{Command, CommandFactory};
use itertools::Itertools;
use pretty_assertions::StrComparison;
use crate::generate_all::Mode;
use crate::ROOT_DIR;
use uv_cli::Cli;
#[derive(clap::Args)]
pub(crate) struct Args {
/// Write the generated output to stdout (rather than to `settings.md`).
#[arg(long, default_value_t, value_enum)]
pub(crate) mode: Mode,
}
pub(crate) fn main(args: &Args) -> Result<()> {
let reference_string = generate();
let filename = "cli.md";
let reference_path = PathBuf::from(ROOT_DIR)
.join("docs")
.join("reference")
.join(filename);
match args.mode {
Mode::DryRun => {
println!("{reference_string}");
}
Mode::Check => match fs_err::read_to_string(reference_path) {
Ok(current) => {
if current == reference_string {
println!("Up-to-date: {filename}");
} else {
let comparison = StrComparison::new(&current, &reference_string);
bail!("{filename} changed, please run `cargo dev generate-cli-reference`:\n{comparison}");
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!("{filename} not found, please run `cargo dev generate-cli-reference`");
}
Err(err) => {
bail!("{filename} changed, please run `cargo dev generate-cli-reference`:\n{err}");
}
},
Mode::Write => match fs_err::read_to_string(&reference_path) {
Ok(current) => {
if current == reference_string {
println!("Up-to-date: {filename}");
} else {
println!("Updating: {filename}");
fs_err::write(reference_path, reference_string.as_bytes())?;
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
println!("Updating: {filename}");
fs_err::write(reference_path, reference_string.as_bytes())?;
}
Err(err) => {
bail!("{filename} changed, please run `cargo dev generate-cli-reference`:\n{err}");
}
},
}
Ok(())
}
fn generate() -> String {
let mut output = String::new();
let mut uv = Cli::command();
// It is very important to build the command before beginning inspection or subcommands
// will be missing all of the propagated options.
uv.build();
let mut parents = Vec::new();
output.push_str("# CLI Reference\n\n");
generate_command(&mut output, &uv, &mut parents);
output
}
fn generate_command<'a>(output: &mut String, command: &'a Command, parents: &mut Vec<&'a Command>) {
if command.is_hide_set() {
return;
}
// Generate the command header.
let name = if parents.is_empty() {
command.get_name().to_string()
} else {
format!(
"{} {}",
parents.iter().map(|cmd| cmd.get_name()).join(" "),
command.get_name()
)
};
// Display the top-level `uv` command at the same level as its children
let level = max(2, parents.len() + 1);
output.push_str(&format!("{} {name}\n\n", "#".repeat(level)));
// Display the command description.
if let Some(about) = command.get_long_about().or_else(|| command.get_about()) {
output.push_str(&about.to_string());
output.push_str("\n\n");
};
// Display the usage
{
// This appears to be the simplest way to get rendered usage from Clap,
// it is complicated to render it manually. It's annoying that it
// requires a mutable reference but it doesn't really matter.
let mut command = command.clone();
output.push_str("<h3 class=\"cli-reference\">Usage</h3>\n\n");
output.push_str(&format!(
"```\n{}\n```",
command
.render_usage()
.to_string()
.trim_start_matches("Usage: "),
));
output.push_str("\n\n");
}
// Display a list of child commands
let mut subcommands = command.get_subcommands().peekable();
let has_subcommands = subcommands.peek().is_some();
if has_subcommands {
output.push_str("<h3 class=\"cli-reference\">Commands</h3>\n\n");
output.push_str("<dl class=\"cli-reference\">");
for subcommand in subcommands {
if subcommand.is_hide_set() {
continue;
}
let subcommand_name = format!("{name} {}", subcommand.get_name());
output.push_str(&format!(
"<dt><a href=\"{}\"><code>{subcommand_name}</code></a></dt>",
subcommand_name.replace(' ', "-")
));
if let Some(about) = subcommand.get_about() {
output.push_str(&format!(
"<dd>{}</dd>\n",
markdown::to_html(&about.to_string())
));
}
}
output.push_str("</dl>\n\n");
}
// Do not display options for commands with children
if !has_subcommands {
// Display positional arguments
let mut arguments = command
.get_positionals()
.filter(|arg| !arg.is_hide_set())
.peekable();
if arguments.peek().is_some() {
output.push_str("<h3 class=\"cli-reference\">Arguments</h3>\n\n");
output.push_str("<dl class=\"cli-reference\">");
for arg in arguments {
output.push_str("<dt>");
output.push_str(&format!(
"<code>{}</code>",
arg.get_id().to_string().to_uppercase()
));
output.push_str("</dt>");
if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
output.push_str("<dd>");
output.push_str(&format!("{}\n", markdown::to_html(&help.to_string())));
output.push_str("</dd>");
}
}
output.push_str("</dl>\n\n");
}
// Display options
let mut options = command
.get_opts()
.filter(|arg| !arg.is_hide_set())
.sorted_by_key(|opt| opt.get_id())
.peekable();
if options.peek().is_some() {
output.push_str("<h3 class=\"cli-reference\">Options</h3>\n\n");
output.push_str("<dl class=\"cli-reference\">");
for opt in command.get_opts() {
if opt.is_hide_set() {
continue;
}
let Some(long) = opt.get_long() else { continue };
output.push_str("<dt>");
output.push_str(&format!("<code>--{long}</code>"));
if let Some(short) = opt.get_short() {
output.push_str(&format!(", <code>-{short}</code>"));
}
if let Some(values) = opt.get_value_names() {
for value in values {
output.push_str(&format!(
" <i>{}</i>",
value.to_lowercase().replace('_', "-")
));
}
}
output.push_str("</dt>");
if let Some(help) = opt.get_long_help().or_else(|| opt.get_help()) {
output.push_str("<dd>");
output.push_str(&format!("{}\n", markdown::to_html(&help.to_string())));
output.push_str("</dd>");
}
}
output.push_str("</dl>");
}
output.push_str("\n\n");
}
parents.push(command);
// Recurse to all of the subcommands.
for subcommand in command.get_subcommands() {
generate_command(output, subcommand, parents);
}
parents.pop();
}
#[cfg(test)]
mod tests {
use std::env;
use anyhow::Result;
use crate::generate_all::Mode;
use super::{main, Args};
#[test]
fn test_generate_cli_reference() -> Result<()> {
let mode = if env::var("UV_UPDATE_SCHEMA").as_deref() == Ok("1") {
Mode::Write
} else {
Mode::Check
};
main(&Args { mode })
}
}

View file

@ -20,6 +20,7 @@ use crate::build::{build, BuildArgs};
use crate::clear_compile::ClearCompileArgs;
use crate::compile::CompileArgs;
use crate::generate_all::Args as GenerateAllArgs;
use crate::generate_cli_reference::Args as GenerateCliReferenceArgs;
use crate::generate_json_schema::Args as GenerateJsonSchemaArgs;
use crate::generate_options_reference::Args as GenerateOptionsReferenceArgs;
#[cfg(feature = "render")]
@ -46,6 +47,7 @@ mod build;
mod clear_compile;
mod compile;
mod generate_all;
mod generate_cli_reference;
mod generate_json_schema;
mod generate_options_reference;
mod render_benchmarks;
@ -69,6 +71,8 @@ enum Cli {
GenerateJSONSchema(GenerateJsonSchemaArgs),
/// Generate the options reference for the documentation.
GenerateOptionsReference(GenerateOptionsReferenceArgs),
/// Generate the CLI reference for the documentation.
GenerateCliReference(GenerateCliReferenceArgs),
#[cfg(feature = "render")]
/// Render the benchmarks.
RenderBenchmarks(RenderBenchmarksArgs),
@ -88,6 +92,7 @@ async fn run() -> Result<()> {
Cli::GenerateAll(args) => generate_all::main(&args)?,
Cli::GenerateJSONSchema(args) => generate_json_schema::main(&args)?,
Cli::GenerateOptionsReference(args) => generate_options_reference::main(&args)?,
Cli::GenerateCliReference(args) => generate_cli_reference::main(&args)?,
#[cfg(feature = "render")]
Cli::RenderBenchmarks(args) => render_benchmarks::render_benchmarks(&args)?,
}

1580
docs/reference/cli.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -147,3 +147,20 @@ See https://github.com/astral-sh/uv/issues/5130 */
.md-nav__link--active {
font-weight: bold;
}
/* Styling for the generated CLI reference page */
.cli-reference dd {
margin-top: 0.1em;
margin-bottom: 0.5em;
}
.cli-reference dd p {
margin-block-start: 0.2em;
margin-block-end: 0.3em;
}
.cli-reference ul {
margin-bottom: 0.1em;
}
h3.cli-reference {
font-size: 1.1em;
margin: 0 0 0 0;
}

View file

@ -120,6 +120,8 @@ nav:
- Declaring dependencies: pip/dependencies.md
- Locking environments: pip/compile.md
- Compatibility with pip: pip/compatibility.md
- Reference:
- Commands: reference/cli.md
- Policies:
- Versioning: versioning.md
- Platform support: platforms.md