Repository navigation
Draw usage groups as their parsers accept input - #1015
Conversation
Usage could disagree with parse() about whether an empty argument list succeeds. For example, or(optional(FILE), optional(DIR)) was drawn as ([FILE] | [DIR]) although or() rejects the ambiguous empty invocation, and an or() alternative that produces a value without tokens left the other alternatives drawn as required. Inferring these facts from the display tree in the formatters produced one special case after another. This change defines what usage promises and moves the facts to the parsers: - Built-in parsers carry internal empty-input facts (the outcome of the empty parse step, completion after it and from the initial state, and the resulting state shape) bound to their parse(), complete() and initialState. The facts are sound but incomplete: anything a rule does not cover, including custom parsers and source-bound wrappers, stays unknown. Property tests check the facts against the runtime. - or(), longestMatch() and multiple() record the new acceptsEmpty field on the exclusive and multiple terms they produce when the outcome of parse(producer, []) is known. Cloning, normalization, visibility filtering and group() keep the record. - A shared internal resolver, exposed to @optique/man through the new @optique/core/internal/usage subpath, rewrites recorded groups with exact local rewrites only: it wraps a group that should be omissible in an optional term and turns optional(X) alternatives or repeated items into X when the group should be required. Groups it cannot draw exactly keep their declared notation. formatUsage(), formatUsageTerm() and the man page SYNOPSIS all go through it, and command expansion still expands groups drawn as optional this way. #1013 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5-1
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@coderabbitai full review |
|
@codex review |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1015 +/- ##
==========================================
+ Coverage 94.29% 94.35% +0.06%
==========================================
Files 112 114 +2
Lines 48673 49352 +679
Branches 11905 12084 +179
==========================================
+ Hits 45895 46568 +673
+ Misses 1764 1762 -2
- Partials 1014 1022 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review. WalkthroughCore parsers now record known empty-input behavior and propagate it into usage terms. Core usage formatting and man-page SYNOPSIS output use this information to show eligible groups as optional or required. The changes add parser and formatting tests, update usage documentation and changelogs, and export the internal usage helper through package and build configuration. Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to Usage notation now reflects known empty-input behavior, while cases that cannot be determined retain their declared notation. No material current-head failure was established, so the change is mergeable after normal checks. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c0360f1284
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Display filtering drops an exclusive alternative as a whole when its leading command is hidden, but the usage resolver still looked at the trailing terms of such an alternative. An optional term after a hidden command could therefore make a group that rejects empty input look unrepresentable, so the other optional alternatives stayed drawn as optional. The resolver now treats these alternatives as invisible, as the display filter does. #1015 (comment) Assisted-by: Claude Code:claude-opus-5-5
map() forwarded every empty-input fact of the parser it wraps, but a successful completion also runs the transform, which may throw. For example, or(map(optional(argument(...)), (v) => v!.length), argument(...)) drew the second argument as optional although parsing an empty argument list throws. map() now keeps the step facts and only the completion facts that report a failure, since a failing completion never reaches the transform; a successful completion stays unknown. #1015 (comment) Assisted-by: Claude Code:claude-opus-5-5
When an or() group has one visible command and a fallback alternative without tokens, the usage resolver draws it as an optional term around the command itself rather than around an exclusive term. Command expansion only recognized the latter, so formatUsage() with expandCommands printed "app [serve]" instead of "app serve" as before. Expansion now treats such an optional term as a group of one alternative when the declared usage ends in an exclusive term. #1015 (comment) Assisted-by: Claude Code:claude-opus-5-5
The previous commit expanded any lone alternative drawn as optional, so a parser that accepts no arguments lost the brackets around its help option and printed "Usage: tool --help" again. Only a lone command alternative needs expanding; other lone alternatives keep their brackets. #1015 (comment) Assisted-by: Claude Code:claude-opus-5-5
|
@codex review |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fda80c517a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
formatUsage() drops an exclusive alternative as a whole when its leading command is hidden, and the usage resolver now relies on that. The man page synopsis only skipped the hidden command itself and still drew the terms after it, so an alternative like "secret [FILE]" showed up as "[FILE]", and next to it a resolved "open" read as "([FILE] | open)". The roff formatter now drops these alternatives the same way. #1015 (comment) Assisted-by: Claude Code:claude-opus-5-5
|
@codex review |
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Usage now draws a group as optional exactly when the parser that produced it accepts an empty argument list.
or(optional(FILE), optional(DIR))reads(FILE | DIR)becauseor()rejects the ambiguous empty case, andor(constant("x"), FILE)reads[FILE].Fixes #1013.
Facts come from parsers, not from usage
#1012 showed that inferring empty-input behavior from the display tree never converges: every rule had a counterexample. Here each built-in parser carries internal facts about its empty parse step, its completion after that step and from the initial state, and the state it leaves behind. Combinators derive their facts from their children by mirroring their own runtime rules, such as
or()'s eligibility check andmultiple()'s item retention.The facts are sound but incomplete. A rule returns unknown for anything it does not cover, and custom parsers, source-bound wrappers,
merge(),concat(),seq(), andconditional()stay unknown. Unknown groups keep their declared notation. The facts are bound to a parser'sparse(),complete(), andinitialState, so spreading a parser and replacing a method drops them. Property tests compare the facts with the runtime across random compositions.Making these facts public is left to #1014.
Groups record the outcome
or(),longestMatch(), andmultiple()set a newacceptsEmptyfield on theexclusiveandmultipleterms they produce. The promise is per group and meansparse(producer, [])succeeds. It does not cover the synopsis as a whole: an enclosingobject()oroptional()can still read differently from how it parses.Cloning, normalization, visibility filtering, and
group()keep the field.normalizeUsage()no longer flattens an inner exclusive that carries it, since flattening would lose the record.One resolver for both formatters
packages/core/src/internal/usage.ts turns records into plain notation, and
formatUsage(),formatUsageTerm(), and the man page SYNOPSIS all use it. @optique/man reaches it through the new@optique/core/internal/usagesubpath, so it is not public API.The resolver only makes exact local rewrites. A group that should be omissible is wrapped in an optional term; a group that should be required has its
optional(X)alternatives or items replaced byX. When that cannot be drawn exactly, for example an alternative made of several optional terms, it keeps the declared notation instead of expanding combinations. Inner groups resolve first, so an enclosing group takes precedence. The output carries no records, which makes resolution idempotent and keeps a hidden required term from being judged twice.Visible changes
Some existing help output changes to match parsing.
or(option("-v"), option("-q"))now reads(-v | -q), and a program whose parser accepts no arguments readsUsage: tool [--help].