Skip to content

experiment: replace clap with usage-rs (usage) for CLI/builtin parsing - #1300

Draft
lu-zero wants to merge 5 commits into
reubeno:mainfrom
lu-zero:experiment/usage-parser
Draft

lu-zero wants to merge 5 commits into
reubeno:mainfrom
lu-zero:experiment/usage-parser

Conversation

@lu-zero

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

Copy link
Copy Markdown
Contributor

Experiment: replace clap with usage-rs

Compares usage-rs (the Rust framework behind usage-cli) against clap 4.6 as brush's argument-parsing layer. Kept as a separate branch/worktree so both parsers can be built and measured from the same sources.

Scope: experimental comparison branch, not a finished migration. Performance numbers are tracked in #1301.

What was migrated

All shipped runtime crates now parse with usage (v6):

  • brush-corebuiltins::Command infrastructure, completion value enums, example
  • brush-builtins — all ~45 builtin argument structs
  • brush-experimental-builtins
  • brush-shell — main CLI (args.rs, entry.rs, brushctl.rs, events.rs)

Deliberately not migrated (dev tooling, not part of the shipped binary): xtask, brush-test-harness.

Notable design changes

  • usage's derive generates inherent methods rather than trait impls, so builtins::Command is no longer bounded on clap::Parser. A small new UsageParse trait bridges parsed types; each builtin adds one line of glue via brush_core::impl_usage_parse!(T) (brush-core/src/builtins.rs).
  • usage parse errors borrow from argv; they are rendered eagerly into an owned builtins::ParseError that preserves clap's exit-code contract (help/version → 0, failure → 2).
  • Strictness parity where clap was strict: unknown_flags = "error" + args_override_self = false. Builtins whose operands may legitimately look like flags (echo, test, printf, let, eval, …) use the permissive mode instead.
  • The existing -/+ option trick still works unchanged (+x--+x, long declared as "+x"); verified against usage's parser.
  • set -o/+o: repeated occurrences accumulate like bash; bare -o/+o (list-all) is distinguished from an explicitly empty name via an unspellable sentinel attached during SetCommand::new's existing argv rewrite pass.
  • printf gained a new() override so a standalone -- appearing after the format string stays an ordinary operand (bash semantics the parser can't express).
  • Colors: help pages and diagnostics are styled on color-capable streams; usage honors NO_COLOR / CLICOLOR_FORCE / per-stream tty detection. Only clap's custom palettes are non-portable.
  • xtask gen man/markdown now emit the CLI's usage KDL spec (renderable by usage-cli); completion scripts come from usage's completions feature.
  • scripts/parser-parity.sh <ref> <cand>: differential harness running a corpus of parsing-sensitive command lines through two builds and diffing stdout + exit status (20 cases covering every drift class found during this experiment).

Verification

  • cargo xtask ci quick green (fmt, clippy, unit tests)
  • Compat suite: 2306/2306 passing; full integration run clean except pre-existing flaky run_suspend_and_fg (flakes identically on main; pty/job-control timing)
  • Real-world scripts verified for three-way output parity (bash vs clap build vs usage build) before any timing
  • scripts/parser-parity.sh: 20/20 vs a clap build

Performance

Microbenchmarks (hyperfine, release builds):

scenario main (clap) this branch Δ
startup: brush -c 'exit 0' 7.1 ms 7.2 ms ~noise
brush --help 1.3 ms 1.3 ms ~noise
builtin-parse hot loop (let ×3000) 69.2 ms 58.3 ms ~1.19×
stripped binary size 6.49 MB 6.71 MB +3.4%

Real-world scripts (details and reproduction in #1301):

workload parse density clap usage Δ
gnuconfig/config.guess (1818 ln) external-bound 177.7 ms 177.4 ms 1.00×
repo scripts/test-across-shells.sh moderate 42.4 ms 42.0 ms 1.01×
benchmarks/real-world/deploy-sim.sh mixed 92.3 ms 89.2 ms 1.03×
sourcing bash_completion (3624 ln) dense builtins 47.7 ms 42.0 ms 1.13×
benchmarks/real-world/config-lint.sh 250 getopts per entry ~71–78 ms ~47–54 ms ~1.5×
config-lint.sh 750 same, scaled 163.9 ms 98.5 ms ~1.66×

Reading: end-to-end time on I/O- or external-command-bound scripts is indistinguishable between parsers; as builtin argument parsing becomes the bottleneck, usage shows a consistent win that grows with parse density (~1.1× → ~1.7×).

Known deviations / follow-ups

  • Help pages use usage's renderer/layout with its automatic palette; clap's custom style palettes (Styles::styled() with brush's yellow/green/magenta/cyan scheme) are not portable.
  • override_usage (declare) dropped — usage only takes literal usage lines; colorized usage line lost.
  • ulimit: dynamic "(supported)/(unsupported)" help suffixes became static text.
  • exit/fc operands: hyphen-leading non-numeric tokens now error instead of being captured (negative numbers still work).
  • Public API changes (breaking): builtins::Command bounds, removal of parse_known, clap::Errorbuiltins::ParseError in Command::new.

Assisted-by: ox-alpha:opencode/x-preview-f-free

Experimental migration to the usage parser (usage-rs v6) for brush-core,
brush-builtins, brush-experimental-builtins, and brush-shell:

- builtins::Command now bounds on a new UsageParse trait bridging
  usage's inherent derive methods; impl_usage_parse! macro supplies
  the glue per parsed type
- parse failures are rendered eagerly into an owned ParseError
  (usage errors borrow argv), preserving clap's exit-code contract
  (0 for help/version, 2 for failures)
- strictness parity via unknown_flags/args_override_self where clap
  was strict; permissive mode where operands may look like flags
  (echo, test, printf, etc.)
- printf gains a new() override keeping bash's post-format '--' as
  operand; set ±o uses Option<Option<String>> (single value per
  invocation, no accumulation)
- xtask doc/completion generation emits the usage KDL spec;
  completion scripts come from usage's completions feature

xtask and brush-test-harness intentionally remain on clap (dev
tooling, not part of the shipped binary).

Known deviations: custom help styles dropped (unsupported);
override_usage literal only; set -o no longer accumulates repeated
occurrences.

Assisted-by: ox-alpha:opencode/x-preview-f-free
Under usage, SetOption's -o/+o fields were ported as Option<Option<String>>,
which made a second occurrence fail with DuplicateFlag. bash (and the
previous clap-based parser) accept repeated occurrences:

    set -o nounset -o xtrace   # both options applied
    set +o posix +o allexport  # both unset

Model each field as Vec<String> with default_missing = "": named
occurrences accumulate, while a bare -o/+o yields an empty-string entry
that triggers the list-all display, preserving both behaviors.

Assisted-by: ox-alpha:opencode/x-preview-f-free
@lu-zero

lu-zero commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up commit 092840b9 fixes the worst deviation reported in the initial description: repeated -o/+o options on set now accumulate again, matching bash and main.

Implementation note for reviewers comparing parser expressiveness: usage models per-occurrence-optional values via default_missing; combining it with a Vec<String> field restores clap's num_args(0..=1) + repeatable behavior:

#[usage(short = 'o', default_missing = "", value_name = "OPT")]
enable: Vec<String>,   // "set -o" -> [""], "set -o a -o b" -> ["a", "b"]

Verified: set -o nounset -o xtrace, mixed -o/+o, attached forms (-oa), bare listing (set -o, set +o byte-identical to main), plus the full compat suite.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Public API changes for crate: brush-core

Removed items

-pub fn brush_core::builtins::parse_known<T: clap_builder::derive::Parser, S>(impl core::iter::traits::collect::IntoIterator<Item = S>) -> (T, core::option::Option<impl core::iter::traits::iterator::Iterator<Item = S>>) where S: core::convert::Into<std::ffi::os_str::OsString> + core::clone::Clone + core::cmp::PartialEq<&'static str>

Added items

+pub enum brush_core::builtins::ParseErrorKind
+pub brush_core::builtins::ParseErrorKind::Failure
+pub brush_core::builtins::ParseErrorKind::Help
+pub brush_core::builtins::ParseErrorKind::Version
+pub struct brush_core::builtins::ParseError
+impl brush_core::builtins::ParseError
+pub const fn brush_core::builtins::ParseError::exit_code(&self) -> i32
+pub fn brush_core::builtins::ParseError::print(&self) -> core::io::error::Result<()>
+impl core::error::Error for brush_core::builtins::ParseError
+impl core::fmt::Display for brush_core::builtins::ParseError
+pub fn brush_core::builtins::ParseError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub trait brush_core::builtins::UsageParse: core::marker::Sized
+pub fn brush_core::builtins::UsageParse::parse_argv<'v>(&[&'v std::ffi::os_str::OsStr]) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+pub fn brush_core::builtins::parse_command_args<T: brush_core::builtins::UsageParse>(impl core::iter::traits::collect::IntoIterator<Item = alloc::string::String>) -> core::result::Result<T, brush_core::builtins::ParseError>
+impl usage_argv::spec::ValueEnum for brush_core::completion::CompleteAction
+pub const brush_core::completion::CompleteAction::ACCEPTED_CHOICES: &'static [&'static str]
+pub const brush_core::completion::CompleteAction::ALIASES: &'static [(&'static str, &'static str)]
+pub const brush_core::completion::CompleteAction::CHOICES: &'static [&'static str]
+pub const brush_core::completion::CompleteAction::DETAILS: &'static [usage_argv::spec::ChoiceMeta<'static>]
+pub const brush_core::completion::CompleteAction::IGNORE_CASE: bool
+pub fn brush_core::completion::CompleteAction::from_choice(&str) -> core::option::Option<Self>
+impl usage_argv::spec::ValueEnum for brush_core::completion::CompleteOption
+pub const brush_core::completion::CompleteOption::ACCEPTED_CHOICES: &'static [&'static str]
+pub const brush_core::completion::CompleteOption::ALIASES: &'static [(&'static str, &'static str)]
+pub const brush_core::completion::CompleteOption::CHOICES: &'static [&'static str]
+pub const brush_core::completion::CompleteOption::DETAILS: &'static [usage_argv::spec::ChoiceMeta<'static>]
+pub const brush_core::completion::CompleteOption::IGNORE_CASE: bool
+pub fn brush_core::completion::CompleteOption::from_choice(&str) -> core::option::Option<Self>
+pub macro brush_core::impl_usage_parse!

Changed items

-pub trait brush_core::builtins::Command: clap_builder::derive::Parser
+pub trait brush_core::builtins::Command: brush_core::builtins::UsageParse
-pub fn brush_core::builtins::Command::new<I>(I) -> core::result::Result<Self, clap_builder::Error> where I: core::iter::traits::collect::IntoIterator<Item = alloc::string::String>
+pub fn brush_core::builtins::Command::new<I>(I) -> core::result::Result<Self, brush_core::builtins::ParseError> where I: core::iter::traits::collect::IntoIterator<Item = alloc::string::String>, Self: core::marker::Sized
-pub fn brush_core::builtins::try_parse_known<T: clap_builder::derive::Parser>(impl core::iter::traits::collect::IntoIterator<Item = alloc::string::String>) -> core::result::Result<(T, core::option::Option<impl core::iter::traits::iterator::Iterator<Item = alloc::string::String>>), clap_builder::Error>
+pub fn brush_core::builtins::try_parse_known<T: brush_core::builtins::UsageParse>(impl core::iter::traits::collect::IntoIterator<Item = alloc::string::String>) -> core::result::Result<(T, core::option::Option<impl core::iter::traits::iterator::Iterator<Item = alloc::string::String>>), brush_core::builtins::ParseError>

Public API changes for crate: brush-shell

Added items

+impl usage_argv::spec::ValueEnum for brush_shell::args::InputBackendType
+pub const brush_shell::args::InputBackendType::ACCEPTED_CHOICES: &'static [&'static str]
+pub const brush_shell::args::InputBackendType::ALIASES: &'static [(&'static str, &'static str)]
+pub const brush_shell::args::InputBackendType::CHOICES: &'static [&'static str]
+pub const brush_shell::args::InputBackendType::DETAILS: &'static [usage_argv::spec::ChoiceMeta<'static>]
+pub const brush_shell::args::InputBackendType::IGNORE_CASE: bool
+pub fn brush_shell::args::InputBackendType::from_choice(&str) -> core::option::Option<Self>
+impl brush_shell::args::CommandLineArgs
+pub fn brush_shell::args::CommandLineArgs::app() -> usage_argv::spec::SpecView<'static>
+pub fn brush_shell::args::CommandLineArgs::command() -> &'static usage_argv::Command<'static>
+pub fn brush_shell::args::CommandLineArgs::completion_install_plan(usage_argv::complete::Shell, &usage_argv::install::Env) -> core::result::Result<usage_argv::install::Plan, usage_argv::install::Error>
+pub fn brush_shell::args::CommandLineArgs::completion_install_plan_for_alias(&str, usage_argv::complete::Shell, &usage_argv::install::Env) -> core::result::Result<usage_argv::install::Plan, usage_argv::install::Error>
+pub fn brush_shell::args::CommandLineArgs::completion_request(&[std::ffi::os_str::OsString]) -> core::option::Option<alloc::string::String>
+pub fn brush_shell::args::CommandLineArgs::completion_script(usage_argv::complete::Shell) -> alloc::string::String
+pub fn brush_shell::args::CommandLineArgs::completion_script_for(&str, usage_argv::complete::Shell) -> core::option::Option<alloc::string::String>
+pub fn brush_shell::args::CommandLineArgs::completion_script_for_alias(&str, usage_argv::complete::Shell) -> alloc::string::String
+pub fn brush_shell::args::CommandLineArgs::install_completion(usage_argv::complete::Shell, &usage_argv::install::Env, usage_argv::install::OnForeign) -> core::result::Result<usage_argv::install::Installed, usage_argv::install::Error>
+pub fn brush_shell::args::CommandLineArgs::install_completion_for_alias(&str, usage_argv::complete::Shell, &usage_argv::install::Env, usage_argv::install::OnForeign) -> core::result::Result<usage_argv::install::Installed, usage_argv::install::Error>
+pub fn brush_shell::args::CommandLineArgs::parse() -> Self
+pub fn brush_shell::args::CommandLineArgs::parse_from<'v>(&[&'v std::ffi::os_str::OsStr]) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::parse_from_argv<'v>(&[&'v std::ffi::os_str::OsStr]) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::parse_from_argv_with_warnings<'v>(&[&'v std::ffi::os_str::OsStr], &mut alloc::vec::Vec<usage_argv::warn::Warning<'static>>) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::parse_from_with_warnings<'v>(&[&'v std::ffi::os_str::OsStr], &mut alloc::vec::Vec<usage_argv::warn::Warning<'static>>) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::render_failure<'v>(&[&'v std::ffi::os_str::OsStr], &usage_argv::Error<'static, 'v>) -> alloc::string::String
+pub fn brush_shell::args::CommandLineArgs::render_help(&usage_argv::Command<'_>, bool) -> core::option::Option<alloc::string::String>
+pub fn brush_shell::args::CommandLineArgs::runtime_app() -> usage_argv::spec::SpecView<'static>
+pub fn brush_shell::args::CommandLineArgs::spec() -> &'static usage_argv::spec::Spec<'static>
+pub fn brush_shell::args::CommandLineArgs::spec_request(&[&std::ffi::os_str::OsStr]) -> core::option::Option<alloc::string::String>
+pub fn brush_shell::args::CommandLineArgs::to_kdl() -> alloc::string::String
+pub fn brush_shell::args::CommandLineArgs::try_parse_from<'v>(&[&'v std::ffi::os_str::OsStr]) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::try_parse_from_with_warnings<'v>(&[&'v std::ffi::os_str::OsStr], &mut alloc::vec::Vec<usage_argv::warn::Warning<'static>>) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::try_update_from<'v>(&mut self, &[&'v std::ffi::os_str::OsStr]) -> core::result::Result<(), usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::try_update_from_argv<'v>(&mut self, &[&'v std::ffi::os_str::OsStr]) -> core::result::Result<(), usage_argv::Error<'static, 'v>>
+pub fn brush_shell::args::CommandLineArgs::update_from<'v>(&mut self, &[&'v std::ffi::os_str::OsStr])
+pub fn brush_shell::args::CommandLineArgs::update_from_argv<'v>(&mut self, &[&'v std::ffi::os_str::OsStr])
+impl brush_core::builtins::UsageParse for brush_shell::args::CommandLineArgs
+pub fn brush_shell::args::CommandLineArgs::parse_argv<'v>(&[&'v std::ffi::os_str::OsStr]) -> core::result::Result<Self, usage_argv::Error<'static, 'v>>
+impl usage_argv::spec::ValueEnum for brush_shell::events::TraceEvent
+pub const brush_shell::events::TraceEvent::ACCEPTED_CHOICES: &'static [&'static str]
+pub const brush_shell::events::TraceEvent::ALIASES: &'static [(&'static str, &'static str)]
+pub const brush_shell::events::TraceEvent::CHOICES: &'static [&'static str]
+pub const brush_shell::events::TraceEvent::DETAILS: &'static [usage_argv::spec::ChoiceMeta<'static>]
+pub const brush_shell::events::TraceEvent::IGNORE_CASE: bool
+pub fn brush_shell::events::TraceEvent::from_choice(&str) -> core::option::Option<Self>

Changed items

-pub brush_shell::args::CommandLineArgs::help: core::option::Option<bool>
+pub brush_shell::args::CommandLineArgs::help: bool
-pub brush_shell::args::CommandLineArgs::version: core::option::Option<bool>
+pub brush_shell::args::CommandLineArgs::version: bool

Performance Benchmark Report

Benchmark name Baseline (μs) Test/PR (μs) Delta (μs) Delta %
clone_shell_object 16.70 μs 16.76 μs 0.06 μs ⚪ Unchanged
eval_arithmetic 0.12 μs 0.12 μs -0.00 μs ⚪ Unchanged
expand_one_string 1.42 μs 1.42 μs 0.00 μs ⚪ Unchanged
for_loop 24.16 μs 24.40 μs 0.23 μs 🟠 +0.96%
full_peg_complex 49.78 μs 48.59 μs -1.19 μs ⚪ Unchanged
full_peg_for_loop 5.63 μs 5.46 μs -0.17 μs ⚪ Unchanged
full_peg_nested_expansions 14.00 μs 13.94 μs -0.06 μs ⚪ Unchanged
full_peg_pipeline 3.81 μs 3.70 μs -0.11 μs ⚪ Unchanged
full_peg_simple 1.53 μs 1.50 μs -0.03 μs ⚪ Unchanged
function_call 3.06 μs 3.08 μs 0.03 μs ⚪ Unchanged
instantiate_shell 48.08 μs 49.35 μs 1.27 μs ⚪ Unchanged
instantiate_shell_with_init_scripts 21089.20 μs 18109.63 μs -2979.56 μs 🟢 -14.13%
parse_peg_bash_completion 1940.06 μs 1936.02 μs -4.04 μs ⚪ Unchanged
parse_peg_complex 16.27 μs 16.25 μs -0.02 μs ⚪ Unchanged
parse_peg_for_loop 1.59 μs 1.59 μs -0.01 μs ⚪ Unchanged
parse_peg_pipeline 1.72 μs 1.72 μs -0.00 μs ⚪ Unchanged
parse_peg_simple 0.92 μs 0.92 μs 0.00 μs ⚪ Unchanged
run_echo_builtin_command 10.89 μs 6.34 μs -4.55 μs 🟢 -41.77%
tokenize_sample_script 2.99 μs 2.99 μs 0.00 μs ⚪ Unchanged

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
brush-builtins/src/alias.rs 🟢 79.17% 🟢 76% 🔴 -3.17%
brush-builtins/src/bind.rs 🟢 75.51% 🟢 75.25% 🔴 -0.26%
brush-builtins/src/break_.rs 🟢 92.86% 🟢 86.67% 🔴 -6.19%
brush-builtins/src/builtin_.rs 🟢 95.24% 🟢 90.91% 🔴 -4.33%
brush-builtins/src/caller.rs 🟢 95.65% 🟢 91.67% 🔴 -3.98%
brush-builtins/src/cd.rs 🟢 86.11% 🟢 83.78% 🔴 -2.33%
brush-builtins/src/command.rs 🟢 100% 🟢 98.8% 🔴 -1.2%
brush-builtins/src/complete.rs 🟢 83.15% 🟢 82.26% 🔴 -0.89%
brush-builtins/src/continue_.rs 🟢 100% 🟢 93.33% 🔴 -6.67%
brush-builtins/src/declare.rs 🟢 90.35% 🟢 90.11% 🔴 -0.24%
brush-builtins/src/dirs.rs 🟢 95.12% 🟢 92.86% 🔴 -2.26%
brush-builtins/src/dot.rs 🟢 100% 🟢 92.86% 🔴 -7.14%
brush-builtins/src/echo.rs 🟢 89.74% 🟢 87.5% 🔴 -2.24%
brush-builtins/src/enable.rs 🟢 80.49% 🟢 78.57% 🔴 -1.92%
brush-builtins/src/eval.rs 🟢 92.86% 🟢 86.67% 🔴 -6.19%
brush-builtins/src/exec.rs 🟢 88.57% 🟢 86.11% 🔴 -2.46%
brush-builtins/src/exit.rs 🟢 100% 🟢 91.67% 🔴 -8.33%
brush-builtins/src/export.rs 🟢 93.48% 🟢 92.47% 🔴 -1.01%
brush-builtins/src/fc.rs 🟢 95.92% 🟢 95.27% 🔴 -0.65%
brush-builtins/src/getopts.rs 🟢 93.28% 🟢 92.89% 🔴 -0.39%
brush-builtins/src/hash.rs 🟢 92.45% 🟢 90.74% 🔴 -1.71%
brush-builtins/src/help.rs 🟢 91.4% 🟢 90.43% 🔴 -0.97%
brush-builtins/src/history.rs 🟢 88.98% 🟢 88.11% 🔴 -0.87%
brush-builtins/src/kill.rs 🟠 59.52% 🟠 58.82% 🔴 -0.7%
brush-builtins/src/let_.rs 🟢 89.47% 🟢 85% 🔴 -4.47%
brush-builtins/src/lib.rs 🟠 75% 🟠 69.23% 🔴 -5.77%
brush-builtins/src/mapfile.rs 🟢 88.24% 🟢 81.74% 🔴 -6.5%
brush-builtins/src/popd.rs 🟢 100% 🟢 92.86% 🔴 -7.14%
brush-builtins/src/printf.rs 🟢 93.58% 🟢 94.35% 🟢 0.77%
brush-builtins/src/pushd.rs 🟢 100% 🟢 94.74% 🔴 -5.26%
brush-builtins/src/pwd.rs 🟢 100% 🟢 94.12% 🔴 -5.88%
brush-builtins/src/read.rs 🟢 91.38% 🟢 91.15% 🔴 -0.23%
brush-builtins/src/return_.rs 🟢 100% 🟢 93.75% 🔴 -6.25%
brush-builtins/src/set.rs 🟢 80.18% 🟢 81.82% 🟢 1.64%
brush-builtins/src/shift.rs 🟢 93.75% 🟢 88.24% 🔴 -5.51%
brush-builtins/src/shopt.rs 🟠 73.42% 🟠 72.5% 🔴 -0.92%
brush-builtins/src/test.rs 🟢 94.12% 🟢 91.43% 🔴 -2.69%
brush-builtins/src/times.rs 🟢 89.47% 🟢 85% 🔴 -4.47%
brush-builtins/src/trap.rs 🟢 86.57% 🟢 85.29% 🔴 -1.28%
brush-builtins/src/type_.rs 🟢 83.48% 🟢 82.76% 🔴 -0.72%
brush-builtins/src/ulimit.rs 🟠 69.33% 🟠 59.86% 🔴 -9.47%
brush-builtins/src/unalias.rs 🟢 82.35% 🟢 77.78% 🔴 -4.57%
brush-builtins/src/unset.rs 🟢 94.12% 🟢 85.48% 🔴 -8.64%
brush-builtins/src/wait.rs 🟠 67.74% 🟠 65.63% 🔴 -2.11%
brush-core/src/builtins.rs 🟠 68.25% 🟠 74.52% 🟢 6.27%
brush-shell/src/brushctl.rs 🔴 6.9% 🔴 6.19% 🔴 -0.71%
brush-shell/src/entry.rs 🟢 90.31% 🟢 90.05% 🔴 -0.26%
Overall Coverage 🟢 76.2% 🟢 76.02% 🔴 -0.18%

Minimum allowed coverage is 70%, this run produced 76.02%
Maximum allowed coverage difference is -5%, this run produced -0.18%

Test Summary: bash-completion test suite

Outcome Count Percentage
✅ Pass 1582 75.01
❗️ Error 17 0.81
❌ Fail 154 7.30
⏩ Skip 341 16.17
❎ Expected Fail 13 0.62
✔️ Unexpected Pass 2 0.09
📊 Total 2109 100.00

The default_missing = "" spelling conflated a bare -o (list-all request)
with 'set -o ""' (an invalid empty option name that must fail). Instead,
SetCommand::new now rewrites bare occurrences to carry an unspellable
sentinel value (BARE_OPTION), leaving explicit values untouched; execute()
keys the list-all display off the sentinel.

Also adds scripts/parser-parity.sh: a differential harness that runs a
corpus of parsing-sensitive command lines through two brush builds and
diffs stdout + exit status, so drift like this is detected mechanically
rather than by review.

Assisted-by: ox-alpha:opencode/x-preview-f-free
@lu-zero

lu-zero commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up commit a4be20eb closes the remaining gap from 092840b9: with default_missing = "", a bare -o and an explicitly empty name (set -o "") were indistinguishable — both listed options, where bash and main reject the empty name (rc 2).

SetCommand::new now detects bare occurrences during its existing +x-renaming pass (next word missing or flag-like) and attaches an unspellable sentinel (BARE_OPTION = "\u{0}") as the occurrence's value; explicit values are untouched. execute() triggers list-all only on the sentinel, so:

input behavior now matches
set -o nounset -o xtrace both applied bash + main ✔
set -o / set +o list all byte-identical to main ✔
set -o "" rc=2 main ✔ (was rc=0)
set -o -x / set -eo / set -oa -ob identical to main

Detection: also added scripts/parser-parity.sh — a differential harness that runs a corpus of parsing-sensitive command lines through two brush builds and diffs stdout + exit status. It encodes every drift class this experiment surfaced (±o accumulation/bare/odd values, flag-like operands for echo/printf/test/let, post-format --). Currently 20/20 vs a clap build; extend CORPUS as new cases appear.

scripts/parser-parity.sh /path/to/clap-brush /path/to/usage-brush

Two deterministic workloads exercising realistic builtin-parse density,
verified for three-way output parity (bash vs clap build vs usage build):

- deploy-sim.sh: getopts-driven deployment flow with directory
  bookkeeping, declarations, and formatted reporting
- config-lint.sh: CI-style validator that re-parses a getopts option
  string per entry; the parse-densest case, and where the usage-based
  parser's end-to-end advantage is most visible

Assisted-by: ox-alpha:opencode/x-preview-f-free
@lu-zero

lu-zero commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Real-world script benchmarks

Added benchmarks/real-world/ with two deterministic workloads (deploy-sim.sh, config-lint.sh), all verified for three-way output parity (bash vs clap build vs usage build) before timing. Timed with hyperfine (release builds, warmup, 100–300 runs):

workload what it stresses clap build usage build Δ
gnuconfig/config.guess (1818 ln) classic configure-style; external-command bound 177.7 ms 177.4 ms 1.00×
repo's own scripts/test-across-shells.sh getopts-style option loop, arrays, heredocs 42.4 ms 42.0 ms 1.01×
deploy-sim.sh -vn -j8 -t … mixed: getopts + pushd/popd + declarations + tar 92.3 ms 89.2 ms 1.03×
sourcing bash_completion (3624 ln) dense builtin parsing, no external cmds 47.7 ms 42.0 ms 1.13×
config-lint.sh 250 fresh getopts pass per entry (~2.5k builtin parses) ~71–78 ms ~47–54 ms ~1.5×
config-lint.sh 750 same, scaled 163.9 ms 98.5 ms ~1.66×

Reading: on scripts dominated by external commands or I/O (config.guess, deploy), the parsers are indistinguishable — end-to-end time is elsewhere. As the share of builtin argument parsing rises, a consistent, growing win for usage appears: ~1.1× → ~1.7× end-to-end, at parity of behavior (also re-verified with scripts/parser-parity.sh: 20/20).

So the earlier "hundreds of times faster" marketing number doesn't translate to shell startup, but for parse-heavy real-world scripts the improvement is material and scales with parse density.

render_parse_error rendered help via the plain renderer, leaving the
main CLI's --help unstyled even on a terminal while builtin content
used render_styled. Use usage's auto styles instead: stdout-based for
help requests, stderr-based for arg_required_else_help output.

usage picks its own palette automatically and honors NO_COLOR /
CLICOLOR_FORCE / per-stream tty detection; only custom clap-style
palettes are non-portable.

Assisted-by: ox-alpha:opencode/x-preview-f-free
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