@optique/core 1.4.0-dev.2708 → 1.4.0-dev.2711

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.
package/dist/usage.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  const require_displaywidth = require('./displaywidth.cjs');
2
2
  const require_terminal_internal = require('./terminal-internal.cjs');
3
+ const require_internal_usage = require('./internal/usage.cjs');
3
4
  const require_validate = require('./validate.cjs');
4
5
 
5
6
  //#region src/usage.ts
@@ -190,16 +191,39 @@ function formatUsage(programName, usage, options = {}) {
190
191
  theme: require_terminal_internal.cacheTerminalTheme(options.theme)
191
192
  };
192
193
  require_validate.validateProgramName(programName);
193
- usage = normalizeUsage(filterUsageForDisplay(usage));
194
+ return formatUsageLines(programName, normalizeUsage(filterUsageForDisplay(require_internal_usage.resolveUsageForDisplay(usage, isUsageHidden))), normalizeUsage(filterUsageForDisplay(require_internal_usage.forgetEmptyInput(usage))), options);
195
+ }
196
+ /**
197
+ * Formats a display-ready usage, expanding a trailing command group into one
198
+ * line per command when requested.
199
+ * @param programName The program name.
200
+ * @param usage The usage to draw, already resolved, filtered and normalized.
201
+ * @param declared The same usage as declared, without empty-input records,
202
+ * filtered and normalized. It tells a group that was drawn
203
+ * as optional because it accepts empty input apart from a
204
+ * group declared optional.
205
+ * @param options The formatting options.
206
+ * @returns The formatted usage.
207
+ */
208
+ function formatUsageLines(programName, usage, declared, options) {
194
209
  if (options.expandCommands) {
195
- const lastTerm = usage.at(-1);
210
+ let lastTerm = usage.at(-1);
211
+ const declaredLast = declared.at(-1);
212
+ if (lastTerm?.type === "optional" && declaredLast?.type === "exclusive") {
213
+ if (lastTerm.terms.length === 1 && lastTerm.terms[0].type === "exclusive") lastTerm = lastTerm.terms[0];
214
+ else if (lastTerm.terms[0]?.type === "command") lastTerm = {
215
+ type: "exclusive",
216
+ terms: [lastTerm.terms]
217
+ };
218
+ }
196
219
  if (usage.length > 0 && usage.slice(0, -1).every((t) => t.type === "command") && lastTerm.type === "exclusive" && lastTerm.terms.every((t) => t.length > 0 && (t[0].type === "command" || t[0].type === "option" || t[0].type === "argument" || t[0].type === "optional" && t[0].terms.length === 1 && (t[0].terms[0].type === "command" || t[0].terms[0].type === "option" || t[0].terms[0].type === "argument")))) {
220
+ const declaredBranches = declaredLast?.type === "exclusive" && declaredLast.terms.length === lastTerm.terms.length ? declaredLast.terms : void 0;
197
221
  const lines = [];
198
- for (let command of lastTerm.terms) {
222
+ for (const [index, command] of lastTerm.terms.entries()) {
199
223
  const firstTerm = command[0];
200
224
  if (firstTerm?.type === "command" && isUsageHidden(firstTerm.hidden)) continue;
201
- if (usage.length > 1) command = [...usage.slice(0, -1), ...command];
202
- lines.push(formatUsage(programName, command, options));
225
+ const line = normalizeUsage(filterUsageForDisplay([...usage.slice(0, -1), ...command]));
226
+ lines.push(formatUsageLines(programName, line, declaredBranches == null ? line : normalizeUsage(filterUsageForDisplay([...declared.slice(0, -1), ...declaredBranches[index]])), options));
203
227
  }
204
228
  if (lines.length > 0) return lines.join("\n");
205
229
  }
@@ -277,7 +301,8 @@ function normalizeUsageTerm(term) {
277
301
  } else if (term.type === "multiple") return {
278
302
  type: "multiple",
279
303
  terms: normalizeUsage(term.terms),
280
- min: term.min
304
+ min: term.min,
305
+ ...acceptsEmptyOf(term)
281
306
  };
282
307
  else if (term.type === "sequence") return {
283
308
  type: "sequence",
@@ -287,14 +312,15 @@ function normalizeUsageTerm(term) {
287
312
  const terms = [];
288
313
  for (const usage of term.terms) {
289
314
  const normalized = normalizeUsage(usage);
290
- if (normalized.length >= 1 && normalized[0].type === "exclusive") {
315
+ if (normalized.length >= 1 && normalized[0].type === "exclusive" && normalized[0].acceptsEmpty == null) {
291
316
  const rest = normalized.slice(1);
292
317
  for (const subUsage of normalized[0].terms) terms.push([...subUsage, ...rest]);
293
318
  } else if (normalized.length > 0 || !containsMalformedLeaf(usage)) terms.push(normalized);
294
319
  }
295
320
  return {
296
321
  type: "exclusive",
297
- terms
322
+ terms,
323
+ ...acceptsEmptyOf(term)
298
324
  };
299
325
  } else {
300
326
  if (term.type === "option") return {
@@ -311,10 +337,14 @@ function normalizeUsageTerm(term) {
311
337
  return { ...term };
312
338
  }
313
339
  }
340
+ function acceptsEmptyOf(term) {
341
+ return term.acceptsEmpty == null ? {} : { acceptsEmpty: term.acceptsEmpty };
342
+ }
314
343
  function isNonDegenerateTerm(term) {
315
344
  if (term.type === "option") return term.names.length > 0;
316
345
  if (term.type === "command") return term.name !== "";
317
346
  if (term.type === "argument") return term.metavar.length > 0;
347
+ if ((term.type === "multiple" || term.type === "exclusive") && term.acceptsEmpty != null) return true;
318
348
  if (term.type === "optional" || term.type === "multiple" || term.type === "exclusive" || term.type === "sequence") return term.terms.length > 0;
319
349
  return true;
320
350
  }
@@ -370,11 +400,13 @@ function cloneUsageTerm(term) {
370
400
  case "multiple": return {
371
401
  type: "multiple",
372
402
  terms: term.terms.map(cloneUsageTerm),
373
- min: term.min
403
+ min: term.min,
404
+ ...acceptsEmptyOf(term)
374
405
  };
375
406
  case "exclusive": return {
376
407
  type: "exclusive",
377
- terms: term.terms.map((u) => u.map(cloneUsageTerm))
408
+ terms: term.terms.map((u) => u.map(cloneUsageTerm)),
409
+ ...acceptsEmptyOf(term)
378
410
  };
379
411
  case "sequence": return {
380
412
  type: "sequence",
@@ -416,7 +448,8 @@ function filterUsageForDisplay(usage, isHidden = isUsageHidden) {
416
448
  if (filtered.length > 0) terms.push({
417
449
  type: "multiple",
418
450
  terms: filtered,
419
- min: term.min
451
+ min: term.min,
452
+ ...acceptsEmptyOf(term)
420
453
  });
421
454
  continue;
422
455
  }
@@ -428,7 +461,8 @@ function filterUsageForDisplay(usage, isHidden = isUsageHidden) {
428
461
  }).filter((branch) => branch.length > 0);
429
462
  if (filteredBranches.length > 0) terms.push({
430
463
  type: "exclusive",
431
- terms: filteredBranches
464
+ terms: filteredBranches,
465
+ ...acceptsEmptyOf(term)
432
466
  });
433
467
  continue;
434
468
  }
@@ -482,7 +516,7 @@ function formatUsageTerm(term, options = {}) {
482
516
  theme: require_terminal_internal.cacheTerminalTheme(options.theme)
483
517
  };
484
518
  const hiddenCheck = options.context === "doc" ? isDocHidden : isUsageHidden;
485
- const visibleTerms = filterUsageForDisplay([term], hiddenCheck);
519
+ const visibleTerms = filterUsageForDisplay(require_internal_usage.resolveUsageForDisplay([term], hiddenCheck), hiddenCheck);
486
520
  if (visibleTerms.length < 1) return "";
487
521
  return wrapUsageTokens(formatUsageTermInternal(visibleTerms[0], options), options);
488
522
  }
package/dist/usage.d.cts CHANGED
@@ -164,6 +164,16 @@ type UsageTerm =
164
164
  * The minimum number of times the term must occur.
165
165
  */
166
166
  readonly min: number;
167
+ /**
168
+ * Whether the parser that produced this group succeeds when it parses an
169
+ * empty argument list on its own. Built-in combinators set this when
170
+ * the outcome is statically known and omit it otherwise. Usage
171
+ * formatters use it to decide whether to draw the group as omissible;
172
+ * it is a declaration for display, not evidence about how any other
173
+ * parser behaves.
174
+ * @since 1.4.0
175
+ */
176
+ readonly acceptsEmpty?: boolean;
167
177
  } |
168
178
  /**
169
179
  * An exclusive term, which represents a group of terms that are mutually
@@ -180,6 +190,16 @@ type UsageTerm =
180
190
  * arguments, options, commands, or other usage terms.
181
191
  */
182
192
  readonly terms: readonly Usage[];
193
+ /**
194
+ * Whether the parser that produced this group succeeds when it parses an
195
+ * empty argument list on its own. Built-in combinators set this when
196
+ * the outcome is statically known and omit it otherwise. Usage
197
+ * formatters use it to decide whether to draw the group as omissible;
198
+ * it is a declaration for display, not evidence about how any other
199
+ * parser behaves.
200
+ * @since 1.4.0
201
+ */
202
+ readonly acceptsEmpty?: boolean;
183
203
  }
184
204
  /**
185
205
  * A sequence term, which preserves the declaration order of its child
package/dist/usage.d.ts CHANGED
@@ -164,6 +164,16 @@ type UsageTerm =
164
164
  * The minimum number of times the term must occur.
165
165
  */
166
166
  readonly min: number;
167
+ /**
168
+ * Whether the parser that produced this group succeeds when it parses an
169
+ * empty argument list on its own. Built-in combinators set this when
170
+ * the outcome is statically known and omit it otherwise. Usage
171
+ * formatters use it to decide whether to draw the group as omissible;
172
+ * it is a declaration for display, not evidence about how any other
173
+ * parser behaves.
174
+ * @since 1.4.0
175
+ */
176
+ readonly acceptsEmpty?: boolean;
167
177
  } |
168
178
  /**
169
179
  * An exclusive term, which represents a group of terms that are mutually
@@ -180,6 +190,16 @@ type UsageTerm =
180
190
  * arguments, options, commands, or other usage terms.
181
191
  */
182
192
  readonly terms: readonly Usage[];
193
+ /**
194
+ * Whether the parser that produced this group succeeds when it parses an
195
+ * empty argument list on its own. Built-in combinators set this when
196
+ * the outcome is statically known and omit it otherwise. Usage
197
+ * formatters use it to decide whether to draw the group as omissible;
198
+ * it is a declaration for display, not evidence about how any other
199
+ * parser behaves.
200
+ * @since 1.4.0
201
+ */
202
+ readonly acceptsEmpty?: boolean;
183
203
  }
184
204
  /**
185
205
  * A sequence term, which preserves the declaration order of its child
package/dist/usage.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getDisplayWidth } from "./displaywidth.js";
2
2
  import { cacheTerminalTheme, formatTerminalLeaf, fragmentTokens, serializeTokens } from "./terminal-internal.js";
3
+ import { forgetEmptyInput, resolveUsageForDisplay } from "./internal/usage.js";
3
4
  import { validateProgramName } from "./validate.js";
4
5
 
5
6
  //#region src/usage.ts
@@ -190,16 +191,39 @@ function formatUsage(programName, usage, options = {}) {
190
191
  theme: cacheTerminalTheme(options.theme)
191
192
  };
192
193
  validateProgramName(programName);
193
- usage = normalizeUsage(filterUsageForDisplay(usage));
194
+ return formatUsageLines(programName, normalizeUsage(filterUsageForDisplay(resolveUsageForDisplay(usage, isUsageHidden))), normalizeUsage(filterUsageForDisplay(forgetEmptyInput(usage))), options);
195
+ }
196
+ /**
197
+ * Formats a display-ready usage, expanding a trailing command group into one
198
+ * line per command when requested.
199
+ * @param programName The program name.
200
+ * @param usage The usage to draw, already resolved, filtered and normalized.
201
+ * @param declared The same usage as declared, without empty-input records,
202
+ * filtered and normalized. It tells a group that was drawn
203
+ * as optional because it accepts empty input apart from a
204
+ * group declared optional.
205
+ * @param options The formatting options.
206
+ * @returns The formatted usage.
207
+ */
208
+ function formatUsageLines(programName, usage, declared, options) {
194
209
  if (options.expandCommands) {
195
- const lastTerm = usage.at(-1);
210
+ let lastTerm = usage.at(-1);
211
+ const declaredLast = declared.at(-1);
212
+ if (lastTerm?.type === "optional" && declaredLast?.type === "exclusive") {
213
+ if (lastTerm.terms.length === 1 && lastTerm.terms[0].type === "exclusive") lastTerm = lastTerm.terms[0];
214
+ else if (lastTerm.terms[0]?.type === "command") lastTerm = {
215
+ type: "exclusive",
216
+ terms: [lastTerm.terms]
217
+ };
218
+ }
196
219
  if (usage.length > 0 && usage.slice(0, -1).every((t) => t.type === "command") && lastTerm.type === "exclusive" && lastTerm.terms.every((t) => t.length > 0 && (t[0].type === "command" || t[0].type === "option" || t[0].type === "argument" || t[0].type === "optional" && t[0].terms.length === 1 && (t[0].terms[0].type === "command" || t[0].terms[0].type === "option" || t[0].terms[0].type === "argument")))) {
220
+ const declaredBranches = declaredLast?.type === "exclusive" && declaredLast.terms.length === lastTerm.terms.length ? declaredLast.terms : void 0;
197
221
  const lines = [];
198
- for (let command of lastTerm.terms) {
222
+ for (const [index, command] of lastTerm.terms.entries()) {
199
223
  const firstTerm = command[0];
200
224
  if (firstTerm?.type === "command" && isUsageHidden(firstTerm.hidden)) continue;
201
- if (usage.length > 1) command = [...usage.slice(0, -1), ...command];
202
- lines.push(formatUsage(programName, command, options));
225
+ const line = normalizeUsage(filterUsageForDisplay([...usage.slice(0, -1), ...command]));
226
+ lines.push(formatUsageLines(programName, line, declaredBranches == null ? line : normalizeUsage(filterUsageForDisplay([...declared.slice(0, -1), ...declaredBranches[index]])), options));
203
227
  }
204
228
  if (lines.length > 0) return lines.join("\n");
205
229
  }
@@ -277,7 +301,8 @@ function normalizeUsageTerm(term) {
277
301
  } else if (term.type === "multiple") return {
278
302
  type: "multiple",
279
303
  terms: normalizeUsage(term.terms),
280
- min: term.min
304
+ min: term.min,
305
+ ...acceptsEmptyOf(term)
281
306
  };
282
307
  else if (term.type === "sequence") return {
283
308
  type: "sequence",
@@ -287,14 +312,15 @@ function normalizeUsageTerm(term) {
287
312
  const terms = [];
288
313
  for (const usage of term.terms) {
289
314
  const normalized = normalizeUsage(usage);
290
- if (normalized.length >= 1 && normalized[0].type === "exclusive") {
315
+ if (normalized.length >= 1 && normalized[0].type === "exclusive" && normalized[0].acceptsEmpty == null) {
291
316
  const rest = normalized.slice(1);
292
317
  for (const subUsage of normalized[0].terms) terms.push([...subUsage, ...rest]);
293
318
  } else if (normalized.length > 0 || !containsMalformedLeaf(usage)) terms.push(normalized);
294
319
  }
295
320
  return {
296
321
  type: "exclusive",
297
- terms
322
+ terms,
323
+ ...acceptsEmptyOf(term)
298
324
  };
299
325
  } else {
300
326
  if (term.type === "option") return {
@@ -311,10 +337,14 @@ function normalizeUsageTerm(term) {
311
337
  return { ...term };
312
338
  }
313
339
  }
340
+ function acceptsEmptyOf(term) {
341
+ return term.acceptsEmpty == null ? {} : { acceptsEmpty: term.acceptsEmpty };
342
+ }
314
343
  function isNonDegenerateTerm(term) {
315
344
  if (term.type === "option") return term.names.length > 0;
316
345
  if (term.type === "command") return term.name !== "";
317
346
  if (term.type === "argument") return term.metavar.length > 0;
347
+ if ((term.type === "multiple" || term.type === "exclusive") && term.acceptsEmpty != null) return true;
318
348
  if (term.type === "optional" || term.type === "multiple" || term.type === "exclusive" || term.type === "sequence") return term.terms.length > 0;
319
349
  return true;
320
350
  }
@@ -370,11 +400,13 @@ function cloneUsageTerm(term) {
370
400
  case "multiple": return {
371
401
  type: "multiple",
372
402
  terms: term.terms.map(cloneUsageTerm),
373
- min: term.min
403
+ min: term.min,
404
+ ...acceptsEmptyOf(term)
374
405
  };
375
406
  case "exclusive": return {
376
407
  type: "exclusive",
377
- terms: term.terms.map((u) => u.map(cloneUsageTerm))
408
+ terms: term.terms.map((u) => u.map(cloneUsageTerm)),
409
+ ...acceptsEmptyOf(term)
378
410
  };
379
411
  case "sequence": return {
380
412
  type: "sequence",
@@ -416,7 +448,8 @@ function filterUsageForDisplay(usage, isHidden = isUsageHidden) {
416
448
  if (filtered.length > 0) terms.push({
417
449
  type: "multiple",
418
450
  terms: filtered,
419
- min: term.min
451
+ min: term.min,
452
+ ...acceptsEmptyOf(term)
420
453
  });
421
454
  continue;
422
455
  }
@@ -428,7 +461,8 @@ function filterUsageForDisplay(usage, isHidden = isUsageHidden) {
428
461
  }).filter((branch) => branch.length > 0);
429
462
  if (filteredBranches.length > 0) terms.push({
430
463
  type: "exclusive",
431
- terms: filteredBranches
464
+ terms: filteredBranches,
465
+ ...acceptsEmptyOf(term)
432
466
  });
433
467
  continue;
434
468
  }
@@ -482,7 +516,7 @@ function formatUsageTerm(term, options = {}) {
482
516
  theme: cacheTerminalTheme(options.theme)
483
517
  };
484
518
  const hiddenCheck = options.context === "doc" ? isDocHidden : isUsageHidden;
485
- const visibleTerms = filterUsageForDisplay([term], hiddenCheck);
519
+ const visibleTerms = filterUsageForDisplay(resolveUsageForDisplay([term], hiddenCheck), hiddenCheck);
486
520
  if (visibleTerms.length < 1) return "";
487
521
  return wrapUsageTokens(formatUsageTermInternal(visibleTerms[0], options), options);
488
522
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "1.4.0-dev.2708",
3
+ "version": "1.4.0-dev.2711",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",
@@ -227,6 +227,14 @@
227
227
  },
228
228
  "import": "./dist/internal/terminal.js",
229
229
  "require": "./dist/internal/terminal.cjs"
230
+ },
231
+ "./internal/usage": {
232
+ "types": {
233
+ "import": "./dist/internal/usage.d.ts",
234
+ "require": "./dist/internal/usage.d.cts"
235
+ },
236
+ "import": "./dist/internal/usage.js",
237
+ "require": "./dist/internal/usage.cjs"
230
238
  }
231
239
  },
232
240
  "imports": {
@@ -237,7 +245,7 @@
237
245
  },
238
246
  "sideEffects": false,
239
247
  "devDependencies": {
240
- "@optique/env": "1.4.0-dev.2708+0119545d",
248
+ "@optique/env": "1.4.0-dev.2711+fda80c51",
241
249
  "@types/node": "^24.0.0",
242
250
  "fast-check": "^4.7.0",
243
251
  "tsdown": "^0.13.0",