diff --git a/CHANGES.md b/CHANGES.md index 50722ad8d..04c27b563 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -64,6 +64,16 @@ To be released. pages without commands free of automatic headings. Existing titled groups are preserved, and help callbacks receive the grouped page. [[#972], [#983]] + - Changed usage output to draw `or()`, `longestMatch()`, and `multiple()` + groups as optional exactly when the parser accepts an empty argument list, + if that is known from the parsers themselves. For example, + `or(optional(argument(FILE)), optional(argument(DIR)))` now reads + `(FILE | DIR)`, because the choice between two empty alternatives is + ambiguous and parsing fails, while `or(constant("x"), argument(FILE))` + reads `[FILE]`. Such groups record the outcome in the new `acceptsEmpty` + field of `exclusive` and `multiple` usage terms. Custom parsers and + parsers bound to outside sources keep the notation they declare. + [[#1013], [#1015]] - Deferred typo diagnostics for discarded parser failures, reducing parsing time for commands with many positional arguments. Custom mismatch error callbacks now run when their diagnostic is requested, so callbacks used @@ -98,6 +108,8 @@ To be released. [#1000]: https://github.com/dahlia/optique/pull/1000 [#1002]: https://github.com/dahlia/optique/issues/1002 [#1005]: https://github.com/dahlia/optique/pull/1005 +[#1013]: https://github.com/dahlia/optique/issues/1013 +[#1015]: https://github.com/dahlia/optique/pull/1015 ### @optique/run @@ -183,6 +195,9 @@ To be released. `generateManPageSync()`, `generateManPageAsync()`, and `formatDocPageAsMan()`, and the `--show-aliases` flag to `optique-man`, for documenting command aliases next to their commands. [[#1002], [#1005]] + - Changed the SYNOPSIS section to follow the same rule as `formatUsage()` + when drawing `or()`, `longestMatch()`, and `multiple()` groups, so both + show the same optional and required parts. [[#1013], [#1015]] ### @optique/inquirer diff --git a/changes.d/core/usage-empty-input.md b/changes.d/core/usage-empty-input.md new file mode 100644 index 000000000..4b47021fb --- /dev/null +++ b/changes.d/core/usage-empty-input.md @@ -0,0 +1,15 @@ +--- +links: + '#1013': https://github.com/dahlia/optique/issues/1013 + '#1015': https://github.com/dahlia/optique/pull/1015 +--- + - Changed usage output to draw `or()`, `longestMatch()`, and `multiple()` + groups as optional exactly when the parser accepts an empty argument list, + if that is known from the parsers themselves. For example, + `or(optional(argument(FILE)), optional(argument(DIR)))` now reads + `(FILE | DIR)`, because the choice between two empty alternatives is + ambiguous and parsing fails, while `or(constant("x"), argument(FILE))` + reads `[FILE]`. Such groups record the outcome in the new `acceptsEmpty` + field of `exclusive` and `multiple` usage terms. Custom parsers and + parsers bound to outside sources keep the notation they declare. + [[#1013], [#1015]] diff --git a/changes.d/man/synopsis-empty-input.md b/changes.d/man/synopsis-empty-input.md new file mode 100644 index 000000000..338730e4b --- /dev/null +++ b/changes.d/man/synopsis-empty-input.md @@ -0,0 +1,8 @@ +--- +links: + '#1013': https://github.com/dahlia/optique/issues/1013 + '#1015': https://github.com/dahlia/optique/pull/1015 +--- + - Changed the SYNOPSIS section to follow the same rule as `formatUsage()` + when drawing `or()`, `longestMatch()`, and `multiple()` groups, so both + show the same optional and required parts. [[#1013], [#1015]] diff --git a/docs/concepts/constructs.md b/docs/concepts/constructs.md index cc91b4901..f73f3b86a 100644 --- a/docs/concepts/constructs.md +++ b/docs/concepts/constructs.md @@ -680,6 +680,55 @@ bindEnv(option("--mode", string()), { }) ~~~~ +### Fallback branches in usage output + +Usage output follows the same rule. When Optique can tell from the parsers +themselves whether `or()` picks a branch for an empty argument list, help text +and man page synopses draw the whole group as optional or required to match: + +~~~~ typescript twoslash +import { or } from "@optique/core/constructs"; +import { optional } from "@optique/core/modifiers"; +import { argument, constant, option } from "@optique/core/primitives"; +import { formatUsage } from "@optique/core/usage"; +import { string } from "@optique/core/valueparser"; +// ---cut-before--- +// Two branches succeed without input, so the choice is ambiguous and an +// empty argument list fails: +formatUsage("app", or( + optional(argument(string({ metavar: "FILE" }))), + optional(argument(string({ metavar: "DIR" }))), +).usage); +// "app (FILE | DIR)" + +// constant() is the only fallback, so FILE may be omitted: +formatUsage("app", or( + constant("default"), + argument(string({ metavar: "FILE" })), +).usage); +// "app [FILE]" + +// The optional option has a leading name, so it is not a fallback: +formatUsage("app", or( + optional(option("--name", string())), + argument(string({ metavar: "FILE" })), +).usage); +// "app (--name STRING | FILE)" +~~~~ + +The same applies to `longestMatch()` and to `multiple()`, which is drawn as +`FILE...` or `[FILE...]` depending on whether it can finish without input. + +A few cases keep the notation the parsers declare. Custom parsers and +parsers bound to an outside source, such as `bindEnv()` or `bindConfig()`, +can only be judged at run time. An alternative made of several optional +terms means “at least one of them,” which synopsis notation cannot say +without listing combinations, so it stays as written. Hidden terms are left +out of the judgment as well: a group whose only required parts are hidden +looks the way its visible terms read. Each group is also judged on its own, +so a group can read as optional even when an enclosing `object()` still +needs input; see the `acceptsEmpty` field of `UsageTerm` for details. + `merge()` parser ---------------- diff --git a/packages/core/deno.json b/packages/core/deno.json index 3a7afc615..223e1bffa 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -24,7 +24,8 @@ "./usage": "./src/usage.ts", "./valueparser": "./src/valueparser.ts", "./terminal": "./src/terminal.ts", - "./internal/terminal": "./src/internal/terminal.ts" + "./internal/terminal": "./src/internal/terminal.ts", + "./internal/usage": "./src/internal/usage.ts" }, "imports": { "#src/": "./src/" diff --git a/packages/core/package.json b/packages/core/package.json index af796c5c8..9d5c94bed 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -227,6 +227,14 @@ }, "import": "./dist/internal/terminal.js", "require": "./dist/internal/terminal.cjs" + }, + "./internal/usage": { + "types": { + "import": "./dist/internal/usage.d.ts", + "require": "./dist/internal/usage.d.cts" + }, + "import": "./dist/internal/usage.js", + "require": "./dist/internal/usage.cjs" } }, "imports": { diff --git a/packages/core/src/constructs.ts b/packages/core/src/constructs.ts index d8f528475..3bc412578 100644 --- a/packages/core/src/constructs.ts +++ b/packages/core/src/constructs.ts @@ -1,3 +1,14 @@ +import { + acceptsEmptyInput, + type EmptyInputFacts, + type ExclusiveBranch, + getEmptyInputFacts, + longestMatchFacts, + objectFacts, + orFacts, + tupleFacts, + withEmptyInputFacts, +} from "./internal/empty-input.ts"; import { adoptOptionScope, combinedOptionScope, @@ -1795,6 +1806,7 @@ function applyHiddenToUsageTerm( type: "multiple", terms: applyHiddenToUsage(term.terms, hidden), min: term.min, + ...(term.acceptsEmpty == null ? {} : { acceptsEmpty: term.acceptsEmpty }), }; } if (term.type === "sequence") { @@ -1807,6 +1819,7 @@ function applyHiddenToUsageTerm( return { type: "exclusive", terms: term.terms.map((u) => applyHiddenToUsage(u, hidden)), + ...(term.acceptsEmpty == null ? {} : { acceptsEmpty: term.acceptsEmpty }), }; } if ( @@ -2440,6 +2453,35 @@ function preserveExclusiveStateAfterOptionsTerminator( }; } +/** + * Describes the branches of an exclusive combinator for the empty-input + * rules. + */ +function getExclusiveBranches( + parsers: readonly Parser[], +): readonly ExclusiveBranch[] { + return parsers.map((parser) => ({ + facts: getEmptyInputFacts(parser), + matchesTokens: parser.leadingNames.size > 0 || parser.acceptingAnyToken, + })); +} + +/** + * Builds the exclusive usage term of `or()` and `longestMatch()`, recording + * whether the combinator accepts an empty argument list when known. + */ +function exclusiveUsage( + parsers: readonly Parser[], + facts: EmptyInputFacts, +): Usage { + const acceptsEmpty = acceptsEmptyInput(facts); + return [{ + type: "exclusive", + terms: parsers.map((p) => p.usage), + ...(acceptsEmpty == null ? {} : { acceptsEmpty }), + }]; +} + /** * Creates a complete() method shared by or() and longestMatch(). * @internal @@ -5330,12 +5372,13 @@ export function or( return error; }; + const emptyInputFacts = orFacts(getExclusiveBranches(parsers)); const singleResult = { mode: combinedMode, $valueType: [], $stateType: [], priority: Math.max(...parsers.map((p) => p.priority)), - usage: [{ type: "exclusive", terms: parsers.map((p) => p.usage) }], + usage: exclusiveUsage(parsers, emptyInputFacts), leadingNames: unionLeadingNames(parsers), acceptingAnyToken: parsers.some((p) => p.acceptingAnyToken), initialState: undefined, @@ -5444,7 +5487,7 @@ export function or( parsers, (state) => selectExclusiveChildren(parsers, state, true), ); - return fluent( + return fluent(withEmptyInputFacts( scopeParser( singleResult as Parser< Mode, @@ -5453,7 +5496,8 @@ export function or( >, combinedOptionScope(parsers, parsers.map((_, index) => index)), ), - ); + emptyInputFacts, + )); } /** @@ -6122,12 +6166,13 @@ function createLongestMatch( return error; }; + const emptyInputFacts = longestMatchFacts(getExclusiveBranches(parsers)); const multiResult = { mode: combinedMode, $valueType: [], $stateType: [], priority: Math.max(...parsers.map((p) => p.priority)), - usage: [{ type: "exclusive", terms: parsers.map((p) => p.usage) }], + usage: exclusiveUsage(parsers, emptyInputFacts), leadingNames: unionLeadingNames(parsers), acceptingAnyToken: parsers.some((p) => p.acceptingAnyToken), initialState: undefined, @@ -6222,7 +6267,7 @@ function createLongestMatch( parsers, (state) => selectExclusiveChildren(parsers, state, false), ); - return fluent( + return fluent(withEmptyInputFacts( scopeParser( multiResult as Parser< Mode, @@ -6231,7 +6276,8 @@ function createLongestMatch( >, combinedOptionScope(parsers, parsers.map((_, index) => index)), ), - ); + emptyInputFacts, + )); } /** @@ -8864,7 +8910,7 @@ export function object< })); }, ); - return fluent( + return fluent(withEmptyInputFacts( scopeParser( objectParser, combinedOptionScope( @@ -8874,7 +8920,16 @@ export function object< undefined, true, ), - ); + combinedMode === "sync" + ? objectFacts( + parserPairs.map(([, p]) => ({ + facts: getEmptyInputFacts(p), + matchesTokens: p.leadingNames.size > 0 || p.acceptingAnyToken, + })), + parserPairs.map(([field]) => field), + ) + : {}, + )); } /** @@ -11262,7 +11317,7 @@ export function tuple< state: getParseChildState(state, state[index], parser), })), ); - return fluent( + return fluent(withEmptyInputFacts( scopeParser( tupleParser, combinedOptionScope( @@ -11270,7 +11325,13 @@ export function tuple< parsers.map((_, index) => index), ), ), - ); + tupleFacts( + parsers.map((p) => ({ + facts: getEmptyInputFacts(p), + matchesTokens: p.leadingNames.size > 0 || p.acceptingAnyToken, + })), + ), + )); } /** @@ -16326,7 +16387,12 @@ export function group( [parser], (state) => [{ parser, state }], ); - return fluent(scopeParser(groupParser, combinedOptionScope([parser]))); + return fluent( + withEmptyInputFacts( + scopeParser(groupParser, combinedOptionScope([parser])), + getEmptyInputFacts(parser), + ), + ); } /** diff --git a/packages/core/src/internal/empty-input.test.ts b/packages/core/src/internal/empty-input.test.ts new file mode 100644 index 000000000..a8a05feac --- /dev/null +++ b/packages/core/src/internal/empty-input.test.ts @@ -0,0 +1,463 @@ +import { group, longestMatch, object, or, tuple } from "../constructs.ts"; +import { createInputTrace } from "../input-trace.ts"; +import { + map, + multiple, + nonEmpty, + optional, + withDefault, +} from "../modifiers.ts"; +import { + argument, + command, + constant, + fail, + flag, + option, + passThrough, +} from "../primitives.ts"; +import { string } from "../valueparser.ts"; +import { acceptsEmptyInput, getEmptyInputFacts } from "./empty-input.ts"; +import { + createParserContext, + type ExecutionContext, + type Mode, + parse, + type Parser, +} from "./parser.ts"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import fc from "fast-check"; + +type AnyParser = Parser<"sync", unknown, unknown>; + +function exec( + parser: AnyParser, + phase: "parse" | "complete", +): ExecutionContext { + return { + usage: parser.usage, + phase, + path: [], + commandPath: [], + trace: createInputTrace(), + }; +} + +interface Observed { + readonly step: "success" | "provisional" | "failure"; + readonly afterStep?: boolean; + readonly fromInitial: boolean; + readonly root: boolean; +} + +function observe(parser: AnyParser): Observed { + const result = parser.parse(createParserContext({ + buffer: [], + state: parser.initialState, + optionsTerminated: false, + }, exec(parser, "parse"))); + const step = !result.success + ? "failure" + : result.provisional + ? "provisional" + : "success"; + return { + step, + ...(result.success + ? { + afterStep: parser.complete( + result.next.state, + exec(parser, "complete"), + ).success, + } + : {}), + fromInitial: parser.complete( + parser.initialState, + exec(parser, "complete"), + ).success, + root: parse(parser, []).success, + }; +} + +/** Asserts that every known fact agrees with the runtime. */ +function assertFactsSound(parser: AnyParser, label: string): void { + const facts = getEmptyInputFacts(parser); + const observed = observe(parser); + if (facts.step != null) { + assert.equal(facts.step, observed.step, `${label}: step`); + } + if (facts.afterStep != null && observed.afterStep != null) { + assert.equal(facts.afterStep, observed.afterStep, `${label}: afterStep`); + } + if (facts.fromInitial != null) { + assert.equal( + facts.fromInitial, + observed.fromInitial, + `${label}: fromInitial`, + ); + } + const root = acceptsEmptyInput(facts); + if (root != null) assert.equal(root, observed.root, `${label}: root`); +} + +const FILE = () => argument(string({ metavar: "FILE" })); +const DIR = () => argument(string({ metavar: "DIR" })); + +describe("empty-input facts", () => { + const cases: readonly [string, () => Parser][] = [ + ["constant string", () => constant("x")], + ["constant null", () => constant(null)], + ["constant object", () => constant({})], + ["constant singleton array", () => constant(["x"])], + ["fail", () => fail()], + ["argument", FILE], + ["valued option", () => option("--x", string())], + ["Boolean option", () => option("--y")], + ["flag", () => flag("--f")], + ["command", () => command("c", constant(1))], + ["passThrough", () => passThrough()], + ["optional(argument)", () => optional(FILE())], + ["optional(constant)", () => optional(constant("x"))], + ["optional(constant null)", () => optional(constant(null))], + ["withDefault(argument, value)", () => withDefault(FILE(), "d")], + [ + "withDefault(argument, throwing thunk)", + () => + withDefault(FILE(), (): string => { + throw new Error("Unavailable."); + }), + ], + ["map(constant)", () => map(constant("x"), (x) => x)], + ["nonEmpty(constant)", () => nonEmpty(constant("x"))], + [ + "nonEmpty(multiple(constant, 1))", + () => nonEmpty(multiple(constant("x"), { min: 1 })), + ], + ["group(optional)", () => group("g", optional(FILE()))], + ["multiple(argument)", () => multiple(FILE())], + ["multiple(argument, 1)", () => multiple(FILE(), { min: 1 })], + [ + "multiple(optional(argument), 1)", + () => multiple(optional(FILE()), { min: 1 }), + ], + ["multiple(constant, 1)", () => multiple(constant("x"), { min: 1 })], + ["multiple(constant, 2)", () => multiple(constant("x"), { min: 2 })], + ["multiple(constant null, 1)", () => multiple(constant(null), { min: 1 })], + ["multiple(constant object, 1)", () => multiple(constant({}), { min: 1 })], + [ + "multiple(constant singleton array, 1)", + () => multiple(constant(["x"]), { min: 1 }), + ], + [ + "multiple(optional(constant), 1)", + () => multiple(optional(constant("x")), { min: 1 }), + ], + [ + "multiple(optional(constant null), 1)", + () => multiple(optional(constant(null)), { min: 1 }), + ], + [ + "optional(multiple(constant null, 1))", + () => optional(multiple(constant(null), { min: 1 })), + ], + [ + "multiple(or(constant null, fail), 1)", + () => multiple(or(constant(null), fail()), { min: 1 }), + ], + [ + "multiple(object(constant), 1)", + () => multiple(object({ c: constant("x") }), { min: 1 }), + ], + [ + "multiple(tuple(constant), 1)", + () => multiple(tuple([constant("x")]), { min: 1 }), + ], + [ + "multiple(tuple(optional(argument)), 1)", + () => multiple(tuple([optional(FILE())]), { min: 1 }), + ], + ["multiple(argument, max)", () => multiple(FILE(), { max: 2 })], + ["multiple(object resembling a bound CLI state, 1)", () => + multiple( + object({ hasCliValue: constant(false), x: optional(constant("x")) }), + { min: 1 }, + )], + ["or(multiple(object resembling a bound CLI state, 1), argument)", () => + or( + multiple( + object({ hasCliValue: constant(false), x: optional(constant("x")) }), + { min: 1 }, + ), + FILE(), + )], + [ + "or(multiple(constant null, fractional min), argument)", + () => or(multiple(constant(null), { min: 0.5 }), FILE()), + ], + ["or(optional, optional)", () => or(optional(FILE()), optional(DIR()))], + [ + "or(optional(option), argument)", + () => or(optional(option("--x", string())), FILE()), + ], + ["or(constant, argument)", () => or(constant("a"), FILE())], + [ + "or(constant, constant, argument)", + () => or(constant("a"), constant("b"), FILE()), + ], + ["or(fail, argument)", () => or(fail(), FILE())], + ["or(passThrough, argument)", () => or(passThrough(), FILE())], + [ + "or(nonEmpty(constant), argument)", + () => or(nonEmpty(constant("x")), FILE()), + ], + [ + "or(multiple(constant null, 1), constant)", + () => or(multiple(constant(null), { min: 1 }), constant("ok")), + ], + [ + "longestMatch(optional, optional)", + () => longestMatch(optional(FILE()), optional(DIR())), + ], + [ + "longestMatch(multiple(constant null, 1), constant)", + () => longestMatch(multiple(constant(null), { min: 1 }), constant("ok")), + ], + [ + "longestMatch(optional(option), argument)", + () => longestMatch(optional(option("--x", string())), FILE()), + ], + ["object()", () => object({})], + ["object(argument)", () => object({ a: FILE() })], + ["object(Boolean option)", () => object({ y: option("--y") })], + ["object(nonEmpty(constant))", () => object({ v: nonEmpty(constant(1)) })], + [ + "object(or(constant, constant))", + () => object({ v: or(constant(1), constant(2)) }), + ], + ["object(longestMatch(optional(option), argument))", () => + object({ + v: longestMatch(optional(option("--x", string())), FILE()), + })], + ["tuple(argument)", () => tuple([FILE()])], + [ + "tuple(optional, optional)", + () => tuple([optional(FILE()), optional(DIR())]), + ], + [ + "tuple(or(constant, constant))", + () => tuple([or(constant(1), constant(2))]), + ], + ["issue example 1", () => or(optional(FILE()), optional(DIR()))], + ["issue example 2", () => + or( + multiple(tuple([optional(fail()), constant("x")]), { min: 1 }), + FILE(), + )], + ]; + + for (const [label, create] of cases) { + it(`agrees with the runtime: ${label}`, () => { + assertFactsSound(create() as AnyParser, label); + }); + } + + it("knows both issue examples", () => { + const ambiguous = or(optional(FILE()), optional(DIR())); + assert.equal(acceptsEmptyInput(getEmptyInputFacts(ambiguous)), false); + const repeated = or( + multiple(tuple([optional(fail()), constant("x")]), { min: 1 }), + FILE(), + ); + assert.equal(acceptsEmptyInput(getEmptyInputFacts(repeated)), true); + }); + + it("does not assume that a map() transform succeeds", () => { + // The transform throws for the value that the first branch completes + // to without input, so parsing an empty argument list throws. + const parser = or( + map(optional(FILE()), (value: string | undefined) => value!.length), + FILE(), + ); + assert.throws(() => parse(parser, []), TypeError); + assert.equal(acceptsEmptyInput(getEmptyInputFacts(parser)), undefined); + assert.equal( + acceptsEmptyInput(getEmptyInputFacts(map(FILE(), (v) => v.length))), + false, + ); + }); + + it("leaves repetition with a fractional minimum unknown", () => { + const parser = multiple(constant(null), { min: 0.5 }); + assert.deepEqual(getEmptyInputFacts(parser), {}); + }); + + it("forgets facts when parse or complete is replaced", () => { + const parser = constant("x"); + assert.equal(getEmptyInputFacts(parser).step, "success"); + const spread = { ...parser }; + assert.deepEqual(getEmptyInputFacts(spread), {}); + const replaced = { + ...parser, + complete: () => ({ success: false as const, error: [] }), + }; + assert.deepEqual(getEmptyInputFacts(replaced), {}); + }); + + it("leaves custom parsers unknown", () => { + const custom: Parser<"sync", string, undefined> = { + ...fail(), + parse: () => ({ success: false, consumed: 0, error: [] }), + }; + assert.deepEqual(getEmptyInputFacts(custom), {}); + const composed = or(custom, constant("x")); + assert.equal(acceptsEmptyInput(getEmptyInputFacts(composed)), undefined); + }); +}); + +type Spec = + | { readonly kind: "constant"; readonly value: unknown } + | { + readonly kind: + | "fail" + | "argument" + | "option" + | "booleanOption" + | "flag" + | "command" + | "passThrough"; + } + | { + readonly kind: + | "optional" + | "withDefault" + | "withDefaultThunk" + | "map" + | "nonEmpty" + | "group"; + readonly child: Spec; + } + | { readonly kind: "multiple"; readonly child: Spec; readonly min: number } + | { + readonly kind: "or" | "longestMatch" | "tuple" | "object"; + readonly children: readonly Spec[]; + }; + +const specArbitrary: fc.Arbitrary = fc.letrec<{ spec: Spec }>((tie) => ({ + spec: fc.oneof( + { depthSize: "small", withCrossShrink: true }, + fc.constantFrom( + { kind: "constant", value: "x" }, + { kind: "constant", value: null }, + { kind: "constant", value: undefined }, + { kind: "constant", value: 0 }, + { kind: "constant", value: {} }, + { kind: "constant", value: ["x"] }, + { kind: "command" }, + { kind: "passThrough" }, + { kind: "fail" }, + { kind: "argument" }, + { kind: "option" }, + { kind: "booleanOption" }, + { kind: "flag" }, + ), + fc.record({ + kind: fc.constantFrom( + "optional" as const, + "withDefault" as const, + "withDefaultThunk" as const, + "map" as const, + "nonEmpty" as const, + "group" as const, + ), + child: tie("spec"), + }), + fc.record({ + kind: fc.constant("multiple" as const), + child: tie("spec"), + min: fc.integer({ min: 0, max: 2 }), + }), + fc.record({ + kind: fc.constantFrom( + "or" as const, + "longestMatch" as const, + "tuple" as const, + "object" as const, + ), + children: fc.array(tie("spec"), { minLength: 1, maxLength: 3 }), + }), + ), +})).spec; + +function build(spec: Spec, counter: { n: number }): AnyParser { + const id = () => `${++counter.n}`; + switch (spec.kind) { + case "constant": + return constant(spec.value) as AnyParser; + case "fail": + return fail() as AnyParser; + case "argument": + return argument(string({ metavar: "ARG" })) as AnyParser; + case "option": + return option(`--o${id()}`, string()) as AnyParser; + case "booleanOption": + return option(`--b${id()}`) as AnyParser; + case "flag": + return flag(`--f${id()}`) as AnyParser; + case "command": + return command(`c${id()}`, constant(1)) as AnyParser; + case "passThrough": + return passThrough() as AnyParser; + case "optional": + return optional(build(spec.child, counter)) as AnyParser; + case "withDefault": + return withDefault(build(spec.child, counter), "d") as AnyParser; + case "withDefaultThunk": + return withDefault(build(spec.child, counter), () => "d") as AnyParser; + case "map": + return map(build(spec.child, counter), (v) => v) as AnyParser; + case "nonEmpty": + return nonEmpty(build(spec.child, counter)) as AnyParser; + case "group": + return group("G", build(spec.child, counter)) as AnyParser; + case "multiple": + return multiple(build(spec.child, counter), { + min: spec.min, + }) as AnyParser; + case "or": { + const [first, ...rest] = spec.children.map((c) => build(c, counter)); + return or(first, ...rest) as AnyParser; + } + case "longestMatch": { + const [first, ...rest] = spec.children.map((c) => build(c, counter)); + return longestMatch(first, ...rest) as AnyParser; + } + case "tuple": + return tuple(spec.children.map((c) => build(c, counter))) as AnyParser; + case "object": + return object( + Object.fromEntries( + spec.children.map((c, i) => [`k${i}`, build(c, counter)]), + ), + ) as AnyParser; + } +} + +describe("empty-input facts (property)", () => { + it("never contradict the runtime", () => { + fc.assert( + fc.property(specArbitrary, (spec) => { + let parser: AnyParser; + try { + parser = build(spec, { n: 0 }); + } catch { + // Some generated compositions are rejected at construction time. + fc.pre(false); + return; + } + assertFactsSound(parser, JSON.stringify(spec)); + }), + { numRuns: 2000 }, + ); + }); +}); diff --git a/packages/core/src/internal/empty-input.ts b/packages/core/src/internal/empty-input.ts new file mode 100644 index 000000000..7ba2bd76e --- /dev/null +++ b/packages/core/src/internal/empty-input.ts @@ -0,0 +1,559 @@ +/** + * Static facts about how built-in parsers behave when they receive no + * tokens. Combinators use these facts to annotate the usage groups they + * produce with {@link UsageTerm} `acceptsEmpty`, so that usage formatters + * can draw a group as omissible exactly when its producer accepts an empty + * argument list. + * + * The facts are *sound but incomplete*: every rule below returns an unknown + * fact (`undefined`) for any input it does not explicitly handle, and + * custom parsers never carry facts. Facts are never inferred from usage + * notation. + * @internal + * @module + */ +/** + * The parser members that empty-input facts are bound to. + * @internal + */ +export interface FactsTarget { + readonly parse: unknown; + readonly complete: unknown; + readonly initialState: unknown; +} + +/** + * The result of calling `parse()` on an empty buffer from a parser's + * initial state. + * @internal + */ +export type EmptyInputStep = "success" | "provisional" | "failure"; + +/** + * Facts about a parser's behavior on an empty argument list. Every member + * is optional; an absent member means the fact is unknown. + * @internal + */ +export interface EmptyInputFacts { + /** + * The outcome of `parse()` on an empty buffer from the initial state. + */ + readonly step?: EmptyInputStep; + + /** + * The state that a successful or provisional empty step produces: either + * the unchanged state that was passed in, or an exemplar of a fresh state. + */ + readonly next?: EmptyStepState; + + /** + * Whether `complete()` succeeds on the state produced by a successful or + * provisional empty step. + */ + readonly afterStep?: boolean; + + /** + * Whether `complete()` succeeds on the initial state. + */ + readonly fromInitial?: boolean; +} + +/** + * The state produced by a successful empty step. + * @internal + */ +export type EmptyStepState = + | { readonly unchanged: true } + | { + /** + * Builds an exemplar of the resulting state from the state the step + * started from, so that parts the step left untouched keep their + * identity, as they do at runtime. + */ + readonly state: (initialState: unknown) => unknown; + }; + +/** + * The state of an empty step that leaves its input state unchanged. + * @internal + */ +export const UNCHANGED: EmptyStepState = Object.freeze({ unchanged: true }); + +/** + * Returns the state that an empty step produces from the given initial + * state. + * @param next The step's resulting state. + * @param initialState The state the step started from. + * @returns The resulting state. + * @internal + */ +export function resolveEmptyStepState( + next: EmptyStepState, + initialState: unknown, +): unknown { + return "unchanged" in next ? initialState : next.state(initialState); +} + +const emptyInputFactsKey: unique symbol = Symbol("emptyInputFacts"); + +interface StoredFacts { + readonly facts: EmptyInputFacts; + readonly parse: unknown; + readonly complete: unknown; + /** The initial state, unless it is computed by a getter. */ + readonly initialState?: { readonly value: unknown }; +} + +type ParserWithFacts = { + readonly [emptyInputFactsKey]?: StoredFacts; +}; + +const UNKNOWN: EmptyInputFacts = Object.freeze({}); + +/** + * Attaches empty-input facts to a parser. The facts are stored as a + * non-enumerable property, so object spread does not copy them, and they + * are bound to the parser's current `parse`, `complete`, and + * `initialState`, so replacing any of those invalidates them. + * @param parser The parser to annotate. + * @param facts The facts to attach. + * @internal + */ +export function defineEmptyInputFacts( + parser: FactsTarget, + facts: EmptyInputFacts, +): void { + if (Object.keys(facts).length < 1) return; + Object.defineProperty(parser, emptyInputFactsKey, { + value: { + facts, + parse: parser.parse, + complete: parser.complete, + // Some parsers compute a fresh initial state on every access, so + // only a plain initial state value can be compared by identity. + ...(Object.getOwnPropertyDescriptor(parser, "initialState")?.get == null + ? { initialState: { value: parser.initialState } } + : {}), + } satisfies StoredFacts, + configurable: true, + enumerable: false, + writable: false, + }); +} + +/** + * Returns the empty-input facts of a parser, or an empty object when the + * facts are unknown. + * @param parser The parser to inspect. + * @returns The facts known for the parser. + * @internal + */ +export function getEmptyInputFacts( + parser: FactsTarget, +): EmptyInputFacts { + const stored = (parser as ParserWithFacts)[emptyInputFactsKey]; + if ( + stored == null || stored.parse !== parser.parse || + stored.complete !== parser.complete || + (stored.initialState != null && + stored.initialState.value !== parser.initialState) + ) { + return UNKNOWN; + } + return stored.facts; +} + +/** + * Returns whether `parse(parser, [])` succeeds, when that is known from the + * facts. + * @param facts The parser's facts. + * @returns `true` or `false` when known, `undefined` otherwise. + * @internal + */ +export function acceptsEmptyInput( + facts: EmptyInputFacts, +): boolean | undefined { + if (facts.step === "failure") return false; + if (facts.step == null) return undefined; + return facts.afterStep; +} + +/** + * Returns the facts of a parser that fails its empty step and whose + * completion from the initial state has the given outcome. + * @param fromInitial Whether completion from the initial state succeeds. + * @returns The facts. + * @internal + */ +export function failingStepFacts(fromInitial: boolean): EmptyInputFacts { + return { step: "failure", fromInitial }; +} + +/** + * Returns whether `complete()` succeeds after the parser was offered an + * empty step whose state change was kept (on success) or discarded (on + * failure). + * @param facts The parser's facts. + * @returns The outcome, or `undefined` when unknown. + * @internal + */ +export function completionAfterOptionalStep( + facts: EmptyInputFacts, +): boolean | undefined { + if (facts.step === "failure") return facts.fromInitial; + if (facts.step == null) return undefined; + return facts.afterStep; +} + +/** + * The state of a wrapper that stores its child's resulting state as the + * only element of a fresh array. + */ +function wrapInFreshArray( + child: EmptyStepState, + childInitialState: unknown, +): EmptyStepState { + return { + state: () => [resolveEmptyStepState(child, childInitialState)], + }; +} + +/** + * Computes the facts of `optional(p)` and `withDefault(p, value)` when + * completing the wrapper from its initial state is known to succeed. + * @param child The facts of the wrapped parser. + * @param childInitialState The wrapped parser's initial state. + * @param fromInitial Whether completing the wrapper from its initial state + * succeeds, or `undefined` when unknown. + * @returns The facts of the wrapper. + * @internal + */ +export function optionalLikeFacts( + child: EmptyInputFacts, + childInitialState: unknown, + fromInitial: boolean | undefined, +): EmptyInputFacts { + if (child.step === "failure") { + return { + step: "success", + next: UNCHANGED, + afterStep: fromInitial, + fromInitial, + }; + } + if (child.step == null) return { fromInitial }; + return { + step: child.step, + ...(child.next == null ? {} : { + next: wrapInFreshArray(child.next, childInitialState), + }), + afterStep: child.afterStep, + fromInitial, + }; +} + +/** + * Computes the facts of `multiple(p, { min })` without a `max` bound. + * @param child The facts of the repeated parser. + * @param retainsItem Whether a successful empty step of the child is kept + * as a repetition item, or `undefined` when unknown. + * @param childInitialState The repeated parser's initial state. + * @param min The minimum number of items. + * @returns The facts of the repetition. + * @internal + */ +export function multipleFacts( + child: EmptyInputFacts, + retainsItem: boolean | undefined, + childInitialState: unknown, + min: number, +): EmptyInputFacts { + const fromInitial = min < 1; + if (child.step === "failure") { + return min < 1 + ? { + step: "success", + next: UNCHANGED, + afterStep: true, + fromInitial, + } + : { step: "failure", fromInitial }; + } + if (child.step == null || retainsItem == null) return { fromInitial }; + if (!retainsItem) { + return { + step: "success", + next: UNCHANGED, + afterStep: min < 1, + fromInitial, + }; + } + return { + step: child.step, + ...(child.next == null ? {} : { + next: wrapInFreshArray(child.next, childInitialState), + }), + afterStep: child.afterStep == null ? undefined : min <= 1 && + child.afterStep, + fromInitial, + }; +} + +/** + * A branch of an exclusive combinator as seen by the empty-input rules. + * @internal + */ +export interface ExclusiveBranch { + readonly facts: EmptyInputFacts; + /** Whether the branch could match a token (named or catch-all). */ + readonly matchesTokens: boolean; +} + +function selectZeroInputCandidate( + branches: readonly ExclusiveBranch[], +): { readonly index: number } | null | undefined { + let index = -1; + let count = 0; + for (let i = 0; i < branches.length; i++) { + const branch = branches[i]; + if (branch.matchesTokens) continue; + if (branch.facts.step == null) return undefined; + if (branch.facts.step !== "success") continue; + if (index < 0) index = i; + count++; + } + return count === 1 ? { index } : null; +} + +/** + * Computes the completion of `or()` and `longestMatch()` from their initial + * state, which selects the unique zero-input candidate among the branches + * that cannot match tokens. + */ +function exclusiveFromInitial( + branches: readonly ExclusiveBranch[], +): boolean | undefined { + const candidate = selectZeroInputCandidate(branches); + if (candidate === undefined) return undefined; + if (candidate === null) return false; + return branches[candidate.index].facts.afterStep; +} + +/** + * An exemplar of an exclusive combinator's state after it selected a branch. + * It is a fresh, non-nullish value, which is all that repetition item checks + * look at. + */ +function exclusiveNextState(index: number): EmptyStepState { + return { state: () => [index, { success: true, consumed: [] }] }; +} + +/** + * Computes the facts of `or()`. + * @param branches The branches of the exclusive combinator. + * @returns The facts of the combinator. + * @internal + */ +export function orFacts( + branches: readonly ExclusiveBranch[], +): EmptyInputFacts { + const candidate = selectZeroInputCandidate(branches); + if (candidate === undefined) return {}; + const fromInitial = exclusiveFromInitial(branches); + if (candidate === null) return { step: "failure", fromInitial }; + return { + step: "success", + next: exclusiveNextState(candidate.index), + afterStep: branches[candidate.index].facts.afterStep, + fromInitial, + }; +} + +/** + * Computes the facts of `longestMatch()`, which selects the first branch + * whose empty step succeeds, preferring a definitive success over a + * provisional one. + * @param branches The branches of the exclusive combinator. + * @returns The facts of the combinator. + * @internal + */ +export function longestMatchFacts( + branches: readonly ExclusiveBranch[], +): EmptyInputFacts { + const fromInitial = exclusiveFromInitial(branches); + let best: { readonly index: number; readonly facts: EmptyInputFacts } | null = + null; + for (let i = 0; i < branches.length; i++) { + const facts = branches[i].facts; + if (facts.step == null) return { fromInitial }; + if (facts.step === "failure") continue; + if ( + best == null || + (best.facts.step === "provisional" && facts.step === "success") + ) { + best = { index: i, facts }; + } + if (best.facts.step === "success") break; + } + if (best == null) return { step: "failure", fromInitial }; + return { + step: best.facts.step, + next: exclusiveNextState(best.index), + afterStep: best.facts.afterStep, + fromInitial, + }; +} + +function all(values: readonly (boolean | undefined)[]): boolean | undefined { + let unknown = false; + for (const value of values) { + if (value === false) return false; + if (value == null) unknown = true; + } + return unknown ? undefined : true; +} + +/** + * A child of a sequential combinator as seen by the empty-input rules. + * @internal + */ +export interface SequentialChild { + readonly facts: EmptyInputFacts; + /** Whether the child could match a token (named or catch-all). */ + readonly matchesTokens: boolean; +} + +/** + * Computes the facts of `tuple()`. On an empty buffer `tuple()` offers + * each child one zero-token step, stores the state of every child whose + * step succeeded in a fresh state array, and skips the rest. + * @param children The children in declaration order. + * @returns The facts of the tuple. + * @internal + */ +export function tupleFacts( + children: readonly SequentialChild[], +): EmptyInputFacts { + const fromInitial = all(children.map((c) => c.facts.fromInitial)); + if (children.some((c) => c.facts.step == null)) { + return { step: "success", fromInitial }; + } + const afterStep = all( + children.map((c) => completionAfterOptionalStep(c.facts)), + ); + let next: EmptyStepState | undefined; + if (children.every((c) => c.facts.step === "failure")) { + next = UNCHANGED; + } else if ( + children.every((c) => c.facts.step === "failure" || c.facts.next != null) + ) { + // tuple() passes each child its slot of the tuple state and copies the + // slots into a fresh array. + const steps = children.map((c) => + c.facts.step === "failure" ? undefined : c.facts.next + ); + next = { + state: (initialState) => { + const slots = Array.isArray(initialState) ? initialState : []; + return steps.map((step, i) => + step == null ? slots[i] : resolveEmptyStepState(step, slots[i]) + ); + }, + }; + } + return { + step: "success", + ...(next == null ? {} : { next }), + afterStep, + fromInitial, + }; +} + +/** + * The state of `object()` after an empty step: a committed field state makes + * it store a fresh record with the other fields unchanged. + */ +function objectNextState( + changed: readonly (readonly [PropertyKey, EmptyStepState])[], +): EmptyStepState { + if (changed.length < 1) return UNCHANGED; + return { + state: (initialState) => { + const record: Record = + initialState != null && typeof initialState === "object" + ? { ...initialState } + : {}; + for (const [key, next] of changed) { + record[key] = resolveEmptyStepState(next, record[key]); + } + return record; + }, + }; +} + +/** + * Computes the facts of a synchronous `object()`. On an empty buffer + * `object()` offers each field that cannot match tokens one zero-token + * step, keeps the resulting state when it changed, and then succeeds only + * if every field can complete. + * @param children The fields. + * @param keys The field keys, parallel to `children`. + * @returns The facts of the object. + * @internal + */ +export function objectFacts( + children: readonly SequentialChild[], + keys: readonly PropertyKey[], +): EmptyInputFacts { + const fromInitial = all(children.map((c) => c.facts.fromInitial)); + const outcomes: (boolean | undefined)[] = []; + const changed: [PropertyKey, EmptyStepState][] = []; + let nextKnown = true; + for (const [i, child] of children.entries()) { + const { step, next } = child.facts; + if (child.matchesTokens || step === "failure") { + outcomes.push(child.facts.fromInitial); + } else if (step == null) { + outcomes.push(undefined); + nextKnown = false; + } else if (next == null) { + nextKnown = false; + outcomes.push( + child.facts.afterStep === child.facts.fromInitial + ? child.facts.afterStep + : undefined, + ); + } else if ("unchanged" in next) { + // object() keeps the field's initial state when the step left it + // unchanged. + outcomes.push(child.facts.fromInitial); + } else { + changed.push([keys[i], next]); + outcomes.push(child.facts.afterStep); + } + } + const accepted = all(outcomes); + if (accepted == null) return { fromInitial }; + if (!accepted) return { step: "failure", fromInitial }; + return { + step: "success", + ...(nextKnown ? { next: objectNextState(changed) } : {}), + afterStep: true, + fromInitial, + }; +} + +/** + * Attaches empty-input facts to a parser and returns the parser. + * @param parser The parser to annotate. + * @param facts The facts to attach. + * @returns The same parser. + * @internal + */ +export function withEmptyInputFacts

( + parser: P, + facts: EmptyInputFacts, +): P { + defineEmptyInputFacts(parser, facts); + return parser; +} diff --git a/packages/core/src/internal/usage.test.ts b/packages/core/src/internal/usage.test.ts new file mode 100644 index 000000000..61b6ad2b0 --- /dev/null +++ b/packages/core/src/internal/usage.test.ts @@ -0,0 +1,246 @@ +import { + formatUsage, + isDocHidden, + isUsageHidden, + normalizeUsage, + type Usage, + type UsageTerm, +} from "../usage.ts"; +import { resolveUsageForDisplay } from "./usage.ts"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +const FILE: UsageTerm = { type: "argument", metavar: "FILE" }; +const DIR: UsageTerm = { type: "argument", metavar: "DIR" }; +const optionalFile: UsageTerm = { type: "optional", terms: [FILE] }; +const optionalDir: UsageTerm = { type: "optional", terms: [DIR] }; +const hiddenFlag: UsageTerm = { + type: "option", + names: ["--secret"], + hidden: true, +}; + +function hasAcceptsEmpty(usage: Usage): boolean { + return usage.some((term) => { + if ( + (term.type === "exclusive" || term.type === "multiple") && + term.acceptsEmpty != null + ) { + return true; + } + if (term.type === "exclusive") return term.terms.some(hasAcceptsEmpty); + if ( + term.type === "optional" || term.type === "multiple" || + term.type === "sequence" + ) { + return hasAcceptsEmpty(term.terms); + } + return false; + }); +} + +describe("resolveUsageForDisplay()", () => { + it("requires optional alternatives of a group that rejects empty input", () => { + assert.deepEqual( + resolveUsageForDisplay([{ + type: "exclusive", + terms: [[optionalFile], [optionalDir]], + acceptsEmpty: false, + }], isUsageHidden), + [{ type: "exclusive", terms: [[FILE], [DIR]] }], + ); + }); + + it("makes a group optional when its omission has no visible branch", () => { + assert.deepEqual( + resolveUsageForDisplay([{ + type: "exclusive", + terms: [[], [FILE]], + acceptsEmpty: true, + }], isUsageHidden), + [{ type: "optional", terms: [FILE] }], + ); + assert.deepEqual( + resolveUsageForDisplay([{ + type: "exclusive", + terms: [[{ type: "optional", terms: [hiddenFlag] }], [FILE], [DIR]], + acceptsEmpty: true, + }], isUsageHidden), + [{ + type: "optional", + terms: [{ type: "exclusive", terms: [[FILE], [DIR]] }], + }], + ); + }); + + it("requires optional repeated items when repetition rejects empty input", () => { + assert.deepEqual( + resolveUsageForDisplay([{ + type: "multiple", + terms: [optionalFile], + min: 1, + acceptsEmpty: false, + }], isUsageHidden), + [{ type: "multiple", terms: [FILE], min: 1 }], + ); + }); + + it("looks through nested optional terms", () => { + const nested: Usage = [{ + type: "multiple", + terms: [{ type: "optional", terms: [optionalFile] }], + min: 1, + acceptsEmpty: false, + }]; + assert.deepEqual(resolveUsageForDisplay(nested, isUsageHidden), [{ + type: "multiple", + terms: [FILE], + min: 1, + }]); + assert.equal(formatUsage("app", nested), "app FILE..."); + assert.equal(formatUsage("app", normalizeUsage(nested)), "app FILE..."); + }); + + it("makes repetition optional when it accepts empty input", () => { + assert.deepEqual( + resolveUsageForDisplay([{ + type: "multiple", + terms: [FILE], + min: 1, + acceptsEmpty: true, + }], isUsageHidden), + [{ + type: "optional", + terms: [{ type: "multiple", terms: [FILE], min: 1 }], + }], + ); + }); + + it("keeps the declared notation when the requirement cannot be drawn", () => { + // Two optional terms in one alternative mean "at least one of them", + // which plain notation cannot express locally. + const twoOptions: Usage = [{ + type: "exclusive", + terms: [[optionalFile, optionalDir], [{ + type: "command", + name: "init", + }]], + acceptsEmpty: false, + }]; + assert.deepEqual(resolveUsageForDisplay(twoOptions, isUsageHidden), [{ + type: "exclusive", + terms: [[optionalFile, optionalDir], [{ type: "command", name: "init" }]], + }]); + // A repetition without a minimum cannot be drawn as required either. + assert.deepEqual( + resolveUsageForDisplay([{ + type: "multiple", + terms: [FILE], + min: 0, + acceptsEmpty: false, + }], isUsageHidden), + [{ type: "multiple", terms: [FILE], min: 0 }], + ); + }); + + it("does not require a visible term next to a hidden required one", () => { + // The first alternative accepts --secret alone, so FILE stays optional + // even though the group rejects empty input. + const usage: Usage = [{ + type: "exclusive", + terms: [[hiddenFlag, optionalFile], [DIR]], + acceptsEmpty: false, + }]; + const resolved = resolveUsageForDisplay(usage, isUsageHidden); + assert.deepEqual(resolved, [{ + type: "exclusive", + terms: [[hiddenFlag, optionalFile], [DIR]], + }]); + assert.equal(formatUsage("app", usage), "app ([FILE] | DIR)"); + assert.equal(formatUsage("app", resolved), "app ([FILE] | DIR)"); + }); + + it("ignores an alternative whose leading command is hidden", () => { + // Display drops the whole "secret" alternative, so it cannot keep the + // optional "open" alternative from being drawn as required. + const usage: Usage = [{ + type: "exclusive", + terms: [ + [{ type: "command", name: "secret", hidden: true }, optionalFile], + [{ type: "optional", terms: [{ type: "command", name: "open" }] }], + ], + acceptsEmpty: false, + }]; + assert.equal(formatUsage("app", usage), "app (open)"); + }); + + it("depends on the visibility context", () => { + const docHidden: UsageTerm = { + type: "option", + names: ["--debug"], + hidden: "doc", + }; + const usage: Usage = [{ + type: "exclusive", + terms: [[{ type: "optional", terms: [docHidden] }], [FILE]], + acceptsEmpty: true, + }]; + assert.deepEqual(resolveUsageForDisplay(usage, isUsageHidden), [{ + type: "exclusive", + terms: [[{ type: "optional", terms: [docHidden] }], [FILE]], + }]); + assert.deepEqual(resolveUsageForDisplay(usage, isDocHidden), [{ + type: "optional", + terms: [FILE], + }]); + }); + + it("lets an enclosing group take precedence over an inner one", () => { + // multiple(or(constant("x"), FILE), { min: 2 }): the alternative alone + // accepts empty input, but two items need at least one FILE. + const usage: Usage = [{ + type: "multiple", + terms: [{ type: "exclusive", terms: [[], [FILE]], acceptsEmpty: true }], + min: 2, + acceptsEmpty: false, + }]; + assert.deepEqual(resolveUsageForDisplay(usage, isUsageHidden), [{ + type: "multiple", + terms: [FILE], + min: 2, + }]); + assert.equal(formatUsage("app", usage), "app FILE FILE..."); + }); + + it("removes every record and is idempotent", () => { + const usages: readonly Usage[] = [ + [{ + type: "exclusive", + terms: [[hiddenFlag, optionalFile], [DIR]], + acceptsEmpty: false, + }], + [{ + type: "exclusive", + terms: [[optionalFile, optionalDir], [DIR]], + acceptsEmpty: false, + }], + [{ type: "exclusive", terms: [[], [FILE]], acceptsEmpty: true }], + [{ + type: "multiple", + terms: [optionalFile], + min: 1, + acceptsEmpty: false, + }], + [{ type: "multiple", terms: [], min: 2, acceptsEmpty: false }], + ]; + for (const usage of usages) { + const resolved = resolveUsageForDisplay(usage, isUsageHidden); + assert.ok(!hasAcceptsEmpty(resolved)); + assert.deepEqual( + resolveUsageForDisplay(resolved, isUsageHidden), + resolved, + ); + assert.equal(formatUsage("app", resolved), formatUsage("app", usage)); + } + }); +}); diff --git a/packages/core/src/internal/usage.ts b/packages/core/src/internal/usage.ts new file mode 100644 index 000000000..68d4ee650 --- /dev/null +++ b/packages/core/src/internal/usage.ts @@ -0,0 +1,271 @@ +/** + * Internal usage helpers shared by Optique's usage formatters. This module + * is not a stable extension API. + * @module + */ +import type { HiddenVisibility, Usage, UsageTerm } from "../usage.ts"; + +/** + * Reports whether a term with the given visibility is hidden in the + * rendering context. + * @internal + */ +export type HiddenPredicate = (hidden?: HiddenVisibility) => boolean; + +/** + * How a term or term list looks once hidden terms are removed. + */ +interface Projection { + /** Whether anything remains visible. */ + readonly visible: boolean; + /** Whether the visible part can be omitted under synopsis notation. */ + readonly omissible: boolean; + /** Whether a hidden term that cannot be omitted was removed. */ + readonly hidesRequired: boolean; +} + +const EMPTY: Projection = { + visible: false, + omissible: true, + hidesRequired: false, +}; + +function isHiddenLeaf(term: UsageTerm, isHidden: HiddenPredicate): boolean { + return (term.type === "argument" || term.type === "option" || + term.type === "command" || term.type === "passthrough") && + isHidden(term.hidden); +} + +function isDegenerateLeaf(term: UsageTerm): boolean { + return (term.type === "option" && term.names.length === 0) || + (term.type === "command" && term.name === "") || + (term.type === "argument" && term.metavar.length === 0) || + (term.type === "literal" && term.value === ""); +} + +function projectTerms( + terms: Usage, + isHidden: HiddenPredicate, +): Projection { + let visible = false; + let omissible = true; + let hidesRequired = false; + for (const term of terms) { + const projection = projectTerm(term, isHidden); + hidesRequired ||= projection.hidesRequired; + if (!projection.visible) continue; + visible = true; + omissible &&= projection.omissible; + } + return { visible, omissible, hidesRequired }; +} + +/** + * Projects an alternative of an exclusive term. Display filtering drops an + * alternative whose leading command is hidden as a whole, trailing terms + * included. + */ +function projectBranch( + branch: Usage, + isHidden: HiddenPredicate, +): Projection { + const first = branch[0]; + if (first?.type === "command" && isHidden(first.hidden)) { + return { ...EMPTY, hidesRequired: true }; + } + return projectTerms(branch, isHidden); +} + +function projectTerm(term: UsageTerm, isHidden: HiddenPredicate): Projection { + if (isHiddenLeaf(term, isHidden)) { + return { + ...EMPTY, + hidesRequired: term.type !== "passthrough", + }; + } + if (isDegenerateLeaf(term)) return EMPTY; + switch (term.type) { + case "argument": + case "option": + case "command": + case "literal": + return { visible: true, omissible: false, hidesRequired: false }; + case "passthrough": + case "ellipsis": + return { visible: true, omissible: true, hidesRequired: false }; + case "optional": { + const inner = projectTerms(term.terms, isHidden); + return { ...inner, omissible: true, hidesRequired: false }; + } + case "sequence": + return projectTerms(term.terms, isHidden); + case "multiple": { + const inner = projectTerms(term.terms, isHidden); + return { + ...inner, + omissible: term.min < 1 || inner.omissible, + hidesRequired: term.min > 0 && inner.hidesRequired, + }; + } + case "exclusive": { + let visible = false; + let omissible = false; + let hidesRequired = false; + for (const branch of term.terms) { + const projection = projectBranch(branch, isHidden); + hidesRequired ||= projection.hidesRequired; + if (!projection.visible) continue; + visible = true; + omissible ||= projection.omissible; + } + return { visible, omissible, hidesRequired }; + } + } +} + +/** + * Returns the terms `X` when the visible part of `terms` is exactly + * `optional(X)`, possibly nested, with a required `X`, and no required term + * was hidden. + */ +function unwrapSingleOptional( + terms: Usage, + isHidden: HiddenPredicate, +): Usage | undefined { + if (projectTerms(terms, isHidden).hidesRequired) return undefined; + const visible = terms.filter((t) => projectTerm(t, isHidden).visible); + if (visible.length !== 1) return undefined; + const [only] = visible; + if (only.type !== "optional") return undefined; + const inner = projectTerms(only.terms, isHidden); + if (!inner.visible || inner.hidesRequired) return undefined; + // optional(optional(X)) reads the same as optional(X). + if (inner.omissible) return unwrapSingleOptional(only.terms, isHidden); + return only.terms; +} + +function resolveTerms(terms: Usage, isHidden: HiddenPredicate): Usage { + return terms.map((term) => resolveTerm(term, isHidden)); +} + +function resolveTerm(term: UsageTerm, isHidden: HiddenPredicate): UsageTerm { + switch (term.type) { + case "optional": + return { type: "optional", terms: resolveTerms(term.terms, isHidden) }; + case "sequence": + return { type: "sequence", terms: resolveTerms(term.terms, isHidden) }; + case "multiple": { + const terms = resolveTerms(term.terms, isHidden); + const resolved: UsageTerm = { type: "multiple", terms, min: term.min }; + if (term.acceptsEmpty == null) return resolved; + const projection = projectTerm(resolved, isHidden); + if (!projection.visible || projection.omissible === term.acceptsEmpty) { + return resolved; + } + if (term.acceptsEmpty) return { type: "optional", terms: [resolved] }; + if (term.min < 1) return resolved; + const required = unwrapSingleOptional(terms, isHidden); + return required == null ? resolved : { ...resolved, terms: required }; + } + case "exclusive": { + const branches = term.terms.map((branch) => + resolveTerms(branch, isHidden) + ); + const resolved: UsageTerm = { type: "exclusive", terms: branches }; + if (term.acceptsEmpty == null) return resolved; + const projection = projectTerm(resolved, isHidden); + if (!projection.visible || projection.omissible === term.acceptsEmpty) { + return resolved; + } + if (term.acceptsEmpty) { + // Every visible alternative is required, but the group as a whole + // may be omitted, for example through a branch that has no visible + // terms. Branches without visible terms are dropped by display + // filtering anyway. + const visible = branches.filter((branch) => + projectBranch(branch, isHidden).visible + ); + return { + type: "optional", + terms: visible.length === 1 + ? visible[0] + : [{ type: "exclusive", terms: visible }], + }; + } + const required: Usage[] = []; + for (const branch of branches) { + const branchProjection = projectBranch(branch, isHidden); + if (!branchProjection.visible || !branchProjection.omissible) { + required.push(branch); + continue; + } + const unwrapped = unwrapSingleOptional(branch, isHidden); + // An omissible branch that is not a single optional term cannot be + // drawn as required; keep the declared notation. + if (unwrapped == null) return resolved; + required.push(unwrapped); + } + return { type: "exclusive", terms: required }; + } + default: + return term; + } +} + +/** + * Rewrites a usage so that plain synopsis notation reflects the recorded + * empty-input behavior of its groups. + * + * Each `exclusive` or `multiple` term that records `acceptsEmpty` is drawn + * as omissible exactly when its parser accepts an empty argument list, + * using only exact local rewrites: a group that should be omissible is + * wrapped in an optional term, and a group that should be required has + * `optional(X)` alternatives or repeated items replaced by `X`. A group + * whose visible terms cannot be drawn that way keeps its declared notation. + * Enclosing groups are resolved after their children, so their constraints + * take precedence. + * + * The result records no `acceptsEmpty` fields, so resolving it again + * returns an equal usage. Hidden terms are kept; callers filter them for + * display afterwards. + * @param usage The usage to resolve. + * @param isHidden Reports whether a term is hidden in the rendering context. + * @returns The resolved usage. + * @internal + */ +export function resolveUsageForDisplay( + usage: Usage, + isHidden: HiddenPredicate, +): Usage { + return resolveTerms(usage, isHidden); +} + +/** + * Removes every `acceptsEmpty` record from a usage without changing how it + * is drawn otherwise. + * @param usage The usage to copy. + * @returns The usage without records. + * @internal + */ +export function forgetEmptyInput(usage: Usage): Usage { + return usage.map(forgetEmptyInputTerm); +} + +function forgetEmptyInputTerm(term: UsageTerm): UsageTerm { + switch (term.type) { + case "optional": + return { type: "optional", terms: forgetEmptyInput(term.terms) }; + case "sequence": + return { type: "sequence", terms: forgetEmptyInput(term.terms) }; + case "multiple": + return { + type: "multiple", + terms: forgetEmptyInput(term.terms), + min: term.min, + }; + case "exclusive": + return { type: "exclusive", terms: term.terms.map(forgetEmptyInput) }; + default: + return term; + } +} diff --git a/packages/core/src/modifiers.ts b/packages/core/src/modifiers.ts index 22baaf504..4f05627f6 100644 --- a/packages/core/src/modifiers.ts +++ b/packages/core/src/modifiers.ts @@ -16,6 +16,15 @@ import { normalizeNestedDelegatedAnnotationState, } from "./annotation-state.ts"; import { composeDependencyMetadata } from "./dependency-metadata.ts"; +import { + acceptsEmptyInput, + defineEmptyInputFacts, + type EmptyInputFacts, + getEmptyInputFacts, + multipleFacts, + optionalLikeFacts, + resolveEmptyStepState, +} from "./internal/empty-input.ts"; import { defineForwardedEffectfulSchedulingNodes } from "./dependency-runtime.ts"; import { formatMessage, type Message, message, text } from "./message.ts"; import { @@ -105,6 +114,21 @@ function isUnstartedMultipleItemState( ); } +/** + * Returns whether an optional-style wrapper around the given parser is known + * to complete successfully from its initial state without delegating to a + * source binding or a deferred completion. + */ +function completesOptionalLikeFallback( + parser: Parser, +): boolean | undefined { + return typeof parser.shouldDeferCompletion !== "function" && + parser.dependencyMetadata?.source == null && + parser[unmatchedNonCliDependencySourceStateMarker] !== true + ? true + : undefined; +} + function isBoundCliWrapperState( state: unknown, ): state is { @@ -1075,7 +1099,19 @@ export function optional( (state) => getKnownCompletion(parser, deriveOptionalInnerParseState(state, parser)), ); - return fluent(scopeParser(optionalParser, combinedOptionScope([parser]))); + const scopedOptionalParser = scopeParser( + optionalParser, + combinedOptionScope([parser]), + ); + defineEmptyInputFacts( + scopedOptionalParser, + optionalLikeFacts( + getEmptyInputFacts(parser), + parser.initialState, + completesOptionalLikeFallback(parser), + ), + ); + return fluent(scopedOptionalParser); } /** @@ -1675,7 +1711,23 @@ export function withDefault< (state) => getKnownCompletion(parser, deriveOptionalInnerParseState(state, parser)), ); - return fluent(scopeParser(withDefaultParser, combinedOptionScope([parser]))); + const scopedWithDefaultParser = scopeParser( + withDefaultParser, + combinedOptionScope([parser]), + ); + // A function default may throw, which completion reports as a failure, + // so only a plain default value is known to complete. + defineEmptyInputFacts( + scopedWithDefaultParser, + optionalLikeFacts( + getEmptyInputFacts(parser), + parser.initialState, + typeof defaultValue === "function" + ? undefined + : completesOptionalLikeFallback(parser), + ), + ); + return fluent(scopedWithDefaultParser); } /** @@ -1930,6 +1982,16 @@ export function map( defineForwardedEffectfulSchedulingNodes(mappedParser, parser); // map() copies parse/suggest and their enumerable scope metadata. Reuse // that scope rather than stacking another wrapper around the same calls. + // map() keeps the inner parse and state, so the step facts carry over. A + // successful completion runs the transform, which may throw, so only a + // failing completion is known without running user code. + const innerFacts = getEmptyInputFacts(parser); + defineEmptyInputFacts(mappedParser, { + ...(innerFacts.step == null ? {} : { step: innerFacts.step }), + ...(innerFacts.next == null ? {} : { next: innerFacts.next }), + ...(innerFacts.afterStep === false ? { afterStep: false } : {}), + ...(innerFacts.fromInitial === false ? { fromInitial: false } : {}), + }); return fluent(mappedParser); } @@ -2271,6 +2333,34 @@ export interface MultipleErrorOptions { readonly tooMany?: Message | ((max: number, actual: number) => Message); } +/** + * Computes the empty-input facts of `multiple(parser, options)`. + */ +function getMultipleEmptyInputFacts( + parser: Parser, + options: MultipleOptions, +): EmptyInputFacts { + // An upper bound changes how items are opened, and a fractional minimum + // is compared differently at runtime; leave both unknown. + const min = options.min ?? 0; + if (options.max != null || !Number.isInteger(min) || min < 0) return {}; + const facts = getEmptyInputFacts(parser); + const childInitialState = parser.initialState; + const retainsItem = facts.step === "success" || + facts.step === "provisional" + ? facts.next == null ? undefined : !isUnstartedMultipleItemState( + resolveEmptyStepState(facts.next, childInitialState), + childInitialState, + ) + : false; + return multipleFacts( + facts, + retainsItem, + childInitialState, + min, + ); +} + /** * Creates a parser that allows multiple occurrences of a given parser. * This parser can be used to parse multiple values of the same type, @@ -2811,17 +2901,25 @@ export function multiple( }; }; + const initialState: MultipleState = []; + const emptyInputFacts = getMultipleEmptyInputFacts(parser, options); + const acceptsEmpty = acceptsEmptyInput(emptyInputFacts); const resultParser = { mode: parser.mode, $valueType: [] as readonly TValue[], $stateType: [] as readonly TState[], priority: parser.priority, - usage: [{ type: "multiple", terms: parser.usage, min }], + usage: [{ + type: "multiple", + terms: parser.usage, + min, + ...(acceptsEmpty == null ? {} : { acceptsEmpty }), + }], leadingNames: parser.leadingNames, // multiple(min=0) can succeed without consuming, so only propagate // catch-all status when at least one match is required. acceptingAnyToken: min > 0 && (parser.acceptingAnyToken ?? false), - initialState: [] as readonly TState[], + initialState, canSkip(state: MultipleState, exec?: ExecutionContext) { if (state.length < min) return false; const currentItemState = state.at(-1); @@ -3481,7 +3579,12 @@ export function multiple( state: unwrapInjectedWrapper(item.state), })), ); - return fluent(scopeParser(resultParser, repeatedOptionScope(parser))); + const scopedResultParser = scopeParser( + resultParser, + repeatedOptionScope(parser), + ); + defineEmptyInputFacts(scopedResultParser, emptyInputFacts); + return fluent(scopedResultParser); } /** @@ -3681,7 +3784,20 @@ export function nonEmpty( [parser], (state) => [{ parser, state }], ); - return fluent(scopeParser(nonEmptyParser, combinedOptionScope([parser]))); + const scopedNonEmptyParser = scopeParser( + nonEmptyParser, + combinedOptionScope([parser]), + ); + // nonEmpty() rejects any step that consumes no tokens, but completes + // from the initial state exactly as its inner parser does. + const innerFacts = getEmptyInputFacts(parser); + defineEmptyInputFacts(scopedNonEmptyParser, { + step: "failure", + ...(innerFacts.fromInitial == null + ? {} + : { fromInitial: innerFacts.fromInitial }), + }); + return fluent(scopedNonEmptyParser); } const fluentParserMarker = Symbol.for("@optique/core/fluent"); diff --git a/packages/core/src/parser.test.ts b/packages/core/src/parser.test.ts index 670889839..1fa84db71 100644 --- a/packages/core/src/parser.test.ts +++ b/packages/core/src/parser.test.ts @@ -970,6 +970,7 @@ describe("Parser usage field", () => { metavar: "STRING", }], min: 0, + acceptsEmpty: true, }]; assert.deepEqual(parser.usage, expected); }); @@ -984,6 +985,7 @@ describe("Parser usage field", () => { metavar: "STRING", }], min: 2, + acceptsEmpty: false, }]; assert.deepEqual(parser.usage, expected); }); @@ -999,6 +1001,7 @@ describe("Parser usage field", () => { metavar: "STRING", }], min: 1, + acceptsEmpty: false, }]; assert.deepEqual(parser.usage, expected); }); @@ -1125,6 +1128,9 @@ describe("Parser usage field", () => { }], }], ], + // Neither branch can be chosen without input, since both match + // option names, so or() rejects an empty argument list. + acceptsEmpty: false, }]; assert.deepEqual(parser.usage, expected); }); diff --git a/packages/core/src/primitives.ts b/packages/core/src/primitives.ts index 84a29926c..c9ee782ee 100644 --- a/packages/core/src/primitives.ts +++ b/packages/core/src/primitives.ts @@ -12,6 +12,11 @@ import { defineReachableChildren, getPassThroughFailure, } from "./internal/passthrough.ts"; +import { + defineEmptyInputFacts, + failingStepFacts, + UNCHANGED, +} from "./internal/empty-input.ts"; import { getWrappedChildParseState, getWrappedChildState, @@ -360,6 +365,12 @@ export function constant(value: T): FluentParser<"sync", T, T> { result, (state) => normalizeInjectedAnnotationState(state), ); + defineEmptyInputFacts(result, { + step: "success", + next: UNCHANGED, + afterStep: true, + fromInitial: true, + }); Object.defineProperty(result, "placeholder", { value, configurable: true, @@ -389,7 +400,7 @@ export function constant(value: T): FluentParser<"sync", T, T> { * @since 1.0.0 */ export function fail(): FluentParser<"sync", T, undefined> { - return fluent({ + const result: Parser<"sync", T, undefined> = { $valueType: [], $stateType: [], mode: "sync", @@ -420,7 +431,9 @@ export function fail(): FluentParser<"sync", T, undefined> { getDocFragments(_state, _defaultValue?) { return { fragments: [], sourceOnly: true }; }, - }); + }; + defineEmptyInputFacts(result, failingStepFacts(false)); + return fluent(result); } /** @@ -1685,6 +1698,10 @@ export function option( configurable: true, enumerable: false, }); + } else { + // A Boolean option without a value parser completes to false when it + // is absent; an option with a value is required. + defineEmptyInputFacts(result, failingStepFacts(valueParser == null)); } // Type assertion via 'unknown' needed because TypeScript's conditional type // ModeValue cannot be verified when M is a generic type parameter. @@ -2050,6 +2067,7 @@ export function flag( enumerable: false, writable: false, }); + defineEmptyInputFacts(result, failingStepFacts(false)); return fluent(result); } @@ -2917,6 +2935,8 @@ export function argument( configurable: true, enumerable: false, }); + } else { + defineEmptyInputFacts(result, failingStepFacts(false)); } // Type assertion via 'unknown' needed because TypeScript's conditional type // ModeValue cannot be verified when M is a generic type parameter. @@ -3828,7 +3848,7 @@ export function command( }, ); - return fluent(scopeParser( + const scoped = scopeParser( result as unknown as Parser>, commandScope.source, (context) => { @@ -3836,7 +3856,9 @@ export function command( commandScope.select(context, enteredScope); } }, - )); + ); + defineEmptyInputFacts(scoped, failingStepFacts(false)); + return fluent(scoped); } /** @@ -4118,5 +4140,6 @@ export function passThrough( : format === "nextToken" && optionPattern.test(token); return accepts ? result.priority : undefined; }); + defineEmptyInputFacts(result, failingStepFacts(true)); return fluent(result); } diff --git a/packages/core/src/public-api.test.ts b/packages/core/src/public-api.test.ts index 139197de9..c6273cfd4 100644 --- a/packages/core/src/public-api.test.ts +++ b/packages/core/src/public-api.test.ts @@ -47,6 +47,7 @@ test("package manifests expose only supported public subpaths", () => { "./facade", "./fluent", "./internal/terminal", + "./internal/usage", "./message", "./modifiers", "./nonempty", @@ -82,6 +83,7 @@ test("package manifests expose only supported public subpaths", () => { "src/internal/dependency.ts", "src/internal/parser.ts", "src/internal/terminal.ts", + "src/internal/usage.ts", "src/message-registry.ts", "src/message-renderer.ts", "src/message.ts", diff --git a/packages/core/src/usage-empty-input.test.ts b/packages/core/src/usage-empty-input.test.ts new file mode 100644 index 000000000..b7b82bafa --- /dev/null +++ b/packages/core/src/usage-empty-input.test.ts @@ -0,0 +1,528 @@ +import { + group, + longestMatch, + object, + or, + tuple, +} from "@optique/core/constructs"; +import { + map, + multiple, + nonEmpty, + optional, + withDefault, +} from "@optique/core/modifiers"; +import { getDocPage, parse, type Parser } from "@optique/core/parser"; +import { + argument, + command, + constant, + fail, + flag, + option, +} from "@optique/core/primitives"; +import { + cloneUsage, + formatUsage, + isUsageHidden, + normalizeUsage, + type Usage, + type UsageTerm, +} from "@optique/core/usage"; +import { string } from "@optique/core/valueparser"; +import { resolveUsageForDisplay } from "@optique/core/internal/usage"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import fc from "fast-check"; + +type AnyParser = Parser<"sync", unknown, unknown>; + +const FILE = () => argument(string({ metavar: "FILE" })); +const DIR = () => argument(string({ metavar: "DIR" })); + +/** + * Whether a usage admits the empty invocation under plain synopsis + * notation, ignoring `acceptsEmpty` records and hidden terms. + */ +function notationAdmitsEmpty(usage: Usage): boolean { + return usage.every(termAdmitsEmpty); +} + +function termAdmitsEmpty(term: UsageTerm): boolean { + switch (term.type) { + case "argument": + case "option": + case "command": + return isUsageHidden(term.hidden); + case "literal": + return term.value === ""; + case "optional": + case "passthrough": + case "ellipsis": + return true; + case "sequence": + return notationAdmitsEmpty(term.terms); + case "multiple": + return term.min < 1 || notationAdmitsEmpty(term.terms); + case "exclusive": { + // Display drops alternatives without visible terms. + const visible = term.terms.filter((branch) => hasVisibleTerm(branch)); + return visible.length < 1 || visible.some(notationAdmitsEmpty); + } + } +} + +function hasVisibleTerm(usage: Usage): boolean { + return usage.some((term) => { + switch (term.type) { + case "argument": + case "option": + case "command": + case "passthrough": + return !isUsageHidden(term.hidden); + case "literal": + return term.value !== ""; + case "ellipsis": + return true; + case "optional": + case "multiple": + case "sequence": + return hasVisibleTerm(term.terms); + case "exclusive": + return term.terms.some(hasVisibleTerm); + } + }); +} + +/** Removes `acceptsEmpty` by formatting through the public formatter. */ +function render(parser: { readonly usage: Usage }): string { + return formatUsage("app", parser.usage); +} + +describe("usage of empty-input behavior", () => { + const cases: readonly [string, () => AnyParser, string][] = [ + [ + "ambiguous optional alternatives (issue example 1)", + () => or(optional(FILE()), optional(DIR())) as AnyParser, + "app (FILE | DIR)", + ], + [ + "alternative that produces an item without tokens (issue example 2)", + () => + or( + multiple(tuple([optional(fail()), constant("x")]), { min: 1 }), + FILE(), + ) as AnyParser, + "app [FILE]", + ], + [ + "constant alternative", + () => or(constant("a"), FILE()) as AnyParser, + "app [FILE]", + ], + [ + "ambiguous constant alternatives", + () => or(constant("a"), constant("b"), FILE()) as AnyParser, + "app (FILE)", + ], + [ + "failing alternative", + () => or(fail(), FILE()) as AnyParser, + "app (FILE)", + ], + [ + "optional option alternative", + () => or(optional(option("--x", string())), FILE()) as AnyParser, + "app (--x STRING | FILE)", + ], + [ + "Boolean option alternatives", + () => or(option("-v"), option("-q")) as AnyParser, + "app (-v | -q)", + ], + [ + "hidden optional flag alternative", + () => + or(optional(flag("--secret", { hidden: true })), FILE()) as AnyParser, + "app (FILE)", + ], + [ + "repeated optional argument", + () => multiple(optional(FILE()), { min: 1 }) as AnyParser, + "app FILE...", + ], + [ + "repeated alternative with a constant", + () => multiple(or(constant("x"), FILE()), { min: 1 }) as AnyParser, + "app [FILE]...", + ], + [ + "two repeated alternatives with a constant", + () => multiple(or(constant("x"), FILE()), { min: 2 }) as AnyParser, + "app FILE FILE...", + ], + [ + "longestMatch() with optional alternatives", + () => longestMatch(optional(FILE()), optional(DIR())) as AnyParser, + "app ([FILE] | [DIR])", + ], + [ + "longestMatch() with constant alternatives", + () => longestMatch(constant("a"), constant("b"), FILE()) as AnyParser, + "app [FILE]", + ], + ]; + + for (const [label, create, expected] of cases) { + it(`draws ${label} as parsing behaves`, () => { + const parser = create(); + assert.equal(render(parser), expected); + assert.equal( + notationAdmitsEmpty( + resolveUsageForDisplay(parser.usage, isUsageHidden), + ), + parse(parser, []).success, + ); + }); + } + + it("draws the same synopsis from raw, cloned, normalized, and doc usage", () => { + const parser = object({ + mode: or(optional(FILE()), optional(DIR())), + items: multiple(or(constant("x"), argument(string())), { min: 1 }), + verbose: optional(flag("--verbose", { hidden: true })), + }); + const expected = render(parser); + assert.equal(formatUsage("app", cloneUsage(parser.usage)), expected); + assert.equal(formatUsage("app", normalizeUsage(parser.usage)), expected); + const page = getDocPage(parser); + assert.ok(page?.usage != null); + assert.equal(formatUsage("app", page.usage), expected); + }); + + it("keeps the record through group() visibility changes", () => { + const parser = group( + "Group", + or(optional(FILE()), optional(DIR())), + { hidden: "doc" }, + ); + assert.equal(render(parser), "app (FILE | DIR)"); + }); +}); + +/** Formats a usage to plain notation, as the formatters see it. */ +function stripRecords(term: UsageTerm): UsageTerm { + switch (term.type) { + case "optional": + return { type: "optional", terms: term.terms.map(stripRecords) }; + case "sequence": + return { type: "sequence", terms: term.terms.map(stripRecords) }; + case "multiple": + return { + type: "multiple", + terms: term.terms.map(stripRecords), + min: term.min, + }; + case "exclusive": + return { + type: "exclusive", + terms: term.terms.map((branch) => branch.map(stripRecords)), + }; + default: + return term; + } +} + +type Spec = + | { readonly kind: "constant"; readonly value: unknown } + | { + readonly kind: + | "fail" + | "argument" + | "option" + | "booleanOption" + | "flag" + | "hiddenFlag" + | "command"; + } + | { + readonly kind: "optional" | "withDefault" | "map" | "nonEmpty"; + readonly child: Spec; + } + | { readonly kind: "multiple"; readonly child: Spec; readonly min: number } + | { + readonly kind: "or" | "longestMatch" | "tuple" | "object"; + readonly children: readonly Spec[]; + }; + +const specArbitrary: fc.Arbitrary = fc.letrec<{ spec: Spec }>(( + tie, +) => ({ + spec: fc.oneof( + { depthSize: "small", withCrossShrink: true }, + fc.constantFrom( + { kind: "constant", value: "x" }, + { kind: "constant", value: null }, + { kind: "fail" }, + { kind: "argument" }, + { kind: "option" }, + { kind: "booleanOption" }, + { kind: "flag" }, + { kind: "hiddenFlag" }, + { kind: "command" }, + ), + fc.record({ + kind: fc.constantFrom( + "optional" as const, + "withDefault" as const, + "map" as const, + "nonEmpty" as const, + ), + child: tie("spec"), + }), + fc.record({ + kind: fc.constant("multiple" as const), + child: tie("spec"), + min: fc.integer({ min: 0, max: 2 }), + }), + fc.record({ + kind: fc.constantFrom( + "or" as const, + "longestMatch" as const, + "tuple" as const, + "object" as const, + ), + children: fc.array(tie("spec"), { minLength: 1, maxLength: 3 }), + }), + ), +})).spec; + +function build(spec: Spec, counter: { n: number }): AnyParser { + const id = () => `${++counter.n}`; + switch (spec.kind) { + case "constant": + return constant(spec.value) as AnyParser; + case "fail": + return fail() as AnyParser; + case "argument": + return argument(string({ metavar: `A${id()}` })) as AnyParser; + case "option": + return option(`--o${id()}`, string()) as AnyParser; + case "booleanOption": + return option(`--b${id()}`) as AnyParser; + case "flag": + return flag(`--f${id()}`) as AnyParser; + case "hiddenFlag": + return flag(`--h${id()}`, { hidden: true }) as AnyParser; + case "command": + return command(`c${id()}`, constant(1)) as AnyParser; + case "optional": + return optional(build(spec.child, counter)) as AnyParser; + case "withDefault": + return withDefault(build(spec.child, counter), "d") as AnyParser; + case "map": + return map(build(spec.child, counter), (v) => v) as AnyParser; + case "nonEmpty": + return nonEmpty(build(spec.child, counter)) as AnyParser; + case "multiple": + return multiple(build(spec.child, counter), { + min: spec.min, + }) as AnyParser; + case "or": { + const [first, ...rest] = spec.children.map((c) => build(c, counter)); + return or(first, ...rest) as AnyParser; + } + case "longestMatch": { + const [first, ...rest] = spec.children.map((c) => build(c, counter)); + return longestMatch(first, ...rest) as AnyParser; + } + case "tuple": + return tuple(spec.children.map((c) => build(c, counter))) as AnyParser; + case "object": + return object( + Object.fromEntries( + spec.children.map((c, i) => [`k${i}`, build(c, counter)]), + ), + ) as AnyParser; + } +} + +/** Whether hiding terms removed a term that cannot be omitted. */ +function hidesRequired(usage: Usage): boolean { + return usage.some((term) => { + switch (term.type) { + case "argument": + case "option": + case "command": + return isUsageHidden(term.hidden); + case "sequence": + return hidesRequired(term.terms); + case "multiple": + return term.min > 0 && hidesRequired(term.terms); + case "exclusive": + return term.terms.some(hidesRequired); + default: + return false; + } + }); +} + +/** + * Whether a resolved group that rejects empty input still reads omissible + * because plain notation cannot draw it as required: an omissible + * alternative or repeated item that is not a single optional term over + * required terms, content next to or inside which a required term is + * hidden, or a repetition without a minimum. + */ +function isUnrepresentable(term: UsageTerm): boolean { + const isSingleOptional = (terms: Usage) => { + const visible = terms.filter((t) => hasVisibleTerm([t])); + return !hidesRequired(terms) && visible.length === 1 && + visible[0].type === "optional" && + !hidesRequired(visible[0].terms) && + !notationAdmitsEmpty(visible[0].terms); + }; + if (term.type === "multiple") { + return term.min < 1 || !isSingleOptional(term.terms); + } + if (term.type === "exclusive") { + return term.terms.some((branch) => + hasVisibleTerm(branch) && notationAdmitsEmpty(branch) && + !isSingleOptional(branch) + ); + } + return false; +} + +describe("usage of empty-input behavior (property)", () => { + it("draws every recorded group as its parser behaves", () => { + let agreed = 0; + fc.assert( + fc.property( + fc.record({ + kind: fc.constantFrom( + "or" as const, + "longestMatch" as const, + "multiple" as const, + ), + children: fc.array(specArbitrary, { minLength: 1, maxLength: 3 }), + min: fc.integer({ min: 0, max: 2 }), + }), + ({ kind, children, min }) => { + const spec: Spec = kind === "multiple" + ? { kind, child: children[0], min } + : { kind, children }; + let parser: AnyParser; + try { + parser = build(spec, { n: 0 }); + } catch { + fc.pre(false); + return; + } + const [term] = parser.usage; + assert.ok(term.type === "exclusive" || term.type === "multiple"); + if (term.acceptsEmpty == null) return; + const accepted = parse(parser, []).success; + assert.equal(term.acceptsEmpty, accepted); + if (!hasVisibleTerm([term])) return; + const resolved = resolveUsageForDisplay([term], isUsageHidden); + const rendered = formatUsage("app", [term]); + // The formatter draws exactly the resolved notation. + assert.equal(formatUsage("app", resolved), rendered); + const plain = notationAdmitsEmpty(resolved); + if (plain !== accepted) { + assert.ok( + !accepted && isUnrepresentable(resolved[0]), + `${JSON.stringify(spec)} renders ${rendered}`, + ); + } else { + agreed++; + } + }, + ), + { numRuns: 1000 }, + ); + assert.ok(agreed > 100, `only ${agreed} groups were checked`); + }); +}); + +describe("usage of empty-input behavior with expandCommands", () => { + it("expands a command group that has a fallback alternative", () => { + const parser = or( + command("serve", object({ port: option("--port", string()) })), + command("build", constant(2)), + constant(0), + ); + assert.ok(parse(parser, []).success); + assert.equal(render(parser), "app [(serve --port STRING | build)]"); + assert.equal( + formatUsage("app", parser.usage, { expandCommands: true }), + "app serve --port STRING\napp build", + ); + }); + + it("draws each expanded line as parsing behaves", () => { + const parser = or( + command("add", or(optional(FILE()), optional(DIR()))), + command("remove", FILE()), + ); + assert.equal( + formatUsage("app", parser.usage, { expandCommands: true }), + // Expansion also splits the arguments of "add" into lines, which + // are required because "add" alone fails. + "app add FILE\napp add DIR\napp remove FILE", + ); + }); + + it("expands a single command that has a fallback alternative", () => { + const parser = or( + command("serve", object({ port: option("--port", string()) })), + constant(0), + ); + assert.equal(render(parser), "app [serve --port STRING]"); + assert.equal( + formatUsage("app", parser.usage, { expandCommands: true }), + "app serve --port STRING", + ); + }); + + it("keeps a lone optional alternative that is not a command", () => { + const parser = or(constant(0), flag("--help")); + assert.equal( + formatUsage("app", parser.usage, { expandCommands: true }), + "app [--help]", + ); + }); + + it("expands a nested command group that has a fallback alternative", () => { + const parser = object({ + cmd: or( + command( + "serve", + object({ + mode: or( + command("fast", constant(1)), + command("slow", constant(2)), + constant(0), + ), + }), + ), + command("build", constant(2)), + ), + }); + assert.equal( + formatUsage("app", parser.usage, { expandCommands: true }), + "app serve fast\napp serve slow\napp build", + ); + }); + + it("does not expand an explicitly optional command group", () => { + const parser = optional( + or(command("serve", constant(1)), command("build", constant(2))), + ); + assert.equal( + formatUsage("app", parser.usage, { expandCommands: true }), + "app [(serve | build)]", + ); + }); +}); diff --git a/packages/core/src/usage.test.ts b/packages/core/src/usage.test.ts index 1a5f9b39d..a0b43187f 100644 --- a/packages/core/src/usage.test.ts +++ b/packages/core/src/usage.test.ts @@ -3778,3 +3778,137 @@ describe("cloneUsage", () => { } }); }); + +describe("acceptsEmpty", () => { + const optionalFile: Usage = [{ + type: "optional", + terms: [{ type: "argument", metavar: "FILE" }], + }]; + const optionalDir: Usage = [{ + type: "optional", + terms: [{ type: "argument", metavar: "DIR" }], + }]; + + it("is preserved by cloneUsage()", () => { + const usage: Usage = [ + { + type: "exclusive", + terms: [optionalFile, optionalDir], + acceptsEmpty: false, + }, + { type: "multiple", terms: optionalFile, min: 1, acceptsEmpty: false }, + ]; + assert.deepEqual(cloneUsage(usage), usage); + }); + + it("is preserved by normalizeUsage()", () => { + const usage: Usage = [ + { + type: "exclusive", + terms: [optionalFile, optionalDir], + acceptsEmpty: false, + }, + { type: "multiple", terms: optionalFile, min: 1, acceptsEmpty: false }, + ]; + assert.deepEqual(normalizeUsage(usage), usage); + }); + + it("keeps an annotated inner exclusive nested in normalizeUsage()", () => { + const inner: UsageTerm = { + type: "exclusive", + terms: [optionalFile, optionalDir], + acceptsEmpty: false, + }; + const usage: Usage = [{ + type: "exclusive", + terms: [[inner], [{ type: "command", name: "init" }]], + }]; + assert.deepEqual(normalizeUsage(usage), usage); + // Without the record, nested alternatives are flattened as before: + assert.deepEqual( + normalizeUsage([{ + type: "exclusive", + terms: [ + [{ type: "exclusive", terms: [optionalFile, optionalDir] }], + [{ type: "command", name: "init" }], + ], + }]), + [{ + type: "exclusive", + terms: [optionalFile, optionalDir, [{ type: "command", name: "init" }]], + }], + ); + }); + + it("keeps an annotated group without terms in normalizeUsage()", () => { + const usage: Usage = [{ + type: "multiple", + terms: [], + min: 2, + acceptsEmpty: false, + }]; + assert.deepEqual(normalizeUsage(usage), usage); + assert.deepEqual( + normalizeUsage([{ type: "multiple", terms: [], min: 2 }]), + [], + ); + }); + + it("decides how formatUsage() draws a group", () => { + assert.equal( + formatUsage("app", [{ + type: "exclusive", + terms: [optionalFile, optionalDir], + acceptsEmpty: false, + }]), + "app (FILE | DIR)", + ); + assert.equal( + formatUsage("app", [{ + type: "exclusive", + terms: [[], [{ type: "argument", metavar: "FILE" }]], + acceptsEmpty: true, + }]), + "app [FILE]", + ); + assert.equal( + formatUsage("app", [{ + type: "multiple", + terms: optionalFile, + min: 1, + acceptsEmpty: false, + }]), + "app FILE...", + ); + assert.equal( + formatUsage("app", [{ + type: "multiple", + terms: [{ type: "argument", metavar: "FILE" }], + min: 1, + acceptsEmpty: true, + }]), + "app [FILE...]", + ); + }); + + it("decides how formatUsageTerm() draws a group", () => { + assert.equal( + formatUsageTerm({ + type: "exclusive", + terms: [optionalFile, optionalDir], + acceptsEmpty: false, + }), + "(FILE | DIR)", + ); + }); + + it("leaves groups without the record as declared", () => { + assert.equal( + formatUsage("app", [{ + type: "exclusive", + terms: [optionalFile, optionalDir], + }]), + "app ([FILE] | [DIR])", + ); + }); +}); diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index 860544543..ec1a75e50 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -12,6 +12,7 @@ import { type TerminalToken, } from "./terminal-internal.ts"; import { getDisplayWidth } from "./displaywidth.ts"; +import { forgetEmptyInput, resolveUsageForDisplay } from "./internal/usage.ts"; import type { NonEmptyString } from "./nonempty.ts"; import { validateProgramName } from "./validate.ts"; @@ -214,6 +215,16 @@ export type UsageTerm = * The minimum number of times the term must occur. */ readonly min: number; + /** + * Whether the parser that produced this group succeeds when it parses an + * empty argument list on its own. Built-in combinators set this when + * the outcome is statically known and omit it otherwise. Usage + * formatters use it to decide whether to draw the group as omissible; + * it is a declaration for display, not evidence about how any other + * parser behaves. + * @since 1.4.0 + */ + readonly acceptsEmpty?: boolean; } | /** * An exclusive term, which represents a group of terms that are mutually @@ -229,6 +240,16 @@ export type UsageTerm = * arguments, options, commands, or other usage terms. */ readonly terms: readonly Usage[]; + /** + * Whether the parser that produced this group succeeds when it parses an + * empty argument list on its own. Built-in combinators set this when + * the outcome is statically known and omit it otherwise. Usage + * formatters use it to decide whether to draw the group as omissible; + * it is a declaration for display, not evidence about how any other + * parser behaves. + * @since 1.4.0 + */ + readonly acceptsEmpty?: boolean; } /** * A sequence term, which preserves the declaration order of its child @@ -579,9 +600,52 @@ export function formatUsage( ): string { options = { ...options, theme: cacheTerminalTheme(options.theme) }; validateProgramName(programName); - usage = normalizeUsage(filterUsageForDisplay(usage)); + return formatUsageLines( + programName, + normalizeUsage( + filterUsageForDisplay(resolveUsageForDisplay(usage, isUsageHidden)), + ), + normalizeUsage(filterUsageForDisplay(forgetEmptyInput(usage))), + options, + ); +} + +/** + * Formats a display-ready usage, expanding a trailing command group into one + * line per command when requested. + * @param programName The program name. + * @param usage The usage to draw, already resolved, filtered and normalized. + * @param declared The same usage as declared, without empty-input records, + * filtered and normalized. It tells a group that was drawn + * as optional because it accepts empty input apart from a + * group declared optional. + * @param options The formatting options. + * @returns The formatted usage. + */ +function formatUsageLines( + programName: string, + usage: Usage, + declared: Usage, + options: UsageFormatOptions, +): string { if (options.expandCommands) { - const lastTerm = usage.at(-1)!; + let lastTerm = usage.at(-1)!; + const declaredLast = declared.at(-1); + // A command group drawn as optional because it accepts empty input + // still expands into one line per command, unlike a group declared + // optional. + if (lastTerm?.type === "optional" && declaredLast?.type === "exclusive") { + if ( + lastTerm.terms.length === 1 && lastTerm.terms[0].type === "exclusive" + ) { + lastTerm = lastTerm.terms[0]; + } else if (lastTerm.terms[0]?.type === "command") { + // When only one command is visible, the optional term holds that + // command's alternative itself. Other lone alternatives, such as + // a help option, keep their brackets. + lastTerm = { type: "exclusive", terms: [lastTerm.terms] }; + } + } if ( usage.length > 0 && usage.slice(0, -1).every((t) => t.type === "command") && @@ -595,8 +659,14 @@ export function formatUsage( t[0].terms[0].type === "argument")) ) ) { + // Declared alternatives line up with the drawn ones unless drawing + // dropped some; then each line falls back to its drawn form. + const declaredBranches = declaredLast?.type === "exclusive" && + declaredLast.terms.length === lastTerm.terms.length + ? declaredLast.terms + : undefined; const lines = []; - for (let command of lastTerm.terms) { + for (const [index, command] of lastTerm.terms.entries()) { // Skip hidden commands in usage expansion const firstTerm = command[0]; if ( @@ -605,10 +675,20 @@ export function formatUsage( ) { continue; } - if (usage.length > 1) { - command = [...usage.slice(0, -1), ...command]; - } - lines.push(formatUsage(programName, command, options)); + const line = normalizeUsage( + filterUsageForDisplay([...usage.slice(0, -1), ...command]), + ); + lines.push(formatUsageLines( + programName, + line, + declaredBranches == null ? line : normalizeUsage( + filterUsageForDisplay([ + ...declared.slice(0, -1), + ...declaredBranches[index], + ]), + ), + options, + )); } if (lines.length > 0) { return lines.join("\n"); @@ -695,6 +775,7 @@ function normalizeUsageTerm(term: UsageTerm): UsageTerm { type: "multiple", terms: normalizeUsage(term.terms), min: term.min, + ...acceptsEmptyOf(term), }; } else if (term.type === "sequence") { return { @@ -705,7 +786,12 @@ function normalizeUsageTerm(term: UsageTerm): UsageTerm { const terms: Usage[] = []; for (const usage of term.terms) { const normalized = normalizeUsage(usage); - if (normalized.length >= 1 && normalized[0].type === "exclusive") { + // An inner group that records its empty-input behavior keeps its + // boundary, since flattening would lose that record. + if ( + normalized.length >= 1 && normalized[0].type === "exclusive" && + normalized[0].acceptsEmpty == null + ) { const rest = normalized.slice(1); for (const subUsage of normalized[0].terms) { terms.push([...subUsage, ...rest]); @@ -719,7 +805,7 @@ function normalizeUsageTerm(term: UsageTerm): UsageTerm { terms.push(normalized); } } - return { type: "exclusive", terms }; + return { type: "exclusive", terms, ...acceptsEmptyOf(term) }; } else { // Clone leaf terms so the normalized output is referentially distinct // from the input. Use a manual spread instead of cloneUsageTerm() to @@ -736,10 +822,24 @@ function normalizeUsageTerm(term: UsageTerm): UsageTerm { } } +function acceptsEmptyOf( + term: UsageTerm & { readonly type: "multiple" | "exclusive" }, +): { readonly acceptsEmpty?: boolean } { + return term.acceptsEmpty == null ? {} : { acceptsEmpty: term.acceptsEmpty }; +} + function isNonDegenerateTerm(term: UsageTerm): boolean { if (term.type === "option") return term.names.length > 0; if (term.type === "command") return term.name !== ""; if (term.type === "argument") return term.metavar.length > 0; + if ( + (term.type === "multiple" || term.type === "exclusive") && + term.acceptsEmpty != null + ) { + // A group without visible terms still records whether its parser + // accepts empty input. + return true; + } if ( term.type === "optional" || term.type === "multiple" || term.type === "exclusive" || term.type === "sequence" @@ -812,11 +912,13 @@ export function cloneUsageTerm(term: UsageTerm): UsageTerm { type: "multiple", terms: term.terms.map(cloneUsageTerm), min: term.min, + ...acceptsEmptyOf(term), }; case "exclusive": return { type: "exclusive", terms: term.terms.map((u) => u.map(cloneUsageTerm)), + ...acceptsEmptyOf(term), }; case "sequence": return { type: "sequence", terms: term.terms.map(cloneUsageTerm) }; @@ -866,7 +968,12 @@ function filterUsageForDisplay( if (term.type === "multiple") { const filtered = filterUsageForDisplay(term.terms, isHidden); if (filtered.length > 0) { - terms.push({ type: "multiple", terms: filtered, min: term.min }); + terms.push({ + type: "multiple", + terms: filtered, + min: term.min, + ...acceptsEmptyOf(term), + }); } continue; } @@ -884,7 +991,11 @@ function filterUsageForDisplay( }) .filter((branch) => branch.length > 0); if (filteredBranches.length > 0) { - terms.push({ type: "exclusive", terms: filteredBranches }); + terms.push({ + type: "exclusive", + terms: filteredBranches, + ...acceptsEmptyOf(term), + }); } continue; } @@ -976,7 +1087,10 @@ export function formatUsageTerm( ): string { options = { ...options, theme: cacheTerminalTheme(options.theme) }; const hiddenCheck = options.context === "doc" ? isDocHidden : isUsageHidden; - const visibleTerms = filterUsageForDisplay([term], hiddenCheck); + const visibleTerms = filterUsageForDisplay( + resolveUsageForDisplay([term], hiddenCheck), + hiddenCheck, + ); if (visibleTerms.length < 1) return ""; return wrapUsageTokens( diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 5fef52398..921575a64 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "src/internal/dependency.ts", "src/internal/parser.ts", "src/internal/terminal.ts", + "src/internal/usage.ts", "src/message.ts", "src/message-renderer.ts", "src/message-registry.ts", diff --git a/packages/man/src/generator.test.ts b/packages/man/src/generator.test.ts index 5ae494cbd..e7a39735d 100644 --- a/packages/man/src/generator.test.ts +++ b/packages/man/src/generator.test.ts @@ -144,6 +144,80 @@ describe("required options in SYNOPSIS", () => { }); }); +describe("empty-input behavior in SYNOPSIS", () => { + const options = { name: "repro", section: 1 as const }; + + it("requires one of ambiguous optional alternatives", () => { + const parser = or( + optional(argument(string({ metavar: "FILE" }))), + optional(argument(string({ metavar: "DIR" }))), + ); + assert.ok(!parse(parser, []).success); + assert.equal(formatUsage("repro", parser.usage), "repro (FILE | DIR)"); + assert.equal( + getSynopsis(generateManPageSync(parser, options)), + '.B "repro"\n(\\fIFILE\\fR | \\fIDIR\\fR)', + ); + }); + + it("makes an alternative optional when another one needs no tokens", () => { + const parser = or( + multiple(tuple([optional(fail()), constant("x")]), { min: 1 }), + argument(string({ metavar: "FILE" })), + ); + assert.ok(parse(parser, []).success); + assert.equal(formatUsage("repro", parser.usage), "repro [FILE]"); + assert.equal( + getSynopsis(generateManPageSync(parser, options)), + '.B "repro"\n[\\fIFILE\\fR]', + ); + }); + + it("requires an optional option that or() cannot choose without input", () => { + const parser = or( + optional(option("--x", string())), + argument(string({ metavar: "FILE" })), + ); + assert.ok(!parse(parser, []).success); + assert.equal( + formatUsage("repro", parser.usage), + "repro (--x STRING | FILE)", + ); + assert.equal( + getSynopsis(generateManPageSync(parser, options)), + '.B "repro"\n(\\fB\\-\\-x\\fR \\fISTRING\\fR | \\fIFILE\\fR)', + ); + }); + + it("requires optional items of a repetition that rejects empty input", () => { + const parser = multiple( + optional(argument(string({ metavar: "FILE" }))), + { min: 1 }, + ); + assert.ok(!parse(parser, []).success); + assert.equal(formatUsage("repro", parser.usage), "repro FILE..."); + assert.equal( + getSynopsis(generateManPageSync(parser, options)), + '.B "repro"\n\\fIFILE\\fR ...', + ); + }); + + it("keeps the record through document visibility changes", () => { + const parser = group( + "g", + or( + optional(argument(string({ metavar: "FILE" }))), + optional(argument(string({ metavar: "DIR" }))), + ), + { hidden: "doc" }, + ); + assert.equal( + getSynopsis(generateManPageSync(parser, options)), + '.B "repro"\n(\\fIFILE\\fR | \\fIDIR\\fR)', + ); + }); +}); + describe("generateManPage()", () => { it("generates man page from simple option parser", () => { const parser = object({ diff --git a/packages/man/src/man.test.ts b/packages/man/src/man.test.ts index b5bd66c26..6022ac412 100644 --- a/packages/man/src/man.test.ts +++ b/packages/man/src/man.test.ts @@ -39,6 +39,59 @@ describe("formatDateForMan()", () => { }); describe("formatUsageTermAsRoff()", () => { + it("draws a group as omissible exactly when it accepts empty input", () => { + const file: UsageTerm = { type: "argument", metavar: "FILE" }; + const dir: UsageTerm = { type: "argument", metavar: "DIR" }; + assert.equal( + formatUsageTermAsRoff({ + type: "exclusive", + terms: [ + [{ type: "optional", terms: [file] }], + [{ type: "optional", terms: [dir] }], + ], + acceptsEmpty: false, + }), + "(\\fIFILE\\fR | \\fIDIR\\fR)", + ); + assert.equal( + formatUsageTermAsRoff({ + type: "exclusive", + terms: [[], [file]], + acceptsEmpty: true, + }), + "[\\fIFILE\\fR]", + ); + assert.equal( + formatUsageTermAsRoff({ + type: "multiple", + terms: [{ type: "optional", terms: [file] }], + min: 1, + acceptsEmpty: false, + }), + "\\fIFILE\\fR ...", + ); + }); + + it("drops an alternative whose leading command is hidden", () => { + assert.equal( + formatUsageTermAsRoff({ + type: "exclusive", + terms: [ + [ + { type: "command", name: "secret", hidden: true }, + { + type: "optional", + terms: [{ type: "argument", metavar: "FILE" }], + }, + ], + [{ type: "optional", terms: [{ type: "command", name: "open" }] }], + ], + acceptsEmpty: false, + }), + "\\fBopen\\fR", + ); + }); + it("formats argument term", () => { const term: UsageTerm = { type: "argument", metavar: "FILE" }; assert.equal(formatUsageTermAsRoff(term), "\\fIFILE\\fR"); diff --git a/packages/man/src/man.ts b/packages/man/src/man.ts index 5851359ab..d43494ae3 100644 --- a/packages/man/src/man.ts +++ b/packages/man/src/man.ts @@ -13,6 +13,7 @@ import { type Usage, type UsageTerm, } from "@optique/core/usage"; +import { resolveUsageForDisplay } from "@optique/core/internal/usage"; import { escapeHyphens, escapeRequestArg, @@ -195,8 +196,9 @@ function formatCommandNameAsRoff(name: string): string { /** * Formats a single {@link UsageTerm} as roff markup for the SYNOPSIS section. - * Optionality follows the supplied usage wrappers. Usage describes display - * structure; empty-input acceptance depends on the parser's runtime behavior. + * Optionality follows the supplied usage wrappers, except that a group + * recording `acceptsEmpty` is drawn as omissible exactly when its parser + * accepts an empty argument list, as in `formatUsage()`. * * @param term The usage term to format. * @returns The roff-formatted string. @@ -204,7 +206,7 @@ function formatCommandNameAsRoff(name: string): string { * @since 0.10.0 */ export function formatUsageTermAsRoff(term: UsageTerm): string { - return formatUsageTermAsRoffInternal(term, false); + return formatUsageAsRoff([term]); } /** @@ -318,7 +320,12 @@ function formatUsageTermAsRoffInternal( } case "exclusive": { + // As in formatUsage(), an alternative whose leading command is hidden + // is dropped as a whole, trailing terms included. const alternatives = term.terms + .filter((t) => + !(t[0]?.type === "command" && isUsageHidden(t[0].hidden)) + ) .map((t) => formatUsageAsRoffInternal(t, false)) .filter((s) => s !== ""); if (alternatives.length === 0) return ""; @@ -354,7 +361,10 @@ function formatUsageTermAsRoffInternal( * @returns The roff-formatted string. */ function formatUsageAsRoff(usage: Usage): string { - return formatUsageAsRoffInternal(usage, false); + return formatUsageAsRoffInternal( + resolveUsageForDisplay(usage, isUsageHidden), + false, + ); } function formatUsageAsRoffInternal( diff --git a/packages/testing/src/run.test.ts b/packages/testing/src/run.test.ts index 1be400649..c8015dbca 100644 --- a/packages/testing/src/run.test.ts +++ b/packages/testing/src/run.test.ts @@ -91,7 +91,8 @@ describe("captureRun()", () => { assert.deepEqual(result, { kind: "exited", exitCode: 0, - stdout: "Usage: tool --help\n\n" + + // The parser accepts no arguments, so --help can be omitted. + stdout: "Usage: tool [--help]\n\n" + " --help Show help information.\n\n", stderr: "", });