Skip to content

Commit

Permalink
Generate CLI reference for documentation (#5685)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
zanieb authored Aug 1, 2024
1 parent 9788496 commit f107406
Show file tree
Hide file tree
Showing 8 changed files with 1,889 additions and 2 deletions.
21 changes: 20 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/uv-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-dev/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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" }
Expand Down
262 changes: 262 additions & 0 deletions crates/uv-dev/src/generate_cli_reference.rs
Original file line number Diff line number Diff line change
@@ -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 })
}
}
5 changes: 5 additions & 0 deletions crates/uv-dev/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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;
Expand All @@ -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),
Expand All @@ -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)?,
}
Expand Down
Loading

0 comments on commit f107406

Please sign in to comment.