Skip to content

Backend-neutral builtin argument contract + three-engine benchmark data - #1305

Closed
lu-zero wants to merge 16 commits into
reubeno:mainfrom
lu-zero:contract-args
Closed

lu-zero wants to merge 16 commits into
reubeno:mainfrom
lu-zero:contract-args

Conversation

@lu-zero

@lu-zero lu-zero commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Draft for design/data inspection. Contains and supersedes the stack in #1302 (bpaf port); this PR's diff = bpaf port + the contract work on top.

What this adds

Builtins declare their arguments as compile-time, backend-neutral data:

static ECHO_SPEC: CommandSpec = CommandSpec {
    args: &[
        ArgSpec::flag("no_newline", &['n'], &[], "Do not output a trailing newline"),
        ArgSpec::value("delimiter", &['d'], &[], "DELIM", "Output delimiter"),
    ],
    positionals: &[PositionalSpec::many("operands", "OPERANDS")],
};

A build selects one engine via features (parser-usage / parser-clap / parser-bpaf; priority in that order). ArgParserBackend translates the spec into engine structures, parses, maps results into slot-indexed ParsedValues. Shell-specific semantics (declaration routing, +x/-x toggles, verbatim trailing words) live once in core instead of per engine. All ~49 builtins converted.

⚠️ Authoring experience is not final — this needs your input

The manual static specs above are interim. Hand-writing stringly-typed ids is worse DX than today's clap derive structs, and unacceptable for third-party builtin authors. The intended endgame is a small proc-macro crate:

#[derive(SpecCommand)]          // emits the static CommandSpec + from_matches
struct Echo {
    #[spec(short = 'n')]
    no_newline: bool,
    #[spec(short = 'd', metavar = "DELIM")]
    delimiter: String,
    operands: Vec<String>,
}

i.e. the ergonomic shape of #[derive(Args)]/usage's #[derive(Cli)], but emitting backend-neutral data so engines stay swappable. Nothing in the contract layer changes for that to slot in — specs are already plain static data. Opening this draft now because the contract shape and the benchmark data below are what need review first; if the direction is wrong, the derive never gets written.

Why this shape at all

usage-rs's #[derive(Cli)] proved static spec → parser graph is the fast path. This keeps that shape but makes two things differ from a single-engine derive: spec source (hand-written today, derived later) and target engine (swappable). The runtime translation layer reconstructs what derive gets free (compile-time statics); an intern cache bounds it to one leaked graph per (builtin, name) — pointer-stability tested.

Benchmark data

benchmarks/three-way.py (committed): pinned CPU, interleaved sampling across shells (machine drift hits every build equally), byte-for-byte output parity before timing, median of 12 samples. Workloads in benchmarks/real-world/.

7 shells, one machine, one run:

workload bash main¹ contract+clap bpaf-single² contract+bpaf usage-single³ contract+usage
startup (ms) 1.6 1.7 1.7 1.7 1.7 1.7 1.7
wordops-10k (ms) 151.8 54.3 53.0 52.7 53.1 52.8 52.6
config-lint-500 (ms) 23.8 95.5 79.5 74.7 71.6 50.6 50.0
deploy-sim (ms) 15.0 20.8 20.6 20.3 20.2 19.4 19.5
interp-loop (ms) 139.0 275.0 284.1 287.4 280.7 227.3 275.1

¹ upstream main (clap derive builtins) · ² #1302 head (native bpaf impls) · ³ usage fork with #[derive(Cli)] builtins

Reading:

  • Engine ranking, parse-dense work: usage ≫ bpaf > clap (config-lint dominated by builtin invocation).
  • Contract overhead vs its own backend: ≤2%, often negative — contract+clap beats main's own clap code by ~17% on config-lint (leaner spec-built Command vs derive path).
  • interp-loop caveat: zero argument parsing in that loop; its spread reflects whole-binary layout variance between independently compiled trees, not arg-model cost. bash wins it outright regardless.
  • Startup/RSS identical across all six brush builds (±0.1 MiB).

Correctness

  • Compat suite: 1854 passed / 0 failed / 456 known-fail — identical to baselines.
  • Backend-parity unit tests: every engine must interpret a spec identically. These caught two latent bugs in the clap backend here (stale positional storage; allow_hyphen_values accepting echo -x where bash errors) — fixed.
  • usage graphs interned per (spec, name); total leakage equals what derive would emit as statics.

Open questions for review

  1. Derive macro as sketched above — wanted? (then hand-written specs become the escape hatch, not the interface)
  2. Keep all three engines behind the contract, or use the data to pick one and delete the rest?
  3. Per-event id→slot scans are linear-in-args (tiny n), measured invisible; resolve slot tables at intern time only if profiles ever disagree.

Assisted-by: ox-alpha (opencode)

lu_zero added 16 commits August 24, 2026 11:27
Replace clap with bpaf 0.9 across the runtime crates (brush-core,
brush-builtins, brush-experimental-builtins, brush-shell, and the test
harness), reworking the builtin Command trait around bpaf's parser
combinators.

Core changes:
- builtins::Command now requires a bpaf 'parser()' plus 'about()' /
  'synopsis()' metadata used by the help builtin; detailed help is
  rendered by triggering bpaf's --help handling
- add bash-faithful option/operand splitting (split_option_section) for
  builtins that capture trailing operands verbatim, replacing the
  per-builtin clap workarounds ('--' splitting, argv0 handling)
- plus-style options (+x, +o): expand '+abc' groups like the shell does
  and match them with bpaf 'literal' parsers; drop the old '+x' ->
  '--+x' encoding, so literal '--+o' input is now rejected like bash
- ValueEnum types become FromStr-based (completion actions/options,
  trace events, input backends)

Shell CLI:
- rewrite CommandLineArgs as a combinatoric parser; bash-compatible
  handling of '-c' with '--', script arguments captured verbatim after
  the option section

Builtins: port all ~50 builtins; several now behave closer to bash
(echo preserves '--', kill -l no longer swallows signals, pwd -LP
last-wins, set -- clears positional parameters, printf/command honor
leading '--').

Dev tooling: xtask doc generation renders help via bpaf (clap_mangen/
clap_markdown/clap_complete dropped); completion scripts are generated
by running the brush binary's own bpaf completion support.

Assisted-by: ox-alpha (opencode)
Adds a self-contained benchmark driver comparing two brush builds
against a bash oracle:

- verifies byte-for-byte output parity across all three shells before
  timing anything
- interleaves samples round-robin so machine drift affects every shell
  equally (sequential test-vs-reference runs proved drift-sensitive on
  multi-tenant machines)
- reports median +- MAD wall time, pairwise speedups, and per-workload
  peak RSS (VmHWM)

Workloads: process startup, pure interpreter loop, string/pattern/array
ops, and the getopts-dense config-lint/deploy-sim scripts (copied from
the usage-parser experiment).

Assisted-by: ox-alpha (opencode)
The first listed shell acts as the oracle (bash); every other entry is a
candidate build. Adds an all-pairs comparison section so parser-backend
and LTO-variant matrices can be captured in one run.

Assisted-by: ox-alpha (opencode)
bpaf supports multiple names per named item via repeated .long()/.short()
calls: the first short and first long are visible in help, further ones
act as hidden aliases. Replaces the construct![a, b] alternative pairs
used for --init-file/--rcfile, --log-enable/--debug,
--log-disable/--disable-event, and kill's -L/-l with single parsers.

This removes duplicated parser definitions, keeps help output showing
only the canonical spellings (matching the clap-era help), and lets
kill accept -L again as a hidden alias of -l.

Reported-by: reubeno (upstream author feedback)

Assisted-by: ox-alpha (opencode)
Assisted-by: ox-alpha (opencode)
…ed deps

The TestOptions port to bpaf lost several clap 'env = ...' fallbacks,
which broke the WASI test job: BRUSH_PLATFORM_TAGS was no longer read,
so the harness appended --input-backend=basic to a minimal-feature wasm
build that does not contain that backend; brush rejected the unknown
option and every integration case failed with exit 1.

Restore all environment fallbacks using bpaf's native .env() support
(BRUSH_VERBOSE, BASH_PATH, BRUSH_PATH, BRUSH_ARGS, BRUSH_LAUNCHER,
BRUSH_PLATFORM_TAGS, BRUSH_TEST_CASES, BRUSH_TEST_PATH_VAR). For
BRUSH_PLATFORM_TAGS, whose value is space-separated, use '.some()' plus
a splitting parse rather than '.many()': many() succeeds with an empty
vector, which would prevent the fallback from ever applying.

Also remove dependencies left unused by the port (pretty_assertions in
brush-shell after its tests were rewritten, clap-markdown in xtask).

Assisted-by: ox-alpha (opencode)
Introduce an argument-description model (brush-core::argmodel) so that
builtins declare their arguments as data and materialize themselves from
parsed matches, with the actual parsing crate as a compile-time
implementation detail:

- CommandSpec/ArgSpec/PositionalSpec describe switches, value-taking
  options (with hidden aliases), and positionals; Matches exposes typed
  lookups plus verbatim trailing operands
- ArgParserBackend trait with three implementations selected by cargo
  features: parser-bpaf (default), parser-usage, parser-clap
- SpecCommand trait + spec_builtin() registration: declare() builds the
  spec, from_matches() builds the command; help content renders through
  the backend
- shell-style option/operand splitting and '+x' group expansion stay in
  core, shared by every backend

Converts echo to the new contract as the reference implementation and
rewrites the custom-builtin example accordingly; remaining builtins
continue using the legacy bpaf-bound trait until converted. The example
now runs identically under all three backends.

Assisted-by: ox-alpha (opencode)
…tract

Every builtin now declares its argument surface as data
(brush-core::argmodel) and materializes from parsed Matches; the legacy
bpaf-bound Command/DeclarationCommand traits are no longer used by any
registration (factory.rs is all spec_builtin, with special markers and
declaration routing preserved).

Notable conversions:
- set: 19 +/- pairs via declare_plus_minus helpers; -o/+o tri-state and
  bare '--' detection preserved through an overridden new()
- complete/compgen/compopt: shared arg surface flattened into each spec;
  flag-looking-value joining kept in overridden new()
- kill: -l/-L as one spec arg with hidden short alias
- history: -a/-n/-r/-w stay independent flags (matching old behavior);
  bare -X lifted out of the option section in overridden new()
- test/[ and unimp: verbatim capture via overridden new()
- pwd/cd: manual last-wins mode scan ported to overridden new()
- export/declare/builtin_: uses_declarations() routes raw
  Vec<CommandArg> operands through set_declarations

Core support added for the contract: uses_declarations()/set_declarations
hooks on SpecCommand with declaration-aware execution routing, plus
declare_plus_minus/read_plus_minus helpers replacing the bpaf-era macro.

All suites green: compat 1854 passed / 0 failed (baseline parity),
unit/integration tests pass, clippy -D warnings clean.

Assisted-by: ox-alpha (opencode)
Introduce an argument-description model (brush-core::argmodel) so that
builtins declare their arguments as data and materialize themselves from
parsed matches, with the actual parsing crate as a compile-time
implementation detail:

- CommandSpec/ArgSpec/PositionalSpec describe switches, value-taking
  options (with hidden aliases), and positionals; Matches exposes typed
  lookups plus verbatim trailing operands
- ArgParserBackend trait with three implementations selected by cargo
  features: parser-bpaf (default), parser-usage, parser-clap
- SpecCommand trait + spec_builtin() registration: declare() builds the
  spec, from_matches() builds the command; help content renders through
  the backend
- shell-style option/operand splitting and '+x' group expansion stay in
  core, shared by every backend

Converts echo to the new contract as the reference implementation and
rewrites the custom-builtin example accordingly; remaining builtins
continue using the legacy bpaf-bound trait until converted. The example
now runs identically under all three backends.

Assisted-by: ox-alpha (opencode)
- backend selection becomes priority-based (usage > clap > bpaf) instead
  of compile_error, so workspace feature unification cannot break builds
  when several backends end up linked; a separate bpaf-linked feature
  keeps the bpaf backend compilable whenever the crate is in the graph
  (the shell CLI still uses bpaf directly for its own options)
- brush-builtins/brush-shell forward parser-* features; brush-builtins
  no longer hard-depends on bpaf
- pwd converts to SpecCommand (overridden new() keeps last-wins -L/-P)

Assisted-by: ox-alpha (opencode)
bpaf_backend now compiles only when bpaf is the *selected* builtin
backend (no parser-usage/parser-clap), so workspace feature unification
that links bpaf for the shell CLI does not produce dead-code errors in
usage-only builds. active() priority: usage > clap > bpaf.

Assisted-by: ox-alpha (opencode)
usage backend: bind Event::Arg by positional id into positional slots;
ParsedValues gains positional storage + accessors
(positional_values/value_of_positional/push_positional_*). Converted
builtins that read positionals via the named-arg getters (compgen word,
mapfile array var, read var names, break/continue/caller/return, etc.)
now use the positional accessors. Backend-parity tests extended to the
usage backend.

Assisted-by: ox-alpha (opencode)
The usage backend previously rebuilt and leaked the engine's
Command<'static> graph on every builtin invocation, so scripts calling
shift/getopts in loops leaked one allocation set per call. Graphs are now
interned in a process-wide cache keyed by (spec address, invocation
name), bounding total leakage to one graph per registered builtin —
matching what usage's derive achieves with compile-time statics.

Also adds backend-parity tests for the usage backend (single positional
binding; graph reuse across parses).

Assisted-by: ox-alpha (opencode)
The clap backend predates the positional-slot redesign and its tests were
stale, masking two bugs:

- Positional values were stored through the named-value API instead of
  positional slots, so plain operands never reached callers.
- 'allow_hyphen_values' made flag-like leading words bind as operand
  values where bash and every other backend reject them ('echo -x' must
  error); trailing_var_arg alone keeps post-first-word words verbatim.

Tests are updated to the current pattern and now cover the same cases as
the bpaf and usage suites. Compat suite: 1854 passed / 0 failed.

Assisted-by: ox-alpha (opencode)
Assisted-by: ox-alpha (opencode)
Assisted-by: ox-alpha (opencode)
@lu-zero

lu-zero commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Closing per author — approach needs rework before review.

@lu-zero lu-zero closed this Aug 25, 2026
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