import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types' import { UnsupportedArgValueError } from './errors' export const VERBOSE_FLAG = 'verbose' export const VERBOSE_CHAR = 'v' export const Flags = { string: stringFlag, stringArray: stringRepeatedFlag, boolean: booleanFlag, integer: integerFlag, outputFormat: outputFormatFlag, } const GLOBAL_FLAGS: Record = { [VERBOSE_FLAG]: Flags.boolean({ char: VERBOSE_CHAR, description: 'enable verbose output', }), } function stringFlag( opts: Opts, ): FlagDefinition { return { type: 'string', multiple: false, ...opts, } } function outputFormatFlag( opts: Opts, ): FlagDefinition { return { type: 'string', description: `output format (${opts.options.join('|')})`, char: 'o', multiple: false, ...opts, } } function stringRepeatedFlag( opts: Opts, ): FlagDefinition { return { type: 'string', multiple: true, ...opts, } } function booleanFlag(opts: { description: string, char?: string, default?: boolean }): FlagDefinition { return { type: 'boolean', ...opts } } function integerFlag( opts: Opts, ): FlagDefinition { return { type: 'integer', ...opts } as FlagDefinition } function stringArg( opts: Opts, ): ArgDefinition { return opts as ArgDefinition } export const Args = { string: stringArg, } function coerceFlagValue(raw: string, def: FlagDefinition): string | boolean | number { switch (def.type) { case 'integer': { const n = Number(raw) if (Number.isNaN(n)) throw new Error(`expected integer, got ${JSON.stringify(raw)}`) return n } case 'boolean': { if (raw === 'true' || raw === '1') return true if (raw === 'false' || raw === '0') return false throw new Error(`expected boolean, got ${JSON.stringify(raw)}`) } default: return raw } } function accumulateFlagValue(flags: ParsedFlags, name: string, value: string | boolean | number, def: FlagDefinition): void { if (def.multiple === true) { const existing = flags[name] flags[name] = Array.isArray(existing) ? [...existing, String(value)] : [String(value)] } else { flags[name] = value } } function resolveByChar(char: string, flags: Record): [name: string, def: FlagDefinition] | undefined { for (const [name, def] of Object.entries(flags)) { if (def.char === char) return [name, def] } return undefined } function validateFlagOptions(name: string, raw: string, def: FlagDefinition): void { if (def.options !== undefined && !def.options.includes(raw)) throw new UnsupportedArgValueError(name, def, raw) } type ResolvedFlag = { name: string, def: FlagDefinition, label: string, inlineRaw: string | undefined } function resolveToken(token: string, flags: Record): ResolvedFlag | null { if (token.startsWith('--')) { const eqIdx = token.indexOf('=') const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2) const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined const def = flags[name] if (!def) throw new Error(`unknown flag: --${name}`) return { name, def, label: `--${name}`, inlineRaw } } if (token.length === 2 && token[1] !== undefined) { const char = token[1] const resolved = resolveByChar(char, flags) if (!resolved) throw new Error(`unknown flag: -${char}`) const [name, def] = resolved return { name, def, label: `-${char}`, inlineRaw: undefined } } return null } // Scans argv for a boolean flag without throwing on unknown tokens, so it is safe // to call before the command-specific flag set is known (e.g. global flags). export function hasBooleanFlag(argv: readonly string[], name: string, char?: string): boolean { for (const token of argv) { if (token === '--') break if (token === `--${name}` || token === `--${name}=true` || token === `--${name}=1`) return true if (char !== undefined && token === `-${char}`) return true } return false } export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } { const flags: ParsedFlags = {} const positional: string[] = [] const argDefs = Object.entries(meta.args) let pastDoubleDash = false for (let i = 0; i < argv.length; i++) { const token = argv[i] if (token === undefined) break if (!pastDoubleDash && token === '--') { pastDoubleDash = true continue } if (pastDoubleDash || !token.startsWith('-')) { positional.push(token) continue } const resolved = resolveToken(token, { ...meta.flags, ...GLOBAL_FLAGS, // pass global flags to prevent unknown flag error }) if (!resolved) { positional.push(token) continue } const { name, def, label, inlineRaw } = resolved if (def.type === 'boolean') { flags[name] = inlineRaw === undefined ? true : coerceFlagValue(inlineRaw, def) continue } let raw: string if (inlineRaw !== undefined) { raw = inlineRaw } else { i++ const next = i < argv.length ? argv[i] : undefined if (next === undefined || next.startsWith('-')) throw new Error(`flag ${label} expects a value`) raw = next } validateFlagOptions(name, raw, def) accumulateFlagValue(flags, name, coerceFlagValue(raw, def), def) } const args: ParsedArgs = {} for (let j = 0; j < argDefs.length; j++) { const entry = argDefs[j] if (!entry) continue const [argName, argDef] = entry if (j < positional.length) { args[argName] = positional[j] } else if (argDef.required) { throw new Error(`missing required argument: ${argName}`) } } for (const [name, def] of Object.entries(meta.flags)) { if (!(name in flags) && def.default !== undefined) flags[name] = def.default } return { args, flags } }