Skip to content

Draw usage groups as their parsers accept input - #1015

Merged
dahlia merged 6 commits into
mainfrom
refactor/usage-empty-input
Oct 6, 2026
Merged

dahlia merged 6 commits into
mainfrom
refactor/usage-empty-input

Conversation

@dahlia

@dahlia dahlia commented Oct 6, 2026

Copy link
Copy Markdown
Owner

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) because or() rejects the ambiguous empty case, and or(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 and multiple()'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(), and conditional() stay unknown. Unknown groups keep their declared notation. The facts are bound to a parser's parse(), complete(), and initialState, 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(), and multiple() set a new acceptsEmpty field on the exclusive and multiple terms they produce. The promise is per group and means parse(producer, []) succeeds. It does not cover the synopsis as a whole: an enclosing object() or optional() 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/usage subpath, 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 by X. 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 reads Usage: tool [--help].

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
@dahlia dahlia added this to the Optique 1.4 milestone Oct 6, 2026
@dahlia dahlia self-assigned this Oct 6, 2026
@dahlia dahlia added the enhancement New feature or request label Oct 6, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T10:29:58.960059Z 7990730 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@dahlia

dahlia commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@dahlia

dahlia commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.60226% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.35%. Comparing base (0119545) to head (7990730).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/core/src/internal/usage.ts 96.17% 2 Missing and 5 partials ⚠️
packages/core/src/internal/empty-input.ts 97.84% 0 Missing and 6 partials ⚠️
packages/core/src/constructs.ts 96.36% 0 Missing and 2 partials ⚠️
packages/core/src/usage.ts 97.22% 0 Missing and 2 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 0a87c547-baab-4147-869d-686a636b4b19
📥 Commits

Reviewing files that changed from the base of the PR and between fda80c5 and 7990730.

📒 Files selected for processing (2)
  • packages/man/src/man.test.ts
  • packages/man/src/man.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.


Walkthrough

Core 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 79907

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.38% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 71 functions across 16 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: usage groups now reflect whether their parsers accept empty input.
Description check ✅ Passed The description explains the parser facts, usage metadata, resolver behavior, and visible output changes covered by the pull request.
Linked Issues check ✅ Passed Issue [#1013] asks usage to reflect empty-input parsing when that behavior is known, preserve the information through composition and visibility changes, retain declared notation when behavior is unkn…
Out of Scope Changes check ✅ Passed The parser facts, usage metadata, shared resolver, formatter integration, internal package subpath, tests, documentation, and changelog entries support issue [#1013]. The incremental hidden-alternativ…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread packages/core/src/usage.ts Outdated
Comment thread packages/core/src/modifiers.ts Outdated
Comment thread packages/core/src/internal/usage.ts
dahlia added 4 commits October 6, 2026 19:03
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
@dahlia

dahlia commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@dahlia

dahlia commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread packages/man/src/man.ts
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
@dahlia

dahlia commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 79907305be

ℹ️ 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".

@dahlia
dahlia merged commit f162fe0 into main Oct 6, 2026
20 checks passed
@dahlia
dahlia deleted the refactor/usage-empty-input branch October 6, 2026 12:09

This branch was successfully deployed

1 active deployment
preview — 79907305 Deployed Oct 6, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Define how usage should represent empty-input behavior

1 participant