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
200 changes: 200 additions & 0 deletions cli/src/command_effects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//! What each `usage` command does to the world.
//!
//! clap has no way to express this, so it is applied to the derived spec on the
//! way out. The same shape mise, hk, pitchfork, aube and communique use.
//!
//! The rule is what the command does to *state the user would miss*, not how
//! much work it does. Reading a spec file and printing a manpage is `read` no
//! matter how much parsing happens in between; writing that manpage to a path
//! the user named is `write`, because something on disk changed.
//!
//! Most commands here print to stdout and take an optional flag to write to a
//! file instead, so they are `read` with the flag raising them — which is the
//! composition rule doing its job: the effect of an invocation is the highest
//! of the command's and those of the flags and args actually supplied.
//!
//! A command that runs code the user supplied is left unset rather than
//! guessed at. See [`UNCLASSIFIED`].

use usage::SpecCommandEffect::{self, Read, Write};

/// Commands whose effect is fixed, keyed by their full path under `usage`.
const EFFECTS: &[(&str, SpecCommandEffect)] = &[
("complete-word", Read),
// Cannot run alone (`subcommand_required`), and every child starts at
// `read`, so the parent is `read` too.
("generate", Read),
("generate completion", Read),
("generate completion-init", Read),
("generate fig", Read),
("generate json", Read),
("generate manpage", Read),
("generate markdown", Read),
// The only generator whose output flag is required: it cannot print an SDK
// to stdout, so every invocation writes a directory.
("generate sdk", Write),
("lint", Read),
("sponsors", Read),
];

/// Flags that raise the effect of the command they are passed to, keyed by
/// `<command path>` and the flag's name.
///
/// All of these redirect output that would otherwise go to stdout.
const FLAG_EFFECTS: &[(&str, &str, SpecCommandEffect)] = &[
("generate fig", "out-file", Write),
("generate manpage", "out-file", Write),
("generate markdown", "out-dir", Write),
("generate markdown", "out-file", Write),
];

/// Commands with no fixed effect, and why.
///
/// These run a script the user supplied, so their effect is whatever that
/// script does. Labeling them would be a lie in whichever direction it was
/// labeled, and `read` in particular would be dangerous.
// Only the coverage test reads this; it exists so the reason a command is left
// unclassified lives next to the decision rather than in a commit message.
#[cfg(test)]
const UNCLASSIFIED: &[(&str, &str)] = &[
("bash", "runs a user-supplied script"),
("exec", "runs a user-supplied script"),
("fish", "runs a user-supplied script"),
("powershell", "runs a user-supplied script"),
("zsh", "runs a user-supplied script"),
];

/// Apply the tables above to a derived spec.
///
/// A path that no longer exists is skipped rather than panicking — the stale
/// entry is caught by the test below, where a failure is readable, instead of
/// at runtime in a user's shell.
pub(crate) fn apply(spec: &mut usage::Spec) {
for (path, effect) in EFFECTS {
if let Some(cmd) = find_mut(spec, path) {
cmd.effect = Some(*effect);
}
}
for (path, flag_name, effect) in FLAG_EFFECTS {
if let Some(cmd) = find_mut(spec, path) {
if let Some(flag) = cmd.flags.iter_mut().find(|f| f.name == *flag_name) {
flag.effect = Some(*effect);
}
}
}
}

fn find_mut<'a>(spec: &'a mut usage::Spec, path: &str) -> Option<&'a mut usage::SpecCommand> {
let mut cmd = &mut spec.cmd;
for segment in path.split(' ') {
cmd = cmd.subcommands.get_mut(segment)?;
}
Some(cmd)
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;

fn spec() -> usage::Spec {
let mut cli = <crate::cli::Cli as clap::CommandFactory>::command();
let mut spec = clap_usage::spec(&mut cli, "usage");
apply(&mut spec);
spec
}

/// Every command path in the spec, deepest-first order irrelevant.
fn walk(cmd: &usage::SpecCommand, path: &mut Vec<String>, out: &mut Vec<(String, bool)>) {
for (name, sub) in &cmd.subcommands {
path.push(name.clone());
out.push((path.join(" "), sub.effect.is_some()));
walk(sub, path, out);
path.pop();
}
}

fn commands() -> Vec<(String, bool)> {
let spec = spec();
let mut out = vec![];
walk(&spec.cmd, &mut vec![], &mut out);
out
}

#[test]
fn no_entry_points_at_a_command_that_does_not_exist() {
// `apply` skips a stale path silently so a rename cannot break the CLI.
// This is where that shows up instead.
let real: HashSet<_> = commands().into_iter().map(|(path, _)| path).collect();
let stale: Vec<_> = EFFECTS
.iter()
.map(|(path, _)| *path)
.chain(FLAG_EFFECTS.iter().map(|(path, _, _)| *path))
.chain(UNCLASSIFIED.iter().map(|(path, _)| *path))
.filter(|path| !real.contains(*path))
.collect();
assert!(stale.is_empty(), "no such commands: {stale:?}");
}

#[test]
fn no_flag_entry_points_at_a_flag_that_does_not_exist() {
let spec = spec();
let missing: Vec<_> = FLAG_EFFECTS
.iter()
.filter(|(path, flag_name, _)| {
find(&spec, path).is_none_or(|cmd| !cmd.flags.iter().any(|f| f.name == *flag_name))
})
.map(|(path, flag_name, _)| format!("{path} --{flag_name}"))
.collect();
assert!(missing.is_empty(), "no such flags: {missing:?}");
}

#[test]
fn nothing_is_unclassified_by_accident() {
// An unset effect means "unknown, ask", which is right for the shell
// commands and a silent gap for anything else.
let deliberate: HashSet<_> = UNCLASSIFIED.iter().map(|(path, _)| *path).collect();
let accidental: Vec<_> = commands()
.into_iter()
.filter(|(path, classified)| !classified && !deliberate.contains(path.as_str()))
.map(|(path, _)| path)
.collect();
assert!(
accidental.is_empty(),
"unclassified with no entry in UNCLASSIFIED: {accidental:?}"
);
}

#[test]
fn an_output_flag_raises_a_read_command_to_write() {
// The composition rule is the reason flags carry effects at all:
// `usage g markdown -f x.kdl` only reads, the same command with
// `--out-file` writes.
let spec = spec();
let md = find(&spec, "generate markdown").unwrap();
assert_eq!(md.effect, Some(Read));
let out_file = md.flags.iter().find(|f| f.name == "out-file").unwrap();
assert_eq!(out_file.effect, Some(Write));
// A flag that changes only the rendering stays unset.
let html = md.flags.iter().find(|f| f.name == "html-encode").unwrap();
assert_eq!(html.effect, None);
}

#[test]
fn a_required_output_flag_makes_the_command_write() {
// `generate sdk` cannot print to stdout, so there is no read-only way
// to invoke it and the effect belongs on the command.
let spec = spec();
let sdk = find(&spec, "generate sdk").unwrap();
assert_eq!(sdk.effect, Some(Write));
assert!(sdk.flags.iter().any(|f| f.name == "output" && f.required));
}

fn find<'a>(spec: &'a usage::Spec, path: &str) -> Option<&'a usage::SpecCommand> {
let mut cmd = &spec.cmd;
for segment in path.split(' ') {
cmd = cmd.subcommands.get(segment)?;
}
Some(cmd)
}
}
1 change: 1 addition & 0 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use miette::Result;
pub use cli::Cli;

mod cli;
mod command_effects;
pub mod env;
mod usage_spec;

Expand Down
13 changes: 12 additions & 1 deletion cli/src/usage_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ use miette::Result;

pub(crate) fn generate() -> Result<()> {
let mut cli = Cli::command().version(env!("CARGO_PKG_VERSION"));
clap_usage::generate(&mut cli, "usage", &mut std::io::stdout());
let mut spec = clap_usage::spec(&mut cli, "usage");

// Declare what each command does to the world. clap cannot express this,
// so it is applied to the derived spec; see command_effects.
crate::command_effects::apply(&mut spec);

println!("// @generated by usage-cli from clap metadata");
// 3.6 added `effect=` and 4.0 added it on flags and args; older `usage`
// CLIs reject the spec outright with "unsupported cmd prop effect", so this
// moves in lockstep with the fields the spec actually carries.
println!(r#"min_usage_version "4.0""#);
println!("{spec}");
println!("{}", include_str!("../assets/usage-extra.usage.kdl").trim());

Ok(())
Expand Down
31 changes: 16 additions & 15 deletions cli/usage.usage.kdl
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @generated by usage-cli from clap metadata
min_usage_version "4.0"
name usage-cli
bin usage
version "4.0.0"
Expand All @@ -20,7 +21,7 @@ to properly escape and quote values with spaces in them.
arg <SCRIPT>
arg "[ARGS]…" help="Arguments to pass to script" required=#false var=#true
}
cmd complete-word help="Generate shell completion candidates for a partial command line" {
cmd complete-word help="Generate shell completion candidates for a partial command line" effect=read {
alias cw
long_help #"""
Generate shell completion candidates for a partial command line
Expand Down Expand Up @@ -65,9 +66,9 @@ to properly escape and quote values with spaces in them.
arg <SCRIPT>
arg "[ARGS]…" help="Arguments to pass to script" required=#false var=#true
}
cmd generate subcommand_required=#true help="Generate completions, documentation, and other artifacts from usage specs" {
cmd generate subcommand_required=#true help="Generate completions, documentation, and other artifacts from usage specs" effect=read {
alias g
cmd completion help="Generate shell completion scripts for bash, fish, nu, powershell, or zsh" {
cmd completion help="Generate shell completion scripts for bash, fish, nu, powershell, or zsh" effect=read {
alias c
alias complete completions hide=#true
flag "-f --file" help="A .usage.kdl spec file to use for generating completions, use \"-\" to read from stdin" {
Expand Down Expand Up @@ -99,7 +100,7 @@ You may need to set this if you have a different bin named "usage"
}
arg <BIN> help="The CLI which we're generating completions for"
}
cmd completion-init help="Generate a shell init script that auto-completes any usage shebang script on $PATH" {
cmd completion-init help="Generate a shell init script that auto-completes any usage shebang script on $PATH" effect=read {
alias ci
alias init completions-init hide=#true
long_help #"""
Expand All @@ -119,31 +120,31 @@ You may need to set this if you have a different bin named "usage"
choices bash fish zsh
}
}
cmd fig help="Generate Fig completion spec for Amazon Q / Fig" {
cmd fig help="Generate Fig completion spec for Amazon Q / Fig" effect=read {
flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" {
arg <FILE>
}
flag --out-file help="File path where the generated Fig spec will be saved" {
flag --out-file help="File path where the generated Fig spec will be saved" effect=write {
arg <OUT_FILE>
}
flag --spec help="Raw string spec input" {
arg <SPEC>
}
}
cmd json help="Outputs a usage spec in json format" {
cmd json help="Outputs a usage spec in json format" effect=read {
flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" {
arg <FILE>
}
flag --spec help="raw string spec input" {
arg <SPEC>
}
}
cmd manpage {
cmd manpage effect=read {
alias man
flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" required=#true {
arg <FILE>
}
flag "-o --out-file" help="Output file path (defaults to stdout)" {
flag "-o --out-file" help="Output file path (defaults to stdout)" effect=write {
arg <OUT_FILE>
}
flag "-s --section" help="Manual section number (default: 1)" default="1" {
Expand All @@ -155,25 +156,25 @@ Common sections: - 1: User commands - 5: File formats - 7: Miscellaneous - 8: Sy
arg <SECTION>
}
}
cmd markdown help="Generate markdown documentation from usage specs" {
cmd markdown help="Generate markdown documentation from usage specs" effect=read {
alias md
flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" required=#true {
arg <FILE>
}
flag "-m --multi" help="Render each subcommand as a separate markdown file"
flag --html-encode help="Escape HTML in markdown"
flag --out-dir help="Output markdown files to this directory (required when using --multi)" {
flag --out-dir help="Output markdown files to this directory (required when using --multi)" effect=write {
arg <OUT_DIR>
}
flag --out-file help="Output file path for single-file markdown generation" {
flag --out-file help="Output file path for single-file markdown generation" effect=write {
arg <OUT_FILE>
}
flag --replace-pre-with-code-fences help="Replace `<pre>` tags with markdown code fences"
flag --url-prefix help="Prefix to add to all URLs" {
arg <URL_PREFIX>
}
}
cmd sdk help="Generate a type-safe SDK from a usage spec" {
cmd sdk help="Generate a type-safe SDK from a usage spec" effect=write {
flag "-f --file" help="A usage spec taken in as a file" {
arg <FILE>
}
Expand All @@ -193,7 +194,7 @@ Common sections: - 1: User commands - 5: File formats - 7: Miscellaneous - 8: Sy
}
}
}
cmd lint help="Lint a usage spec file for common issues" {
cmd lint help="Lint a usage spec file for common issues" effect=read {
flag "-f --format" help="Output format" default=text {
arg <FORMAT> {
choices text json
Expand All @@ -216,7 +217,7 @@ to properly escape and quote values with spaces in them.
arg <SCRIPT>
arg "[ARGS]…" help="Arguments to pass to script" required=#false var=#true
}
cmd sponsors help="Show the companies sponsoring usage and the jdx.dev open source tools"
cmd sponsors help="Show the companies sponsoring usage and the jdx.dev open source tools" effect=read
cmd zsh help="Execute a shell script using zsh" {
long_help #"""
Execute a shell script with the specified shell
Expand Down
Loading
Loading