From fdbd18ada61f4e82b96ab0bb8ea01c0014170039 Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Fri, 5 Apr 2024 12:54:01 +0200 Subject: [PATCH] Use node parseArgs instead of Optionator --- .../client/src/repl/@types/optionator.d.ts | 18 +- packages/client/src/repl/args.mts | 210 +++++++++++++----- packages/client/src/repl/args.test.mts | 208 ++++++++++------- 3 files changed, 289 insertions(+), 147 deletions(-) diff --git a/packages/client/src/repl/@types/optionator.d.ts b/packages/client/src/repl/@types/optionator.d.ts index f9f750a5e..05f43d5eb 100644 --- a/packages/client/src/repl/@types/optionator.d.ts +++ b/packages/client/src/repl/@types/optionator.d.ts @@ -4,11 +4,11 @@ declare module 'optionator' { // eslint-disable-next-line module optionator { - interface IOptionatorHeading { + interface OptionatorHeading { heading: string; } - interface IOptionatorOption { + interface OptionatorOption { option: string; alias?: string | string[]; type: string; @@ -25,7 +25,7 @@ declare module 'optionator' { example?: string | string[]; } - interface IOptionatorHelpStyle { + interface OptionatorHelpStyle { aliasSeparator?: string; typeSeparator?: string; descriptionSeparator?: string; @@ -34,19 +34,19 @@ declare module 'optionator' { maxPadFactor?: number; } - interface IOptionatorArgs { + interface OptionatorArgs { prepend?: string; append?: string; - options: (IOptionatorHeading | IOptionatorOption)[]; - helpStyle?: IOptionatorHelpStyle; + options: (OptionatorHeading | OptionatorOption)[]; + helpStyle?: OptionatorHelpStyle; mutuallyExclusive?: (string | string[])[]; positionalAnywhere?: boolean; typeAliases?: object; - defaults?: Partial; + defaults?: Partial; stdout?: NodeJS.WritableStream; } - interface IOptionator { + interface Optionator { parse(input: string | string[] | object, parseOptions?: { slice?: number }): any; parseArgv(input: string[]): any; generateHelp(helpOptions?: { showHidden?: boolean; interpolate?: any }): string; @@ -54,7 +54,7 @@ declare module 'optionator' { } } - function optionator(args: optionator.IOptionatorArgs): optionator.IOptionator; + function optionator(args: optionator.OptionatorArgs): optionator.Optionator; export = optionator; } diff --git a/packages/client/src/repl/args.mts b/packages/client/src/repl/args.mts index e9eb700a9..f0fb3ead2 100644 --- a/packages/client/src/repl/args.mts +++ b/packages/client/src/repl/args.mts @@ -1,7 +1,13 @@ +import type { ParseArgsConfig } from 'node:util'; +import { parseArgs } from 'node:util'; + import assert from 'assert'; import { splitIntoLines } from './textUtils.mjs'; +type NodeParsedResults = ReturnType; +type ParsedToken = Exclude[number]; + const defaultWidth = 80; export class Command { @@ -33,9 +39,59 @@ export class Command { + const tokenizer = createTokenizer(this); + assert(argv[0] == this.name, `Command name mismatch: ${argv[0]} != ${this.name}`); + const tokens = tokenizer(argv.slice(1)); + + console.log('tokens: %o', tokens); + + const positionals: string[] = []; + const args = { _: positionals } as ArgDefsToArgs; + const shadowArgs: Record = args; + const options = {} as OptDefsToOpts; + const shadowOpts: Record = options; + + const argDefs = this.arguments; + let i = 0; + for (const token of tokens) { + switch (token.kind) { + case 'option': + { + const opt = this.#options.find((o) => o.name == token.name); + if (!opt) { + throw new Error(`Unknown option: ${token.name}`); + } + const name = opt.name; + const value = castValueToType(token.value, opt.baseType); + shadowOpts[name] = opt.multiple ? append(options[name], value) : value; + } + break; + case 'positional': + { + positionals.push(token.value); + if (i >= argDefs.length) { + throw new Error(`Unexpected argument: ${token.value}`); + } + const arg = argDefs[i]; + const value = token.value; + shadowArgs[arg.name] = arg.multiple ? append(args[arg.name], value) : value; + i += arg.multiple ? 0 : 1; + } + break; + case 'option-terminator': + break; + } + } + + return { args, options, argv }; + } + + async exec(argv: string[]) { assert(this.#handler, 'handler not set'); assert(argv[0] == this.name); + const parsedArgs = this.parse(argv); + await this.#handler(parsedArgs); } getArgString() { @@ -127,6 +183,10 @@ export class Application { } return lines.join('\n'); } + + getCommand(cmdName: string): Command | undefined { + return this.#commands.get(cmdName); + } } function commandHelpLine(cmd: Command) { @@ -134,7 +194,7 @@ function commandHelpLine(cmd: Command) { return { cmd: cmd.name, args: argLine, description: cmd.description }; } -class Argument implements Required> { +class Argument implements Required> { readonly multiple: boolean; readonly description: string; readonly type: V; @@ -156,14 +216,14 @@ class Argument imple } } -class Option implements Required> { +class Option implements Required> { readonly multiple: boolean; readonly description: string; readonly type: V; readonly short: string; readonly param: string; readonly required: boolean; - readonly baseType: TypeBaseNames; + readonly baseType: OptionTypeBaseNames; readonly variadic: boolean; constructor( @@ -178,6 +238,10 @@ class Option impleme this.baseType = typeNameToBaseTypeName(this.type); this.multiple = this.type.endsWith('[]'); this.variadic = def.variadic || false; + + if (this.variadic && !this.multiple) { + throw new Error('variadic option must be multiple'); + } } toString() { @@ -192,7 +256,7 @@ class Option impleme } } -interface OptionDef { +interface OptionDef { /** * The description of the option */ @@ -221,7 +285,7 @@ interface OptionDef { readonly variadic?: boolean | undefined; } -interface ArgDef { +interface ArgDef { /** * The description of the option */ @@ -239,23 +303,27 @@ interface ArgDef { readonly required?: boolean | undefined; } -interface ArgTypeDefBase { +interface OptionTypeDefBase { boolean: boolean; string: string; number: number; } -interface ArgTypeDefs extends ArgTypeDefBase { +interface OptionTypeDefs extends OptionTypeDefBase { 'boolean[]': boolean[]; 'string[]': string[]; 'number[]': number[]; } -export type TypeBaseNames = keyof ArgTypeDefBase; -export type TypeNames = keyof ArgTypeDefs; -export type ArgTypes = ArgTypeDefs[TypeNames]; +export type OptionTypeBaseNames = keyof OptionTypeDefBase; +export type OptionTypeNames = keyof OptionTypeDefs; + +type ArgTypeDefs = Pick; -export type TypeNameToType = T extends 'boolean' +export type ArgTypeNames = keyof ArgTypeDefs; +export type ArgTypes = ArgTypeDefs[ArgTypeNames]; + +export type TypeNameToType = T extends 'boolean' ? boolean : T extends 'string' ? string @@ -269,10 +337,13 @@ export type TypeNameToType = T extends 'boolean' ? number[] : never; -export type DefToType> = { +export type DefToType> = { [K in keyof T]: TypeNameToType; }; +type SpecificOptionTypes = OptionTypeDefs[K]; +type OptionTypes = SpecificOptionTypes; + export type TypeToTypeName = T extends boolean ? 'boolean' : T extends string @@ -288,40 +359,30 @@ export type TypeToTypeName = T extends boolean : never; export type ArgsDefinitions = { - [k in string]: ArgDef; + [k in string]: ArgDef; }; type ArgDefsToArgs = { - [k in keyof T]: TypeNameToType; -}; + [k in keyof T]?: TypeNameToType; +} & { _: string[] }; export type OptionDefinitions = { - [k in string]: OptionDef; + [k in string]: OptionDef; }; type OptDefsToOpts = { [k in keyof T]: TypeNameToType; }; -type HandlerFn = (parsedArgs: { +type ParsedResults = { args: ArgDefsToArgs; options: OptDefsToOpts; argv: string[]; -}) => Promise | void; - -const cmd2 = new Command( - 'test', - 'test command', - { globs: { description: 'globs', type: 'string[]' } }, - { verbose: { description: 'verbose', type: 'boolean', short: 'v' } }, -); - -cmd2.handler(({ args, options }) => { - const globs = args.globs; - globs.forEach((g) => console.log(g)); - console.log(args.globs); - console.log(options.verbose); -}); +}; + +type HandlerFn = ( + parsedArgs: ParsedResults, +) => Promise | void; function formatTwoColumns(columns: readonly (readonly [string, string])[], width: number, sep = ' ') { const lines = []; @@ -338,31 +399,72 @@ function formatTwoColumns(columns: readonly (readonly [string, string])[], width return lines.join('\n'); } -// function commandToParseArgsConfig(command: Command): ParseArgsConfig { -// const options: Exclude = {}; -// const config: ParseArgsConfig = { -// options, -// allowPositionals: true, -// tokens: true, -// }; - -// for (const opt of command.options) { -// options[opt.name] = { -// type: opt.baseType !== 'boolean' ? 'string' : 'boolean', -// multiple: opt.multiple, -// short: opt.short || undefined, -// }; -// } -// return config; -// } - function typeNameToBaseTypeName(type: 'boolean'): 'boolean'; function typeNameToBaseTypeName(type: 'number'): 'number'; function typeNameToBaseTypeName(type: 'string'): 'string'; function typeNameToBaseTypeName(type: 'boolean[]'): 'boolean'; function typeNameToBaseTypeName(type: 'number[]'): 'number'; function typeNameToBaseTypeName(type: 'string[]'): 'string'; -function typeNameToBaseTypeName(type: TypeNames): TypeBaseNames; -function typeNameToBaseTypeName(type: TypeNames): TypeBaseNames { - return type.replace('[]', '') as TypeBaseNames; +function typeNameToBaseTypeName(type: OptionTypeNames): OptionTypeBaseNames; +function typeNameToBaseTypeName(type: OptionTypeNames): OptionTypeBaseNames { + return type.replace('[]', '') as OptionTypeBaseNames; +} + +function append(...values: (T | T[] | undefined)[]): T[] { + return values.flatMap((a) => a).filter((v): v is T => v !== undefined); +} + +function createTokenizer( + command: Command, +): (args: string[]) => ParsedToken[] { + const options: ParseArgsConfig['options'] = {}; + + for (const opt of command.options) { + options[opt.name] = { + type: opt.baseType !== 'boolean' ? 'string' : 'boolean', + multiple: opt.multiple, + }; + if (opt.short) { + options[opt.name].short = opt.short; + } + } + + return (args: string[]) => { + const result = parseArgs({ args, options, allowPositionals: true, tokens: true, strict: false }); + const tokens = result.tokens || []; + return tokens; + }; +} + +export function castValueToType(value: unknown, type: T): SpecificOptionTypes; +export function castValueToType(value: unknown, type: OptionTypeBaseNames): SpecificOptionTypes; +export function castValueToType(value: unknown, type: OptionTypeBaseNames): SpecificOptionTypes { + switch (type) { + case 'boolean': + return toBoolean(value ?? true); + case 'number': + return toNumber(value); + case 'string': + return typeof value == 'string' ? value : `${value}`; + } +} + +export function toBoolean(value: unknown): boolean; +export function toBoolean(value: unknown | undefined): boolean | undefined; +export function toBoolean(value: unknown | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value == 'boolean') return value; + if (typeof value == 'number') return !!value; + if (typeof value == 'string') { + const v = value.toLowerCase().trim(); + if (['true', 't', 'yes', 'y', '1', 'ok'].includes(v)) return true; + if (['false', 'f', 'no', 'n', '0', ''].includes(v)) return false; + } + throw new Error(`Invalid boolean value: ${value}`); +} + +export function toNumber(value: unknown): number { + const num = Number(value); + if (!Number.isNaN(num)) return num; + throw new Error(`Invalid number value: ${value}`); } diff --git a/packages/client/src/repl/args.test.mts b/packages/client/src/repl/args.test.mts index bc106ab6f..7655bffc1 100644 --- a/packages/client/src/repl/args.test.mts +++ b/packages/client/src/repl/args.test.mts @@ -1,16 +1,13 @@ +import assert from 'node:assert'; import type { ParseArgsConfig } from 'node:util'; import { parseArgs } from 'node:util'; -import createOptionParser from 'optionator'; -import { describe, expect, test, vi } from 'vitest'; -import { EventEmitter } from 'vscode'; +import { describe, expect, test } from 'vitest'; -import { Application, Command } from './args.mjs'; -import { emitterToWriteStream } from './emitterToWriteStream.mjs'; +import { Application, castValueToType, Command, toBoolean } from './args.mjs'; +import { parseCommandLineIntoArgs } from './parseCommandLine.js'; import { unindent } from './textUtils.mjs'; -vi.mock('vscode'); - const ac = expect.arrayContaining; const tokens = ac([]); @@ -19,6 +16,9 @@ const T = true; const r = unindent; +/* + * Test our parseArgs assumptions. + */ describe('parseArgs', () => { test.each` args | expected @@ -42,97 +42,39 @@ describe('parseArgs', () => { // console.log('%o', result); expect(result).toEqual(expected); }); -}); - -// cspell:words optionator - -describe('optionator', () => { - const config1: createOptionParser.IOptionatorArgs = { - prepend: 'Usage: test [options] [target]', - append: 'Version 1.0.0', - options: [ - { heading: 'Options' }, - { option: 'verbose', alias: 'v', type: 'Boolean', description: 'Show extra details' }, - { option: 'upper', alias: 'u', type: 'Boolean', description: 'Show in uppercase' }, - { option: 'lower', alias: 'l', type: 'Boolean', description: 'Show in lowercase' }, - { option: 'pad-left', type: 'Number', description: 'Pad the left side', default: '0' }, - { option: 'pad-right', type: 'Number', description: 'Pad the right side', default: '0' }, - { option: 'help', alias: 'h', type: 'Boolean', description: 'Show help' }, - ], - positionalAnywhere: true, - }; - - test('generateHelp', () => { - const emitter = new EventEmitter(); - const outputFn = vi.fn(); - emitter.event(outputFn); - const stdout = emitterToWriteStream(emitter); - const optionator = createOptionParser({ ...config1, stdout }); - - expect(optionator.generateHelp()).toBe( - unindent(`\ - Usage: test [options] [target] - - Options: - -v, --verbose Show extra details - -u, --upper Show in uppercase - -l, --lower Show in lowercase - --pad-left Number Pad the left side - default: 0 - --pad-right Number Pad the right side - default: 0 - -h, --help Show help - - Version 1.0.0`), - ); - - expect(outputFn).not.toHaveBeenCalled(); - }); test.each` - args | expected - ${['-v', '--pad-left', '3', 'foo']} | ${{ _: ['foo'], verbose: true, padLeft: 3, padRight: 0 }} - ${'one two three'} | ${{ _: ['one', 'two', 'three'], padLeft: 0, padRight: 0 }} - ${'show --no-upper'} | ${{ _: ['show'], padLeft: 0, padRight: 0, upper: false }} - ${'show --upper=false'} | ${{ _: ['show'], padLeft: 0, padRight: 0, upper: false }} - ${'show -u -- again'} | ${{ _: ['show', 'again'], padLeft: 0, padRight: 0, upper: true }} - ${'show -u -- again'.split(' ')} | ${{ _: ['show', 'again'], padLeft: 0, padRight: 0, upper: true }} - `('parse $args', ({ args, expected }) => { - const emitter = new EventEmitter(); - const outputFn = vi.fn(); - emitter.event(outputFn); - const stdout = emitterToWriteStream(emitter); - const optionator = createOptionParser({ ...config1, stdout }); - - const result = optionator.parse(args, { slice: 0 }); + args | expected + ${['-a', 'red', '-C', '8', '-vvv', '--verbose', '--verbose=false']} | ${{ positionals: ['red'], values: { apple: true, code: '8', verbose: [T, T, T, T, 'false'] }, tokens }} + ${['-a', 'red', '-C', '8', '-vvv', '--verbose', '-v=false']} | ${{ positionals: ['red'], values: { apple: true, code: '8', fruit: ['alse'], verbose: [T, T, T, T, T], '=': T }, tokens }} + `('pareArgs $args', ({ args, expected }) => { + const options: ParseArgsConfig['options'] = { + apple: { type: 'boolean', short: 'a' }, + banana: { type: 'boolean', short: 'b' }, + cherry: { type: 'boolean', short: 'c' }, + code: { type: 'string', short: 'C' }, + verbose: { type: 'boolean', short: 'v', multiple: true }, + fruit: { type: 'string', short: 'f', multiple: true }, + }; + const result = parseArgs({ args, options, allowPositionals: true, tokens: true, strict: false }); + console.log('%o', result); expect(result).toEqual(expected); - expect(outputFn).not.toHaveBeenCalled(); - }); - - test.each` - args | expected - ${'--no-show'} | ${"Invalid option '--show' - perhaps you meant '-h'?"} - `('parse fail $args', ({ args, expected }) => { - const emitter = new EventEmitter(); - const outputFn = vi.fn(); - emitter.event(outputFn); - const stdout = emitterToWriteStream(emitter); - const optionator = createOptionParser({ ...config1, stdout }); - - expect(() => optionator.parse(args, { slice: 0 })).toThrow(expected); - expect(outputFn).not.toHaveBeenCalled(); }); }); describe('Application', () => { + const anyArgs = ac([]); const cmdFoo = new Command( 'foo', 'Display some foo.', { - count: { type: 'number', required: true, description: 'Amount of foo to display' }, + count: { type: 'string', required: true, description: 'Amount of foo to display' }, names: { type: 'string[]', description: 'Optional names to display.' }, }, { verbose: { type: 'boolean', short: 'v', description: 'Show extra details' }, upper: { type: 'boolean', short: 'u', description: 'Show in uppercase' }, + repeat: { type: 'number', short: 'r', description: 'Repeat the message' }, }, ); @@ -200,6 +142,26 @@ describe('Application', () => { ); }); + test.each` + cmd | expected + ${'bar hello --loud'} | ${{ argv: anyArgs, args: { _: ['hello'], message: 'hello' }, options: { loud: true } }} + ${'bar -l none'} | ${{ argv: anyArgs, args: { _: ['none'], message: 'none' }, options: { loud: true } }} + ${'foo 5 one two -r 2'} | ${{ argv: anyArgs, args: { _: ['5', 'one', 'two'], count: '5', names: ['one', 'two'] }, options: { repeat: 2 } }} + ${'foo 5 one two -r 2 -r7'} | ${{ argv: anyArgs, args: { _: ['5', 'one', 'two'], count: '5', names: ['one', 'two'] }, options: { repeat: 7 } }} + ${'foo 42 --repeat=7'} | ${{ argv: anyArgs, args: { _: ['42'], count: '42' }, options: { repeat: 7 } }} + ${'complex a b c d -v -v -v -v'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: { verbose: true } }} + ${'complex a b c d'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: {} }} + ${'complex a b c --verbose=false d'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: { verbose: false } }} + `('Parse Command $cmd', ({ cmd: commandLine, expected }) => { + const commands = [cmdFoo, cmdBar, cmdComplex, cmdHelp]; + const app = new Application('test', 'Test Application.').addCommands(commands); + const argv = parseCommandLineIntoArgs(commandLine); + const command = app.getCommand(argv[0]); + assert(command); + const args = command.parse(argv); + expect(args).toEqual(expected); + }); + test('Command Help', () => { const commands = [cmdFoo, cmdBar, cmdComplex, cmdHelp]; const app = new Application('test', 'Test Application.').addCommands(commands); @@ -215,8 +177,9 @@ describe('Application', () => { [names...] Optional names to display. Options: - -v, --verbose Show extra details - -u, --upper Show in uppercase`), + -v, --verbose Show extra details + -u, --upper Show in uppercase + -r, --repeat Repeat the message`), ); expect(app.getHelp('bar')).toBe( @@ -241,5 +204,82 @@ describe('Application', () => { Arguments: [command] Show Help for command.`), ); + + expect(app.getHelp('complex')).toBe( + r(`\ + Usage: complex [options] [many...] + + This is a command with unnecessary complexity and options. + Even the description is long and verbose. with a lot of words and new lines. + - one: Argument one. + - two: Argument two. + + + Arguments: + Argument one. + Argument two. + [many...] The rest of the arguments. + + Options: + -v, --verbose Show extra details + -u, --upper Show in uppercase + -l, --lower Show in lowercase + --pad-left Pad the left side + --pad-right Pad the right side`), + ); + }); +}); + +describe('conversions', () => { + test.each` + value | expected + ${true} | ${true} + ${false} | ${false} + ${1} | ${true} + ${0} | ${false} + ${NaN} | ${false} + ${'True'} | ${true} + ${'False'} | ${false} + ${'T'} | ${true} + ${'F'} | ${false} + ${'1'} | ${true} + ${'0'} | ${false} + ${'yes'} | ${true} + ${'no'} | ${false} + ${undefined} | ${undefined} + `('toBoolean $value', ({ value, expected }) => { + expect(toBoolean(value)).toBe(expected); + }); + + test.each` + value | expected + ${'sunny'} | ${'Invalid boolean value: sunny'} + `('toBoolean $value with error', ({ value, expected }) => { + expect(() => toBoolean(value)).toThrow(expected); + }); + + test.each` + value | optType | expected + ${true} | ${'boolean'} | ${true} + ${true} | ${'string'} | ${'true'} + ${42} | ${'string'} | ${'42'} + ${{ a: 'b' }} | ${'string'} | ${'[object Object]'} + ${NaN} | ${'string'} | ${'NaN'} + ${true} | ${'number'} | ${1} + ${42} | ${'number'} | ${42} + ${'42'} | ${'number'} | ${42} + ${'0x10'} | ${'number'} | ${16} + ${'010'} | ${'number'} | ${10} + `('castValueToType $value $optType', ({ value, optType, expected }) => { + expect(castValueToType(value, optType)).toBe(expected); + }); + + test.each` + value | optType | expected + ${'42b'} | ${'number'} | ${'Invalid number value: 42b'} + ${'42b'} | ${'boolean'} | ${'Invalid boolean value: 42b'} + ${{}} | ${'boolean'} | ${'Invalid boolean value: [object Object]'} + `('castValueToType $value $optType to error', ({ value, optType, expected }) => { + expect(() => castValueToType(value, optType)).toThrow(expected); }); });