1/**
2 * Parsing Pipeline for CLI Commands
3 * ==================================
4 *
5 * The parser transforms raw argv tokens into structured command input through
6 * three main phases:
7 *
8 * 1. **Lexer** (external): Converts argv strings into typed tokens
9 * - LongOption: --name or --name=value
10 * - ShortOption: -n or -n=value (also handles -abc as three flags)
11 * - Value: positional arguments
12 *
13 * 2. **Built-in Extraction**: Peels off built-in flags (help/version/completions)
14 * before command-specific parsing begins.
15 *
16 * 3. **Command Parsing**: Recursively processes command levels:
17 * - Collects flags defined at this level
18 * - Detects subcommand from first value token
19 * - Forwards remaining tokens to child command
20 *
21 * Key Behaviors
22 * -------------
23 * - Inherited parent flags may appear before OR after the subcommand name (npm-style)
24 * - Only the first Value token can open a subcommand
25 * - Errors accumulate rather than throwing exceptions
26 */
27import * as Effect from "../../Effect.ts"
28import * as Option from "../../Option.ts"
29import * as CliError from "../CliError.ts"
30import type { Command, Environment, ParsedTokens } from "../Command.ts"
31import * as Param from "../Param.ts"
32import * as Primitive from "../Primitive.ts"
33import { suggest } from "./auto-suggest.ts"
34import { toImpl } from "./command.ts"
35import type { LexResult, Token } from "./lexer.ts"
36
37/* ========================================================================== */
38/* Public API */
39/* ========================================================================== */
40
41/**
42 * @internal
43 */
44export const getCommandPath = (parsedInput: ParsedTokens): ReadonlyArray<string> =>
45 Option.match(parsedInput.subcommand, {
46 onNone: () => [],
47 onSome: (subcommand) => [subcommand.name, ...getCommandPath(subcommand.parsedInput)]
48 })
49
50/**
51 * @internal
52 */
53export const parseArgs = (
54 lexResult: LexResult,
55 command: Command.Any,
56 commandPath: ReadonlyArray<string> = []
57): Effect.Effect<ParsedTokens, never, Environment> =>
58 Effect.gen(function*() {
59 const { tokens, trailingOperands: afterEndOfOptions } = lexResult
60 const newCommandPath = [...commandPath, command.name]
61
62 const commandImpl = toImpl(command)
63 const singles = commandImpl.config.flags.flatMap(Param.extractSingleParams)
64 const flagParams = singles.filter(Param.isFlagParam)
65 const flagRegistry = createFlagRegistry(flagParams)
66
67 const inheritedSingles = commandImpl.contextConfig.flags.flatMap(Param.extractSingleParams)
68 const inheritedFlagParams = inheritedSingles.filter(Param.isFlagParam)
69 const inheritedFlagRegistry = createFlagRegistry(inheritedFlagParams)
70 const inheritedNames = new Set(inheritedFlagParams.map((param) => param.name))
71
72 const context: CommandContext = {
73 command,
74 commandPath: newCommandPath,
75 flagRegistry,
76 inheritedFlagRegistry,
77 localFlagNames: flagParams.filter((param) => !inheritedNames.has(param.name)).map((param) => param.name)
78 }
79
80 const result = scanCommandLevel(tokens, context)
81
82 if (result._tag === "Leaf") {
83 return {
84 flags: result.flags,
85 arguments: [...result.arguments, ...afterEndOfOptions],
86 subcommand: Option.none(),
87 ...(result.errors.length > 0 && { errors: result.errors })
88 }
89 }
90
91 const subLex: LexResult = { tokens: result.childTokens, trailingOperands: afterEndOfOptions }
92 const subParsed = yield* parseArgs(
93 subLex,
94 result.sub,
95 newCommandPath
96 )
97
98 const allErrors = [...result.errors, ...(subParsed.errors ?? [])]
99 return {
100 flags: result.flags,
101 arguments: [],
102 subcommand: Option.some({ name: result.sub.name, parsedInput: subParsed }),
103 ...(allErrors.length > 0 && { errors: allErrors })
104 }
105 })
106
107/* ========================================================================== */
108/* Types */
109/* ========================================================================== */
110
111type FlagParam = Param.Single<typeof Param.flagKind, unknown>
112
113/**
114 * Mutable map of flag names to their collected string values.
115 * Used internally during parsing; converted to readonly at boundaries.
116 */
117type FlagMap = Record<string, Array<string>>
118
119/**
120 * Registry of known flags for a command level.
121 * Enables O(1) lookup of flag definitions by name or alias.
122 */
123type FlagRegistry = {
124 /** All flag params declared at this command level */
125 readonly params: ReadonlyArray<FlagParam>
126 /** Maps canonical names and aliases → flag param */
127 readonly index: Map<string, FlagParam>
128}
129
130/**
131 * Mutable accumulator for collecting flag values during parsing.
132 * Provides methods to add values and merge results from sub-scans.
133 */
134type FlagAccumulator = {
135 /** Record a value for a flag. No-op if raw is undefined (for boolean flags). */
136 readonly add: (name: string, raw: string | undefined) => void
137 /** Merge values from another flag map into this accumulator */
138 readonly merge: (from: FlagMap) => void
139 /** Return immutable snapshot of accumulated values */
140 readonly snapshot: () => Readonly<FlagMap>
141}
142
143/**
144 * Context for parsing a command level.
145 */
146type CommandContext = {
147 readonly command: Command.Any
148 readonly commandPath: ReadonlyArray<string>
149 readonly flagRegistry: FlagRegistry
150 readonly inheritedFlagRegistry: FlagRegistry
151 readonly localFlagNames: ReadonlyArray<string>
152}
153
154/**
155 * Parsing mode during command-level scanning.
156 * Determines how value tokens are interpreted.
157 */
158type ParseMode =
159 | { readonly _tag: "AwaitingFirstValue" }
160 | { readonly _tag: "CollectingArguments" }
161
162/**
163 * Mutable state accumulated during command-level parsing.
164 */
165type ParseState = {
166 readonly flags: FlagAccumulator
167 readonly arguments: Array<string>
168 readonly errors: Array<CliError.NonShowHelpErrors>
169 mode: ParseMode
170}
171
172/**
173 * Result when first value token is processed.
174 */
175type FirstValueResult =
176 | { readonly _tag: "Subcommand"; readonly result: SubcommandResult }
177 | { readonly _tag: "Argument" }
178
179/**
180 * Terminal result: command level has no subcommand.
181 */
182type LeafResult = {
183 readonly _tag: "Leaf"
184 readonly flags: Readonly<FlagMap>
185 readonly arguments: ReadonlyArray<string>
186 readonly errors: ReadonlyArray<CliError.NonShowHelpErrors>
187}
188
189/**
190 * Continuation result: subcommand detected, child parsing needed.
191 */
192type SubcommandResult = {
193 readonly _tag: "Sub"
194 readonly flags: Readonly<FlagMap>
195 readonly sub: Command<string, unknown, unknown, unknown, unknown>
196 readonly childTokens: ReadonlyArray<Token>
197 readonly errors: ReadonlyArray<CliError.NonShowHelpErrors>
198}
199
200type LevelResult = LeafResult | SubcommandResult
201
202/* ========================================================================== */
203/* Token Cursor */
204/* ========================================================================== */
205
206/**
207 * Stateful cursor for navigating a token stream.
208 * Provides peek/take semantics for single-pass parsing.
209 */
210interface TokenCursor {
211 /** View next token without consuming */
212 readonly peek: () => Token | undefined
213 /** Consume and return next token */
214 readonly take: () => Token | undefined
215 /** Get all remaining tokens (does not consume) */
216 readonly rest: () => ReadonlyArray<Token>
217}
218
219const makeCursor = (tokens: ReadonlyArray<Token>): TokenCursor => {
220 let i = 0
221 return {
222 peek: () => tokens[i],
223 take: () => tokens[i++],
224 rest: () => tokens.slice(i)
225 }
226}
227
228/* ========================================================================== */
229/* Flag Registry */
230/* ========================================================================== */
231
232/**
233 * Creates a registry for O(1) flag lookup by name or alias.
234 * @internal
235 */
236export const createFlagRegistry = (params: ReadonlyArray<FlagParam>): FlagRegistry => {
237 const index = new Map<string, FlagParam>()
238
239 for (const param of params) {
240 if (index.has(param.name)) {
241 throw new Error(`Duplicate flag name "${param.name}" in command definition`)
242 }
243 index.set(param.name, param)
244
245 for (const alias of param.aliases) {
246 if (index.has(alias)) {
247 throw new Error(
248 `Duplicate flag/alias "${alias}" in command definition (conflicts with "${index.get(alias)!.name}")`
249 )
250 }
251 index.set(alias, param)
252 }
253 }
254
255 return { params, index }
256}
257
258const buildSubcommandIndex = (
259 subcommands: Command.Any["subcommands"]
260): Map<string, Command<string, unknown, unknown, unknown, unknown>> => {
261 const index = new Map<string, Command<string, unknown, unknown, unknown, unknown>>()
262 const setKey = (key: string, command: Command<string, unknown, unknown, unknown, unknown>) => {
263 const existing = index.get(key)
264 if (existing && existing !== command) {
265 throw new Error(
266 `Duplicate subcommand name/alias "${key}" in command definition (conflicts with "${existing.name}")`
267 )
268 }
269 index.set(key, command)
270 }
271
272 for (const group of subcommands) {
273 for (const subcommand of group.commands) {
274 setKey(subcommand.name, subcommand)
275 if (subcommand.alias && subcommand.alias !== subcommand.name) {
276 setKey(subcommand.alias, subcommand)
277 }
278 }
279 }
280 return index
281}
282
283/* ========================================================================== */
284/* Flag Accumulator */
285/* ========================================================================== */
286
287/** Creates an empty flag map with all known flag names initialized to [].
288 * @internal
289 */
290export const createEmptyFlagMap = (params: ReadonlyArray<FlagParam>): FlagMap =>
291 Object.fromEntries(params.map((p) => [p.name, []]))
292
293/**
294 * Creates a mutable accumulator for collecting flag values.
295 * Pre-initializes empty arrays for all known flags.
296 */
297const createFlagAccumulator = (params: ReadonlyArray<FlagParam>): FlagAccumulator => {
298 const map = createEmptyFlagMap(params)
299
300 return {
301 add: (name, raw) => {
302 if (raw !== undefined) map[name].push(raw)
303 },
304 merge: (from) => {
305 for (const key in from) {
306 const values = from[key]
307 if (values?.length) {
308 for (let i = 0; i < values.length; i++) {
309 map[key].push(values[i])
310 }
311 }
312 }
313 },
314 snapshot: () => map
315 }
316}
317
318/* ========================================================================== */
319/* Token Classification */
320/* ========================================================================== */
321
322type FlagToken = Extract<Token, { _tag: "LongOption" | "ShortOption" }>
323type ResolvedFlag = {
324 readonly param: FlagParam
325 readonly negated: boolean
326}
327type ConsumedFlagValue =
328 | {
329 readonly _tag: "Value"
330 readonly value: string | undefined
331 }
332 | {
333 readonly _tag: "Error"
334 readonly error: CliError.InvalidValue
335 }
336type ConsumedFlagValueWithTokens =
337 | {
338 readonly _tag: "Value"
339 readonly value: string | undefined
340 readonly tokens: ReadonlyArray<Token>
341 }
342 | {
343 readonly _tag: "Error"
344 readonly error: CliError.InvalidValue
345 readonly tokens: ReadonlyArray<Token>
346 }
347
348const isFlagToken = (t: Token): t is FlagToken => t._tag === "LongOption" || t._tag === "ShortOption"
349
350const getFlagName = (t: FlagToken): string => t._tag === "LongOption" ? t.name : t.flag
351
352const resolveFlag = (
353 token: FlagToken,
354 registry: FlagRegistry
355): ResolvedFlag | undefined => {
356 const tokenName = getFlagName(token)
357 const direct = registry.index.get(tokenName)
358 if (direct && direct.name === tokenName) {
359 return {
360 param: direct,
361 negated: false
362 }
363 }
364
365 if (token._tag === "LongOption" && token.name.startsWith("no-")) {
366 const canonicalName = token.name.slice(3)
367 const param = registry.index.get(canonicalName)
368 if (param && param.name === canonicalName && Primitive.isBoolean(param.primitiveType)) {
369 return {
370 param,
371 negated: true
372 }
373 }
374 }
375
376 if (direct) {
377 return {
378 param: direct,
379 negated: false
380 }
381 }
382
383 return undefined
384}
385
386const invalidNegatedFlagValue = (
387 token: FlagToken,
388 spec: FlagParam,
389 value: string
390): CliError.InvalidValue =>
391 new CliError.InvalidValue({
392 option: spec.name,
393 value,
394 expected: `omit the value and use ${token.raw} by itself to set --${spec.name} to false`,
395 kind: "flag"
396 })
397
398const missingFlagValue = (spec: FlagParam): CliError.InvalidValue => {
399 const choices = Primitive.getChoiceKeys(spec.primitiveType)
400 return new CliError.InvalidValue({
401 option: spec.name,
402 value: "",
403 expected: choices === undefined
404 ? spec.typeName ?? Primitive.getTypeName(spec.primitiveType)
405 : choices.join(" | "),
406 kind: "flag"
407 })
408}
409
410/**
411 * Checks whether a token is a boolean literal value.
412 * Recognizes: true/false, yes/no, on/off, 1/0
413 */
414const asBooleanLiteral = (token: Token | undefined): string | undefined =>
415 token?._tag === "Value" && (Primitive.isTrueLiteral(token.value) || Primitive.isFalseLiteral(token.value))
416 ? token.value
417 : undefined
418
419/* ========================================================================== */
420/* Flag Value Consumption */
421/* ========================================================================== */
422
423/**
424 * Reads a flag's value from the token stream.
425 *
426 * Value resolution order:
427 * 1. Inline value: --flag=value or -f=value
428 * 2. Boolean special case: implicit "true" or explicit boolean literal
429 * 3. Next token: consume following Value token if present
430 */
431const consumeFlagValue = (
432 cursor: TokenCursor,
433 token: FlagToken,
434 spec: FlagParam,
435 negated = false
436): ConsumedFlagValue => {
437 const consumed = consumeFlagValueWithTokens(cursor, token, spec, negated)
438 switch (consumed._tag) {
439 case "Value":
440 return {
441 _tag: "Value",
442 value: consumed.value
443 }
444 case "Error":
445 return {
446 _tag: "Error",
447 error: consumed.error
448 }
449 }
450}
451
452const consumeFlagValueWithTokens = (
453 cursor: TokenCursor,
454 token: FlagToken,
455 spec: FlagParam,
456 negated = false
457): ConsumedFlagValueWithTokens => {
458 // Inline value has highest priority
459 if (negated) {
460 if (token.value !== undefined) {
461 return {
462 _tag: "Error",
463 error: invalidNegatedFlagValue(token, spec, token.value),
464 tokens: []
465 }
466 }
467
468 const literal = asBooleanLiteral(cursor.peek())
469 if (literal !== undefined) {
470 const literalToken = cursor.take()
471 return {
472 _tag: "Error",
473 error: invalidNegatedFlagValue(token, spec, literal),
474 tokens: literalToken === undefined ? [] : [literalToken]
475 }
476 }
477
478 return {
479 _tag: "Value",
480 value: "false",
481 tokens: []
482 }
483 }
484
485 if (token.value !== undefined) {
486 return {
487 _tag: "Value",
488 value: token.value,
489 tokens: []
490 }
491 }
492
493 // Boolean flags: check for explicit literal or default to "true"
494 if (Primitive.isBoolean(spec.primitiveType)) {
495 const literal = asBooleanLiteral(cursor.peek())
496 const literalToken = literal !== undefined ? cursor.take() : undefined
497 return {
498 _tag: "Value",
499 value: literal ?? "true",
500 tokens: literalToken === undefined ? [] : [literalToken]
501 }
502 }
503
504 // Non-boolean: try to consume next Value token
505 const next = cursor.peek()
506 if (next?._tag === "Value") {
507 const valueToken = cursor.take()
508 return {
509 _tag: "Value",
510 value: next.value,
511 tokens: valueToken === undefined ? [] : [valueToken]
512 }
513 }
514
515 return {
516 _tag: "Error",
517 error: missingFlagValue(spec),
518 tokens: []
519 }
520}
521
522/**
523 * Consumes known flags from a token stream.
524 * Unrecognized tokens are passed through to remainder.
525 * Used for both global flag extraction and npm-style parent flag collection.
526 * @internal
527 */
528export const consumeKnownFlags = (
529 tokens: ReadonlyArray<Token>,
530 registry: FlagRegistry
531): { flagMap: FlagMap; remainder: ReadonlyArray<Token>; errors: ReadonlyArray<CliError.InvalidValue> } => {
532 const flagMap = createEmptyFlagMap(registry.params)
533 const remainder: Array<Token> = []
534 const errors: Array<CliError.InvalidValue> = []
535 const cursor = makeCursor(tokens)
536
537 for (let t = cursor.take(); t; t = cursor.take()) {
538 if (!isFlagToken(t)) {
539 remainder.push(t)
540 continue
541 }
542
543 const resolved = resolveFlag(t, registry)
544 if (!resolved) {
545 remainder.push(t)
546 continue
547 }
548
549 const consumed = consumeFlagValue(cursor, t, resolved.param, resolved.negated)
550 if (consumed._tag === "Error") {
551 errors.push(consumed.error)
552 continue
553 }
554 if (consumed.value !== undefined) {
555 flagMap[resolved.param.name].push(consumed.value)
556 }
557 }
558
559 return { flagMap, remainder, errors }
560}
561
562const extractFlagParams = (command: Command.Any): ReadonlyArray<FlagParam> => {
563 const commandImpl = toImpl(command)
564 const singles = commandImpl.config.flags.flatMap(Param.extractSingleParams)
565 return singles.filter(Param.isFlagParam)
566}
567
568const extractContextFlagParams = (command: Command.Any): ReadonlyArray<FlagParam> => {
569 const commandImpl = toImpl(command)
570 const singles = commandImpl.contextConfig.flags.flatMap(Param.extractSingleParams)
571 return singles.filter(Param.isFlagParam)
572}
573
574const resolveFromRegistries = (
575 token: FlagToken,
576 registries: ReadonlyArray<FlagRegistry>
577): ResolvedFlag | undefined => {
578 for (const registry of registries) {
579 const resolved = resolveFlag(token, registry)
580 if (resolved !== undefined) {
581 return resolved
582 }
583 }
584 return undefined
585}
586
587const preserveFlag = (
588 remainder: Array<Token>,
589 cursor: TokenCursor,
590 token: FlagToken,
591 resolved: ResolvedFlag
592): void => {
593 remainder.push(token)
594 const consumed = consumeFlagValueWithTokens(cursor, token, resolved.param, resolved.negated)
595 remainder.push(...consumed.tokens)
596}
597
598const localFlagWouldPrecedeSubcommand = (
599 token: FlagToken,
600 remainingTokens: ReadonlyArray<Token>,
601 resolved: ResolvedFlag,
602 subIndex: Map<string, Command<string, unknown, unknown, unknown, unknown>>,
603 registries: ReadonlyArray<FlagRegistry>
604): boolean => {
605 const cursor = makeCursor(remainingTokens)
606 consumeFlagValueWithTokens(cursor, token, resolved.param, resolved.negated)
607 for (let token = cursor.take(); token; token = cursor.take()) {
608 if (isFlagToken(token)) {
609 const known = resolveFromRegistries(token, registries)
610 if (known !== undefined) {
611 consumeFlagValueWithTokens(cursor, token, known.param, known.negated)
612 }
613 continue
614 }
615
616 if (token._tag === "Value") {
617 return subIndex.has(token.value)
618 }
619 }
620 return false
621}
622
623/**
624 * Consumes global flags while walking the command tree.
625 *
626 * Command-local flags take precedence over global flags at the selected command
627 * level. This lets commands reuse a global flag name, for example a subcommand
628 * with its own `--version <value>` flag overriding the built-in global
629 * `--version` action.
630 * @internal
631 */
632export const consumeGlobalFlags = (
633 tokens: ReadonlyArray<Token>,
634 command: Command.Any,
635 registry: FlagRegistry
636): { flagMap: FlagMap; remainder: ReadonlyArray<Token>; errors: ReadonlyArray<CliError.InvalidValue> } => {
637 const flagMap = createEmptyFlagMap(registry.params)
638 const errors: Array<CliError.InvalidValue> = []
639
640 const consumeLevel = (
641 tokens: ReadonlyArray<Token>,
642 command: Command.Any,
643 ignoredRegistries: ReadonlyArray<FlagRegistry>
644 ): ReadonlyArray<Token> => {
645 const localRegistry = createFlagRegistry(extractFlagParams(command))
646 const inheritedRegistry = createFlagRegistry(extractContextFlagParams(command))
647 const subIndex = buildSubcommandIndex(command.subcommands)
648 const cursor = makeCursor(tokens)
649 const remainder: Array<Token> = []
650 let awaitingFirstValue = true
651
652 for (let token = cursor.take(); token; token = cursor.take()) {
653 if (isFlagToken(token)) {
654 const ignored = resolveFromRegistries(token, ignoredRegistries)
655 if (ignored !== undefined) {
656 preserveFlag(remainder, cursor, token, ignored)
657 continue
658 }
659
660 const inherited = resolveFlag(token, inheritedRegistry)
661 if (inherited !== undefined) {
662 preserveFlag(remainder, cursor, token, inherited)
663 continue
664 }
665
666 const local = resolveFlag(token, localRegistry)
667 const global = resolveFlag(token, registry)
668 if (local !== undefined) {
669 if (
670 global === undefined || !awaitingFirstValue ||
671 !localFlagWouldPrecedeSubcommand(token, cursor.rest(), local, subIndex, [
672 localRegistry,
673 inheritedRegistry,
674 registry
675 ])
676 ) {
677 preserveFlag(remainder, cursor, token, local)
678 continue
679 }
680 }
681
682 if (global !== undefined) {
683 const consumed = consumeFlagValueWithTokens(cursor, token, global.param, global.negated)
684 if (consumed._tag === "Error") {
685 errors.push(consumed.error)
686 continue
687 }
688 if (consumed.value !== undefined) {
689 flagMap[global.param.name].push(consumed.value)
690 }
691 continue
692 }
693
694 remainder.push(token)
695 continue
696 }
697
698 if (token._tag === "Value" && awaitingFirstValue) {
699 const sub = subIndex.get(token.value)
700 if (sub !== undefined) {
701 remainder.push(token)
702 remainder.push(...consumeLevel(cursor.rest(), sub, [...ignoredRegistries, inheritedRegistry]))
703 return remainder
704 }
705 awaitingFirstValue = false
706 }
707
708 remainder.push(token)
709 }
710
711 return remainder
712 }
713
714 return { flagMap, remainder: consumeLevel(tokens, command, []), errors }
715}
716
717/* ========================================================================== */
718/* Error Creation */
719/* ========================================================================== */
720
721const createUnrecognizedFlagError = (
722 token: FlagToken,
723 params: ReadonlyArray<FlagParam>,
724 commandPath: ReadonlyArray<string>
725): CliError.UnrecognizedOption => {
726 const printable = token._tag === "LongOption" ? `--${token.name}` : `-${token.flag}`
727 const validNames: Array<string> = []
728
729 for (const p of params) {
730 // Exclude hidden flags so a near-miss typo cannot reveal a flag name
731 // that was intentionally kept out of --help.
732 if (p.hidden) continue
733 validNames.push(p.name)
734 if (Primitive.isBoolean(p.primitiveType)) {
735 validNames.push(`no-${p.name}`)
736 }
737 for (const alias of p.aliases) {
738 validNames.push(alias)
739 }
740 }
741
742 const suggestions = suggest(getFlagName(token), validNames)
743 .map((n) => (n.length === 1 ? `-${n}` : `--${n}`))
744
745 return new CliError.UnrecognizedOption({
746 option: printable,
747 suggestions,
748 command: commandPath
749 })
750}
751
752/* ========================================================================== */
753/* Parse State */
754/* ========================================================================== */
755
756const createParseState = (registry: FlagRegistry): ParseState => ({
757 flags: createFlagAccumulator(registry.params),
758 arguments: [],
759 errors: [],
760 mode: { _tag: "AwaitingFirstValue" }
761})
762
763const toLeafResult = (state: ParseState): LeafResult => ({
764 _tag: "Leaf",
765 flags: state.flags.snapshot(),
766 arguments: state.arguments,
767 errors: state.errors
768})
769
770/* ========================================================================== */
771/* First Value Resolution */
772/* ========================================================================== */
773
774/**
775 * Determines how to handle the first value token.
776 *
777 * If it matches a known subcommand:
778 * - Collect inherited parent flags from remaining tokens (npm-style)
779 * - Return SubcommandResult with child tokens
780 *
781 * Otherwise:
782 * - Return Argument to treat it as a positional argument
783 * - Report error if command expects subcommand but got unknown value
784 */
785const resolveFirstValue = (
786 value: string,
787 cursor: TokenCursor,
788 context: CommandContext,
789 state: ParseState
790): FirstValueResult => {
791 const { command, commandPath, inheritedFlagRegistry, localFlagNames } = context
792 const subIndex = buildSubcommandIndex(command.subcommands)
793 const sub = subIndex.get(value)
794
795 if (sub) {
796 const selectedPath = [...commandPath, sub.name]
797
798 // Local flags are not inherited by subcommands.
799 const parentFlags = state.flags.snapshot()
800 for (const localFlagName of localFlagNames) {
801 const values = parentFlags[localFlagName]
802 if (values !== undefined && values.length > 0) {
803 state.errors.push(
804 new CliError.UnrecognizedOption({
805 option: `--${localFlagName}`,
806 suggestions: [],
807 command: selectedPath
808 })
809 )
810 }
811 }
812
813 // npm-style: inherited parent flags can appear after subcommand name
814 const tail = consumeKnownFlags(cursor.rest(), inheritedFlagRegistry)
815 state.flags.merge(tail.flagMap)
816 state.errors.push(...tail.errors)
817
818 return {
819 _tag: "Subcommand",
820 result: {
821 _tag: "Sub",
822 flags: state.flags.snapshot(),
823 sub,
824 childTokens: tail.remainder,
825 errors: state.errors
826 }
827 }
828 }
829
830 // Not a subcommand. Check if this looks like a typo.
831 const expectsArgs = toImpl(command).config.arguments.length > 0
832 if (!expectsArgs && subIndex.size > 0) {
833 // Exclude unlisted subcommands so a typo cannot reveal a subcommand name
834 // that was intentionally kept out of --help. Unlisted commands still
835 // resolve via subIndex when invoked by exact name.
836 const visibleKeys: Array<string> = []
837 for (const [key, sub] of subIndex) {
838 if (!sub.unlisted) visibleKeys.push(key)
839 }
840 const suggestions = suggest(value, visibleKeys)
841 state.errors.push(
842 new CliError.UnknownSubcommand({
843 subcommand: value,
844 parent: commandPath,
845 suggestions
846 })
847 )
848 }
849
850 return { _tag: "Argument" }
851}
852
853/* ========================================================================== */
854/* Token Processing */
855/* ========================================================================== */
856
857/**
858 * Processes a flag token: looks up in registry, consumes value, records it.
859 * Reports unrecognized flags as errors.
860 */
861const processFlag = (
862 token: FlagToken,
863 cursor: TokenCursor,
864 context: CommandContext,
865 state: ParseState
866): void => {
867 const { commandPath, flagRegistry } = context
868 const resolved = resolveFlag(token, flagRegistry)
869
870 if (!resolved) {
871 state.errors.push(createUnrecognizedFlagError(token, flagRegistry.params, commandPath))
872 return
873 }
874
875 const consumed = consumeFlagValue(cursor, token, resolved.param, resolved.negated)
876 if (consumed._tag === "Error") {
877 state.errors.push(consumed.error)
878 return
879 }
880
881 state.flags.add(resolved.param.name, consumed.value)
882}
883
884/**
885 * Processes a value token based on current parsing mode.
886 *
887 * In AwaitingFirstValue mode:
888 * - Check if value is a subcommand
889 * - If so, return SubcommandResult to exit scanning
890 * - If not, switch to CollectingArguments mode
891 *
892 * In CollectingArguments mode:
893 * - Simply add value to arguments list
894 */
895const processValue = (
896 value: string,
897 cursor: TokenCursor,
898 context: CommandContext,
899 state: ParseState
900): SubcommandResult | undefined => {
901 if (state.mode._tag === "AwaitingFirstValue") {
902 const result = resolveFirstValue(value, cursor, context, state)
903
904 if (result._tag === "Subcommand") {
905 return result.result
906 }
907
908 state.mode = { _tag: "CollectingArguments" }
909 }
910
911 state.arguments.push(value)
912 return undefined
913}
914
915/* ========================================================================== */
916/* Command Level Scanning */
917/* ========================================================================== */
918
919/**
920 * Scans a single command level, processing all tokens.
921 *
922 * For each token:
923 * - Flags: Look up, consume value, record in accumulator
924 * - Values: Check for subcommand (first value only), then collect as arguments
925 *
926 * Returns LeafResult if no subcommand detected, SubcommandResult otherwise.
927 */
928const scanCommandLevel = (
929 tokens: ReadonlyArray<Token>,
930 context: CommandContext
931): LevelResult => {
932 const cursor = makeCursor(tokens)
933 const state = createParseState(context.flagRegistry)
934
935 for (let token = cursor.take(); token; token = cursor.take()) {
936 if (isFlagToken(token)) {
937 processFlag(token, cursor, context, state)
938 continue
939 }
940
941 if (token._tag === "Value") {
942 const subResult = processValue(token.value, cursor, context, state)
943 if (subResult) return subResult
944 }
945 }
946
947 return toLeafResult(state)
948}