Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions changes.d/core/usage-empty-input.md
Original file line number Diff line number Diff line change
@@ -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]]
8 changes: 8 additions & 0 deletions changes.d/man/synopsis-empty-input.md
Original file line number Diff line number Diff line change
@@ -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]]
49 changes: 49 additions & 0 deletions docs/concepts/constructs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------
Expand Down
3 changes: 2 additions & 1 deletion packages/core/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
8 changes: 8 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
88 changes: 77 additions & 11 deletions packages/core/src/constructs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import {
acceptsEmptyInput,
type EmptyInputFacts,
type ExclusiveBranch,
getEmptyInputFacts,
longestMatchFacts,
objectFacts,
orFacts,
tupleFacts,
withEmptyInputFacts,
} from "./internal/empty-input.ts";
import {
adoptOptionScope,
combinedOptionScope,
Expand Down Expand Up @@ -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") {
Expand All @@ -1807,6 +1819,7 @@ function applyHiddenToUsageTerm(
return {
type: "exclusive",
terms: term.terms.map((u) => applyHiddenToUsage(u, hidden)),
...(term.acceptsEmpty == null ? {} : { acceptsEmpty: term.acceptsEmpty }),
};
}
if (
Expand Down Expand Up @@ -2440,6 +2453,35 @@ function preserveExclusiveStateAfterOptionsTerminator(
};
}

/**
* Describes the branches of an exclusive combinator for the empty-input
* rules.
*/
function getExclusiveBranches(
parsers: readonly Parser<Mode, unknown, unknown>[],
): 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<Mode, unknown, unknown>[],
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -5444,7 +5487,7 @@ export function or(
parsers,
(state) => selectExclusiveChildren(parsers, state, true),
);
return fluent(
return fluent(withEmptyInputFacts(
scopeParser(
singleResult as Parser<
Mode,
Expand All @@ -5453,7 +5496,8 @@ export function or(
>,
combinedOptionScope(parsers, parsers.map((_, index) => index)),
),
);
emptyInputFacts,
));
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -6222,7 +6267,7 @@ function createLongestMatch(
parsers,
(state) => selectExclusiveChildren(parsers, state, false),
);
return fluent(
return fluent(withEmptyInputFacts(
scopeParser(
multiResult as Parser<
Mode,
Expand All @@ -6231,7 +6276,8 @@ function createLongestMatch(
>,
combinedOptionScope(parsers, parsers.map((_, index) => index)),
),
);
emptyInputFacts,
));
}

/**
Expand Down Expand Up @@ -8864,7 +8910,7 @@ export function object<
}));
},
);
return fluent(
return fluent(withEmptyInputFacts(
scopeParser(
objectParser,
combinedOptionScope(
Expand All @@ -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),
)
: {},
));
}

/**
Expand Down Expand Up @@ -11262,15 +11317,21 @@ export function tuple<
state: getParseChildState(state, state[index], parser),
})),
);
return fluent(
return fluent(withEmptyInputFacts(
scopeParser(
tupleParser,
combinedOptionScope(
parsers,
parsers.map((_, index) => index),
),
),
);
tupleFacts(
parsers.map((p) => ({
facts: getEmptyInputFacts(p),
matchesTokens: p.leadingNames.size > 0 || p.acceptingAnyToken,
})),
),
));
}

/**
Expand Down Expand Up @@ -16326,7 +16387,12 @@ export function group<M extends Mode, TValue, TState>(
[parser],
(state) => [{ parser, state }],
);
return fluent(scopeParser(groupParser, combinedOptionScope([parser])));
return fluent(
withEmptyInputFacts(
scopeParser(groupParser, combinedOptionScope([parser])),
getEmptyInputFacts(parser),
),
);
}

/**
Expand Down
Loading
Loading