Skip to content

Commit a813a67

Browse files
committed
Release 0.9.10
2 parents cee8256 + 0f8b84d commit a813a67

File tree

3 files changed

+138
-6
lines changed

3 files changed

+138
-6
lines changed

CHANGES.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,22 @@ Optique changelog
44
Version 0.9.10
55
--------------
66

7-
To be released.
7+
Released on February 19, 2026.
8+
9+
### @optique/core
10+
11+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
12+
disappearing from the subcommand list in help output when the parser uses
13+
a `withDefault(or(...))` construct. The root cause was that
14+
`getDocPage()` used a `do...while` loop, which ran the parser at least
15+
once even with an empty argument buffer. Because `withDefault(or(...))`
16+
allows the inner parser to succeed without consuming any tokens, the
17+
`longestMatch` combinator would record the user's parser as "selected"
18+
and subsequently return only that parser's doc fragments—silently
19+
dropping the meta command entries. The loop is now a `while` loop that
20+
skips parsing entirely when the buffer is empty. [[#121]]
21+
22+
[#121]: https://github.com/dahlia/optique/issues/121
823

924

1025
Version 0.9.9
@@ -539,6 +554,26 @@ remotes) using [isomorphic-git]. [[#71], [#72]]
539554
[#72]: https://github.com/dahlia/optique/pull/72
540555
541556
557+
Version 0.8.16
558+
--------------
559+
560+
Released on February 19, 2026.
561+
562+
### @optique/core
563+
564+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
565+
disappearing from the subcommand list in help output when the parser uses
566+
a `withDefault(or(...))` construct. The root cause was that
567+
`getDocPage()` used a `do...while` loop, which ran the parser at least
568+
once even with an empty argument buffer. Because `withDefault(or(...))`
569+
allows the inner parser to succeed without consuming any tokens, the
570+
`longestMatch` combinator would record the user's parser as "selected"
571+
and subsequently return only that parser's doc fragments—silently
572+
dropping the meta command entries. The loop is now a `while` loop that
573+
skips parsing entirely when the buffer is empty. [[#121]]
574+
575+
[#121]: https://github.com/dahlia/optique/issues/121
576+
542577
543578
Version 0.8.15
544579
--------------
@@ -968,6 +1003,26 @@ parsing strategies.
9681003
[LogTape]: https://logtape.org/
9691004
9701005
1006+
Version 0.7.18
1007+
--------------
1008+
1009+
Released on February 19, 2026.
1010+
1011+
### @optique/core
1012+
1013+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
1014+
disappearing from the subcommand list in help output when the parser uses
1015+
a `withDefault(or(...))` construct. The root cause was that
1016+
`getDocPage()` used a `do...while` loop, which ran the parser at least
1017+
once even with an empty argument buffer. Because `withDefault(or(...))`
1018+
allows the inner parser to succeed without consuming any tokens, the
1019+
`longestMatch` combinator would record the user's parser as "selected"
1020+
and subsequently return only that parser's doc fragments—silently
1021+
dropping the meta command entries. The loop is now a `while` loop that
1022+
skips parsing entirely when the buffer is empty. [[#121]]
1023+
1024+
[#121]: https://github.com/dahlia/optique/issues/121
1025+
9711026
9721027
Version 0.7.17
9731028
--------------

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 { integer, string } from "@optique/core/valueparser";
1824
import assert from "node:assert/strict";
1925
import { describe, it } from "node:test";
@@ -1825,3 +1831,74 @@ describe("merge() should propagate brief/description/footer from inner parsers",
18251831
);
18261832
});
18271833
});
1834+
1835+
describe("getDocPage regression: meta commands with withDefault(or(...))", () => {
1836+
// Regression test for https://github.com/dahlia/optique/issues/121
1837+
// Meta commands were missing from the command list when the user parser
1838+
// included withDefault(or(...)), because getDocPage's do...while loop
1839+
// ran the parser once even with an empty buffer, causing longestMatch to
1840+
// record the user parser as "selected" and skip all other parsers in
1841+
// getDocFragments.
1842+
it("should include all commands when longestMatch wraps a parser with withDefault(or(...))", () => {
1843+
// Reproduce the issue: a user parser where withDefault(or(...)) allows
1844+
// the merge to succeed with zero consumed tokens.
1845+
const configOption = withDefault(
1846+
or(
1847+
object({ ignoreConfig: flag("--ignore-config") }),
1848+
object({ configPath: option("--config", string({ metavar: "PATH" })) }),
1849+
),
1850+
{ ignoreConfig: false, configPath: undefined } as {
1851+
readonly ignoreConfig: boolean;
1852+
readonly configPath: string | undefined;
1853+
},
1854+
);
1855+
1856+
const userParser = merge(
1857+
or(
1858+
command("foo", object({}), { description: message`foo cmd` }),
1859+
command("bar", object({}), { description: message`bar cmd` }),
1860+
),
1861+
configOption,
1862+
);
1863+
1864+
// Simulate what run() does: combine the user parser with meta commands
1865+
// via longestMatch.
1866+
const helpCmd = command("help", object({}));
1867+
const versionCmd = command("version", object({}));
1868+
const combined = longestMatch(helpCmd, versionCmd, userParser);
1869+
1870+
// Root-level help: getDocPage called with empty args (no subcommand selected).
1871+
const doc = getDocPage(combined, []);
1872+
assert.ok(doc, "doc should not be undefined");
1873+
1874+
const allEntries = doc.sections.flatMap((s) => s.entries);
1875+
const commandNames = allEntries
1876+
.filter((e) => e.term.type === "command")
1877+
.map((e) => (e.term.type === "command" ? e.term.name : ""));
1878+
1879+
assert.ok(
1880+
commandNames.includes("help"),
1881+
`"help" should appear in the command list, got: [${
1882+
commandNames.join(", ")
1883+
}]`,
1884+
);
1885+
assert.ok(
1886+
commandNames.includes("version"),
1887+
`"version" should appear in the command list, got: [${
1888+
commandNames.join(", ")
1889+
}]`,
1890+
);
1891+
assert.ok(
1892+
commandNames.includes("foo"),
1893+
`"foo" should appear in the command list, got: [${
1894+
commandNames.join(", ")
1895+
}]`,
1896+
);
1897+
assert.ok(
1898+
commandNames.includes("bar"),
1899+
`"bar" should appear in the command list, got: [${
1900+
commandNames.join(", ")
1901+
}]`,
1902+
);
1903+
});
1904+
});

packages/core/src/parser.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -816,11 +816,11 @@ function getDocPageSyncImpl(
816816
state: parser.initialState,
817817
usage: parser.usage,
818818
};
819-
do {
819+
while (context.buffer.length > 0) {
820820
const result = parser.parse(context);
821821
if (!result.success) break;
822822
context = result.next;
823-
} while (context.buffer.length > 0);
823+
}
824824
return buildDocPage(parser, context, args);
825825
}
826826

@@ -837,11 +837,11 @@ async function getDocPageAsyncImpl(
837837
state: parser.initialState,
838838
usage: parser.usage,
839839
};
840-
do {
840+
while (context.buffer.length > 0) {
841841
const result = await parser.parse(context);
842842
if (!result.success) break;
843843
context = result.next;
844-
} while (context.buffer.length > 0);
844+
}
845845
return buildDocPage(parser, context, args);
846846
}
847847

0 commit comments

Comments
 (0)