import { findSubcommand, optionsOf, type CommandSpec, type OptionSpec } from './command.ts'; import { UsageError } from './errors.ts'; import { configured, type SettingKey } from './config.ts'; // Turning argv into a command and its options. // // `node:util`'s parseArgs knows one flat set of options; a command tree needs // the set to change as the words name deeper commands, global options accepted // anywhere, and `--help` honoured wherever it appears, even on a line that // would otherwise not parse. So the walk is done here: // // * Leading words name subcommands for as long as they match one. // * Options may come before, between or after positionals. // * `--name value`, `--name=value`, `-n value`, `-nvalue`, and bundled short // booleans (`-hv`) all work. `--no-` turns a boolean off. // * `--` ends options: everything after it is positional, whatever it looks like. // * `--help`/`-h` anywhere before `--` asks for help on the deepest command // named, and nothing else on the line is checked. export type OptionValues = Record; export interface Parsed { /** The commands named, outermost first. `path[0]` is the root. */ path: CommandSpec[]; command: CommandSpec; options: OptionValues; positionals: string[]; help: boolean; } export function parse(root: CommandSpec, argv: readonly string[], settings: (key: SettingKey) => string | undefined = configured): Parsed { const path: CommandSpec[] = [root]; let command = root; const raw: Array<{ spec: OptionSpec; value: string | boolean; word: string }> = []; const positionals: string[] = []; const unknown: string[] = []; let help = false; let ended = false; /** Where in argv the deepest command was named, so a default subcommand can be put after it. */ let named = -1; const lookup = (name: string) => optionsOf(command).find((spec) => spec.name === name); const lookupShort = (letter: string) => optionsOf(command).find((spec) => spec.short === letter); for (let i = 0; i < argv.length; i++) { const word = argv[i]!; if (ended || word === '-' || !word.startsWith('-')) { // A word names a subcommand only while no positional has been taken. const sub = !ended && positionals.length === 0 ? findSubcommand(command, word) : undefined; if (sub) { command = sub; path.push(sub); named = i; } else if (!ended && positionals.length === 0 && !command.run && command.default && findSubcommand(command, command.default)) { // A group's default subcommand takes the arguments given to // the group: `profile bsky.app` is `profile show bsky.app`. command = findSubcommand(command, command.default)!; path.push(command); positionals.push(word); } else { positionals.push(word); } continue; } if (word === '--') { ended = true; continue; } if (word.startsWith('--')) { const eq = word.indexOf('='); const name = eq === -1 ? word.slice(2) : word.slice(2, eq); const inline = eq === -1 ? undefined : word.slice(eq + 1); let spec = lookup(name); let negated = false; if (!spec && name.startsWith('no-')) { const positive = lookup(name.slice(3)); if (positive?.type === 'boolean') { spec = positive; negated = true; } } if (!spec) { unknown.push(word); continue; } if (spec.type === 'boolean') { if (inline !== undefined) { const value = parseBoolean(inline); if (value === undefined) throw new UsageError(`--${name} takes no value (or true/false), not '${inline}'`, path); raw.push({ spec, value: negated ? !value : value, word }); } else { raw.push({ spec, value: !negated, word }); } if (spec.name === 'help' && !negated) help = true; continue; } if (negated) throw new UsageError(`--${name} is not a switch that can be turned off`, path); let value = inline; if (value === undefined) { if (i + 1 >= argv.length) { if (help) continue; // Help is checked for later on the line too, so look before failing. if (argv.slice(i + 1).some(isHelp)) { help = true; continue; } throw new UsageError(`--${name} needs a value${spec.value ? ` (${spec.value})` : ''}`, path); } // `--reply --help` is a request for help, not a reply to "--help". if (isHelp(argv[i + 1]!)) { help = true; i++; continue; } value = argv[++i]!; } raw.push({ spec, value, word }); continue; } // Short options: `-n 5`, `-n5`, `-hv`. for (let j = 1; j < word.length; j++) { const letter = word[j]!; const spec = lookupShort(letter); if (!spec) { unknown.push(`-${letter}`); break; } if (spec.type === 'boolean') { raw.push({ spec, value: true, word }); if (spec.name === 'help') help = true; continue; } let value = word.slice(j + 1); if (!value) { if (i + 1 >= argv.length) { if (help) break; throw new UsageError(`-${letter} (--${spec.name}) needs a value${spec.value ? ` (${spec.value})` : ''}`, path); } if (isHelp(argv[i + 1]!)) { help = true; i++; break; } value = argv[++i]!; } raw.push({ spec, value, word }); break; } } if (help) return { path, command, options: {}, positionals, help }; // A group given no subcommand runs its default, if it has one — with the // options meant for it. if (!command.run && command.default && positionals.length === 0 && findSubcommand(command, command.default)) { return parse(root, [...argv.slice(0, named + 1), command.default, ...argv.slice(named + 1)], settings); } if (unknown.length) { throw new UsageError(`${command === root ? '' : `'${path.slice(1).map((c) => c.name).join(' ')}' `}does not take ${unknown.join(', ')}`, path); } const options: OptionValues = {}; for (const { spec, value } of raw) { options[spec.name] = coerce(spec, value, options[spec.name], path); } for (const spec of optionsOf(command)) { if (options[spec.name] !== undefined) continue; const fromSetting = spec.setting ? settings(spec.setting) : undefined; if (fromSetting !== undefined && fromSetting !== '') { options[spec.name] = coerce(spec, spec.type === 'boolean' ? parseBoolean(fromSetting) ?? true : fromSetting, undefined, path); } else if (spec.default !== undefined) { options[spec.name] = spec.type === 'list' ? [String(spec.default)] : spec.default; } } checkArgs(command, positionals, path); return { path, command, options, positionals, help: false }; } function isHelp(word: string): boolean { return word === '--help' || word === '-h'; } function parseBoolean(text: string): boolean | undefined { if (/^(true|1|yes|on)$/i.test(text)) return true; if (/^(false|0|no|off)$/i.test(text)) return false; return undefined; } function coerce(spec: OptionSpec, value: string | boolean, previous: OptionValues[string], path: CommandSpec[]): OptionValues[string] { const flag = `--${spec.name}`; if (spec.type === 'boolean') return Boolean(value); const text = String(value); if (spec.choices && !spec.choices.includes(text)) { throw new UsageError(`${flag} must be one of ${spec.choices.join(', ')}, not '${text}'`, path); } if (spec.type === 'number') { const number = Number(text); if (!Number.isFinite(number) || text.trim() === '') throw new UsageError(`${flag} takes a number, not '${text}'`, path); return number; } if (spec.type === 'list') return [...(Array.isArray(previous) ? previous : []), text]; return text; } function checkArgs(command: CommandSpec, positionals: string[], path: CommandSpec[]): void { const name = path.slice(1).map((c) => c.name).join(' '); if (!command.run) { if (positionals.length) { throw new UsageError(`${name ? `'${name}' has no subcommand` : 'there is no command'} named '${positionals[0]}'`, path); } return; } const specs = command.args ?? []; const required = specs.filter((arg) => arg.required).length; const variadic = specs.some((arg) => arg.variadic); if (positionals.length < required) { const missing = specs.filter((arg) => arg.required).slice(positionals.length).map((arg) => `<${arg.name}>`); throw new UsageError(`'${name}' needs ${missing.join(' ')}`, path); } if (!variadic && positionals.length > specs.length) { throw new UsageError(`'${name}' takes ${specs.length ? `at most ${specs.length} argument${specs.length === 1 ? '' : 's'}` : 'no arguments'}, but was given '${positionals.slice(specs.length).join(' ')}'`, path); } } /** The positionals, keyed by argument name; a variadic argument gets the rest as an array. */ export function bindArgs(command: CommandSpec, positionals: string[]): Record { const bound: Record = {}; const specs = command.args ?? []; specs.forEach((spec, index) => { bound[spec.name] = spec.variadic ? positionals.slice(index) : positionals[index]; }); return bound; }