@optique/core 1.0.0-dev.400 → 1.0.0-dev.407

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,50 +3,13 @@ const require_dependency = require('./dependency.cjs');
3
3
  const require_mode_dispatch = require('./mode-dispatch.cjs');
4
4
  const require_usage = require('./usage.cjs');
5
5
  const require_suggestion = require('./suggestion.cjs');
6
+ const require_usage_internals = require('./usage-internals.cjs');
6
7
 
7
8
  //#region src/constructs.ts
8
- /**
9
- * Collects option names and command names that are valid at the current
10
- * parse position by walking the usage tree. Only "leading" candidates
11
- * (those reachable before a required positional argument) are collected.
12
- */
13
- function collectLeadingCandidates(terms, optionNames, commandNames) {
14
- if (!terms || !Array.isArray(terms)) return true;
15
- for (const term of terms) {
16
- if (term.type === "option") {
17
- for (const name of term.names) optionNames.add(name);
18
- return false;
19
- }
20
- if (term.type === "command") {
21
- commandNames.add(term.name);
22
- return false;
23
- }
24
- if (term.type === "argument") return false;
25
- if (term.type === "optional") {
26
- collectLeadingCandidates(term.terms, optionNames, commandNames);
27
- continue;
28
- }
29
- if (term.type === "multiple") {
30
- collectLeadingCandidates(term.terms, optionNames, commandNames);
31
- if (term.min === 0) continue;
32
- return false;
33
- }
34
- if (term.type === "exclusive") {
35
- let allAlternativesSkippable = true;
36
- for (const exclusiveUsage of term.terms) {
37
- const alternativeSkippable = collectLeadingCandidates(exclusiveUsage, optionNames, commandNames);
38
- allAlternativesSkippable = allAlternativesSkippable && alternativeSkippable;
39
- }
40
- if (allAlternativesSkippable) continue;
41
- return false;
42
- }
43
- }
44
- return true;
45
- }
46
9
  function createUnexpectedInputErrorWithScopedSuggestions(baseError, invalidInput, parsers, customFormatter) {
47
10
  const options = /* @__PURE__ */ new Set();
48
11
  const commands = /* @__PURE__ */ new Set();
49
- for (const parser of parsers) collectLeadingCandidates(parser.usage, options, commands);
12
+ for (const parser of parsers) require_usage_internals.collectLeadingCandidates(parser.usage, options, commands);
50
13
  const candidates = new Set([...options, ...commands]);
51
14
  const suggestions = require_suggestion.findSimilar(invalidInput, candidates, require_suggestion.DEFAULT_FIND_SIMILAR_OPTIONS);
52
15
  const suggestionMsg = customFormatter ? customFormatter(suggestions) : require_suggestion.createSuggestionMessage(suggestions);
@@ -2006,7 +1969,7 @@ function group(label, parser) {
2006
1969
  complete: (state) => parser.complete(state),
2007
1970
  suggest: (context, prefix) => parser.suggest(context, prefix),
2008
1971
  getDocFragments: (state, defaultValue) => {
2009
- const { description, fragments } = parser.getDocFragments(state, defaultValue);
1972
+ const { brief, description, footer, fragments } = parser.getDocFragments(state, defaultValue);
2010
1973
  const allEntries = [];
2011
1974
  const titledSections = [];
2012
1975
  for (const fragment of fragments) if (fragment.type === "entry") allEntries.push(fragment);
@@ -2016,15 +1979,22 @@ function group(label, parser) {
2016
1979
  kind: "available",
2017
1980
  state: parser.initialState
2018
1981
  }, void 0);
2019
- const initialHasCommands = initialFragments.fragments.some((f) => f.type === "entry" && f.term.type === "command" || f.type === "section" && f.entries.some((e) => e.term.type === "command"));
2020
- const currentHasCommands = allEntries.some((e) => e.term.type === "command");
2021
- const applyLabel = !initialHasCommands || currentHasCommands;
1982
+ const initialCommandNames = /* @__PURE__ */ new Set();
1983
+ for (const f of initialFragments.fragments) if (f.type === "entry" && f.term.type === "command") initialCommandNames.add(f.term.name);
1984
+ else if (f.type === "section") {
1985
+ for (const e of f.entries) if (e.term.type === "command") initialCommandNames.add(e.term.name);
1986
+ }
1987
+ const initialHasCommands = initialCommandNames.size > 0;
1988
+ const currentCommandsAreGroupOwn = allEntries.some((e) => e.term.type === "command" && initialCommandNames.has(e.term.name));
1989
+ const applyLabel = !initialHasCommands || currentCommandsAreGroupOwn;
2022
1990
  const labeledSection = applyLabel ? {
2023
1991
  title: label,
2024
1992
  entries: allEntries
2025
1993
  } : { entries: allEntries };
2026
1994
  return {
1995
+ brief,
2027
1996
  description,
1997
+ footer,
2028
1998
  fragments: [...titledSections.map((s) => ({
2029
1999
  ...s,
2030
2000
  type: "section"
@@ -3,46 +3,9 @@ import { DependencyRegistry, dependencyId, isDeferredParseState, isDependencySou
3
3
  import { dispatchByMode, dispatchIterableByMode } from "./mode-dispatch.js";
4
4
  import { extractArgumentMetavars, extractCommandNames, extractOptionNames } from "./usage.js";
5
5
  import { DEFAULT_FIND_SIMILAR_OPTIONS, createErrorWithSuggestions, createSuggestionMessage, deduplicateSuggestions, findSimilar } from "./suggestion.js";
6
+ import { collectLeadingCandidates } from "./usage-internals.js";
6
7
 
7
8
  //#region src/constructs.ts
8
- /**
9
- * Collects option names and command names that are valid at the current
10
- * parse position by walking the usage tree. Only "leading" candidates
11
- * (those reachable before a required positional argument) are collected.
12
- */
13
- function collectLeadingCandidates(terms, optionNames, commandNames) {
14
- if (!terms || !Array.isArray(terms)) return true;
15
- for (const term of terms) {
16
- if (term.type === "option") {
17
- for (const name of term.names) optionNames.add(name);
18
- return false;
19
- }
20
- if (term.type === "command") {
21
- commandNames.add(term.name);
22
- return false;
23
- }
24
- if (term.type === "argument") return false;
25
- if (term.type === "optional") {
26
- collectLeadingCandidates(term.terms, optionNames, commandNames);
27
- continue;
28
- }
29
- if (term.type === "multiple") {
30
- collectLeadingCandidates(term.terms, optionNames, commandNames);
31
- if (term.min === 0) continue;
32
- return false;
33
- }
34
- if (term.type === "exclusive") {
35
- let allAlternativesSkippable = true;
36
- for (const exclusiveUsage of term.terms) {
37
- const alternativeSkippable = collectLeadingCandidates(exclusiveUsage, optionNames, commandNames);
38
- allAlternativesSkippable = allAlternativesSkippable && alternativeSkippable;
39
- }
40
- if (allAlternativesSkippable) continue;
41
- return false;
42
- }
43
- }
44
- return true;
45
- }
46
9
  function createUnexpectedInputErrorWithScopedSuggestions(baseError, invalidInput, parsers, customFormatter) {
47
10
  const options = /* @__PURE__ */ new Set();
48
11
  const commands = /* @__PURE__ */ new Set();
@@ -2006,7 +1969,7 @@ function group(label, parser) {
2006
1969
  complete: (state) => parser.complete(state),
2007
1970
  suggest: (context, prefix) => parser.suggest(context, prefix),
2008
1971
  getDocFragments: (state, defaultValue) => {
2009
- const { description, fragments } = parser.getDocFragments(state, defaultValue);
1972
+ const { brief, description, footer, fragments } = parser.getDocFragments(state, defaultValue);
2010
1973
  const allEntries = [];
2011
1974
  const titledSections = [];
2012
1975
  for (const fragment of fragments) if (fragment.type === "entry") allEntries.push(fragment);
@@ -2016,15 +1979,22 @@ function group(label, parser) {
2016
1979
  kind: "available",
2017
1980
  state: parser.initialState
2018
1981
  }, void 0);
2019
- const initialHasCommands = initialFragments.fragments.some((f) => f.type === "entry" && f.term.type === "command" || f.type === "section" && f.entries.some((e) => e.term.type === "command"));
2020
- const currentHasCommands = allEntries.some((e) => e.term.type === "command");
2021
- const applyLabel = !initialHasCommands || currentHasCommands;
1982
+ const initialCommandNames = /* @__PURE__ */ new Set();
1983
+ for (const f of initialFragments.fragments) if (f.type === "entry" && f.term.type === "command") initialCommandNames.add(f.term.name);
1984
+ else if (f.type === "section") {
1985
+ for (const e of f.entries) if (e.term.type === "command") initialCommandNames.add(e.term.name);
1986
+ }
1987
+ const initialHasCommands = initialCommandNames.size > 0;
1988
+ const currentCommandsAreGroupOwn = allEntries.some((e) => e.term.type === "command" && initialCommandNames.has(e.term.name));
1989
+ const applyLabel = !initialHasCommands || currentCommandsAreGroupOwn;
2022
1990
  const labeledSection = applyLabel ? {
2023
1991
  title: label,
2024
1992
  entries: allEntries
2025
1993
  } : { entries: allEntries };
2026
1994
  return {
1995
+ brief,
2027
1996
  description,
1997
+ footer,
2028
1998
  fragments: [...titledSections.map((s) => ({
2029
1999
  ...s,
2030
2000
  type: "section"
package/dist/doc.cjs CHANGED
@@ -3,6 +3,28 @@ const require_usage = require('./usage.cjs');
3
3
 
4
4
  //#region src/doc.ts
5
5
  /**
6
+ * Classifies a {@link DocSection} by its content type for use in the
7
+ * default smart sort.
8
+ *
9
+ * @returns `0` for command-only sections, `1` for mixed sections, `2` for
10
+ * option/argument/passthrough-only sections.
11
+ */
12
+ function classifySection(section) {
13
+ const hasCommand = section.entries.some((e) => e.term.type === "command");
14
+ const hasNonCommand = section.entries.some((e) => e.term.type !== "command");
15
+ if (hasCommand && !hasNonCommand) return 0;
16
+ if (hasCommand && hasNonCommand) return 1;
17
+ return 2;
18
+ }
19
+ /**
20
+ * The default section comparator: command-only sections come first, then
21
+ * mixed sections, then option/argument-only sections. Sections with the
22
+ * same score preserve their original relative order (stable sort).
23
+ */
24
+ function defaultSectionOrder(a, b) {
25
+ return classifySection(a) - classifySection(b);
26
+ }
27
+ /**
6
28
  * Formats a documentation page into a human-readable string.
7
29
  *
8
30
  * This function takes a structured {@link DocPage} and converts it into
@@ -64,7 +86,16 @@ function formatDocPage(programName, page, options = {}) {
64
86
  });
65
87
  output += "\n";
66
88
  }
67
- const sections = page.sections.toSorted((a, b) => a.title == null && b.title == null ? 0 : a.title == null ? -1 : 1);
89
+ const comparator = options.sectionOrder ?? defaultSectionOrder;
90
+ const sections = page.sections.map((s, i) => ({
91
+ section: s,
92
+ index: i
93
+ })).toSorted((a, b) => {
94
+ const cmp = comparator(a.section, b.section);
95
+ if (cmp !== 0) return cmp;
96
+ const titleCmp = (a.section.title == null ? 0 : 1) - (b.section.title == null ? 0 : 1);
97
+ return titleCmp !== 0 ? titleCmp : a.index - b.index;
98
+ }).map(({ section }) => section);
68
99
  for (const section of sections) {
69
100
  if (section.entries.length < 1) continue;
70
101
  output += "\n";
package/dist/doc.d.cts CHANGED
@@ -228,6 +228,28 @@ interface DocPageFormatOptions {
228
228
  * ```
229
229
  */
230
230
  showChoices?: boolean | ShowChoicesOptions;
231
+ /**
232
+ * A custom comparator function to control the order of sections in the
233
+ * help output. When provided, it is used instead of the default smart
234
+ * sort (command-only sections first, then mixed, then option/argument-only
235
+ * sections). Sections that compare equal (return `0`) preserve their
236
+ * original relative order (stable sort).
237
+ *
238
+ * @param a The first section to compare.
239
+ * @param b The second section to compare.
240
+ * @returns A negative number if `a` should appear before `b`, a positive
241
+ * number if `a` should appear after `b`, or `0` if they are equal.
242
+ * @since 1.0.0
243
+ *
244
+ * @example
245
+ * ```typescript
246
+ * // Sort sections alphabetically by title
247
+ * {
248
+ * sectionOrder: (a, b) => (a.title ?? "").localeCompare(b.title ?? "")
249
+ * }
250
+ * ```
251
+ */
252
+ sectionOrder?: (a: DocSection, b: DocSection) => number;
231
253
  }
232
254
  /**
233
255
  * Formats a documentation page into a human-readable string.
package/dist/doc.d.ts CHANGED
@@ -228,6 +228,28 @@ interface DocPageFormatOptions {
228
228
  * ```
229
229
  */
230
230
  showChoices?: boolean | ShowChoicesOptions;
231
+ /**
232
+ * A custom comparator function to control the order of sections in the
233
+ * help output. When provided, it is used instead of the default smart
234
+ * sort (command-only sections first, then mixed, then option/argument-only
235
+ * sections). Sections that compare equal (return `0`) preserve their
236
+ * original relative order (stable sort).
237
+ *
238
+ * @param a The first section to compare.
239
+ * @param b The second section to compare.
240
+ * @returns A negative number if `a` should appear before `b`, a positive
241
+ * number if `a` should appear after `b`, or `0` if they are equal.
242
+ * @since 1.0.0
243
+ *
244
+ * @example
245
+ * ```typescript
246
+ * // Sort sections alphabetically by title
247
+ * {
248
+ * sectionOrder: (a, b) => (a.title ?? "").localeCompare(b.title ?? "")
249
+ * }
250
+ * ```
251
+ */
252
+ sectionOrder?: (a: DocSection, b: DocSection) => number;
231
253
  }
232
254
  /**
233
255
  * Formats a documentation page into a human-readable string.
package/dist/doc.js CHANGED
@@ -3,6 +3,28 @@ import { formatUsage, formatUsageTerm } from "./usage.js";
3
3
 
4
4
  //#region src/doc.ts
5
5
  /**
6
+ * Classifies a {@link DocSection} by its content type for use in the
7
+ * default smart sort.
8
+ *
9
+ * @returns `0` for command-only sections, `1` for mixed sections, `2` for
10
+ * option/argument/passthrough-only sections.
11
+ */
12
+ function classifySection(section) {
13
+ const hasCommand = section.entries.some((e) => e.term.type === "command");
14
+ const hasNonCommand = section.entries.some((e) => e.term.type !== "command");
15
+ if (hasCommand && !hasNonCommand) return 0;
16
+ if (hasCommand && hasNonCommand) return 1;
17
+ return 2;
18
+ }
19
+ /**
20
+ * The default section comparator: command-only sections come first, then
21
+ * mixed sections, then option/argument-only sections. Sections with the
22
+ * same score preserve their original relative order (stable sort).
23
+ */
24
+ function defaultSectionOrder(a, b) {
25
+ return classifySection(a) - classifySection(b);
26
+ }
27
+ /**
6
28
  * Formats a documentation page into a human-readable string.
7
29
  *
8
30
  * This function takes a structured {@link DocPage} and converts it into
@@ -64,7 +86,16 @@ function formatDocPage(programName, page, options = {}) {
64
86
  });
65
87
  output += "\n";
66
88
  }
67
- const sections = page.sections.toSorted((a, b) => a.title == null && b.title == null ? 0 : a.title == null ? -1 : 1);
89
+ const comparator = options.sectionOrder ?? defaultSectionOrder;
90
+ const sections = page.sections.map((s, i) => ({
91
+ section: s,
92
+ index: i
93
+ })).toSorted((a, b) => {
94
+ const cmp = comparator(a.section, b.section);
95
+ if (cmp !== 0) return cmp;
96
+ const titleCmp = (a.section.title == null ? 0 : 1) - (b.section.title == null ? 0 : 1);
97
+ return titleCmp !== 0 ? titleCmp : a.index - b.index;
98
+ }).map(({ section }) => section);
68
99
  for (const section of sections) {
69
100
  if (section.entries.length < 1) continue;
70
101
  output += "\n";
package/dist/facade.cjs CHANGED
@@ -369,7 +369,7 @@ function classifyResult(result, args) {
369
369
  * Handles shell completion requests.
370
370
  * @since 0.6.0
371
371
  */
372
- function handleCompletion(completionArgs, programName, parser, completionParser, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName) {
372
+ function handleCompletion(completionArgs, programName, parser, completionParser, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder) {
373
373
  const shellName = completionArgs[0] || "";
374
374
  const args = completionArgs.slice(1);
375
375
  const callOnError = (code) => {
@@ -392,7 +392,8 @@ function handleCompletion(completionArgs, programName, parser, completionParser,
392
392
  const doc = require_parser.getDocPage(completionParser, ["completion"]);
393
393
  if (doc) stderr(require_doc.formatDocPage(programName, doc, {
394
394
  colors,
395
- maxWidth
395
+ maxWidth,
396
+ sectionOrder
396
397
  }));
397
398
  }
398
399
  if (parser.$mode === "async") return Promise.resolve(callOnError(1));
@@ -457,7 +458,7 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
457
458
  args = argsOrOptions;
458
459
  options = optionsParam ?? {};
459
460
  }
460
- const { colors, maxWidth, showDefault, showChoices, aboveError = "usage", onError = () => {
461
+ const { colors, maxWidth, showDefault, showChoices, sectionOrder, aboveError = "usage", onError = () => {
461
462
  throw new RunParserError("Failed to parse command line arguments.");
462
463
  }, stderr = console.error, stdout = console.log, brief, description, examples, author, bugs, footer } = options;
463
464
  const helpMode = options.help?.mode ?? "option";
@@ -500,7 +501,7 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
500
501
  } : createCompletionParser(completion, programName, availableShells, completionName, completionHelpVisibility);
501
502
  if (options.completion) {
502
503
  const hasHelpOption = args.includes("--help");
503
- if ((completionMode === "command" || completionMode === "both") && args.length >= 1 && ((completionName === "singular" || completionName === "both" ? args[0] === "completion" : false) || (completionName === "plural" || completionName === "both" ? args[0] === "completions" : false)) && !hasHelpOption) return handleCompletion(args.slice(1), programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName);
504
+ if ((completionMode === "command" || completionMode === "both") && args.length >= 1 && ((completionName === "singular" || completionName === "both" ? args[0] === "completion" : false) || (completionName === "plural" || completionName === "both" ? args[0] === "completions" : false)) && !hasHelpOption) return handleCompletion(args.slice(1), programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder);
504
505
  if (completionMode === "option" || completionMode === "both") for (let i = 0; i < args.length; i++) {
505
506
  const arg = args[i];
506
507
  const singularMatch = completionName === "singular" || completionName === "both" ? arg.startsWith("--completion=") : false;
@@ -508,14 +509,14 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
508
509
  if (singularMatch || pluralMatch) {
509
510
  const shell = arg.slice(arg.indexOf("=") + 1);
510
511
  const completionArgs = args.slice(i + 1);
511
- return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName);
512
+ return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder);
512
513
  } else {
513
514
  const singularMatchExact = completionName === "singular" || completionName === "both" ? arg === "--completion" : false;
514
515
  const pluralMatchExact = completionName === "plural" || completionName === "both" ? arg === "--completions" : false;
515
516
  if (singularMatchExact || pluralMatchExact) {
516
517
  const shell = i + 1 < args.length ? args[i + 1] : "";
517
518
  const completionArgs = i + 1 < args.length ? args.slice(i + 2) : [];
518
- return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName);
519
+ return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder);
519
520
  }
520
521
  }
521
522
  }
@@ -625,8 +626,8 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
625
626
  const shouldOverride = !isMetaCommandHelp && !isSubcommandHelp;
626
627
  const augmentedDoc = {
627
628
  ...doc,
628
- brief: shouldOverride ? brief ?? doc.brief : doc.brief ?? brief,
629
- description: shouldOverride ? description ?? doc.description : doc.description ?? description,
629
+ brief: shouldOverride ? brief ?? doc.brief : doc.brief,
630
+ description: shouldOverride ? description ?? doc.description : doc.description,
630
631
  examples: isTopLevel && !isMetaCommandHelp ? examples ?? doc.examples : void 0,
631
632
  author: isTopLevel && !isMetaCommandHelp ? author ?? doc.author : void 0,
632
633
  bugs: isTopLevel && !isMetaCommandHelp ? bugs ?? doc.bugs : void 0,
@@ -636,7 +637,8 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
636
637
  colors,
637
638
  maxWidth,
638
639
  showDefault,
639
- showChoices
640
+ showChoices,
641
+ sectionOrder
640
642
  }));
641
643
  }
642
644
  try {
package/dist/facade.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Message } from "./message.cjs";
2
- import { ShowChoicesOptions, ShowDefaultOptions } from "./doc.cjs";
2
+ import { DocSection, ShowChoicesOptions, ShowDefaultOptions } from "./doc.cjs";
3
3
  import { InferMode, InferValue, Mode, ModeValue, Parser } from "./parser.cjs";
4
4
  import { ShellCompletion } from "./completion.cjs";
5
5
  import { ParserValuePlaceholder, SourceContext } from "./context.cjs";
@@ -148,6 +148,20 @@ interface RunOptions<THelp, TError> {
148
148
  * @since 0.10.0
149
149
  */
150
150
  readonly showChoices?: boolean | ShowChoicesOptions;
151
+ /**
152
+ * A custom comparator function to control the order of sections in the
153
+ * help output. When provided, it is used instead of the default smart
154
+ * sort (command-only sections first, then mixed, then option/argument-only
155
+ * sections). Sections that compare equal (return `0`) preserve their
156
+ * original relative order.
157
+ *
158
+ * @param a The first section to compare.
159
+ * @param b The second section to compare.
160
+ * @returns A negative number if `a` should appear before `b`, a positive
161
+ * number if `a` should appear after `b`, or `0` if they are equal.
162
+ * @since 1.0.0
163
+ */
164
+ readonly sectionOrder?: (a: DocSection, b: DocSection) => number;
151
165
  /**
152
166
  * Help configuration. When provided, enables help functionality.
153
167
  */
package/dist/facade.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Message } from "./message.js";
2
- import { ShowChoicesOptions, ShowDefaultOptions } from "./doc.js";
2
+ import { DocSection, ShowChoicesOptions, ShowDefaultOptions } from "./doc.js";
3
3
  import { InferMode, InferValue, Mode, ModeValue, Parser } from "./parser.js";
4
4
  import { ShellCompletion } from "./completion.js";
5
5
  import { ParserValuePlaceholder, SourceContext } from "./context.js";
@@ -148,6 +148,20 @@ interface RunOptions<THelp, TError> {
148
148
  * @since 0.10.0
149
149
  */
150
150
  readonly showChoices?: boolean | ShowChoicesOptions;
151
+ /**
152
+ * A custom comparator function to control the order of sections in the
153
+ * help output. When provided, it is used instead of the default smart
154
+ * sort (command-only sections first, then mixed, then option/argument-only
155
+ * sections). Sections that compare equal (return `0`) preserve their
156
+ * original relative order.
157
+ *
158
+ * @param a The first section to compare.
159
+ * @param b The second section to compare.
160
+ * @returns A negative number if `a` should appear before `b`, a positive
161
+ * number if `a` should appear after `b`, or `0` if they are equal.
162
+ * @since 1.0.0
163
+ */
164
+ readonly sectionOrder?: (a: DocSection, b: DocSection) => number;
151
165
  /**
152
166
  * Help configuration. When provided, enables help functionality.
153
167
  */
package/dist/facade.js CHANGED
@@ -369,7 +369,7 @@ function classifyResult(result, args) {
369
369
  * Handles shell completion requests.
370
370
  * @since 0.6.0
371
371
  */
372
- function handleCompletion(completionArgs, programName, parser, completionParser, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName) {
372
+ function handleCompletion(completionArgs, programName, parser, completionParser, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder) {
373
373
  const shellName = completionArgs[0] || "";
374
374
  const args = completionArgs.slice(1);
375
375
  const callOnError = (code) => {
@@ -392,7 +392,8 @@ function handleCompletion(completionArgs, programName, parser, completionParser,
392
392
  const doc = getDocPage(completionParser, ["completion"]);
393
393
  if (doc) stderr(formatDocPage(programName, doc, {
394
394
  colors,
395
- maxWidth
395
+ maxWidth,
396
+ sectionOrder
396
397
  }));
397
398
  }
398
399
  if (parser.$mode === "async") return Promise.resolve(callOnError(1));
@@ -457,7 +458,7 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
457
458
  args = argsOrOptions;
458
459
  options = optionsParam ?? {};
459
460
  }
460
- const { colors, maxWidth, showDefault, showChoices, aboveError = "usage", onError = () => {
461
+ const { colors, maxWidth, showDefault, showChoices, sectionOrder, aboveError = "usage", onError = () => {
461
462
  throw new RunParserError("Failed to parse command line arguments.");
462
463
  }, stderr = console.error, stdout = console.log, brief, description, examples, author, bugs, footer } = options;
463
464
  const helpMode = options.help?.mode ?? "option";
@@ -500,7 +501,7 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
500
501
  } : createCompletionParser(completion, programName, availableShells, completionName, completionHelpVisibility);
501
502
  if (options.completion) {
502
503
  const hasHelpOption = args.includes("--help");
503
- if ((completionMode === "command" || completionMode === "both") && args.length >= 1 && ((completionName === "singular" || completionName === "both" ? args[0] === "completion" : false) || (completionName === "plural" || completionName === "both" ? args[0] === "completions" : false)) && !hasHelpOption) return handleCompletion(args.slice(1), programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName);
504
+ if ((completionMode === "command" || completionMode === "both") && args.length >= 1 && ((completionName === "singular" || completionName === "both" ? args[0] === "completion" : false) || (completionName === "plural" || completionName === "both" ? args[0] === "completions" : false)) && !hasHelpOption) return handleCompletion(args.slice(1), programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder);
504
505
  if (completionMode === "option" || completionMode === "both") for (let i = 0; i < args.length; i++) {
505
506
  const arg = args[i];
506
507
  const singularMatch = completionName === "singular" || completionName === "both" ? arg.startsWith("--completion=") : false;
@@ -508,14 +509,14 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
508
509
  if (singularMatch || pluralMatch) {
509
510
  const shell = arg.slice(arg.indexOf("=") + 1);
510
511
  const completionArgs = args.slice(i + 1);
511
- return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName);
512
+ return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder);
512
513
  } else {
513
514
  const singularMatchExact = completionName === "singular" || completionName === "both" ? arg === "--completion" : false;
514
515
  const pluralMatchExact = completionName === "plural" || completionName === "both" ? arg === "--completions" : false;
515
516
  if (singularMatchExact || pluralMatchExact) {
516
517
  const shell = i + 1 < args.length ? args[i + 1] : "";
517
518
  const completionArgs = i + 1 < args.length ? args.slice(i + 2) : [];
518
- return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName);
519
+ return handleCompletion([shell, ...completionArgs], programName, parser, completionParsers.completionCommand, stdout, stderr, onCompletion, onError, availableShells, colors, maxWidth, completionMode, completionName, sectionOrder);
519
520
  }
520
521
  }
521
522
  }
@@ -625,8 +626,8 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
625
626
  const shouldOverride = !isMetaCommandHelp && !isSubcommandHelp;
626
627
  const augmentedDoc = {
627
628
  ...doc,
628
- brief: shouldOverride ? brief ?? doc.brief : doc.brief ?? brief,
629
- description: shouldOverride ? description ?? doc.description : doc.description ?? description,
629
+ brief: shouldOverride ? brief ?? doc.brief : doc.brief,
630
+ description: shouldOverride ? description ?? doc.description : doc.description,
630
631
  examples: isTopLevel && !isMetaCommandHelp ? examples ?? doc.examples : void 0,
631
632
  author: isTopLevel && !isMetaCommandHelp ? author ?? doc.author : void 0,
632
633
  bugs: isTopLevel && !isMetaCommandHelp ? bugs ?? doc.bugs : void 0,
@@ -636,7 +637,8 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
636
637
  colors,
637
638
  maxWidth,
638
639
  showDefault,
639
- showChoices
640
+ showChoices,
641
+ sectionOrder
640
642
  }));
641
643
  }
642
644
  try {
@@ -2,6 +2,7 @@ const require_message = require('./message.cjs');
2
2
  const require_dependency = require('./dependency.cjs');
3
3
  const require_usage = require('./usage.cjs');
4
4
  const require_suggestion = require('./suggestion.cjs');
5
+ const require_usage_internals = require('./usage-internals.cjs');
5
6
  const require_valueparser = require('./valueparser.cjs');
6
7
 
7
8
  //#region src/primitives.ts
@@ -929,11 +930,10 @@ function command(name, parser, options = {}) {
929
930
  if (context.state === void 0) {
930
931
  if (context.buffer.length < 1 || context.buffer[0] !== name) {
931
932
  const actual = context.buffer.length > 0 ? context.buffer[0] : null;
933
+ const leadingCmds = require_usage_internals.extractLeadingCommandNames(context.usage);
934
+ const suggestions = actual ? require_suggestion.findSimilar(actual, leadingCmds, require_suggestion.DEFAULT_FIND_SIMILAR_OPTIONS) : [];
932
935
  if (options.errors?.notMatched) {
933
936
  const errorMessage = options.errors.notMatched;
934
- const candidates = /* @__PURE__ */ new Set();
935
- for (const cmdName of require_usage.extractCommandNames(context.usage)) candidates.add(cmdName);
936
- const suggestions = actual ? require_suggestion.findSimilar(actual, candidates, require_suggestion.DEFAULT_FIND_SIMILAR_OPTIONS) : [];
937
937
  return {
938
938
  success: false,
939
939
  consumed: 0,
@@ -946,10 +946,15 @@ function command(name, parser, options = {}) {
946
946
  error: require_message.message`Expected command ${require_message.optionName(name)}, but got end of input.`
947
947
  };
948
948
  const baseError = require_message.message`Expected command ${require_message.optionName(name)}, but got ${actual}.`;
949
+ const suggestionMsg = require_suggestion.createSuggestionMessage(suggestions);
949
950
  return {
950
951
  success: false,
951
952
  consumed: 0,
952
- error: require_suggestion.createErrorWithSuggestions(baseError, actual, context.usage, "command")
953
+ error: suggestionMsg.length > 0 ? [
954
+ ...baseError,
955
+ require_message.text("\n\n"),
956
+ ...suggestionMsg
957
+ ] : baseError
953
958
  };
954
959
  }
955
960
  return {
@@ -1,7 +1,8 @@
1
- import { message, metavar, optionName, optionNames, valueSet } from "./message.js";
1
+ import { message, metavar, optionName, optionNames, text, valueSet } from "./message.js";
2
2
  import { createDeferredParseState, createDependencySourceState, createPendingDependencySourceState, dependencyId, getDefaultValuesFunction, getDependencyIds, isDeferredParseState, isDependencySource, isDependencySourceState, isDerivedValueParser, isPendingDependencySourceState, suggestWithDependency } from "./dependency.js";
3
- import { extractCommandNames, extractOptionNames } from "./usage.js";
4
- import { DEFAULT_FIND_SIMILAR_OPTIONS, createErrorWithSuggestions, findSimilar } from "./suggestion.js";
3
+ import { extractOptionNames } from "./usage.js";
4
+ import { DEFAULT_FIND_SIMILAR_OPTIONS, createErrorWithSuggestions, createSuggestionMessage, findSimilar } from "./suggestion.js";
5
+ import { extractLeadingCommandNames } from "./usage-internals.js";
5
6
  import { isValueParser } from "./valueparser.js";
6
7
 
7
8
  //#region src/primitives.ts
@@ -929,11 +930,10 @@ function command(name, parser, options = {}) {
929
930
  if (context.state === void 0) {
930
931
  if (context.buffer.length < 1 || context.buffer[0] !== name) {
931
932
  const actual = context.buffer.length > 0 ? context.buffer[0] : null;
933
+ const leadingCmds = extractLeadingCommandNames(context.usage);
934
+ const suggestions = actual ? findSimilar(actual, leadingCmds, DEFAULT_FIND_SIMILAR_OPTIONS) : [];
932
935
  if (options.errors?.notMatched) {
933
936
  const errorMessage = options.errors.notMatched;
934
- const candidates = /* @__PURE__ */ new Set();
935
- for (const cmdName of extractCommandNames(context.usage)) candidates.add(cmdName);
936
- const suggestions = actual ? findSimilar(actual, candidates, DEFAULT_FIND_SIMILAR_OPTIONS) : [];
937
937
  return {
938
938
  success: false,
939
939
  consumed: 0,
@@ -946,10 +946,15 @@ function command(name, parser, options = {}) {
946
946
  error: message`Expected command ${optionName(name)}, but got end of input.`
947
947
  };
948
948
  const baseError = message`Expected command ${optionName(name)}, but got ${actual}.`;
949
+ const suggestionMsg = createSuggestionMessage(suggestions);
949
950
  return {
950
951
  success: false,
951
952
  consumed: 0,
952
- error: createErrorWithSuggestions(baseError, actual, context.usage, "command")
953
+ error: suggestionMsg.length > 0 ? [
954
+ ...baseError,
955
+ text("\n\n"),
956
+ ...suggestionMsg
957
+ ] : baseError
953
958
  };
954
959
  }
955
960
  return {
@@ -0,0 +1,73 @@
1
+
2
+ //#region src/usage-internals.ts
3
+ /**
4
+ * Collects option names and command names that are valid as the *immediate*
5
+ * next token at the current parse position ("leading candidates").
6
+ *
7
+ * Unlike the full-tree extractors in `usage.ts`, this function stops
8
+ * descending into a branch as soon as it hits a required (blocking) term —
9
+ * an option, a command, or a required argument. Optional and zero-or-more
10
+ * terms are traversed but do not block.
11
+ *
12
+ * @param terms The usage terms to inspect.
13
+ * @param optionNames Accumulator for leading option names.
14
+ * @param commandNames Accumulator for leading command names.
15
+ * @returns `true` if every term in `terms` is skippable (i.e., the caller
16
+ * may continue scanning the next sibling term), `false` otherwise.
17
+ */
18
+ function collectLeadingCandidates(terms, optionNames, commandNames) {
19
+ if (!terms || !Array.isArray(terms)) return true;
20
+ for (const term of terms) {
21
+ if (term.type === "option") {
22
+ for (const name of term.names) optionNames.add(name);
23
+ return false;
24
+ }
25
+ if (term.type === "command") {
26
+ commandNames.add(term.name);
27
+ return false;
28
+ }
29
+ if (term.type === "argument") return false;
30
+ if (term.type === "optional") {
31
+ collectLeadingCandidates(term.terms, optionNames, commandNames);
32
+ continue;
33
+ }
34
+ if (term.type === "multiple") {
35
+ collectLeadingCandidates(term.terms, optionNames, commandNames);
36
+ if (term.min === 0) continue;
37
+ return false;
38
+ }
39
+ if (term.type === "exclusive") {
40
+ let allSkippable = true;
41
+ for (const branch of term.terms) {
42
+ const branchSkippable = collectLeadingCandidates(branch, optionNames, commandNames);
43
+ allSkippable = allSkippable && branchSkippable;
44
+ }
45
+ if (allSkippable) continue;
46
+ return false;
47
+ }
48
+ }
49
+ return true;
50
+ }
51
+ /**
52
+ * Returns the set of command names that are valid as the *immediate* next
53
+ * token, derived from the leading candidates of `usage`.
54
+ *
55
+ * This is the command-only projection of {@link collectLeadingCandidates}
56
+ * and is used to generate accurate "Did you mean?" suggestions in
57
+ * `command()` error messages — suggestions are scoped to commands actually
58
+ * reachable at the current parse position rather than all commands anywhere
59
+ * in the usage tree.
60
+ *
61
+ * @param usage The usage tree to inspect.
62
+ * @returns A `Set` of command names valid as the next input token.
63
+ */
64
+ function extractLeadingCommandNames(usage) {
65
+ const options = /* @__PURE__ */ new Set();
66
+ const commands = /* @__PURE__ */ new Set();
67
+ collectLeadingCandidates(usage, options, commands);
68
+ return commands;
69
+ }
70
+
71
+ //#endregion
72
+ exports.collectLeadingCandidates = collectLeadingCandidates;
73
+ exports.extractLeadingCommandNames = extractLeadingCommandNames;
@@ -0,0 +1,71 @@
1
+ //#region src/usage-internals.ts
2
+ /**
3
+ * Collects option names and command names that are valid as the *immediate*
4
+ * next token at the current parse position ("leading candidates").
5
+ *
6
+ * Unlike the full-tree extractors in `usage.ts`, this function stops
7
+ * descending into a branch as soon as it hits a required (blocking) term —
8
+ * an option, a command, or a required argument. Optional and zero-or-more
9
+ * terms are traversed but do not block.
10
+ *
11
+ * @param terms The usage terms to inspect.
12
+ * @param optionNames Accumulator for leading option names.
13
+ * @param commandNames Accumulator for leading command names.
14
+ * @returns `true` if every term in `terms` is skippable (i.e., the caller
15
+ * may continue scanning the next sibling term), `false` otherwise.
16
+ */
17
+ function collectLeadingCandidates(terms, optionNames, commandNames) {
18
+ if (!terms || !Array.isArray(terms)) return true;
19
+ for (const term of terms) {
20
+ if (term.type === "option") {
21
+ for (const name of term.names) optionNames.add(name);
22
+ return false;
23
+ }
24
+ if (term.type === "command") {
25
+ commandNames.add(term.name);
26
+ return false;
27
+ }
28
+ if (term.type === "argument") return false;
29
+ if (term.type === "optional") {
30
+ collectLeadingCandidates(term.terms, optionNames, commandNames);
31
+ continue;
32
+ }
33
+ if (term.type === "multiple") {
34
+ collectLeadingCandidates(term.terms, optionNames, commandNames);
35
+ if (term.min === 0) continue;
36
+ return false;
37
+ }
38
+ if (term.type === "exclusive") {
39
+ let allSkippable = true;
40
+ for (const branch of term.terms) {
41
+ const branchSkippable = collectLeadingCandidates(branch, optionNames, commandNames);
42
+ allSkippable = allSkippable && branchSkippable;
43
+ }
44
+ if (allSkippable) continue;
45
+ return false;
46
+ }
47
+ }
48
+ return true;
49
+ }
50
+ /**
51
+ * Returns the set of command names that are valid as the *immediate* next
52
+ * token, derived from the leading candidates of `usage`.
53
+ *
54
+ * This is the command-only projection of {@link collectLeadingCandidates}
55
+ * and is used to generate accurate "Did you mean?" suggestions in
56
+ * `command()` error messages — suggestions are scoped to commands actually
57
+ * reachable at the current parse position rather than all commands anywhere
58
+ * in the usage tree.
59
+ *
60
+ * @param usage The usage tree to inspect.
61
+ * @returns A `Set` of command names valid as the next input token.
62
+ */
63
+ function extractLeadingCommandNames(usage) {
64
+ const options = /* @__PURE__ */ new Set();
65
+ const commands = /* @__PURE__ */ new Set();
66
+ collectLeadingCandidates(usage, options, commands);
67
+ return commands;
68
+ }
69
+
70
+ //#endregion
71
+ export { collectLeadingCandidates, extractLeadingCommandNames };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "1.0.0-dev.400+a38bcb54",
3
+ "version": "1.0.0-dev.407+21363ee4",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",