Skip to content

Commit 1356ed0

Browse files
committed
Release 0.10.4
2 parents a001d27 + a813a67 commit 1356ed0

File tree

4 files changed

+154
-7
lines changed

4 files changed

+154
-7
lines changed

CHANGES.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Optique changelog
66
Version 0.10.4
77
--------------
88

9-
To be released.
9+
Released on February 19, 2026.
1010

1111
### @optique/core
1212

@@ -17,6 +17,19 @@ To be released.
1717
space at the start of the next line. The newline immediately following a
1818
`lineBreak()` term is now dropped instead of being converted to a space.
1919

20+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
21+
disappearing from the subcommand list in help output when the parser uses
22+
a `withDefault(or(...))` construct. The root cause was that
23+
`getDocPage()` used a `do...while` loop, which ran the parser at least
24+
once even with an empty argument buffer. Because `withDefault(or(...))`
25+
allows the inner parser to succeed without consuming any tokens, the
26+
`longestMatch` combinator would record the user's parser as “selected”
27+
and subsequently return only that parser's doc fragments—silently
28+
dropping the meta command entries. The loop is now a `while` loop that
29+
skips parsing entirely when the buffer is empty. [[#121]]
30+
31+
[#121]: https://github.com/dahlia/optique/issues/121
32+
2033

2134
Version 0.10.3
2235
--------------
@@ -929,6 +942,25 @@ to generate Unix man pages that stay synchronized with parser definitions.
929942
[#77]: https://github.com/dahlia/optique/issues/77
930943

931944

945+
Version 0.9.10
946+
--------------
947+
948+
Released on February 19, 2026.
949+
950+
### @optique/core
951+
952+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
953+
disappearing from the subcommand list in help output when the parser uses
954+
a `withDefault(or(...))` construct. The root cause was that
955+
`getDocPage()` used a `do...while` loop, which ran the parser at least
956+
once even with an empty argument buffer. Because `withDefault(or(...))`
957+
allows the inner parser to succeed without consuming any tokens, the
958+
`longestMatch` combinator would record the user's parser as “selected
959+
and subsequently return only that parser's doc fragments—silently
960+
dropping the meta command entries. The loop is now a `while` loop that
961+
skips parsing entirely when the buffer is empty. [[#121]]
962+
963+
932964
Version 0.9.9
933965
-------------
934966

@@ -1452,6 +1484,25 @@ remotes) using [isomorphic-git]. [[#71], [#72]]
14521484
[#72]: https://github.com/dahlia/optique/pull/72
14531485
14541486
1487+
Version 0.8.16
1488+
--------------
1489+
1490+
Released on February 19, 2026.
1491+
1492+
### @optique/core
1493+
1494+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
1495+
disappearing from the subcommand list in help output when the parser uses
1496+
a `withDefault(or(...))` construct. The root cause was that
1497+
`getDocPage()` used a `do...while` loop, which ran the parser at least
1498+
once even with an empty argument buffer. Because `withDefault(or(...))`
1499+
allows the inner parser to succeed without consuming any tokens, the
1500+
`longestMatch` combinator would record the user's parser as “selected”
1501+
and subsequently return only that parser's doc fragments—silently
1502+
dropping the meta command entries. The loop is now a `while` loop that
1503+
skips parsing entirely when the buffer is empty. [[#121]]
1504+
1505+
14551506
Version 0.8.15
14561507
--------------
14571508
@@ -1884,6 +1935,25 @@ parsing strategies.
18841935
[LogTape]: https://logtape.org/
18851936
18861937
1938+
Version 0.7.18
1939+
--------------
1940+
1941+
Released on February 19, 2026.
1942+
1943+
### @optique/core
1944+
1945+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
1946+
disappearing from the subcommand list in help output when the parser uses
1947+
a `withDefault(or(...))` construct. The root cause was that
1948+
`getDocPage()` used a `do...while` loop, which ran the parser at least
1949+
once even with an empty argument buffer. Because `withDefault(or(...))`
1950+
allows the inner parser to succeed without consuming any tokens, the
1951+
`longestMatch` combinator would record the user's parser as “selected”
1952+
and subsequently return only that parser's doc fragments—silently
1953+
dropping the meta command entries. The loop is now a `while` loop that
1954+
skips parsing entirely when the buffer is empty. [[#121]]
1955+
1956+
18871957
Version 0.7.17
18881958
--------------
18891959

packages/core/src/parser.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ import {
1313
} from "@optique/core/message";
1414
import { multiple, optional, withDefault } from "@optique/core/modifiers";
1515
import { getDocPage, parse } from "@optique/core/parser";
16-
import { argument, command, constant, option } from "@optique/core/primitives";
16+
import {
17+
argument,
18+
command,
19+
constant,
20+
flag,
21+
option,
22+
} from "@optique/core/primitives";
1723
import { choice, integer, string } from "@optique/core/valueparser";
1824
import { formatDocPage } from "@optique/core/doc";
1925
import assert from "node:assert/strict";
@@ -2022,3 +2028,74 @@ describe("Annotations system", () => {
20222028
assert.equal(annotations[testKey], testData);
20232029
});
20242030
});
2031+
2032+
describe("getDocPage regression: meta commands with withDefault(or(...))", () => {
2033+
// Regression test for https://github.com/dahlia/optique/issues/121
2034+
// Meta commands were missing from the command list when the user parser
2035+
// included withDefault(or(...)), because getDocPage's do...while loop
2036+
// ran the parser once even with an empty buffer, causing longestMatch to
2037+
// record the user parser as "selected" and skip all other parsers in
2038+
// getDocFragments.
2039+
it("should include all commands when longestMatch wraps a parser with withDefault(or(...))", () => {
2040+
// Reproduce the issue: a user parser where withDefault(or(...)) allows
2041+
// the merge to succeed with zero consumed tokens.
2042+
const configOption = withDefault(
2043+
or(
2044+
object({ ignoreConfig: flag("--ignore-config") }),
2045+
object({ configPath: option("--config", string({ metavar: "PATH" })) }),
2046+
),
2047+
{ ignoreConfig: false, configPath: undefined } as {
2048+
readonly ignoreConfig: boolean;
2049+
readonly configPath: string | undefined;
2050+
},
2051+
);
2052+
2053+
const userParser = merge(
2054+
or(
2055+
command("foo", object({}), { description: message`foo cmd` }),
2056+
command("bar", object({}), { description: message`bar cmd` }),
2057+
),
2058+
configOption,
2059+
);
2060+
2061+
// Simulate what run() does: combine the user parser with meta commands
2062+
// via longestMatch.
2063+
const helpCmd = command("help", object({}));
2064+
const versionCmd = command("version", object({}));
2065+
const combined = longestMatch(helpCmd, versionCmd, userParser);
2066+
2067+
// Root-level help: getDocPage called with empty args (no subcommand selected).
2068+
const doc = getDocPage(combined, []);
2069+
assert.ok(doc, "doc should not be undefined");
2070+
2071+
const allEntries = doc.sections.flatMap((s) => s.entries);
2072+
const commandNames = allEntries
2073+
.filter((e) => e.term.type === "command")
2074+
.map((e) => (e.term.type === "command" ? e.term.name : ""));
2075+
2076+
assert.ok(
2077+
commandNames.includes("help"),
2078+
`"help" should appear in the command list, got: [${
2079+
commandNames.join(", ")
2080+
}]`,
2081+
);
2082+
assert.ok(
2083+
commandNames.includes("version"),
2084+
`"version" should appear in the command list, got: [${
2085+
commandNames.join(", ")
2086+
}]`,
2087+
);
2088+
assert.ok(
2089+
commandNames.includes("foo"),
2090+
`"foo" should appear in the command list, got: [${
2091+
commandNames.join(", ")
2092+
}]`,
2093+
);
2094+
assert.ok(
2095+
commandNames.includes("bar"),
2096+
`"bar" should appear in the command list, got: [${
2097+
commandNames.join(", ")
2098+
}]`,
2099+
);
2100+
});
2101+
});

packages/core/src/parser.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -932,11 +932,11 @@ function getDocPageSyncImpl(
932932
state: initialState,
933933
usage: parser.usage,
934934
};
935-
do {
935+
while (context.buffer.length > 0) {
936936
const result = parser.parse(context);
937937
if (!result.success) break;
938938
context = result.next;
939-
} while (context.buffer.length > 0);
939+
}
940940
return buildDocPage(parser, context, args);
941941
}
942942

@@ -966,11 +966,11 @@ async function getDocPageAsyncImpl(
966966
state: initialState,
967967
usage: parser.usage,
968968
};
969-
do {
969+
while (context.buffer.length > 0) {
970970
const result = await parser.parse(context);
971971
if (!result.success) break;
972972
context = result.next;
973-
} while (context.buffer.length > 0);
973+
}
974974
return buildDocPage(parser, context, args);
975975
}
976976

packages/tmp/test-repo

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Subproject commit 05c5e385ad8c4b7190e1301a12cde3f1fbda4602
1+
Subproject commit ebb536469e4e836f2a2f00b24b86c6d9a5ec72c2

0 commit comments

Comments
 (0)