From 1ea0918e5de9101304f85ddadb2ceacea2f1bc66 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:43:32 +0000 Subject: [PATCH 1/3] feat(go): emit Go parse tables from a spec, which is what Go has instead of a derive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go has no macros. What a Rust CLI gets from `#[derive(Cli)]` at compile time, a Go CLI has to get from a generator at build time, so `usage generate go` is the same milestone for usage-go that usage-derive is for the Rust side — the point at which a spec, rather than a hand-written table, is what a CLI is declared in. //go:generate usage generate go -f mycli.usage.kdl -o tables.go It emits binding tables and nothing else. Help text, choices, defaults and `env` are absent for the same reason they are absent from the Rust hot path: a successful parse never reads them, and mise's several hundred kilobytes of help strings do not belong in front of a parser. A cold table is separate work. The output is gofmt-clean as it comes out, which took some care and is worth it: the alternative is every adopter running a formatter before they can commit, and this repo's own CI failing `gofmt -l` on the table it will check in. gofmt pads within runs of consecutive single-line entries and starts a new run after anything spanning lines, so the emitter models a literal as a list of fields and blocks rather than printing strings. Verified against mise's spec, usage's own, and the examples spec: `gofmt -w` changes nothing in any of them. What the generated file gives an author beyond speed is the key constants. An event carries a Key, so generated code dispatches on `mise.FlagUseGlobal` instead of comparing strings, and a flag that is renamed in the spec fails to compile rather than silently never matching. Three things resolved at generation time, so the parser reads one field per command instead of walking: `unknown_flags` inheritance, `default_subcommand` into a pointer at the node it names, and hidden aliases folded in beside visible ones — hiding is a help-output concern and binding never reads it. Identifier collisions are ordinary rather than exotic, and are handled: mise declares both a `macos-defaults` command and a `macos defaults` path, and both want to be spelled `CmdMacosDefaults`. Checked end to end before committing, though the checked-in fixture that will keep it honest is the next commit in this stack: the generated mise tables compile, parse `mise use -g node@20`, resolve `x` to `exec` through its alias, split `tasks run build extra --dry-run -- --verbose` across ARGS and ARGS_LAST, and produce a package with no init function whose Root is a type D symbol — 211 commands and 711 flags that cost nothing before main. Co-Authored-By: Claude Opus 5 --- cli/assets/fig.ts | 43 ++ cli/assets/usage.1 | 26 + cli/src/cli/generate/go.rs | 48 ++ cli/src/cli/generate/mod.rs | 3 + cli/src/command_effects.rs | 2 + cli/usage.usage.kdl | 21 + docs/cli/reference/commands.json | 87 +++ docs/cli/reference/generate.md | 1 + docs/cli/reference/generate/go.md | 33 + docs/cli/reference/index.md | 1 + lib/src/go/mod.rs | 660 ++++++++++++++++++ ...fault_subcommand_points_into_the_tree.snap | 45 ++ .../usage__go__tests__a_whole_cli.snap | 85 +++ ...liding_names_get_distinct_identifiers.snap | 59 ++ ...n_flags_are_inherited_and_overridable.snap | 62 ++ lib/src/lib.rs | 1 + 16 files changed, 1177 insertions(+) create mode 100644 cli/src/cli/generate/go.rs create mode 100644 docs/cli/reference/generate/go.md create mode 100644 lib/src/go/mod.rs create mode 100644 lib/src/go/snapshots/usage__go__tests__a_default_subcommand_points_into_the_tree.snap create mode 100644 lib/src/go/snapshots/usage__go__tests__a_whole_cli.snap create mode 100644 lib/src/go/snapshots/usage__go__tests__colliding_names_get_distinct_identifiers.snap create mode 100644 lib/src/go/snapshots/usage__go__tests__unknown_flags_are_inherited_and_overridable.snap diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index 8871e822d..c2b857f02 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -341,6 +341,49 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: "go", + description: "Generate Go parse tables from a usage spec", + options: [ + { + name: ["-f", "--file"], + description: + 'A usage spec taken in as a file, use "-" to read from stdin', + isRepeatable: false, + args: { + name: "file", + template: "filepaths", + }, + }, + { + name: ["-o", "--out-file"], + description: + 'File path where the generated Go source will be saved, or "-" for stdout', + isRepeatable: false, + args: { + name: "out_file", + template: "filepaths", + }, + }, + { + name: ["-p", "--package"], + description: + "Go package clause for the generated file (defaults to the spec's bin name)", + isRepeatable: false, + args: { + name: "package", + }, + }, + { + name: "--spec", + description: "Raw string spec input", + isRepeatable: false, + args: { + name: "spec", + }, + }, + ], + }, { name: "json", description: "Outputs a usage spec in json format", diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 31b99af70..5948c8c75 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -55,6 +55,9 @@ Generate a shell init script that auto\-completes any usage shebang script on $P \fBgenerate fig\fR Generate Fig completion spec for Amazon Q / Fig .TP +\fBgenerate go\fR +Generate Go parse tables from a usage spec +.TP \fBgenerate json\fR Outputs a usage spec in json format .TP @@ -265,6 +268,29 @@ File path where the generated Fig spec will be saved, or "\-" for stdout .TP \fB\-\-spec\fR \fI\fR Raw string spec input +.SH "USAGE GENERATE GO" +Generate Go parse tables from a usage spec + +The tables are read by github.com/jdx/usage/go/argv. Go has no macros, so what a Rust CLI gets from a derive at compile time, a Go CLI gets from this at build time — typically from a `go:generate` line: + +//go:generate usage generate go \-f mycli.usage.kdl \-o tables.go +.PP +\fBUsage:\fR usage generate go [OPTIONS] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-f, \-\-file\fR \fI\fR +A usage spec taken in as a file, use "\-" to read from stdin +.TP +\fB\-o, \-\-out\-file\fR \fI\fR +File path where the generated Go source will be saved, or "\-" for stdout +.TP +\fB\-p, \-\-package\fR \fI\fR +Go package clause for the generated file (defaults to the spec's bin name) +.TP +\fB\-\-spec\fR \fI\fR +Raw string spec input .SH "USAGE GENERATE JSON" Outputs a usage spec in json format .PP diff --git a/cli/src/cli/generate/go.rs b/cli/src/cli/generate/go.rs new file mode 100644 index 000000000..b5ff2f5ac --- /dev/null +++ b/cli/src/cli/generate/go.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +use clap::Args; +use miette::Result; +use usage::go::GoOptions; + +use crate::cli::generate; + +/// Generate Go parse tables from a usage spec +/// +/// The tables are read by github.com/jdx/usage/go/argv. Go has no macros, so what +/// a Rust CLI gets from a derive at compile time, a Go CLI gets from this at build +/// time — typically from a `go:generate` line: +/// +/// //go:generate usage generate go -f mycli.usage.kdl -o tables.go +#[derive(Args)] +#[clap()] +pub struct Go { + /// A usage spec taken in as a file, use "-" to read from stdin + #[clap(short, long)] + file: Option, + + /// File path where the generated Go source will be saved, or "-" for stdout + #[clap(short, long, value_hint = clap::ValueHint::FilePath)] + out_file: Option, + + /// Go package clause for the generated file (defaults to the spec's bin name) + #[clap(short, long)] + package: Option, + + /// Raw string spec input + #[clap(long, required_unless_present = "file", overrides_with = "file")] + spec: Option, +} + +impl Go { + pub fn run(&self) -> Result<()> { + let spec = generate::file_or_spec(&self.file, &self.spec)?; + let out = usage::go::generate( + &spec, + &GoOptions { + package: self.package.clone(), + }, + ); + generate::write_or_stdout(self.out_file.as_deref(), &out)?; + Ok(()) + } +} diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs index 3a365876c..7f6c23072 100644 --- a/cli/src/cli/generate/mod.rs +++ b/cli/src/cli/generate/mod.rs @@ -7,6 +7,7 @@ use usage::Spec; mod completion; mod completion_init; mod fig; +mod go; mod json; mod json_schema; mod manpage; @@ -26,6 +27,7 @@ pub enum Command { Completion(completion::Completion), CompletionInit(completion_init::CompletionInit), Fig(fig::Fig), + Go(go::Go), Json(json::Json), JsonSchema(json_schema::JsonSchema), Manpage(manpage::Manpage), @@ -39,6 +41,7 @@ impl Generate { Command::Completion(cmd) => cmd.run(), Command::CompletionInit(cmd) => cmd.run(), Command::Fig(cmd) => cmd.run(), + Command::Go(cmd) => cmd.run(), Command::Json(cmd) => cmd.run(), Command::JsonSchema(cmd) => cmd.run(), Command::Manpage(cmd) => cmd.run(), diff --git a/cli/src/command_effects.rs b/cli/src/command_effects.rs index 680b63b4f..1184eb3f8 100644 --- a/cli/src/command_effects.rs +++ b/cli/src/command_effects.rs @@ -27,6 +27,7 @@ const EFFECTS: &[(&str, SpecCommandEffect)] = &[ ("generate completion", Read), ("generate completion-init", Read), ("generate fig", Read), + ("generate go", Read), ("generate json", Read), ("generate json-schema", Read), ("generate manpage", Read), @@ -48,6 +49,7 @@ const EFFECTS: &[(&str, SpecCommandEffect)] = &[ /// All of these redirect output that would otherwise go to stdout. const FLAG_EFFECTS: &[(&str, &str, SpecCommandEffect)] = &[ ("generate fig", "out-file", Write), + ("generate go", "out-file", Write), ("generate json-schema", "out-file", Write), ("generate manpage", "out-file", Write), ("generate markdown", "out-dir", Write), diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 66cf6a74f..de554ef04 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -132,6 +132,27 @@ You may need to set this if you have a different bin named "usage" arg } } + cmd go help="Generate Go parse tables from a usage spec" effect=read unknown_flags=error { + long_help #""" +Generate Go parse tables from a usage spec + +The tables are read by github.com/jdx/usage/go/argv. Go has no macros, so what a Rust CLI gets from a derive at compile time, a Go CLI gets from this at build time — typically from a `go:generate` line: + +//go:generate usage generate go -f mycli.usage.kdl -o tables.go +"""# + flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" { + arg + } + flag "-o --out-file" help="File path where the generated Go source will be saved, or \"-\" for stdout" effect=write { + arg + } + flag "-p --package" help="Go package clause for the generated file (defaults to the spec's bin name)" { + arg + } + flag --spec help="Raw string spec input" { + arg + } + } cmd json help="Outputs a usage spec in json format" effect=read unknown_flags=error { flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" { arg diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json index 8a0046ae4..e425ce399 100644 --- a/docs/cli/reference/commands.json +++ b/docs/cli/reference/commands.json @@ -522,6 +522,93 @@ "hidden_aliases": [], "examples": [] }, + "go": { + "full_cmd": ["generate", "go"], + "usage": "generate go [FLAGS]", + "subcommands": {}, + "args": [], + "flags": [ + { + "name": "file", + "usage": "-f --file ", + "help": "A usage spec taken in as a file, use \"-\" to read from stdin", + "help_first_line": "A usage spec taken in as a file, use \"-\" to read from stdin", + "short": ["f"], + "long": ["file"], + "hide": false, + "global": false, + "arg": { + "name": "FILE", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "out-file", + "usage": "-o --out-file ", + "help": "File path where the generated Go source will be saved, or \"-\" for stdout", + "help_first_line": "File path where the generated Go source will be saved, or \"-\" for stdout", + "short": ["o"], + "long": ["out-file"], + "hide": false, + "global": false, + "arg": { + "name": "OUT_FILE", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + }, + "effect": "write" + }, + { + "name": "package", + "usage": "-p --package ", + "help": "Go package clause for the generated file (defaults to the spec's bin name)", + "help_first_line": "Go package clause for the generated file (defaults to the spec's bin name)", + "short": ["p"], + "long": ["package"], + "hide": false, + "global": false, + "arg": { + "name": "PACKAGE", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "spec", + "usage": "--spec ", + "help": "Raw string spec input", + "help_first_line": "Raw string spec input", + "short": [], + "long": ["spec"], + "hide": false, + "global": false, + "arg": { + "name": "SPEC", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + } + ], + "mounts": [], + "effect": "read", + "unknown_flags": "error", + "hide": false, + "help": "Generate Go parse tables from a usage spec", + "help_long": "Generate Go parse tables from a usage spec\n\nThe tables are read by github.com/jdx/usage/go/argv. Go has no macros, so what a Rust CLI gets from a derive at compile time, a Go CLI gets from this at build time — typically from a `go:generate` line:\n\n//go:generate usage generate go -f mycli.usage.kdl -o tables.go", + "name": "go", + "aliases": [], + "hidden_aliases": [], + "examples": [] + }, "json": { "full_cmd": ["generate", "json"], "usage": "generate json [-f --file ] [--spec ]", diff --git a/docs/cli/reference/generate.md b/docs/cli/reference/generate.md index a69dc0cd4..ed97c32a8 100644 --- a/docs/cli/reference/generate.md +++ b/docs/cli/reference/generate.md @@ -14,6 +14,7 @@ Generate completions, documentation, and other artifacts from usage specs - [`usage generate completion [FLAGS] `](/cli/reference/generate/completion.md) - [`usage generate completion-init [--usage-bin ] `](/cli/reference/generate/completion-init.md) - [`usage generate fig [FLAGS]`](/cli/reference/generate/fig.md) +- [`usage generate go [FLAGS]`](/cli/reference/generate/go.md) - [`usage generate json [-f --file ] [--spec ]`](/cli/reference/generate/json.md) - [`usage generate json-schema [FLAGS]`](/cli/reference/generate/json-schema.md) - [`usage generate manpage `](/cli/reference/generate/manpage.md) diff --git a/docs/cli/reference/generate/go.md b/docs/cli/reference/generate/go.md new file mode 100644 index 000000000..78333b9be --- /dev/null +++ b/docs/cli/reference/generate/go.md @@ -0,0 +1,33 @@ + + +# `usage generate go` + +- **Usage**: `usage generate go [FLAGS]` +- **Effect**: read-only +- **Source code**: [`cli/src/cli/generate/go.rs`](https://github.com/jdx/usage/blob/main/cli/src/cli/generate/go.rs) + +Generate Go parse tables from a usage spec + +The tables are read by github.com/jdx/usage/go/argv. Go has no macros, so what a Rust CLI gets from a derive at compile time, a Go CLI gets from this at build time — typically from a `go:generate` line: + +//go:generate usage generate go -f mycli.usage.kdl -o tables.go + +## Flags + +### `-f --file ` + +A usage spec taken in as a file, use "-" to read from stdin + +### `-o --out-file ` + +**Effect**: modifies state + +File path where the generated Go source will be saved, or "-" for stdout + +### `-p --package ` + +Go package clause for the generated file (defaults to the spec's bin name) + +### `--spec ` + +Raw string spec input diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md index a0faba01e..fc8f417a7 100644 --- a/docs/cli/reference/index.md +++ b/docs/cli/reference/index.md @@ -30,6 +30,7 @@ Outputs a `usage.kdl` spec for this CLI itself - [`usage generate completion [FLAGS] `](/cli/reference/generate/completion.md) - [`usage generate completion-init [--usage-bin ] `](/cli/reference/generate/completion-init.md) - [`usage generate fig [FLAGS]`](/cli/reference/generate/fig.md) +- [`usage generate go [FLAGS]`](/cli/reference/generate/go.md) - [`usage generate json [-f --file ] [--spec ]`](/cli/reference/generate/json.md) - [`usage generate json-schema [FLAGS]`](/cli/reference/generate/json-schema.md) - [`usage generate manpage `](/cli/reference/generate/manpage.md) diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs new file mode 100644 index 000000000..4b4d449c3 --- /dev/null +++ b/lib/src/go/mod.rs @@ -0,0 +1,660 @@ +//! Emitting Go parse tables from a spec. +//! +//! The Go side of usage has no derive macro to emit its tables, because Go has no +//! macros: what a Rust CLI gets from `#[derive(Cli)]` at compile time, a Go CLI +//! gets from this, at build time, through `go:generate`. The output is a plain Go +//! file an author checks in and a reviewer can read. +//! +//! # What it emits, and what it does not +//! +//! Binding tables only — which token becomes which flag or argument. Help text, +//! choices, defaults, `env`, and every other thing that needs a value's type are +//! deliberately absent, for the same reason they are absent from the Rust hot +//! path: a successful parse never touches them, and a table that carried them +//! would put mise's several hundred kilobytes of help strings in front of the +//! parser. They belong in a second, cold table, which is a separate piece of work. +//! +//! # Why package-level `var` and not `const` +//! +//! Go has no `const` for composite data. What it does have is a linker that +//! statically initializes package-level variables holding plain data, which is +//! the property the whole design rests on: `go tool nm` reports these symbols as +//! type `D`, and the generated package has no `init` function. So a 211-command +//! table costs bytes in the binary and no instructions at startup — the thing +//! cobra and kong each pay a million or more for. +//! +//! Commands are emitted as separate variables rather than one nested literal +//! because `default_subcommand` has to point at a node inside the tree, and a +//! composite literal cannot refer to its own interior. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use heck::AsPascalCase; + +use crate::spec::unknown_flags::UnknownFlags; +use crate::{Spec, SpecArg, SpecCommand, SpecDoubleDashChoices, SpecFlag}; + +/// How to emit. +#[derive(Debug, Clone, Default)] +pub struct GoOptions { + /// The Go package clause. Defaults to the spec's `bin`, made into an + /// identifier. + pub package: Option, +} + +/// Turn a spec into a Go source file declaring its parse tables. +pub fn generate(spec: &Spec, opts: &GoOptions) -> String { + Emitter::new(spec, opts).run() +} + +/// One entry's identifiers: the exported key constant, and for a command the +/// variable holding it. +struct Named { + key: String, + var: String, + number: u64, +} + +struct Emitter<'a> { + spec: &'a Spec, + package: String, + /// Every identifier handed out, so a second entry wanting the same spelling + /// gets a suffix instead of silently colliding. + taken: HashMap, + /// Assigned in emission order, so a key is stable as long as the spec is. + next_key: u64, + out: String, +} + +impl<'a> Emitter<'a> { + fn new(spec: &'a Spec, opts: &GoOptions) -> Self { + let package = opts + .package + .clone() + .unwrap_or_else(|| package_ident(&spec.bin)); + Emitter { + spec, + package, + taken: HashMap::new(), + next_key: 0, + out: String::new(), + } + } + + /// Reserve an identifier, adding a numeric suffix if the spelling is taken. + /// + /// Collisions are ordinary rather than exotic: mise has both a `macos-defaults` + /// command and a `macos defaults` path, and both want to be spelled + /// `CmdMacosDefaults`. + fn unique(&mut self, base: &str) -> String { + let n = self.taken.entry(base.to_string()).or_insert(0); + *n += 1; + if *n == 1 { + base.to_string() + } else { + format!("{base}{n}") + } + } + + fn name(&mut self, prefix: &str, path: &[&str], own: &str) -> Named { + let mut base = String::from(prefix); + for segment in path { + let _ = write!(base, "{}", AsPascalCase(segment)); + } + let _ = write!(base, "{}", AsPascalCase(own)); + let key = self.unique(&base); + self.next_key += 1; + Named { + var: format!("cmd{}", &key[prefix.len()..]), + key, + number: self.next_key, + } + } + + fn run(mut self) -> String { + // Collected first so the constants can be emitted in one block before any + // table refers to them, which is also the order a reader wants: the names + // they will switch on, then the data. + let mut commands = Vec::new(); + self.collect(&self.spec.cmd.clone(), &[], true, &mut commands); + + self.header(); + self.constants(&commands); + self.tables(&commands); + + // Each command is followed by a blank line, which leaves one at the end of + // the file. gofmt strips it, and a generated file that is not gofmt-clean + // is one every adopter has to run a formatter over before committing. + let trimmed = self.out.trim_end().len(); + self.out.truncate(trimmed); + self.out.push('\n'); + self.out + } + + /// Walk the tree, naming everything, so that emission is a second pass with no + /// lookaheads. + fn collect(&mut self, cmd: &SpecCommand, path: &[&str], root: bool, out: &mut Vec) { + let named = if root { + self.next_key += 1; + Named { + key: "CmdRoot".to_string(), + var: "Root".to_string(), + number: self.next_key, + } + } else { + self.name("Cmd", &path[..path.len() - 1], path[path.len() - 1]) + }; + + let flags = cmd + .flags + .iter() + .map(|f| (f.clone(), self.name("Flag", path, &f.name))) + .collect::>(); + let args = cmd + .args + .iter() + .map(|a| (a.clone(), self.name("Arg", path, &a.name))) + .collect::>(); + + let index = out.len(); + out.push(Emitted { + named, + cmd: cmd.clone(), + flags, + args, + subcommands: Vec::new(), + root, + }); + + // Declaration order, not sorted: a recent change made the spec hold the + // order a CLI declares its commands in, and a generated file that reordered + // them would lose it for no gain — lookup is by name either way. + let mut children = Vec::new(); + for (name, sub) in &cmd.subcommands { + // An alias appears in `subcommands` under its own key as well as the + // canonical name; emitting it twice would declare two commands where the + // spec has one. + if name != &sub.name { + continue; + } + let mut child_path = path.to_vec(); + child_path.push(name); + let at = out.len(); + self.collect(sub, &child_path, false, out); + children.push(at); + } + out[index].subcommands = children; + } + + fn header(&mut self) { + let _ = writeln!( + self.out, + "// Code generated by `usage generate go`. DO NOT EDIT.\n\ + //\n\ + // Binding tables for `{}`, read by\n\ + // [github.com/jdx/usage/go/argv]. Regenerate rather than editing: the spec is\n\ + // the definition, and a hand-edit here is a difference no reviewer can see.\n\ + //\n\ + // These are package-level variables holding plain data, so the linker lays them\n\ + // out and nothing runs before main.\n\ + \n\ + package {}\n\ + \n\ + import \"github.com/jdx/usage/go/argv\"\n", + self.spec.bin, self.package + ); + + if let Some(version) = &self.spec.version { + let _ = writeln!( + self.out, + "// Version is what the spec declares, so a caller answering `--version` has it\n\ + // without the parse tables carrying a string binding never reads.\n\ + const Version = {}\n", + go_string(version) + ); + } + } + + fn constants(&mut self, commands: &[Emitted]) { + let _ = writeln!( + self.out, + "// Keys identify a table entry without a string comparison: switch on the Key an\n\ + // event carries rather than on its Name, which is there for diagnostics.\n\ + const (" + ); + let mut entries: Vec<(&str, u64)> = Vec::new(); + for e in commands { + entries.push((&e.named.key, e.named.number)); + entries.extend(e.flags.iter().map(|(_, n)| (n.key.as_str(), n.number))); + entries.extend(e.args.iter().map(|(_, n)| (n.key.as_str(), n.number))); + } + // One run, so every name pads to the longest — which is what gofmt does to + // a const block with no blank line in it. + let width = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + for (key, number) in entries { + let _ = writeln!( + self.out, + "\t{key}{:pad$} uint64 = {number}", + "", + pad = width - key.len() + ); + } + let _ = writeln!(self.out, ")\n"); + } + + fn tables(&mut self, commands: &[Emitted]) { + // Resolved once, against the root's own subcommands, because the spec + // declares it once at the top. A name nothing answers to is left unset + // rather than guessed at. + let default_subcommand = self.spec.default_subcommand.as_ref().and_then(|name| { + commands + .iter() + .find(|e| { + !e.root + && (&e.cmd.name == name + || e.cmd.aliases.contains(name) + || e.cmd.hidden_aliases.contains(name)) + }) + .map(|e| e.named.var.clone()) + }); + + for (i, e) in commands.iter().enumerate() { + let doc = if e.root { + format!( + "// Root is the command tree for `{}`. Pass it to argv.New.", + self.spec.bin + ) + } else { + format!("// {}", e.cmd.full_cmd.join(" ")) + }; + let mut lines = vec![ + Line::Field("Name".into(), go_string(&e.cmd.name)), + Line::Field("Key".into(), e.named.key.clone()), + ]; + + let aliases: Vec<&String> = e + .cmd + .aliases + .iter() + .chain(e.cmd.hidden_aliases.iter()) + .collect(); + if !aliases.is_empty() { + // A hidden alias selects a command exactly as a visible one does: + // hiding is about help output, which binding never reads. + let list = aliases + .iter() + .map(|a| go_string(a)) + .collect::>() + .join(", "); + lines.push(Line::Field("Aliases".into(), format!("[]string{{{list}}}"))); + } + + if !e.flags.is_empty() { + let mut block = vec!["Flags: []*argv.Flag{".to_string()]; + for (flag, named) in &e.flags { + block.push(format!("\t{},", flag_literal(flag, named))); + } + block.push("},".to_string()); + lines.push(Line::Block(block)); + } + + if !e.args.is_empty() { + let mut block = vec!["Args: []*argv.Arg{".to_string()]; + for (arg, named) in &e.args { + block.push(format!("\t{},", arg_literal(arg, named))); + } + block.push("},".to_string()); + lines.push(Line::Block(block)); + } + + if !e.subcommands.is_empty() { + let list = e + .subcommands + .iter() + .map(|at| commands[*at].named.var.clone()) + .collect::>() + .join(", "); + lines.push(Line::Field( + "Subcommands".into(), + format!("[]*argv.Command{{{list}}}"), + )); + } + + // Already resolved: inheritance is the generator's job, so that the + // parser reads one field rather than walking ancestors per token. + if effective_unknown_flags(self.spec, commands, i) == UnknownFlags::Error { + lines.push(Line::Field( + "UnknownFlags".into(), + "argv.UnknownFlagsError".into(), + )); + } + + if e.root { + if let Some(var) = &default_subcommand { + lines.push(Line::Field("DefaultSubcommand".into(), var.clone())); + } + if self.spec.version.is_some() { + // Only where the CLI declares a version: a `--version` that answers + // with nothing is worse than one that is not there. + lines.push(Line::Field("Version".into(), "true".into())); + } + } + + let _ = writeln!(self.out, "{doc}"); + let _ = writeln!(self.out, "var {} = &argv.Command{{", e.named.var); + render(&mut self.out, "\t", &lines); + let _ = writeln!(self.out, "}}\n"); + } + } +} + +/// A line inside a `const` block or a composite literal. +/// +/// The distinction exists only to reproduce gofmt's alignment, which pads within +/// *runs* of consecutive single-line entries and starts a new run after anything +/// that spans lines. Emitting gofmt-clean output rather than close-enough output +/// is what lets a generated file be committed as it comes out: the alternative is +/// every adopter needing a formatting step, and this repo's own CI failing +/// `gofmt -l` on the table it checks in. +enum Line { + /// `Key: value,` — aligned against its neighbours. + Field(String, String), + /// Verbatim, and it breaks the run either side of it. + Block(Vec), +} + +/// Render lines with gofmt's column alignment. +fn render(out: &mut String, indent: &str, lines: &[Line]) { + let mut run: Vec<(&String, &String)> = Vec::new(); + + fn flush(out: &mut String, indent: &str, run: &mut Vec<(&String, &String)>) { + let width = run.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + for (key, value) in run.iter() { + let _ = writeln!( + out, + "{indent}{key}:{:width$} {value},", + "", + width = width - key.len() + ); + } + run.clear(); + } + + for line in lines { + match line { + Line::Field(key, value) => run.push((key, value)), + Line::Block(block) => { + flush(out, indent, &mut run); + for l in block { + let _ = writeln!(out, "{indent}{l}"); + } + } + } + } + flush(out, indent, &mut run); +} + +/// One command, named and ready to emit. +struct Emitted { + named: Named, + cmd: SpecCommand, + flags: Vec<(SpecFlag, Named)>, + args: Vec<(SpecArg, Named)>, + /// Indices into the flat list, in declaration order. + subcommands: Vec, + root: bool, +} + +/// What an unrecognized flag-like token means at a command, with inheritance +/// applied. +/// +/// The nearest enclosing command that states a preference wins, then the spec, +/// then `value`. Walked over `full_cmd` rather than threaded through the collect +/// pass, so that emission does not depend on the order commands happen to sit in. +fn effective_unknown_flags(spec: &Spec, commands: &[Emitted], at: usize) -> UnknownFlags { + let path = &commands[at].cmd.full_cmd; + for depth in (0..=path.len()).rev() { + let ancestor = commands + .iter() + .find(|e| e.cmd.full_cmd.len() == depth && e.cmd.full_cmd[..] == path[..depth]); + if let Some(mode) = ancestor.and_then(|e| e.cmd.unknown_flags) { + return mode; + } + } + spec.unknown_flags.unwrap_or_default() +} + +fn flag_literal(flag: &SpecFlag, named: &Named) -> String { + let mut fields = vec![ + format!("Key: {}", named.key), + format!("Name: {}", go_string(&flag.name)), + ]; + if !flag.long.is_empty() { + let longs = flag + .long + .iter() + .map(|l| go_string(l)) + .collect::>() + .join(", "); + fields.push(format!("Longs: []string{{{longs}}}")); + } + if !flag.short.is_empty() { + let shorts = flag + .short + .iter() + .map(|c| go_byte(*c)) + .collect::>() + .join(", "); + fields.push(format!("Shorts: []byte{{{shorts}}}")); + } + if let Some(negate) = &flag.negate { + // The spec stores the negation with its dashes; the table wants the bare + // name, since that is what the parser has after stripping the `--`. + fields.push(format!( + "Negate: {}", + go_string(negate.trim_start_matches('-')) + )); + } + if flag.arg.is_some() { + fields.push("TakesValue: true".to_string()); + } + // Only a variadic *argument* is greedy. The spec's flag-level `var` means the + // flag may be repeated and takes one value each time, which needs nothing from + // the parser: it reports every occurrence separately either way. Conflating the + // two makes a merely repeatable flag greedy enough to eat a positional. + if let Some(arg) = flag.arg.as_ref().filter(|a| a.var) { + fields.push("Variadic: true".to_string()); + if let Some(max) = arg.var_max { + fields.push(format!("VarMax: {}", clamp_var_max(max))); + } + } + if flag.global { + fields.push("Global: true".to_string()); + } + format!("{{{}}}", fields.join(", ")) +} + +fn arg_literal(arg: &SpecArg, named: &Named) -> String { + let mut fields = vec![ + format!("Key: {}", named.key), + format!("Name: {}", go_string(&arg.name)), + ]; + if arg.var { + fields.push("Var: true".to_string()); + if let Some(max) = arg.var_max { + fields.push(format!("VarMax: {}", clamp_var_max(max))); + } + } + let double_dash = match arg.double_dash { + SpecDoubleDashChoices::Required => Some("argv.DoubleDashRequired"), + SpecDoubleDashChoices::Preserve => Some("argv.DoubleDashPreserve"), + SpecDoubleDashChoices::Automatic => Some("argv.DoubleDashAutomatic"), + _ => None, + }; + if let Some(dd) = double_dash { + fields.push(format!("DoubleDash: {dd}")); + } + format!("{{{}}}", fields.join(", ")) +} + +/// Zero means unbounded in the table, which is also what an absent `var_max` +/// lowers to, so the two agree. A bound past a `uint32` saturates rather than +/// wrapping: truncating four billion and one to one would read as "stop at once" +/// rather than "no real limit". +fn clamp_var_max(max: usize) -> u32 { + u32::try_from(max).unwrap_or(u32::MAX) +} + +/// A Go package identifier from a binary name: `my-cli` is not one, `mycli` is. +fn package_ident(bin: &str) -> String { + let cleaned: String = bin + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + let lowered = cleaned.to_ascii_lowercase(); + if lowered.is_empty() || lowered.starts_with(|c: char| c.is_ascii_digit()) { + format!("cli{lowered}") + } else { + lowered + } +} + +/// A Go string literal. +/// +/// Written out rather than borrowed from Rust's `{:?}`, which escapes to Rust's +/// rules: it spells a delete character `\u{7f}`, which Go does not accept. +fn go_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 || c as u32 == 0x7f => { + let _ = write!(out, "\\x{:02x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +/// A Go byte literal for a short flag. +/// +/// Non-ASCII shorts are emitted as their low byte, which can never match: a +/// cluster is walked one byte at a time. The spec is what should refuse them, and +/// silently dropping one here would be a flag that vanished. +fn go_byte(c: char) -> String { + match c { + '\'' => "'\\''".to_string(), + '\\' => "'\\\\'".to_string(), + c if c.is_ascii_graphic() => format!("'{c}'"), + c => format!("0x{:02x}", (c as u32) & 0xff), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn go(kdl: &str) -> String { + let spec: Spec = kdl.parse().expect("the fixture spec should parse"); + generate(&spec, &GoOptions::default()) + } + + #[test] + fn a_whole_cli() { + let out = go(r#" +name "ex" +bin "ex" +version "1.2.3" +flag "-v --verbose" global=#true help="be loud" +flag "--color" negate="--no-color" +flag "-j --jobs " +flag "--include ..." var_max=3 +arg "" +arg "[rest]..." var=#true +cmd "install" { + alias "i" + flag "-f --force" + arg "" +} +cmd "config" { + cmd "ls" { + flag "--no-header" + } +} +"#); + insta::assert_snapshot!(out); + } + + /// Inheritance is resolved here so the parser reads one field per command. + #[test] + fn unknown_flags_are_inherited_and_overridable() { + let out = go(r#" +name "ex" +bin "ex" +unknown_flags "error" +cmd "strict" { + cmd "deep" {} +} +cmd "exec" unknown_flags="value" { + cmd "nested" {} +} +"#); + insta::assert_snapshot!(out); + } + + /// mise declares both a `macos-defaults` command and a `macos defaults` path, + /// and both want the same Go identifier. + #[test] + fn colliding_names_get_distinct_identifiers() { + let out = go(r#" +name "ex" +bin "ex" +cmd "macos-defaults" { + flag "--apply" +} +cmd "macos" { + cmd "defaults" { + flag "--apply" + } +} +"#); + insta::assert_snapshot!(out); + } + + #[test] + fn a_default_subcommand_points_into_the_tree() { + let out = go(r#" +name "ex" +bin "ex" +default_subcommand "run" +arg "[task]" +cmd "run" { + arg "[args]..." var=#true +} +"#); + insta::assert_snapshot!(out); + } + + #[test] + fn a_bin_name_that_is_not_an_identifier_still_gives_a_package() { + assert_eq!(package_ident("my-cli"), "mycli"); + assert_eq!(package_ident("7zip"), "cli7zip"); + assert_eq!(package_ident(""), "cli"); + } + + #[test] + fn strings_are_escaped_to_go_rules() { + assert_eq!(go_string(r#"a"b\c"#), r#""a\"b\\c""#); + assert_eq!(go_string("tab\there"), r#""tab\there""#); + // Rust would spell this `\u{7f}`, which Go rejects. + assert_eq!(go_string("\u{7f}"), r#""\x7f""#); + } +} diff --git a/lib/src/go/snapshots/usage__go__tests__a_default_subcommand_points_into_the_tree.snap b/lib/src/go/snapshots/usage__go__tests__a_default_subcommand_points_into_the_tree.snap new file mode 100644 index 000000000..19dbb90ad --- /dev/null +++ b/lib/src/go/snapshots/usage__go__tests__a_default_subcommand_points_into_the_tree.snap @@ -0,0 +1,45 @@ +--- +source: lib/src/go/mod.rs +expression: out +--- +// Code generated by `usage generate go`. DO NOT EDIT. +// +// Binding tables for `ex`, read by +// [github.com/jdx/usage/go/argv]. Regenerate rather than editing: the spec is +// the definition, and a hand-edit here is a difference no reviewer can see. +// +// These are package-level variables holding plain data, so the linker lays them +// out and nothing runs before main. + +package ex + +import "github.com/jdx/usage/go/argv" + +// Keys identify a table entry without a string comparison: switch on the Key an +// event carries rather than on its Name, which is there for diagnostics. +const ( + CmdRoot uint64 = 1 + ArgTask uint64 = 2 + CmdRun uint64 = 3 + ArgRunArgs uint64 = 4 +) + +// Root is the command tree for `ex`. Pass it to argv.New. +var Root = &argv.Command{ + Name: "ex", + Key: CmdRoot, + Args: []*argv.Arg{ + {Key: ArgTask, Name: "task"}, + }, + Subcommands: []*argv.Command{cmdRun}, + DefaultSubcommand: cmdRun, +} + +// run +var cmdRun = &argv.Command{ + Name: "run", + Key: CmdRun, + Args: []*argv.Arg{ + {Key: ArgRunArgs, Name: "args", Var: true}, + }, +} diff --git a/lib/src/go/snapshots/usage__go__tests__a_whole_cli.snap b/lib/src/go/snapshots/usage__go__tests__a_whole_cli.snap new file mode 100644 index 000000000..070023e75 --- /dev/null +++ b/lib/src/go/snapshots/usage__go__tests__a_whole_cli.snap @@ -0,0 +1,85 @@ +--- +source: lib/src/go/mod.rs +expression: out +--- +// Code generated by `usage generate go`. DO NOT EDIT. +// +// Binding tables for `ex`, read by +// [github.com/jdx/usage/go/argv]. Regenerate rather than editing: the spec is +// the definition, and a hand-edit here is a difference no reviewer can see. +// +// These are package-level variables holding plain data, so the linker lays them +// out and nothing runs before main. + +package ex + +import "github.com/jdx/usage/go/argv" + +// Version is what the spec declares, so a caller answering `--version` has it +// without the parse tables carrying a string binding never reads. +const Version = "1.2.3" + +// Keys identify a table entry without a string comparison: switch on the Key an +// event carries rather than on its Name, which is there for diagnostics. +const ( + CmdRoot uint64 = 1 + FlagVerbose uint64 = 2 + FlagColor uint64 = 3 + FlagJobs uint64 = 4 + FlagInclude uint64 = 5 + ArgFile uint64 = 6 + ArgRest uint64 = 7 + CmdInstall uint64 = 8 + FlagInstallForce uint64 = 9 + ArgInstallPkg uint64 = 10 + CmdConfig uint64 = 11 + CmdConfigLs uint64 = 12 + FlagConfigLsNoHeader uint64 = 13 +) + +// Root is the command tree for `ex`. Pass it to argv.New. +var Root = &argv.Command{ + Name: "ex", + Key: CmdRoot, + Flags: []*argv.Flag{ + {Key: FlagVerbose, Name: "verbose", Longs: []string{"verbose"}, Shorts: []byte{'v'}, Global: true}, + {Key: FlagColor, Name: "color", Longs: []string{"color"}, Negate: "no-color"}, + {Key: FlagJobs, Name: "jobs", Longs: []string{"jobs"}, Shorts: []byte{'j'}, TakesValue: true}, + {Key: FlagInclude, Name: "include", Longs: []string{"include"}, TakesValue: true, Variadic: true}, + }, + Args: []*argv.Arg{ + {Key: ArgFile, Name: "file"}, + {Key: ArgRest, Name: "rest", Var: true}, + }, + Subcommands: []*argv.Command{cmdInstall, cmdConfig}, + Version: true, +} + +// install +var cmdInstall = &argv.Command{ + Name: "install", + Key: CmdInstall, + Aliases: []string{"i"}, + Flags: []*argv.Flag{ + {Key: FlagInstallForce, Name: "force", Longs: []string{"force"}, Shorts: []byte{'f'}}, + }, + Args: []*argv.Arg{ + {Key: ArgInstallPkg, Name: "pkg"}, + }, +} + +// config +var cmdConfig = &argv.Command{ + Name: "config", + Key: CmdConfig, + Subcommands: []*argv.Command{cmdConfigLs}, +} + +// config ls +var cmdConfigLs = &argv.Command{ + Name: "ls", + Key: CmdConfigLs, + Flags: []*argv.Flag{ + {Key: FlagConfigLsNoHeader, Name: "no-header", Longs: []string{"no-header"}}, + }, +} diff --git a/lib/src/go/snapshots/usage__go__tests__colliding_names_get_distinct_identifiers.snap b/lib/src/go/snapshots/usage__go__tests__colliding_names_get_distinct_identifiers.snap new file mode 100644 index 000000000..7a100cc9e --- /dev/null +++ b/lib/src/go/snapshots/usage__go__tests__colliding_names_get_distinct_identifiers.snap @@ -0,0 +1,59 @@ +--- +source: lib/src/go/mod.rs +expression: out +--- +// Code generated by `usage generate go`. DO NOT EDIT. +// +// Binding tables for `ex`, read by +// [github.com/jdx/usage/go/argv]. Regenerate rather than editing: the spec is +// the definition, and a hand-edit here is a difference no reviewer can see. +// +// These are package-level variables holding plain data, so the linker lays them +// out and nothing runs before main. + +package ex + +import "github.com/jdx/usage/go/argv" + +// Keys identify a table entry without a string comparison: switch on the Key an +// event carries rather than on its Name, which is there for diagnostics. +const ( + CmdRoot uint64 = 1 + CmdMacosDefaults uint64 = 2 + FlagMacosDefaultsApply uint64 = 3 + CmdMacos uint64 = 4 + CmdMacosDefaults2 uint64 = 5 + FlagMacosDefaultsApply2 uint64 = 6 +) + +// Root is the command tree for `ex`. Pass it to argv.New. +var Root = &argv.Command{ + Name: "ex", + Key: CmdRoot, + Subcommands: []*argv.Command{cmdMacosDefaults, cmdMacos}, +} + +// macos-defaults +var cmdMacosDefaults = &argv.Command{ + Name: "macos-defaults", + Key: CmdMacosDefaults, + Flags: []*argv.Flag{ + {Key: FlagMacosDefaultsApply, Name: "apply", Longs: []string{"apply"}}, + }, +} + +// macos +var cmdMacos = &argv.Command{ + Name: "macos", + Key: CmdMacos, + Subcommands: []*argv.Command{cmdMacosDefaults2}, +} + +// macos defaults +var cmdMacosDefaults2 = &argv.Command{ + Name: "defaults", + Key: CmdMacosDefaults2, + Flags: []*argv.Flag{ + {Key: FlagMacosDefaultsApply2, Name: "apply", Longs: []string{"apply"}}, + }, +} diff --git a/lib/src/go/snapshots/usage__go__tests__unknown_flags_are_inherited_and_overridable.snap b/lib/src/go/snapshots/usage__go__tests__unknown_flags_are_inherited_and_overridable.snap new file mode 100644 index 000000000..c5536ba52 --- /dev/null +++ b/lib/src/go/snapshots/usage__go__tests__unknown_flags_are_inherited_and_overridable.snap @@ -0,0 +1,62 @@ +--- +source: lib/src/go/mod.rs +expression: out +--- +// Code generated by `usage generate go`. DO NOT EDIT. +// +// Binding tables for `ex`, read by +// [github.com/jdx/usage/go/argv]. Regenerate rather than editing: the spec is +// the definition, and a hand-edit here is a difference no reviewer can see. +// +// These are package-level variables holding plain data, so the linker lays them +// out and nothing runs before main. + +package ex + +import "github.com/jdx/usage/go/argv" + +// Keys identify a table entry without a string comparison: switch on the Key an +// event carries rather than on its Name, which is there for diagnostics. +const ( + CmdRoot uint64 = 1 + CmdStrict uint64 = 2 + CmdStrictDeep uint64 = 3 + CmdExec uint64 = 4 + CmdExecNested uint64 = 5 +) + +// Root is the command tree for `ex`. Pass it to argv.New. +var Root = &argv.Command{ + Name: "ex", + Key: CmdRoot, + Subcommands: []*argv.Command{cmdStrict, cmdExec}, + UnknownFlags: argv.UnknownFlagsError, +} + +// strict +var cmdStrict = &argv.Command{ + Name: "strict", + Key: CmdStrict, + Subcommands: []*argv.Command{cmdStrictDeep}, + UnknownFlags: argv.UnknownFlagsError, +} + +// strict deep +var cmdStrictDeep = &argv.Command{ + Name: "deep", + Key: CmdStrictDeep, + UnknownFlags: argv.UnknownFlagsError, +} + +// exec +var cmdExec = &argv.Command{ + Name: "exec", + Key: CmdExec, + Subcommands: []*argv.Command{cmdExecNested}, +} + +// exec nested +var cmdExecNested = &argv.Command{ + Name: "nested", + Key: CmdExecNested, +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index aa40ff22b..2826bc52f 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -25,6 +25,7 @@ pub use error::Result; #[cfg(feature = "docs")] pub mod docs; +pub mod go; pub mod parse; pub mod sdk; pub mod sh; From ad5a6b3e52b50a25169dfaa84926c79b5b1129de Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:35:56 +0000 Subject: [PATCH 2/3] fix(go): resolve a default subcommand against the root, not the whole tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the emitter, one of which the checked-in mise tables in the next commit had already made visible: `Root.DefaultSubcommand` pointed at `oci run` rather than the top-level `run`. `default_subcommand` names a subcommand *of the root* — the spec declares it once at the top — but the resolution scanned every command in the tree and took the first match in depth-first order, which for mise is `oci run`. A parse would then have descended into a command that is not the root's child at all, so `mise build` would have run something under `oci`. The comment above the code already said "the root's own subcommands"; the code just did not do it, which is the version of this mistake that survives review easiest. The root's key was spelled rather than claimed, so a subcommand named `root` was handed `CmdRoot` too and the file declared the constant twice and would not compile. It goes through the same counter as everything else now, and gets `CmdRoot2` as any other collision would. A package name that is not a Go identifier produced a file that would not compile either — `--package my-pkg`, or a spec whose bin is `go` or `type`, both of which are plausible names for a CLI. Names derived from the spec are sanitized, since the author did not choose `bin` for this purpose and cannot be asked to fix it; an explicit `--package` is refused with a message instead, because quietly turning `my-pkg` into `mypkg` is a surprise waiting in somebody's build script. `is_valid_package` is public so the check and the sanitizing cannot drift. Not changed: the fourth finding, that a variadic flag's `var_max` is dropped. The two spellings are different questions and the corpus pins them apart. On the flag's *argument* it bounds one occurrence's values and belongs in the binding table, which is the corpus vector `a-bound-stops-a-variadic-flag`, and the emitter does emit it. On the flag itself it counts *occurrences*, which no single token can decide, so it is a post-binding check — `flag-var-too-many`, labelled post-binding for exactly that reason. usage-argv's tables and the derive's `counts_occurrences` split it the same way. A test now pins both halves so the question does not have to be re-derived next time. Co-Authored-By: Claude Opus 5 --- cli/src/cli/generate/go.rs | 14 +++ lib/src/go/mod.rs | 193 +++++++++++++++++++++++++++++++++---- 2 files changed, 190 insertions(+), 17 deletions(-) diff --git a/cli/src/cli/generate/go.rs b/cli/src/cli/generate/go.rs index b5ff2f5ac..060fb85df 100644 --- a/cli/src/cli/generate/go.rs +++ b/cli/src/cli/generate/go.rs @@ -35,6 +35,20 @@ pub struct Go { impl Go { pub fn run(&self) -> Result<()> { + // Checked here rather than sanitized, because this one came from a person: + // quietly turning `--package my-pkg` into `mypkg` is a surprise waiting in + // somebody's build script, and the file would not compile if it were not + // sanitized at all. + if let Some(package) = &self.package { + if !usage::go::is_valid_package(package) { + miette::bail!( + "`--package {package}` is not a Go package name. It must be \ + letters, digits and underscores, not start with a digit, and \ + not be one of Go's keywords." + ); + } + } + let spec = generate::file_or_spec(&self.file, &self.spec)?; let out = usage::go::generate( &spec, diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 4b4d449c3..cba47ec53 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -40,6 +40,10 @@ use crate::{Spec, SpecArg, SpecCommand, SpecDoubleDashChoices, SpecFlag}; pub struct GoOptions { /// The Go package clause. Defaults to the spec's `bin`, made into an /// identifier. + /// + /// Must satisfy [`is_valid_package`]. A caller taking this from a user should + /// check it and say so; one that does not gets it sanitized, because emitting a + /// file that cannot compile helps nobody. pub package: Option, } @@ -69,10 +73,14 @@ struct Emitter<'a> { impl<'a> Emitter<'a> { fn new(spec: &'a Spec, opts: &GoOptions) -> Self { - let package = opts - .package - .clone() - .unwrap_or_else(|| package_ident(&spec.bin)); + // An explicit package that is not an identifier is sanitized rather than + // emitted: a caller that wants to reject it should ask `is_valid_package` + // first, which the CLI does. + let package = match opts.package.as_deref() { + Some(name) if is_valid_package(name) => name.to_string(), + Some(name) => package_ident(name), + None => package_ident(&spec.bin), + }; Emitter { spec, package, @@ -138,7 +146,11 @@ impl<'a> Emitter<'a> { let named = if root { self.next_key += 1; Named { - key: "CmdRoot".to_string(), + // Claimed through the same counter as everything else, not just + // spelled: a subcommand named `root` would otherwise be handed + // `CmdRoot` too, and the file would declare the constant twice and + // fail to compile. + key: self.unique("CmdRoot"), var: "Root".to_string(), number: self.next_key, } @@ -244,17 +256,21 @@ impl<'a> Emitter<'a> { } fn tables(&mut self, commands: &[Emitted]) { - // Resolved once, against the root's own subcommands, because the spec - // declares it once at the top. A name nothing answers to is left unset - // rather than guessed at. + // Resolved once, against the root's *direct* subcommands, because the spec + // declares it once at the top and it names one of them. Searching the whole + // tree instead is what wired mise's `default_subcommand run` to `oci run`, + // which comes first in a depth-first walk — the parser would then have + // descended into a command that is not the root's child at all. A name + // nothing answers to is left unset rather than guessed at. let default_subcommand = self.spec.default_subcommand.as_ref().and_then(|name| { - commands + commands[0] + .subcommands .iter() + .map(|at| &commands[*at]) .find(|e| { - !e.root - && (&e.cmd.name == name - || e.cmd.aliases.contains(name) - || e.cmd.hidden_aliases.contains(name)) + &e.cmd.name == name + || e.cmd.aliases.contains(name) + || e.cmd.hidden_aliases.contains(name) }) .map(|e| e.named.var.clone()) }); @@ -506,14 +522,66 @@ fn clamp_var_max(max: usize) -> u32 { u32::try_from(max).unwrap_or(u32::MAX) } +/// Go's reserved words, which cannot be a package name. +/// +/// Not hypothetical: `go`, `range`, `select`, `import` and `package` are all +/// plausible names for a CLI, and `package go` does not compile. +const GO_KEYWORDS: &[&str] = &[ + "break", + "case", + "chan", + "const", + "continue", + "default", + "defer", + "else", + "fallthrough", + "for", + "func", + "go", + "goto", + "if", + "import", + "interface", + "map", + "package", + "range", + "return", + "select", + "struct", + "switch", + "type", + "var", +]; + +/// Whether a string can be written after `package`. +/// +/// Deliberately ASCII-only. Go itself allows a Unicode letter, but a package name +/// that needs one is a worse problem for an adopter than the restriction is. +pub fn is_valid_package(name: &str) -> bool { + !name.is_empty() + && !GO_KEYWORDS.contains(&name) + && !name.starts_with(|c: char| c.is_ascii_digit()) + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + /// A Go package identifier from a binary name: `my-cli` is not one, `mycli` is. +/// +/// Only ever applied to a name derived from the spec, which the author did not +/// choose for this purpose and cannot be asked to fix. A `--package` given +/// explicitly is checked rather than mangled — see [`is_valid_package`] — because +/// silently emitting `mypkg` for someone who asked for `my-pkg` is a surprise +/// waiting in a build script. fn package_ident(bin: &str) -> String { - let cleaned: String = bin + let lowered: String = bin .chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect(); - let lowered = cleaned.to_ascii_lowercase(); - if lowered.is_empty() || lowered.starts_with(|c: char| c.is_ascii_digit()) { + .collect::() + .to_ascii_lowercase(); + if lowered.is_empty() + || lowered.starts_with(|c: char| c.is_ascii_digit()) + || GO_KEYWORDS.contains(&lowered.as_str()) + { format!("cli{lowered}") } else { lowered @@ -648,6 +716,97 @@ cmd "run" { assert_eq!(package_ident("my-cli"), "mycli"); assert_eq!(package_ident("7zip"), "cli7zip"); assert_eq!(package_ident(""), "cli"); + // `package go` does not compile, and `go` is a plausible name for a CLI. + assert_eq!(package_ident("go"), "cligo"); + assert_eq!(package_ident("type"), "clitype"); + } + + #[test] + fn a_package_that_would_not_compile_is_refused_rather_than_emitted() { + assert!(is_valid_package("mycli")); + assert!(is_valid_package("mise_tables")); + assert!(!is_valid_package("my-pkg")); + assert!(!is_valid_package("7zip")); + assert!(!is_valid_package("")); + assert!(!is_valid_package("range")); + + // A library caller that skips the check still gets a file that compiles. + let spec: Spec = "name \"ex\"\nbin \"ex\"\n".parse().unwrap(); + let out = generate( + &spec, + &GoOptions { + package: Some("my-pkg".into()), + }, + ); + assert!(out.contains("package mypkg"), "{out}"); + } + + /// The bug the checked-in mise tables caught: `default_subcommand run` was + /// wired to `oci run`, which a depth-first walk reaches first. + /// + /// It names a subcommand *of the root*, so nothing deeper is a candidate — and + /// the parser would otherwise descend into a command that is not the root's + /// child at all. + #[test] + fn a_default_subcommand_ignores_a_deeper_command_of_the_same_name() { + let out = go(r#" +name "ex" +bin "ex" +default_subcommand "run" +cmd "oci" { + cmd "run" {} +} +cmd "run" { + arg "[args]..." var=#true +} +"#); + assert!( + out.contains("DefaultSubcommand: cmdRun,"), + "should point at the root's own `run`, got:\n{out}" + ); + } + + /// A subcommand actually named `root` wants the constant the root has. + #[test] + fn a_subcommand_named_root_does_not_collide_with_the_root() { + let out = go(r#" +name "ex" +bin "ex" +cmd "root" { + flag "--wat" +} +"#); + // By first token, because the const block is column-aligned: matching + // "CmdRoot uint64" would find nothing and pass for the wrong reason. + let declared = |name: &str| { + out.lines() + .filter(|l| l.split_whitespace().next() == Some(name)) + .count() + }; + assert_eq!(declared("CmdRoot"), 1, "CmdRoot declared twice:\n{out}"); + assert_eq!(declared("CmdRoot2"), 1, "no distinct key for it:\n{out}"); + } + + /// The two `var_max` are different questions, and the corpus pins them apart: + /// on a flag's *argument* it bounds one occurrence's values and belongs in the + /// binding table, while on the flag it counts occurrences and is checked after + /// the parse. + #[test] + fn only_the_per_occurrence_bound_reaches_the_table() { + let out = go(r#" +name "ex" +bin "ex" +flag "--include ..." { + arg "..." var=#true var_max=2 +} +flag "--tag " var=#true var_max=1 +"#); + assert!( + out.contains("Name: \"include\", Longs: []string{\"include\"}, TakesValue: true, Variadic: true, VarMax: 2"), + "{out}" + ); + let tag = out.lines().find(|l| l.contains("\"tag\"")).unwrap(); + assert!(!tag.contains("VarMax"), "occurrence bound leaked: {tag}"); } #[test] From 858445b88b1d02db82e7b329aa9380f808a8091e Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:35:53 +0000 Subject: [PATCH 3/3] fix(go): reserve the suffixed identifier, and refuse two more package names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up findings on the emitter, both about emitting a file that does not compile. `unique` counted per base and never claimed the spelling it handed out, so a third command could be given an identifier the second had already taken: `macos-defaults` and `macos defaults` produce `CmdMacosDefaults` and `CmdMacosDefaults2`, and a command named `macos-defaults2` then asked for `CmdMacosDefaults2` directly and got it. It searches for a free candidate now and reserves it. The third lands on `CmdMacosDefaults22`, which is unlovely and unique; the test asserts no constant is declared twice rather than pinning the suffix scheme, which is the property that actually matters. `_` and `init` are refused as package names, for two different reasons that are worth keeping straight. `package _` is rejected where it is written — `invalid package name _`. `package init` declares perfectly well and cannot be *imported*: an import binds the package name as an identifier in file scope, and `init` may only be a func, so an importer gets `cannot import package as init - init must be a func`. A table package exists to be imported, so it is out either way. Both checked against the compiler rather than taken from the citation offered, which is about the import and would have had this rejecting `package init` for a reason that is not true. `__` stays valid, and only the exact names are reserved, so `initialize` is fine. `package_ident` now asks `is_valid_package` instead of repeating its conditions, so the sanitizer cannot come to disagree with the check about what is acceptable — and a test asserts that every name the sanitizer produces is one the validator accepts. Co-Authored-By: Claude Opus 5 --- lib/src/go/mod.rs | 116 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index cba47ec53..b1c11f403 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -95,13 +95,28 @@ impl<'a> Emitter<'a> { /// Collisions are ordinary rather than exotic: mise has both a `macos-defaults` /// command and a `macos defaults` path, and both want to be spelled /// `CmdMacosDefaults`. + /// + /// The suffixed spelling is reserved too, and the loop is what makes that + /// safe. Counting alone was not enough: `macos-defaults` and `macos defaults` + /// produce `CmdMacosDefaults` and `CmdMacosDefaults2`, and a third command + /// named `macos-defaults2` asks for `CmdMacosDefaults2` directly — which was + /// unclaimed, so the file declared it twice and did not compile. fn unique(&mut self, base: &str) -> String { - let n = self.taken.entry(base.to_string()).or_insert(0); - *n += 1; - if *n == 1 { - base.to_string() - } else { - format!("{base}{n}") + let mut n = self.taken.get(base).copied().unwrap_or(0); + loop { + n += 1; + let candidate = if n == 1 { + base.to_string() + } else { + format!("{base}{n}") + }; + if !self.taken.contains_key(&candidate) { + self.taken.insert(base.to_string(), n); + // The spelling itself, so a later entry that asks for it by name is + // suffixed rather than handed a duplicate. + self.taken.entry(candidate.clone()).or_insert(0); + return candidate; + } } } @@ -554,13 +569,28 @@ const GO_KEYWORDS: &[&str] = &[ "var", ]; -/// Whether a string can be written after `package`. +/// Two more names a table package cannot have, for two different reasons. +/// +/// `_` is refused where it is written: `invalid package name _`. `init` declares +/// perfectly well and cannot be *imported* — an import binds the package name as +/// an identifier in file scope, and `init` may only be a func, so an importer gets +/// `cannot import package as init - init must be a func`. A table package exists +/// to be imported, so it is out either way. +/// +/// Both checked against the compiler rather than taken from a citation. The issue +/// usually cited for `init` is about the import, and `package init` on its own +/// does build — so a validator written from the citation would have rejected it +/// for a reason that is not true. +const UNUSABLE_PACKAGE_NAMES: &[&str] = &["_", "init"]; + +/// Whether a string can be written after `package` and then imported. /// /// Deliberately ASCII-only. Go itself allows a Unicode letter, but a package name /// that needs one is a worse problem for an adopter than the restriction is. pub fn is_valid_package(name: &str) -> bool { !name.is_empty() && !GO_KEYWORDS.contains(&name) + && !UNUSABLE_PACKAGE_NAMES.contains(&name) && !name.starts_with(|c: char| c.is_ascii_digit()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') } @@ -578,13 +608,13 @@ fn package_ident(bin: &str) -> String { .filter(|c| c.is_ascii_alphanumeric() || *c == '_') .collect::() .to_ascii_lowercase(); - if lowered.is_empty() - || lowered.starts_with(|c: char| c.is_ascii_digit()) - || GO_KEYWORDS.contains(&lowered.as_str()) - { - format!("cli{lowered}") - } else { + if is_valid_package(&lowered) { lowered + } else { + // One rule rather than a second copy of the conditions, so the sanitizer + // cannot come to disagree with the validator about what is acceptable. + // `cli` in front keeps it recognizable: `cligo`, `cli7zip`, `cliinit`. + format!("cli{lowered}") } } @@ -719,6 +749,62 @@ cmd "run" { // `package go` does not compile, and `go` is a plausible name for a CLI. assert_eq!(package_ident("go"), "cligo"); assert_eq!(package_ident("type"), "clitype"); + // `package _` is refused outright; `package init` declares fine and cannot + // be imported, which for a table package is the same thing. + assert_eq!(package_ident("_"), "cli_"); + assert_eq!(package_ident("init"), "cliinit"); + // Two underscores is fine, and only the exact name is reserved. + assert_eq!(package_ident("__"), "__"); + assert_eq!(package_ident("initialize"), "initialize"); + + // Whatever it produces must be something the validator accepts, for every + // one of these — the sanitizer disagreeing with the check is how a file + // that does not compile gets emitted. + for bin in [ + "my-cli", "7zip", "", "go", "type", "_", "init", "__", "MiSe", + ] { + let out = package_ident(bin); + assert!(is_valid_package(&out), "{bin:?} sanitized to {out:?}"); + } + } + + /// Counting alone let a third command collide with a generated suffix. + #[test] + fn a_name_matching_a_generated_suffix_still_gets_its_own() { + let out = go(r#" +name "ex" +bin "ex" +cmd "macos-defaults" {} +cmd "macos" { + cmd "defaults" {} +} +cmd "macos-defaults2" {} +"#); + // The invariant, not a guess at the spelling. The third command lands on + // `CmdMacosDefaults22` rather than `...3`, which is unlovely and correct; + // asserting the exact name would pin the suffix scheme instead of the + // property that matters, which is that nothing is declared twice. + assert_declares_each_constant_once(&out); + } + + /// Every constant in the emitted `const` block, in declaration order. + fn constant_names(out: &str) -> Vec<&str> { + out.lines() + .skip_while(|l| !l.starts_with("const (")) + .skip(1) + .take_while(|l| !l.starts_with(')')) + .filter_map(|l| l.split_whitespace().next()) + .collect() + } + + /// Two entries sharing a constant is a file that does not compile. + fn assert_declares_each_constant_once(out: &str) { + let names = constant_names(out); + assert!(!names.is_empty(), "no constants at all:\n{out}"); + let mut seen = std::collections::HashSet::new(); + for name in &names { + assert!(seen.insert(*name), "{name} is declared twice:\n{out}"); + } } #[test] @@ -729,6 +815,9 @@ cmd "run" { assert!(!is_valid_package("7zip")); assert!(!is_valid_package("")); assert!(!is_valid_package("range")); + assert!(!is_valid_package("_")); + assert!(!is_valid_package("init")); + assert!(is_valid_package("__")); // A library caller that skips the check still gets a file that compiles. let spec: Spec = "name \"ex\"\nbin \"ex\"\n".parse().unwrap(); @@ -785,6 +874,7 @@ cmd "root" { }; assert_eq!(declared("CmdRoot"), 1, "CmdRoot declared twice:\n{out}"); assert_eq!(declared("CmdRoot2"), 1, "no distinct key for it:\n{out}"); + assert_declares_each_constant_once(&out); } /// The two `var_max` are different questions, and the corpus pins them apart: