Skip to content

test(parse): add normative argv grammar and conformance corpus - #797

Merged
jdx merged 2 commits into
mainfrom
agent/argv-grammar
Aug 11, 2026
Merged

test(parse): add normative argv grammar and conformance corpus#797
jdx merged 2 commits into
mainfrom
agent/argv-grammar

Conversation

@jdx

@jdx jdx commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Why

How argv binds against a spec is currently defined only by lib/src/parse.rs. That is fine while usage-lib is the only parser, and a problem as soon as it is not: a second implementation — a Rust parser, a Go port, a completion script doing its own matching — has no way to know whether it agrees, and no way to prove it.

This writes the grammar down and makes it executable, so "compatible with usage" becomes a thing you can run rather than a thing you assert.

What's here

docs/spec/argv.md — the normative grammar, published at /spec/argv. Token classification, the single left-to-right pass, long and short flag forms (attached, =-attached, detached), positional filling, subcommand routing and flag scope, the -- separator and the double_dash modes, where values come from when argv doesn't supply them, and the error classes.

corpus/ — 83 JSON vectors covering the same ground. Deliberately language-neutral: a Go, JS, or Python implementation can run these without reimplementing a test format. corpus/README.md documents the format for exactly that audience.

conformance/ — the harness (publish = false). cargo test -p usage-conformance checks the corpus is well formed and that every vector's reference label is accurate. cargo run -p usage-conformance --bin oracle reports what usage-lib actually does with each vector, which is how the labels got filled in.

The interesting part: 15 of 83 vectors diverge

Every vector records whether usage-lib agrees, as a measurement, and the suite fails if a label is wrong in either direction — so a divergence that gets fixed reports itself as "delete this label" rather than rotting into folklore.

Four causes, all in lib/src/parse.rs:

  1. Unrecognized flags fall through to positionals. ex --wat binds --wat to a free argument, or reports unexpected_arg if there isn't one. This accounts for most of the divergences, including the cases where the grammar and usage-lib agree a flag is out of scope but disagree on which error to report.
  2. A flag missing its value is dropped silently. ex --jobs parses successfully with nothing bound, so a forgotten value looks like a working command. ex --jobs --force likewise binds force and leaves jobs unset.
  3. = is kept in attached short values. -j=8 binds =8.
  4. A repeated -- is eaten, so a forwarded command line containing its own separator is altered in transit.

Plus two smaller gaps: --jobs= binds nothing rather than the empty string, and a flag with a variadic argument (--include <pattern>..., which /spec/reference/flag documents) rejects its second value.

I've written the grammar as the intent and left usage-lib's behavior labeled, rather than describing current behavior as correct — several of these look like bugs worth fixing, and the corpus is how a fix would be verified. Happy to flip any of them the other way if you'd rather the grammar match the implementation; that's a one-line change per vector.

Not covered yet

restart_token (mise's :::) needs a multi-invocation expectation shape, mount needs process stubbing, and parse_partial is a different contract that deserves its own corpus. All three are noted in the doc.

Context

Groundwork for a compiled Rust parser (usage-rs), but it stands on its own: it makes usage-lib a verified reference implementation rather than an assumed one, and it is the artifact any other-language port would be built against.

AI-assisted — Tool: Claude Code; model: anthropic/claude-fable-5; version: unavailable.


Note

Low Risk
New test/documentation and a non-published conformance crate; production parsing in lib is unchanged, with divergences recorded rather than fixed here.

Overview
Introduces a normative argv grammar (docs/spec/argv.md, linked from the spec nav) so parsing behavior is defined outside usage-lib alone, and backs it with an executable conformance corpus (corpus/*.json) of spec/argv/expected-result vectors across flags, positionals, subcommands, globals, --, env/defaults, and post-bind constraints.

Adds workspace crate usage-conformance: loads corpus JSON with strict typing, runs each vector through usage-lib via a reference adapter (including error-class mapping from diagnostics), cargo test -p usage-conformance for corpus integrity and accurate per-vector reference labels (agrees vs diverges), and an oracle binary to report observed vs expected when authoring vectors. Vectors that intentionally disagree with current usage-lib behavior are explicitly labeled so CI catches fixed divergences; the doc summarizes known gaps (e.g. unknown flags falling through to positionals) without changing the parser in this PR.

Reviewed by Cursor Bugbot for commit 38b4b75. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Added command-line argument conformance testing and reference reporting.
    • Added coverage for flags, positionals, subcommands, globals, --, environment variables, defaults, choices, and required values.
    • Added machine-readable and human-readable result formats.
  • Documentation

    • Added the normative argv grammar specification and corpus usage guidance.
    • Added argv grammar documentation to the navigation.
  • Tests

    • Added validation for corpus integrity, specifications, unique identifiers, and reference results.

How argv binds against a spec was defined only by lib/src/parse.rs, so a
second implementation had no way to know whether it agreed and no way to
prove it. This writes the grammar down and makes it executable.

docs/spec/argv.md is the normative description: token classification,
long and short flag forms, positional filling, subcommand routing, flag
scope, the `--` separator, and the error classes. corpus/ is the same
thing as 83 JSON vectors, deliberately language-neutral so a Go, JS, or
Python implementation can run them without reimplementing a test format.

Each vector records whether usage-lib agrees, measured rather than
assumed, and the test suite fails if a label is wrong in either
direction — so a divergence that gets fixed reports itself instead of
rotting into folklore. 15 of 83 currently diverge, from four causes
documented in the doc: unrecognized flags fall through to positionals, a
flag missing its value is silently dropped, `=` is kept in attached short
values, and a repeated `--` is eaten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The workspace adds an argv conformance crate. It defines corpus schemas, executes vectors through usage-lib, validates reference labels, provides an oracle binary, adds coverage vectors, and documents the argv grammar.

Changes

ARGV conformance

Layer / File(s) Summary
Corpus contract and loading
Cargo.toml, conformance/Cargo.toml, conformance/src/lib.rs
The workspace includes the conformance crate. The crate defines corpus data types, expectations, error codes, reference labels, JSON loading, and corpus path resolution.
Reference execution and validation
conformance/src/reference.rs, conformance/tests/reference.rs
The adapter runs vectors with usage-lib, converts parsed values, classifies parser diagnostics, detects duplicate IDs, and validates declared reference results.
ARGV behavior corpus and specification
corpus/*.json, corpus/README.md, docs/spec/argv.md, docs/.vitepress/config.mts
The corpus covers flags, positionals, subcommands, globals, --, environment and defaults, choices, and required inputs. The documentation defines the grammar, vector format, divergences, and validation commands.
Oracle evaluation and reporting
conformance/src/bin/oracle.rs
The oracle loads vectors, supports ID filtering and JSON output, compares observed and expected results, and reports vector and reference-label counts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Oracle
  participant CorpusLoader
  participant ReferenceAdapter
  participant UsageLib
  Oracle->>CorpusLoader: load JSON vectors
  Oracle->>ReferenceAdapter: run each vector
  ReferenceAdapter->>UsageLib: parse spec, argv, and environment
  UsageLib-->>ReferenceAdapter: parsed values or diagnostic
  ReferenceAdapter-->>Oracle: observed result
  Oracle-->>Oracle: compare expectation and reference label
Loading

Possibly related PRs

  • jdx/usage#762: The corpus tests and classifies required--- positional behavior introduced by this PR.

Poem

A rabbit checks each flag and arg,
Through corpus paths both neat and large.
The oracle records each trail,
And marks agreement or detail.
-- points the parsing way—
Conformance runs today.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the addition of the normative argv grammar and conformance corpus, which are the main changes in the pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▃▁▂█▄▅▄▃▂▂▅▅▂▆▃ 110,572,763 → 110,533,628 -0.04% 10.77 → 10.70ms -0.61%
startup ▁▃▃▁▄▅██▅▅▆▆▇▆▆▆ 1,201,455 → 1,201,475 +0.00% 0.93 → 0.99ms +5.80%

No instruction-count regression above 1%.

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

38b4b7527f54 vs 54e60e68ddb6 · measured on the runner, not pushed to the history.

@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR defines a normative argv grammar and adds a language-neutral conformance corpus backed by a Rust reference harness.

  • Documents token classification, flag and positional binding, subcommand routing, separators, defaults, and error classes.
  • Adds corpus vectors covering long and short flags, positionals, subcommands, globals, double-dash behavior, fallback values, choices, and required or bounded values.
  • Adds workspace tests and an oracle binary that compare corpus expectations with usage-lib behavior while tracking declared divergences.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
docs/spec/argv.md Defines the normative argv parsing contract and documents known differences from usage-lib.
corpus/06-double-dash.json Adds separator vectors, including executable coverage for double_dash="automatic".
corpus/08-choices-and-required.json Adds required-value, choice, and flag var_min/var_max vectors that resolve the previously reported flag-bound coverage gap.
conformance/src/lib.rs Defines the language-neutral corpus schema and strict JSON loader.
conformance/src/reference.rs Adapts usage-lib parse results and diagnostics into the corpus result model.
conformance/tests/reference.rs Validates corpus integrity and bidirectional accuracy of reference divergence labels.

Reviews (2): Last reviewed commit: "test(parse): close corpus gaps and harde..." | Re-trigger Greptile

Comment thread docs/spec/argv.md

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@conformance/src/bin/oracle.rs`:
- Around line 75-78: Update the diagnostic condition in the vector-reporting
logic around agrees and declared so expected and observed values are printed for
every mislabeled vector, including the agrees=true and declared=false case that
emits MISM. Preserve the existing output formatting and only broaden the
condition governing those two println! calls.

In `@conformance/src/lib.rs`:
- Around line 19-26: Add #[serde(deny_unknown_fields)] to the VectorFile,
Vector, and Parsed structs so deserialization rejects unrecognized JSON fields
instead of silently applying defaults. Keep the existing serde derives and field
definitions unchanged.
- Around line 143-147: Update the directory iteration that builds paths to
propagate individual read_dir entry failures instead of silently discarding them
via filter_map(Result::ok). Convert each entry error into the existing
descriptive error format, while preserving JSON extension filtering and the
surrounding error-return behavior.

In `@conformance/src/reference.rs`:
- Around line 97-137: Update the unexpected-argument branch in classify to match
only msg.contains("unexpected word:"). Remove the broader "Unexpected argument"
and "unexpected" checks while preserving the existing UnexpectedArg
classification for usage-lib’s unexpected word diagnostics.

In `@corpus/01-long-flags.json`:
- Line 4: Reconcile the corpus total for the eight corpus files: either add the
six intended vectors so the collection contains 83 vectors, or update the stated
objective/count to accurately reflect the existing 77 vectors before publishing.

In `@corpus/07-env-and-defaults.json`:
- Around line 60-65: Rename the test vector ID from
env-does-not-satisfy-required-arg-position to
env-satisfies-required-arg-position, leaving its specification, environment,
arguments, and expected result unchanged.

In `@docs/spec/argv.md`:
- Around line 42-52: Update the token-reading algorithm and related
subcommand-routing rules to explicitly define when descent into a matching
subcommand remains available. State that routing is allowed only before any
positional binding consumes a word, and that positional bindings—especially
variadic or otherwise consuming bindings—disable further subcommand descent,
preserving the intended behavior for inputs such as “ex other install.”
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 6df69cd6-015f-4e49-8d91-529e6839c9a3

📥 Commits

Reviewing files that changed from the base of the PR and between 54e60e6 and ac8143c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • Cargo.toml
  • conformance/Cargo.toml
  • conformance/src/bin/oracle.rs
  • conformance/src/lib.rs
  • conformance/src/reference.rs
  • conformance/tests/reference.rs
  • corpus/01-long-flags.json
  • corpus/02-short-flags.json
  • corpus/03-positionals.json
  • corpus/04-subcommands.json
  • corpus/05-globals.json
  • corpus/06-double-dash.json
  • corpus/07-env-and-defaults.json
  • corpus/08-choices-and-required.json
  • corpus/README.md
  • docs/.vitepress/config.mts
  • docs/spec/argv.md

Comment thread conformance/src/bin/oracle.rs Outdated
Comment thread conformance/src/lib.rs
Comment thread conformance/src/lib.rs
Comment on lines +97 to +137
/// Map a usage-lib error onto one of the grammar's error codes.
///
/// Matching on the rendered message rather than on `UsageErr` variants: parsing
/// returns a `miette::Error`, so the concrete type is erased by the time it gets
/// here, and `InvalidFlag` would need its `reason` inspected anyway. The strings
/// below are load-bearing and will need revisiting whenever usage-lib rewords a
/// diagnostic — which is why an unrecognized message becomes
/// [`Observed::Unclassified`] and fails loudly instead of quietly matching
/// whatever the vector expected.
fn classify(msg: &str) -> Observed {
let code = if msg.contains("Missing required flag") {
ErrorCode::MissingRequiredFlag
} else if msg.contains("Missing required arg") {
ErrorCode::MissingRequiredArg
} else if msg.contains("can only be set after a `--` separator") {
ErrorCode::ArgRequiresDoubleDash
} else if msg.contains("requires at least") {
// Both the arg and flag forms of "too few" say this; they differ only in
// whether the subject is rendered as <name> or --name, and the corpus
// does not distinguish subject.
ErrorCode::VarTooFew
} else if msg.contains("accepts at most") {
ErrorCode::VarTooMany
} else if msg.contains("Invalid flag") {
// usage-lib funnels several distinct situations through InvalidFlag, so
// the reason has to be read to tell them apart.
if msg.contains("requires an argument") || msg.contains("missing value") {
ErrorCode::MissingFlagValue
} else if msg.contains("Invalid choice") || msg.contains("expected one of") {
ErrorCode::InvalidChoice
} else {
ErrorCode::UnknownFlag
}
} else if msg.contains("Invalid choice") || msg.contains("expected one of") {
ErrorCode::InvalidChoice
} else if msg.contains("Unexpected argument") || msg.contains("unexpected") {
ErrorCode::UnexpectedArg
} else {
return Observed::Unclassified(msg.to_string());
};
Observed::Failed(code)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the canonical usage-lib diagnostic before narrowing this matcher.
rg -n -C 4 'Unexpected argument|unexpected|UsageErr' lib

Repository: jdx/usage

Length of output: 45794


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- conformance/src/reference.rs ---'
cat -n conformance/src/reference.rs | sed -n '80,150p'

printf '%s\n' '--- UsageErr display definitions ---'
cat -n lib/src/error.rs | sed -n '1,100p'

printf '%s\n' '--- parser diagnostic construction ---'
cat -n lib/src/parse.rs | sed -n '880,960p'
cat -n lib/src/parse.rs | sed -n '1220,1310p'

printf '%s\n' '--- conformance references and dependency metadata ---'
rg -n -C 3 'Unexpected argument|UnexpectedArg|unclassified|classify\(|usage-lib|path =|git =|version' conformance Cargo.toml Cargo.lock 2>/dev/null || true

Repository: jdx/usage

Length of output: 50366


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- parse return path ---'
rg -n -C 5 'pub fn parse|fn parse|bail!\("unexpected word|miette::Result|anyhow' lib/src/parse.rs lib/src/lib.rs

printf '%s\n' '--- exact diagnostic strings ---'
rg -n -C 2 'Unexpected argument|unexpected word|unexpected' conformance lib/src --glob '!**/bash-completion/**'

printf '%s\n' '--- corpus vectors involving unexpected arguments ---'
rg -n -C 8 -i 'unexpected[_ ]arg|unexpected argument|too many|extra argument' conformance corpus . --glob '*.json' --glob '*.yaml' --glob '*.yml' 2>/dev/null | head -n 300

printf '%s\n' '--- source-level matcher probe ---'
python3 - <<'PY'
from pathlib import Path
import re

reference = Path("conformance/src/reference.rs").read_text()
library = Path("lib/src/parse.rs").read_text()

m = re.search(r'bail!\("unexpected word: \{w\}"\)', library)
print("usage-lib emits canonical unexpected diagnostic:", bool(m))
print("classifier matches `Unexpected argument`:", "msg.contains(\"Unexpected argument\")" in reference)
print("classifier matches generic `unexpected`:", "msg.contains(\"unexpected\")" in reference)

for msg in ["unexpected word: value", "Unexpected argument: value", "unexpected number of arguments"]:
    broad = "unexpected" in msg
    title_only = "Unexpected argument" in msg
    print(f"{msg!r}: broad={broad}, title_only={title_only}")
PY

Repository: jdx/usage

Length of output: 30275


Narrow the unexpected-argument matcher to unexpected word:.

usage-lib emits unexpected-argument errors as unexpected word: {w}, not Unexpected argument. Match msg.contains("unexpected word:") to avoid classifying unrelated diagnostics as UnexpectedArg.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@conformance/src/reference.rs` around lines 97 - 137, Update the
unexpected-argument branch in classify to match only msg.contains("unexpected
word:"). Remove the broader "Unexpected argument" and "unexpected" checks while
preserving the existing UnexpectedArg classification for usage-lib’s unexpected
word diagnostics.

Comment thread corpus/01-long-flags.json
{
"section": "long-flags",
"about": "How `--name` tokens bind. Long names match exactly — there is no abbreviation inference — and a value may be attached with `=` or given as the following word.",
"vectors": [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reconcile the corpus count.

The eight corpus files in this PR contain 77 vectors, but the PR objective states 83 vectors. Add the six intended vectors or correct the stated count before publishing the corpus.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@corpus/01-long-flags.json` at line 4, Reconcile the corpus total for the
eight corpus files: either add the six intended vectors so the collection
contains 83 vectors, or update the stated objective/count to accurately reflect
the existing 77 vectors before publishing.

Comment on lines +60 to +65
"id": "env-does-not-satisfy-required-arg-position",
"doc": "An env-backed required positional is satisfied by the environment, so the command line need not supply it.",
"spec": "name \"ex\"\nbin \"ex\"\narg \"<file>\" env=\"EX_FILE\"\n",
"argv": [],
"env": { "EX_FILE": "x.txt" },
"expect": { "ok": { "args": { "file": "x.txt" } } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Rename the vector to state its behavior.

env-does-not-satisfy-required-arg-position says that the environment does not satisfy the required argument. The documentation and expectation show that it does. Rename it to env-satisfies-required-arg-position.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@corpus/07-env-and-defaults.json` around lines 60 - 65, Rename the test vector
ID from env-does-not-satisfy-required-arg-position to
env-satisfies-required-arg-position, leaving its specification, environment,
arguments, and expected result unchanged.

Comment thread docs/spec/argv.md
Comment on lines +42 to +52
At each token, in order:

1. If flag interpretation has stopped (a `--` was consumed), the token is a
value.
2. If the token is exactly `--`, flag interpretation stops. The token is consumed
and is not itself a value.
3. If the token is flag-like, it is matched as a flag ([long](#long-flags) or
[short](#short-flags)). No match is an error.
4. Otherwise the token is a word: it selects a [subcommand](#subcommands) if one
matches, and is otherwise offered to the command's
[positional arguments](#positional-arguments).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Define the subcommand routing condition in the algorithm.

Step 4 says that every matching word selects a subcommand. Lines 131-133 later prohibit routing after a positional consumes a word. The grammar does not define this state in the token-reading algorithm.

State when descent remains available, and state which positional bindings disable it. This removes ambiguity for inputs such as ex other install.

Also applies to: 121-133

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/spec/argv.md` around lines 42 - 52, Update the token-reading algorithm
and related subcommand-routing rules to explicitly define when descent into a
matching subcommand remains available. State that routing is allowed only before
any positional binding consumes a word, and that positional bindings—especially
variadic or otherwise consuming bindings—disable further subcommand descent,
preserving the intended behavior for inputs such as “ex other install.”

Review feedback on the corpus contract and its coverage.

The loader accepted unknown fields, so a misspelled `reference` would
default to `agrees` and a misspelled `flags` would become an empty
expectation — a malformed vector could load and pass while testing
nothing. All three corpus types now deny unknown fields. A failed
directory entry was also being skipped rather than reported, which would
let CI validate a partial corpus and still pass.

Coverage: the grammar described `double_dash="automatic"` and variadic
bounds with nothing exercising the first and only positional cases for the
second, so an implementation could ignore both and still pass. Adds a
vector for automatic mode — which usage-lib does not enforce, as the arg
reference already says — and two for flag-level var_min and var_max, which
it does.

86 vectors, 16 recorded divergences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jdx
jdx merged commit e32539d into main Aug 11, 2026
9 checks passed
@jdx
jdx deleted the agent/argv-grammar branch August 11, 2026 01:08
jdx added a commit that referenced this pull request Aug 11, 2026
Stacked on #797 — review that one first; this PR's diff against `main`
will include it until it merges.

## What this is

`argv/` — a new crate, `usage-argv`, implementing the binding half of
[the grammar from #797](https://usage.jdx.dev/spec/argv). No command
tree, no allocation, one pass over `argv`.

Not published (name is reserved on crates.io at 0.0.0), and deliberately
outside the shared-version release cycle, the way `clap_usage` already
is.

## Two design decisions worth a look

**Events, not a map.** Parsing yields `Event::{Command, Flag, Arg}`
rather than returning a structure. A map would have to allocate and then
be read back out; an event can be assigned straight into a struct field
by generated code. Same reasoning as serde deserializing into your type
instead of into a `Value`.

**Values are `&[u8]`.** Borrowed from `argv`, converted by the caller
with `as_str`. Slicing an `OsStr` into `&str` pieces needs either an
allocation or `unsafe`, and this crate forbids `unsafe`. The upside is
that a non-UTF-8 command line still *parses* — flags match, subcommands
route — and only the values actually looked at can fail to convert,
which is where that failure belongs.

Tables are borrowed slices (`&'a [&'a Flag<'a>]`) so a derive can emit
the whole tree as `static` data. Tables and `argv` carry separate
lifetimes; a single lifetime compiled but forced `argv` to be `'static`,
which a doctest caught.

## Scope

Binding only. `required`, `choices`, `env` fallback, defaults,
`var_min`/`var_max`, and `overrides` all happen after the last token is
read and need to know a value's type, so they belong to the layer that
owns the target struct. Keeping them out is what keeps this loop small.

## How it's verified

**The corpus.** `conformance/` gained a second runner, so the same
vectors now exercise both parsers. usage-argv answers **61 of 83**; the
remaining 22 are post-binding and report *why* they're exempt, with the
count asserted so the exempt set can't quietly grow. A separate test
asserts that **all 15 vectors where usage-lib diverges from the grammar
are ones this parser gets right** — that was the point of writing the
grammar down.

Since nothing emits tables yet, the harness builds them from a `Spec`
and leaks them. A test process building a few small tables is the one
place where leaking is the simplest correct answer; generated code has
no such problem.

**Zero allocation.** `argv/tests/no_alloc.rs` arms a counting global
allocator around parses of ten realistic command lines and four failing
ones, and asserts zero. It also asserts the counter observes a
deliberate allocation, so the test can't pass vacuously.

One wrinkle worth knowing if you write similar tests: the file holds
exactly one `#[test]`, because the counter is global and a sibling test
building a `Vec` on another thread showed up as four phantom allocations
here.

## One grammar change

Implementing this surfaced a rule the doc didn't state: a flag with a
variadic argument (`--include <pattern>...`) consumes following tokens
until one is flag-like. I've added it to `docs/spec/argv.md`, including
the note that it is greedy and will eat positionals — inherent to the
feature, ended explicitly with `--`.

## Next

A derive that emits these tables, and then a benchmark against a
clap-shaped equivalent at mise's scale, which is the gate for whether
this is worth continuing.

*AI-assisted — Tool: Claude Code; model: anthropic/claude-fable-5;
version: unavailable.*

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> New public parser surface and grammar-aligned behavior that differs
from usage-lib on recorded divergences; release script changes affect
publishing, but no changes to existing usage-lib parse paths in this
diff.
> 
> **Overview**
> Introduces **`usage-argv`**, a dependency-free crate that implements
the argv **binding** half of the usage grammar in one pass with no heap
allocation. Parsing is driven by static `Command` / `Flag` / `Arg`
tables (intended for future derive output) and streams **`Event`**
values with **`&[u8]`** payloads instead of building a map.
> 
> **Conformance** gains a second runner (`conformance/src/argv.rs` +
`conformance/tests/argv.rs`) that builds leaked tables from corpus specs
and asserts **62** in-scope binding vectors pass, including cases where
usage-lib diverges; post-binding features (`required`, `choices`, `env`,
defaults, etc.) are explicitly **out of scope** with a fixed count.
**`argv/tests/no_alloc.rs`** enforces zero allocations during parse via
a counting global allocator.
> 
> **`docs/spec/argv.md`** documents greedy variadic flag value
consumption and notes dual-parser corpus testing.
**`tasks/release-plz`** publishes `usage-argv` with the shared version
and includes `argv/**` in changelog path filters.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
180aec7. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added a zero-allocation command-line argument parser supporting flags,
arguments, subcommands, aliases, negation, short flag bundles, attached
values, variadic values, and `--` handling.
- Added structured parsing events and detailed errors for invalid
command-line input.

- **Documentation**
- Documented variadic flag behavior and updated conformance coverage
details.

- **Tests**
- Added comprehensive parser, allocation, error-handling, and
conformance tests.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jdx added a commit that referenced this pull request Aug 11, 2026
`PLAN.md` — the plan for this work written down end to end, including
the parts that do not exist yet.

Three PRs in (#797, #798, #799), the plan lived in PR descriptions and
in my head. That is fine for one PR and not for a dozen, especially for
the config layer, where the shape is worth arguing about *before* it
gets built.

Checkboxes rather than prose, so the file doubles as status: **an
unchecked box means the thing does not exist.** Ticking them as things
land keeps it honest, and makes it obvious when a branch of the plan has
stalled.

## What it covers

- **Why** — mise's measured numbers, and the fact that mise already
hand-maintains two argv scanners to avoid building its clap tree. That
workaround existing is the argument for the project.
- **How it is arranged** — the four rules that hold it together: code
authors and the spec defines; usage-lib is the reference implementation;
the hot path stays small; end users never need a second binary.
- **Milestones** — what is done, the derive work next, the table stakes
after it (help, self-contained completions, docs, diagnostics).
- **The gate** — the perf targets, measured with `tak` against a shadow
CLI generated from mise's own committed spec. Explicitly: if the targets
miss by a wide margin, write that down and stop. Nothing touches mise
before this.
- **Known usage-lib divergences** — as a to-do list, since each is a
small change to `lib/src/parse.rs` and the corpus already knows how to
verify a fix.
- **Config** — the v2 design, from reading all four CLIs.

## The config section is the part worth reviewing

mise, hk, pitchfork, and fnox have each independently built the same
settings model — a TOML registry, `build.rs` codegen, a typed `Settings`
plus a meta map, project-over-global-over-defaults layering — and agree
on ~80% of the vocabulary. The differences are mostly *drift* rather
than intent:

- Every one hand-writes the CLI-to-settings binding, and every one has a
hole in it: hk declares `sources.cli` entries nothing reads, pitchfork's
`--help` documents a CLI layer it does not have (copied by hand into its
committed spec), and fnox resolves `age_key_file` through a hardcoded
five-way chain because its settings and config files are separate
systems.
- Only hk can say where a value came from, and it needed a second
parallel merge to do it.
- Docs/schema generation is three separate reimplementations, and fnox
has none.

The proposal is to declare props in code, lower them into the spec's
`config { prop ... }` block — which **exists today and no CLI emits or
consumes** — and generate the CLI binding instead of hand-writing it.
That block needs extending first (`deprecated`, `enum`, `optional`,
`aliases`, `merge`, scope, per-source lists), which is spec-first per
the canonicality rule.

Three open questions are listed rather than decided, including whether
config belongs in this repo at all.

*AI-assisted — Tool: Claude Code; model: anthropic/claude-fable-5;
version: unavailable.*

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation-only addition with no runtime, build, or API changes.
> 
> **Overview**
> Introduces **`PLAN.md`** as the canonical, in-repo plan for the
compiled argv parser work and the later shared config layer—replacing
plan text that lived only in PR descriptions.
> 
> The doc uses **unchecked checkboxes as status** (unchecked = not built
yet) and covers motivation (mise/clap cost), architecture (spec →
usage-derive / usage-argv / usage-lib), milestones (done vs derive vs
gate vs adoption), perf gate targets, corpus gaps, known **usage-lib**
divergences as a fix list, and a **config** design sketch (unify
mise/hk/pitchfork/fnox settings) with open questions—not implementation.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
96b60f9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant