From 7fca53bdbfe6f55ff8c122cfc7ceb609605053d2 Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sun, 3 Sep 2023 15:01:52 +0100 Subject: [PATCH 1/4] feat: improve arguments --- .envrc | 3 +- src/builder/argument.ts | 49 ++++++++++++++++--- src/builder/default-arguments.ts | 6 ++- src/internal/parse/coerce.ts | 14 +++--- src/internal/parse/schematic-validation.ts | 10 ++-- src/opts.ts | 48 +++++++++++-------- test/integrations/simple.test.ts | 55 +++++++--------------- test/util.test.ts | 2 +- 8 files changed, 108 insertions(+), 79 deletions(-) diff --git a/.envrc b/.envrc index 8392d15..e4898cb 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ -use flake \ No newline at end of file +use flake +layout node \ No newline at end of file diff --git a/src/builder/argument.ts b/src/builder/argument.ts index 945e6ea..bfd3be4 100644 --- a/src/builder/argument.ts +++ b/src/builder/argument.ts @@ -1,4 +1,5 @@ import { CoercedValue } from '../internal/parse/types' +import { ArgumentOpts, defaultArgumentOpts } from '../opts' interface CoercionResultOk { ok: true @@ -16,32 +17,36 @@ export type CoercionResult = CoercionResultOk | CoercionResultErr export type ArgumentType = string interface ArgumentState { - specifiedDefault: T | undefined + resolveDefault: (specificity: 'specified' | 'unspecified') => T | undefined dependencies: string[] requiredUnlessPresent: string[] conflicts: string[] - unspecifiedDefault: T | undefined description: string | undefined optional: boolean isMultiType: boolean exclusive: boolean otherParsers: Array> + opts: ArgumentOpts } +/** + * @internal + */ export type MinimalArgument = Pick, '_state' | 'coerce' | 'type' | 'negate'> export abstract class Argument { protected _specifiedDefault: T | undefined = undefined + protected _unspecifiedDefault: T | undefined = undefined protected _dependencies: string[] = [] protected _conflicts: string[] = [] protected _requiredUnlessPresent: string[] = [] - protected _unspecifiedDefault: T | undefined = undefined protected _description: string | undefined protected _optional: boolean = false protected _isMultiType: boolean = false protected _exclusive: boolean = false protected _otherParsers: Array> = [] protected _negated: boolean = false + protected _opts: ArgumentOpts = { ...defaultArgumentOpts } // Internal getter to avoid cluttering completion with ^ our private fields that need to be accessed by other internal APIs // Conveniently also means we encapsulate our data, so it cannot be easily tampered with by consumers @@ -50,16 +55,16 @@ export abstract class Argument { */ get _state (): ArgumentState { return { - specifiedDefault: this._specifiedDefault, + resolveDefault: this.resolveDefault.bind(this), dependencies: this._dependencies, - unspecifiedDefault: this._unspecifiedDefault, requiredUnlessPresent: this._requiredUnlessPresent, description: this._description, conflicts: this._conflicts, optional: this._optional, isMultiType: this._isMultiType, exclusive: this._exclusive, - otherParsers: this._otherParsers + otherParsers: this._otherParsers, + opts: this._opts } } @@ -83,6 +88,14 @@ export abstract class Argument { } } + protected resolveDefault (specificity: 'specified' | 'unspecified'): T | undefined { + if (specificity === 'specified') { + return this._specifiedDefault + } + + return this._unspecifiedDefault + } + /** * Try to coerce a string value into the (`T`) type of this Argument. * @@ -102,6 +115,30 @@ export abstract class Argument { return this } + /** + * Sets a single option on this argument. + * This does not copy the provided value. + * @param key - The key to set + * @param value - The value to set the key to + * @returns this + */ + public opt (key: K, value: ArgumentOpts[K]): Argument { + this._opts[key] = value + return this + } + + /** + * Configures a new options object for this argument. + * This will not deep copy the provided object, which may be mutated + * by subsequent calls on this object. + * @param newOpts - The new options to set + * @returns this + */ + public opts (newOpts: ArgumentOpts): Argument { + this._opts = newOpts + return this + } + /** * Inverts the negation status for this argument. Negation is handled independently (or not at all) * by each argument type as it coerces a value. diff --git a/src/builder/default-arguments.ts b/src/builder/default-arguments.ts index 50e98c5..3b30fec 100644 --- a/src/builder/default-arguments.ts +++ b/src/builder/default-arguments.ts @@ -204,8 +204,10 @@ class BooleanArgument extends Argument { } class EnumArgument extends Argument { - constructor (private readonly validValues: T) { + private readonly validValues: T + constructor (...validValues: T) { super(validValues.join(' | ')) + this.validValues = validValues } async coerce (value: string): Promise> { @@ -244,6 +246,6 @@ export const custom = (...args: ConstructorParameters(...args) } -export const oneOf = (...args: ConstructorParameters>): EnumArgument => { +export const oneOf = (...args: T): EnumArgument => { return new EnumArgument(...args) } diff --git a/src/internal/parse/coerce.ts b/src/internal/parse/coerce.ts index a838fa4..366be6c 100644 --- a/src/internal/parse/coerce.ts +++ b/src/internal/parse/coerce.ts @@ -121,7 +121,7 @@ async function resolveArgumentDefault ( value: { isMulti: false, raw: ``, - coerced: argument.inner._state.unspecifiedDefault + coerced: argument.inner._state.resolveDefault('unspecified') } }) } @@ -146,7 +146,7 @@ async function resolveArgumentDefault ( value: { isMulti: false, raw: ` { - const { arrayMultipleDefinitions, tooManyDefinitions } = opts +function handleAdditionalArgumentDefinition (argument: InternalArgument): Result<'overwrite' | 'skip' | 'append', CoercionError> { + const { arrayMultipleDefinitions, tooManyDefinitions } = argument.inner._state.opts if (argument.inner._state.isMultiType) { if (arrayMultipleDefinitions === 'append') { return Ok('append') @@ -298,7 +298,7 @@ export async function coerce ( // Multiple definitions found, let's see what we should do with them if (Array.isArray(userArgument) && userArgument.length > 1) { - const multipleBehaviourResult = handleAdditionalArgumentDefinition(argument, opts) + const multipleBehaviourResult = handleAdditionalArgumentDefinition(argument) if (!multipleBehaviourResult.ok) { return multipleBehaviourResult } @@ -336,7 +336,7 @@ export async function coerce ( // User passed more than one argument, and this is not a multi type if (!argument.inner._state.isMultiType && inputValues.length > 1) { // Throw if appropriate, slice off the other arguments if not (acts as a skip) - const { tooManyArgs } = opts + const { tooManyArgs } = argument.inner._state.opts if (tooManyArgs === 'throw') { const pretty = inputValues.slice(1).map(s => `'${s}'`).join(', ') return Err([new CoercionError(argument.inner.type, inputValues.join(' '), `excess argument(s) to ${getArgDenotion(argument)}: ${pretty}`, getArgDenotion(argument))]) diff --git a/src/internal/parse/schematic-validation.ts b/src/internal/parse/schematic-validation.ts index c1ba01b..188fb10 100644 --- a/src/internal/parse/schematic-validation.ts +++ b/src/internal/parse/schematic-validation.ts @@ -24,7 +24,7 @@ export function validateFlagSchematically ( flags: Map, argument: InternalFlagArgument, opts: StoredParserOpts, - resolveres: Resolver[] + resolvers: Resolver[] ): Result { let foundFlags = flags.get(argument.longFlag) if (argument.aliases.length && !foundFlags) { @@ -37,12 +37,13 @@ export function validateFlagSchematically ( } } - let { specifiedDefault, unspecifiedDefault, optional, dependencies, conflicts, exclusive, requiredUnlessPresent } = argument.inner._state + let { resolveDefault, optional, dependencies, conflicts, exclusive, requiredUnlessPresent } = argument.inner._state + const [specifiedDefault, unspecifiedDefault] = [resolveDefault('specified'), resolveDefault('unspecified')] // Test our resolvers to see if any of them have a value, so we know whether to reject below let resolversHaveValue = false - for (const resolver of resolveres) { + for (const resolver of resolvers) { if (resolver.keyExists(argument.longFlag, opts)) { resolversHaveValue = true } @@ -102,7 +103,8 @@ export function validatePositionalSchematically ( middlewares: Resolver[] ): Result { const foundFlag = positionals.get(argument.index) - const { unspecifiedDefault, optional } = argument.inner._state + const { resolveDefault, optional } = argument.inner._state + const unspecifiedDefault = resolveDefault('unspecified') // Test our middlewares to see if any of them have a value, so we know whether to reject below let middlewaresHaveValue = false diff --git a/src/opts.ts b/src/opts.ts index a98208c..2ebf2d4 100644 --- a/src/opts.ts +++ b/src/opts.ts @@ -28,20 +28,6 @@ export interface StoredParserOpts { * What to do when an unrecognised command (not defined in the schema) is passed into the user input. */ unrecognisedCommand: 'into-positional' | 'throw' - /** - * What to do when many arguments are passed to a type that only expects a single argument - */ - tooManyArgs: 'drop' | 'throw' - /** - * What to do when an argument is specified multiple times. This is only for *non* array arguments. - * @see arrayMultipleDefinitions for array argument behaviour - */ - tooManyDefinitions: 'drop' | 'throw' | 'overwrite' - /** - * What to do when an argument is specified multiple times. This is only for array arguments. - * @see arrayMultipleDefinitions for single argument behaviour - */ - arrayMultipleDefinitions: 'append' | 'drop' | 'throw' | 'overwrite' /** * Whether to enable the "rest" syntax: * When enabled, the values are collected @@ -128,7 +114,6 @@ export interface StoredParserOpts { export const defaultParserOpts = { unrecognisedArgument: 'throw', unrecognisedCommand: 'into-positional', - tooManyArgs: 'throw', deprecatedCommands: 'error', restSyntax: 'collect', shortFlagGroups: true, @@ -136,8 +121,6 @@ export const defaultParserOpts = { environmentPrefix: undefined, mustProvideCommand: true, negatedBooleanPrefix: 'no-', - tooManyDefinitions: 'throw', - arrayMultipleDefinitions: 'append', logger: new Logger('default'), resolvers: [ new EnvironmentResolver('env') @@ -145,7 +128,7 @@ export const defaultParserOpts = { } as const satisfies Partial /** - * @see StoredParserOpts for documentation + * @see {@link StoredParserOpts} for documentation */ export type ParserOpts = MakePassedOpts @@ -185,7 +168,7 @@ export const defaultCommandOpts = { // Must override `parserOpts` in `StoredCommandOpts` so users can pass their single value around /** - * @see StoredCommandOpts for documentation + * @see {@link StoredCommandOpts} for documentation */ export type CommandOpts = ( & Omit @@ -194,3 +177,30 @@ export type CommandOpts = ( parserOpts: ParserOpts } ) + +export interface ArgumentOpts { + /** + * What to do when many arguments are passed to a type that only expects a single argument + */ + tooManyArgs: 'drop' | 'throw' + /** + * What to do when an argument is specified multiple times. This is only for *non* array arguments. + * @see arrayMultipleDefinitions for array argument behaviour + */ + tooManyDefinitions: 'drop' | 'throw' | 'overwrite' + /** + * What to do when an argument is specified multiple times. This is only for array arguments. + * @see arrayMultipleDefinitions for single argument behaviour + */ + arrayMultipleDefinitions: 'append' | 'drop' | 'throw' | 'overwrite' +} + +/** + * The default argument options to use. Set as the default when an {@link Argument} is constructed. + * Subject to change. These are opinionated defaults. +*/ +export const defaultArgumentOpts = { + tooManyArgs: 'throw', + tooManyDefinitions: 'throw', + arrayMultipleDefinitions: 'append' +} as const satisfies Partial diff --git a/test/integrations/simple.test.ts b/test/integrations/simple.test.ts index cc8a1d4..4929d41 100644 --- a/test/integrations/simple.test.ts +++ b/test/integrations/simple.test.ts @@ -65,7 +65,6 @@ describe('Flag integrations', () => { const result = await runArgsExecution(parser, ['--boolean', 'false']) expect(result.boolean).toBe(false) }) - it('can parse long-flag quoted strings', async () => { const parser = new Args(parserOpts) .arg(['--string', '-s'], a.string()) @@ -380,11 +379,8 @@ could not parse a 'boolean' because 'xyz' is not a boolean, expected 'boolean' r }) it('skips when excess values are passed to an argument', async () => { - const parser = new Args({ - ...parserOpts, - tooManyArgs: 'drop' - }) - .arg(['--string', '-s'], a.string()) + const parser = new Args(parserOpts) + .arg(['--string', '-s'], a.string().opt('tooManyArgs', 'drop')) const result = await runArgsExecution(parser, '-s one two') expect(result.string).toBe('one') @@ -523,33 +519,23 @@ describe('Logical argument testing', () => { }) it('drops multiple definitions', async () => { - const parser = new Args({ - ...parserOpts, - tooManyDefinitions: 'drop' - }) - .arg(['--array'], a.string()) + const parser = new Args(parserOpts) + .arg(['--array'], a.string().opt('tooManyDefinitions', 'drop')) const result = await runArgsExecution(parser, '--array value1 --array value2') expect(result.array).toStrictEqual('value1') }) it('overwrites multiple definitions', async () => { - const parser = new Args({ - ...parserOpts, - tooManyDefinitions: 'overwrite' - }) - .arg(['--array'], a.string()) - + const parser = new Args(parserOpts) + .arg(['--array'], a.string().opt('tooManyDefinitions', 'overwrite')) const result = await runArgsExecution(parser, '--array value1 --array value2') expect(result.array).toStrictEqual('value2') }) it('throws when multiple definitions', async () => { - const parser = new Args({ - ...parserOpts, - tooManyDefinitions: 'throw' - }) - .arg(['--array'], a.string()) + const parser = new Args(parserOpts) + .arg(['--array'], a.string().opt('tooManyDefinitions', 'throw')) const result = expect(async () => await runArgsExecution(parser, '--array value1 --array value2')) await result.rejects.toMatchInlineSnapshot(`[Error: argument --array' is not permitted to have multiple definitions, expected 'single definition' received 'multiple definitions' @ --array]`) @@ -675,7 +661,7 @@ describe('Logical argument testing', () => { it('fails when an invalid enum value is provided', async () => { const parser = new Args(parserOpts) - .arg(['--enum'], a.oneOf(['x', 'y', 'z'] as const)) + .arg(['--enum'], a.oneOf('x', 'y', 'z')) const result = expect(async () => await runArgsExecution(parser, '--enum a')) await result.rejects.toMatchInlineSnapshot(`[Error: could not parse a 'x | y | z' because value must be one of 'x, y, z' got 'a', expected 'x | y | z' received 'a' @ --enum]`) @@ -683,7 +669,7 @@ describe('Logical argument testing', () => { it('passes when a valid enum value is provided', async () => { const parser = new Args(parserOpts) - .arg(['--enum'], a.oneOf(['x', 'y', 'z'] as const)) + .arg(['--enum'], a.oneOf('x', 'y', 'z')) const result = await runArgsExecution(parser, '--enum x') expect(result.enum).toEqual('x') @@ -738,33 +724,24 @@ describe('Array testing', () => { }) it('drops to arrays with multiple definitions', async () => { - const parser = new Args({ - ...parserOpts, - arrayMultipleDefinitions: 'drop' - }) - .arg(['--array'], a.string().array()) + const parser = new Args(parserOpts) + .arg(['--array'], a.string().array().opt('arrayMultipleDefinitions', 'drop')) const result = await runArgsExecution(parser, '--array value1 --array value2') expect(result.array).toStrictEqual(['value1']) }) it('overwrites to arrays with multiple definitions', async () => { - const parser = new Args({ - ...parserOpts, - arrayMultipleDefinitions: 'overwrite' - }) - .arg(['--array'], a.string().array()) + const parser = new Args(parserOpts) + .arg(['--array'], a.string().array().opt('arrayMultipleDefinitions', 'overwrite')) const result = await runArgsExecution(parser, '--array value1 --array value2') expect(result.array).toStrictEqual(['value2']) }) it('throws when multiple array definitions', async () => { - const parser = new Args({ - ...parserOpts, - arrayMultipleDefinitions: 'throw' - }) - .arg(['--array'], a.string().array()) + const parser = new Args(parserOpts) + .arg(['--array'], a.string().array().opt('arrayMultipleDefinitions', 'throw')) const result = expect(async () => await runArgsExecution(parser, '--array value1 --array value2')) await result.rejects.toMatchInlineSnapshot(`[Error: argument --array' is not permitted to have multiple definitions, expected 'single definition' received 'multiple definitions' @ --array]`) diff --git a/test/util.test.ts b/test/util.test.ts index 35a19fe..d4166ff 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -124,7 +124,7 @@ describe('Help generation utils', () => { .arg(['--flag', '-f'], a.string().optional()) .arg(['--opt-multi', '-o'], a.string().array().optional()) .arg(['--opt-req', '-r'], a.string().array()) - .arg(['--enum', '-e'], a.oneOf(['a', 'b', 'c'])) + .arg(['--enum', '-e'], a.oneOf('a', 'b', 'c')) .arg(['--long'], a.number()) .arg(['--long-optional'], a.number().optional()) .positional('', a.string()) From 18b368b0c4a4ae8a8d21e67263cd922f3e92ce9c Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sun, 3 Sep 2023 15:03:09 +0100 Subject: [PATCH 2/4] fix: change `rest` to `--` --- src/args.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/args.ts b/src/args.ts index 3dc34f2..3dcf104 100644 --- a/src/args.ts +++ b/src/args.ts @@ -30,7 +30,7 @@ interface ParsedArgs { type ParseSuccess = FoundCommand | ReturnedCommand | ParsedArgs export interface DefaultArgTypes { [k: string]: CoercedValue - rest?: string + ['--']?: string } export interface ArgsState { @@ -302,7 +302,7 @@ export class Args { } })) as TArgTypes if (rest) { - out.rest = rest + out['--'] = rest } return out From 0f0407c081524ecf9a3b34855cfb8eb874ee3faf Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sun, 3 Sep 2023 15:31:36 +0100 Subject: [PATCH 3/4] feat: support prompting --- examples/07-prompting/package-lock.json | 97 ++++++++++++++++++++++ examples/07-prompting/package.json | 17 ++++ examples/07-prompting/src/index.ts | 55 ++++++++++++ examples/07-prompting/tsconfig.json | 72 ++++++++++++++++ flake.nix | 2 +- src/builder/argument.ts | 4 +- src/builder/default-resolvers.ts | 4 +- src/builder/resolver.ts | 4 +- src/internal/parse/coerce.ts | 20 ++--- src/internal/parse/schematic-validation.ts | 28 +++---- test/integrations/simple.test.ts | 6 +- 11 files changed, 275 insertions(+), 34 deletions(-) create mode 100644 examples/07-prompting/package-lock.json create mode 100644 examples/07-prompting/package.json create mode 100644 examples/07-prompting/src/index.ts create mode 100644 examples/07-prompting/tsconfig.json diff --git a/examples/07-prompting/package-lock.json b/examples/07-prompting/package-lock.json new file mode 100644 index 0000000..f774203 --- /dev/null +++ b/examples/07-prompting/package-lock.json @@ -0,0 +1,97 @@ +{ + "name": "06-builtin-commands", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "06-builtin-commands", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "args.ts": "file:../..", + "typescript": "^5.1.6" + } + }, + "../..": { + "name": "args.ts", + "version": "1.1.1", + "license": "OSL-3.0", + "devDependencies": { + "@tsd/typescript": "^5.2.2", + "@types/jest": "^29.0.3", + "@types/node": "^18.7.18", + "@typescript-eslint/eslint-plugin": "^5.37.0", + "@typescript-eslint/parser": "^5.37.0", + "concurrently": "^7.4.0", + "eslint": "^8.23.1", + "eslint-config-standard-with-typescript": "^23.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.2.5", + "eslint-plugin-promise": "^6.0.1", + "eslint-plugin-tsdoc": "^0.2.17", + "husky": "^8.0.1", + "jest": "^29.0.3", + "jest-runner-tsd": "^6.0.0", + "lint-staged": "^13.0.3", + "nodemon": "^2.0.20", + "ts-cleaner": "^1.0.5", + "ts-jest": "^29.0.1", + "typedoc": "^0.23.15", + "typescript": "^5.1.6" + }, + "engines": { + "node": "18.x" + } + }, + "node_modules/args.ts": { + "resolved": "../..", + "link": true + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + }, + "dependencies": { + "args.ts": { + "version": "file:../..", + "requires": { + "@tsd/typescript": "^5.2.2", + "@types/jest": "^29.0.3", + "@types/node": "^18.7.18", + "@typescript-eslint/eslint-plugin": "^5.37.0", + "@typescript-eslint/parser": "^5.37.0", + "concurrently": "^7.4.0", + "eslint": "^8.23.1", + "eslint-config-standard-with-typescript": "^23.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.2.5", + "eslint-plugin-promise": "^6.0.1", + "eslint-plugin-tsdoc": "^0.2.17", + "husky": "^8.0.1", + "jest": "^29.0.3", + "jest-runner-tsd": "^6.0.0", + "lint-staged": "^13.0.3", + "nodemon": "^2.0.20", + "ts-cleaner": "^1.0.5", + "ts-jest": "^29.0.1", + "typedoc": "^0.23.15", + "typescript": "^5.1.6" + } + }, + "typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==" + } + } +} diff --git a/examples/07-prompting/package.json b/examples/07-prompting/package.json new file mode 100644 index 0000000..e48c98e --- /dev/null +++ b/examples/07-prompting/package.json @@ -0,0 +1,17 @@ +{ + "name": "07-prompting", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "npm run build && node lib/index.js", + "build": "tsc" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "args.ts": "file:../..", + "typescript": "^5.1.6" + } +} diff --git a/examples/07-prompting/src/index.ts b/examples/07-prompting/src/index.ts new file mode 100644 index 0000000..e89bca2 --- /dev/null +++ b/examples/07-prompting/src/index.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import { Args, ParserOpts, Resolver, a, util } from 'args.ts' +import readline from 'readline/promises' + +export const parserOpts: ParserOpts = { + programName: '07-prompting', + programDescription: 'description', + programVersion: 'v1' +} + +// Provide a custom resolver for the username key. +// This does have the downside that it will *always* try and resolve the key +// whether the user provides the flag or not. +// +// If this distinction matters, use an Argument and override the `resolveDefault` method +// to control the behaviour dependant on specificity +class UsernamePromptResolver extends Resolver { + private readonly rl: readline.Interface + constructor (id: string) { + super(id) + + this.rl = readline.createInterface({ + input: process.stdin, output: process.stdout + }) + } + + async keyExists (key: string): Promise { + // We only care about resolving our username argument + return key === 'username' + } + + async resolveKey (): Promise { + const res = await this.rl.question('Enter username: ') + this.rl.close() + return res + } +} + +async function main (): Promise { + const parser = new Args(parserOpts) + .arg(['--username'], a.string()) + .resolver(new UsernamePromptResolver('username')) + + const result = await parser.parse(util.makeArgs()) + + if (result.mode !== 'args') { + console.error('Did not get args back') + return + } + + console.log('Username:', result.args.username) +} + +main().catch(console.error) diff --git a/examples/07-prompting/tsconfig.json b/examples/07-prompting/tsconfig.json new file mode 100644 index 0000000..f9cb42a --- /dev/null +++ b/examples/07-prompting/tsconfig.json @@ -0,0 +1,72 @@ +{ + "exclude": [ + "node_modules", + "test", + "lib" + ], + "compilerOptions": { + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "ESNEXT", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + "lib": ["ESNext"], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "lib", /* Redirect output structure to the directory. */ + "rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } + } + \ No newline at end of file diff --git a/flake.nix b/flake.nix index 7168a81..433f84d 100644 --- a/flake.nix +++ b/flake.nix @@ -11,7 +11,7 @@ in pkgs.mkShell { packages = with pkgs; [ - nodejs-16_x + nodejs-18_x nodePackages.npm ]; }; diff --git a/src/builder/argument.ts b/src/builder/argument.ts index bfd3be4..95b243a 100644 --- a/src/builder/argument.ts +++ b/src/builder/argument.ts @@ -17,7 +17,7 @@ export type CoercionResult = CoercionResultOk | CoercionResultErr export type ArgumentType = string interface ArgumentState { - resolveDefault: (specificity: 'specified' | 'unspecified') => T | undefined + resolveDefault: (specificity: 'specified' | 'unspecified') => Promise dependencies: string[] requiredUnlessPresent: string[] conflicts: string[] @@ -88,7 +88,7 @@ export abstract class Argument { } } - protected resolveDefault (specificity: 'specified' | 'unspecified'): T | undefined { + protected async resolveDefault (specificity: 'specified' | 'unspecified'): Promise { if (specificity === 'specified') { return this._specifiedDefault } diff --git a/src/builder/default-resolvers.ts b/src/builder/default-resolvers.ts index 0ba915a..e80ee7e 100644 --- a/src/builder/default-resolvers.ts +++ b/src/builder/default-resolvers.ts @@ -3,13 +3,13 @@ import { StoredParserOpts } from '../opts' import { Resolver } from './resolver' export class EnvironmentResolver extends Resolver { - keyExists (key: string, opts: StoredParserOpts): boolean { + async keyExists (key: string, opts: StoredParserOpts): Promise { const envKey = `${opts.environmentPrefix}_${key.toUpperCase()}` const platform = currentPlatform() return platform.getEnv(envKey) !== undefined } - resolveKey (key: string, opts: StoredParserOpts): string { + async resolveKey (key: string, opts: StoredParserOpts): Promise { const envKey = `${opts.environmentPrefix}_${key.toUpperCase()}` const platform = currentPlatform() const value = platform.getEnv(envKey) diff --git a/src/builder/resolver.ts b/src/builder/resolver.ts index 3c0a17e..559ca7a 100644 --- a/src/builder/resolver.ts +++ b/src/builder/resolver.ts @@ -15,7 +15,7 @@ export abstract class Resolver { * @param key - The key to check * @param opts - The parser opts */ - abstract keyExists (key: string, opts: StoredParserOpts): boolean + abstract keyExists (key: string, opts: StoredParserOpts): Promise /** * Resolve the provided key to its string value. * @@ -23,5 +23,5 @@ export abstract class Resolver { * @param key - The key to resolve * @param opts - The parser opts */ - abstract resolveKey (key: string, opts: StoredParserOpts): string + abstract resolveKey (key: string, opts: StoredParserOpts): Promise } diff --git a/src/internal/parse/coerce.ts b/src/internal/parse/coerce.ts index 366be6c..b66a733 100644 --- a/src/internal/parse/coerce.ts +++ b/src/internal/parse/coerce.ts @@ -83,15 +83,15 @@ async function resolveArgumentDefault ( userArguments: ParsedPositionalArgument | AnyParsedFlagArgument[] | undefined, argument: InternalArgument, opts: StoredParserOpts, - middlewares: Resolver[] + resolvers: Resolver[] ): Promise> { // Only attempt middleware resolution if the user args are not set if (!userArguments) { const key = argument.type === 'flag' ? argument.longFlag : argument.key - for (const middleware of middlewares) { - if (middleware.keyExists(key, opts)) { - const value = middleware.resolveKey(key, opts) + for (const resolver of resolvers) { + if (await resolver.keyExists(key, opts)) { + const value = await resolver.resolveKey(key, opts) if (!value) { continue @@ -106,7 +106,7 @@ async function resolveArgumentDefault ( isDefault: true, value: { isMulti: false, - raw: ``, + raw: ``, coerced: coercionResult.val.coerced } }) @@ -121,7 +121,7 @@ async function resolveArgumentDefault ( value: { isMulti: false, raw: ``, - coerced: argument.inner._state.resolveDefault('unspecified') + coerced: await argument.inner._state.resolveDefault('unspecified') } }) } @@ -146,7 +146,7 @@ async function resolveArgumentDefault ( value: { isMulti: false, raw: `, argument: InternalFlagArgument, opts: StoredParserOpts, resolvers: Resolver[] -): Result { +): Promise> { let foundFlags = flags.get(argument.longFlag) if (argument.aliases.length && !foundFlags) { for (const alias of argument.aliases) { @@ -38,13 +38,13 @@ export function validateFlagSchematically ( } let { resolveDefault, optional, dependencies, conflicts, exclusive, requiredUnlessPresent } = argument.inner._state - const [specifiedDefault, unspecifiedDefault] = [resolveDefault('specified'), resolveDefault('unspecified')] + const [specifiedDefault, unspecifiedDefault] = await Promise.all([resolveDefault('specified'), resolveDefault('unspecified')]) // Test our resolvers to see if any of them have a value, so we know whether to reject below let resolversHaveValue = false for (const resolver of resolvers) { - if (resolver.keyExists(argument.longFlag, opts)) { + if (await resolver.keyExists(argument.longFlag, opts)) { resolversHaveValue = true } } @@ -96,26 +96,26 @@ export function validateFlagSchematically ( return Ok(foundFlags) } -export function validatePositionalSchematically ( +export async function validatePositionalSchematically ( positionals: Map, argument: InternalPositionalArgument, opts: StoredParserOpts, - middlewares: Resolver[] -): Result { + resolvers: Resolver[] +): Promise> { const foundFlag = positionals.get(argument.index) const { resolveDefault, optional } = argument.inner._state - const unspecifiedDefault = resolveDefault('unspecified') + const unspecifiedDefault = await resolveDefault('unspecified') - // Test our middlewares to see if any of them have a value, so we know whether to reject below - let middlewaresHaveValue = false + // Test our resolvers to see if any of them have a value, so we know whether to reject below + let resolversHaveValue = false - for (const middleware of middlewares) { - if (middleware.keyExists(argument.key, opts)) { - middlewaresHaveValue = true + for (const middleware of resolvers) { + if (await middleware.keyExists(argument.key, opts)) { + resolversHaveValue = true } } - if (!optional && unspecifiedDefault === undefined && !foundFlag?.values && !middlewaresHaveValue) { + if (!optional && unspecifiedDefault === undefined && !foundFlag?.values && !resolversHaveValue) { return Err(new CoercionError(argument.inner.type, '', `positional argument '${argument.key}' is not declared as optional, does not have a default, and was not provided a value`, argument.key)) } diff --git a/test/integrations/simple.test.ts b/test/integrations/simple.test.ts index 4929d41..d4ff8da 100644 --- a/test/integrations/simple.test.ts +++ b/test/integrations/simple.test.ts @@ -244,7 +244,7 @@ describe('Rest arguments', () => { const result = await runArgsExecution(parser, 'true true -- false true') expect(result.boolean).toEqual([true, true]) - expect(result.rest).toEqual('false true') + expect(result['--']).toEqual('false true') }) it('can parse rest arguments on flag based parsers', async () => { @@ -253,7 +253,7 @@ describe('Rest arguments', () => { const result = await runArgsExecution(parser, '--flag true -- false true') expect(result.flag).toEqual(true) - expect(result.rest).toEqual('false true') + expect(result['--']).toEqual('false true') }) it('can parse rest arguments on mixed parsers', async () => { @@ -264,7 +264,7 @@ describe('Rest arguments', () => { const result = await runArgsExecution(parser, 'true false true --flag true -- false true') expect(result.flag).toEqual(true) expect(result.boolean).toEqual([true, false, true]) - expect(result.rest).toEqual('false true') + expect(result['--']).toEqual('false true') }) it('errors if the rest syntax is not enabled', async () => { From aacdf16bc312a4d79bd3741ea24f21d5f12668ee Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sat, 9 Sep 2023 19:12:15 +0100 Subject: [PATCH 4/4] feat: async resolution; better logging support in commands --- examples/01-basic-flags/package.json | 2 +- examples/02-error-handling/package.json | 2 +- examples/03-simple-commands/package.json | 2 +- examples/04-package-manager/package.json | 2 +- examples/05-application-config/package.json | 2 +- examples/05-application-config/src/index.ts | 4 ++-- examples/06-builtin-commands/package.json | 2 +- examples/07-prompting/package.json | 2 +- examples/07-prompting/src/index.ts | 10 ++-------- src/args.ts | 8 ++++---- src/builder/builtin.ts | 19 ++++++++++++++++--- src/builder/command.ts | 5 ++++- src/builder/default-resolvers.ts | 2 +- src/builder/resolver.ts | 3 ++- src/internal/parse/coerce.ts | 2 +- src/internal/parse/schematic-validation.ts | 6 ++++-- src/internal/util.ts | 4 ++-- src/util/help.ts | 8 ++++---- src/util/logging.ts | 13 ++++++++----- test/integrations/resolver.test.ts | 12 ++++++------ test/parsing/utils.ts | 13 +++++++------ test/schema/validation.test.ts | 8 ++++---- test/util.test.ts | 2 +- 23 files changed, 75 insertions(+), 58 deletions(-) diff --git a/examples/01-basic-flags/package.json b/examples/01-basic-flags/package.json index f90fff6..7d78c5b 100644 --- a/examples/01-basic-flags/package.json +++ b/examples/01-basic-flags/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "npm run build && node lib/index.js", + "start": "node --enable-source-maps lib/index.js", "build": "tsc" }, "keywords": [], diff --git a/examples/02-error-handling/package.json b/examples/02-error-handling/package.json index 9771a41..4f2ea31 100644 --- a/examples/02-error-handling/package.json +++ b/examples/02-error-handling/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "npm run build && node lib/index.js", + "start": "node --enable-source-maps lib/index.js", "build": "tsc" }, "keywords": [], diff --git a/examples/03-simple-commands/package.json b/examples/03-simple-commands/package.json index ebd4a96..399a17d 100644 --- a/examples/03-simple-commands/package.json +++ b/examples/03-simple-commands/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "npm run build && node lib/index.js", + "start": "node --enable-source-maps lib/index.js", "build": "tsc" }, "keywords": [], diff --git a/examples/04-package-manager/package.json b/examples/04-package-manager/package.json index ca31683..3a67355 100644 --- a/examples/04-package-manager/package.json +++ b/examples/04-package-manager/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "npm run build && node lib/index.js", + "start": "node --enable-source-maps lib/index.js", "build": "tsc" }, "keywords": [], diff --git a/examples/05-application-config/package.json b/examples/05-application-config/package.json index 75c806f..8a322b3 100644 --- a/examples/05-application-config/package.json +++ b/examples/05-application-config/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "npm run build && node lib/index.js", + "start": "node --enable-source-maps lib/index.js", "build": "tsc" }, "keywords": [], diff --git a/examples/05-application-config/src/index.ts b/examples/05-application-config/src/index.ts index 3488625..cda7335 100644 --- a/examples/05-application-config/src/index.ts +++ b/examples/05-application-config/src/index.ts @@ -20,11 +20,11 @@ class UserConfigResolver extends Resolver { return this } - keyExists (key: string): boolean { + async keyExists (key: string): Promise { return this.data[key] !== undefined } - resolveKey (key: string): string { + async resolveKey (key: string): Promise { const value = this.data[key] if (value === undefined) { diff --git a/examples/06-builtin-commands/package.json b/examples/06-builtin-commands/package.json index 4ab779f..3057b32 100644 --- a/examples/06-builtin-commands/package.json +++ b/examples/06-builtin-commands/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "npm run build && node lib/index.js", + "start": "node --enable-source-maps lib/index.js", "build": "tsc" }, "keywords": [], diff --git a/examples/07-prompting/package.json b/examples/07-prompting/package.json index e48c98e..a557da1 100644 --- a/examples/07-prompting/package.json +++ b/examples/07-prompting/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "npm run build && node lib/index.js", + "start": "node --enable-source-maps lib/index.js", "build": "tsc" }, "keywords": [], diff --git a/examples/07-prompting/src/index.ts b/examples/07-prompting/src/index.ts index e89bca2..d505d8e 100644 --- a/examples/07-prompting/src/index.ts +++ b/examples/07-prompting/src/index.ts @@ -9,12 +9,6 @@ export const parserOpts: ParserOpts = { programVersion: 'v1' } -// Provide a custom resolver for the username key. -// This does have the downside that it will *always* try and resolve the key -// whether the user provides the flag or not. -// -// If this distinction matters, use an Argument and override the `resolveDefault` method -// to control the behaviour dependant on specificity class UsernamePromptResolver extends Resolver { private readonly rl: readline.Interface constructor (id: string) { @@ -25,9 +19,9 @@ class UsernamePromptResolver extends Resolver { }) } - async keyExists (key: string): Promise { + async keyExists (key: string, userDidPassArg: boolean): Promise { // We only care about resolving our username argument - return key === 'username' + return key === 'username' && userDidPassArg } async resolveKey (): Promise { diff --git a/src/args.ts b/src/args.ts index 3dcf104..4705a7f 100644 --- a/src/args.ts +++ b/src/args.ts @@ -116,9 +116,9 @@ export class Args { * @param inherit - Whether to inherit arguments from this configuration into the parser * @returns this */ - public command ( - [name, ...aliases]: [`${TName}`, ...string[]], - command: TCommand, + public command ( + [name, ...aliases]: [string, ...string[]], + command: Command, inherit = false ): Args { if (this._state.commands.has(name)) { @@ -409,7 +409,7 @@ export class Args { * @returns The result of the parse */ public async parseToResult (argString: string | string[], executeCommands = false): Promise, ParseError | CoercionError[] | CommandError>> { - this.opts.logger.debug(`Beginning parse of input '${argString}'`) + this.opts.logger.internal(`Beginning parse of input '${argString}'`) const tokenResult = tokenise(Array.isArray(argString) ? argString.join(' ') : argString) diff --git a/src/builder/builtin.ts b/src/builder/builtin.ts index 70e8fc6..65f4d92 100644 --- a/src/builder/builtin.ts +++ b/src/builder/builtin.ts @@ -58,8 +58,21 @@ export abstract class Builtin { * @returns The generated help string */ public helpInfo (): string { - return `${this.commandTriggers.map(cmd => `${cmd} <...args>`).join(', ')} | ${this.argumentTriggers.map(arg => `--${arg}`).join(', ')}` + const commands = this.commandTriggers.map(cmd => `${cmd} <...args>`).join(', ') + const args = this.argumentTriggers.map(arg => `--${arg}`).join(', ') + + if (commands && args) { + return `${commands} | ${args}` + } + + if (commands) { + return commands + } + + if (args) { + return args + } + + return `${this.constructor.name} | no triggers` } } - -export type BuiltinType = 'help' | 'completion' | 'version' | 'fallback' diff --git a/src/builder/command.ts b/src/builder/command.ts index 7aeba97..3b78af4 100644 --- a/src/builder/command.ts +++ b/src/builder/command.ts @@ -2,13 +2,14 @@ import { Args, DefaultArgTypes } from '../args' import { CommandError } from '../error' import { InternalCommand } from '../internal/parse/types' import { CommandOpts, StoredCommandOpts, defaultCommandOpts, defaultParserOpts } from '../opts' -import { ArgType } from '../util' +import { ArgType, Logger } from '../util' /** * Base class for all commands, including subcommands. Any user implemented command must extend from this class. */ export abstract class Command { public readonly opts: StoredCommandOpts + protected readonly log: Logger constructor ( opts: CommandOpts @@ -21,6 +22,8 @@ export abstract class Command { ...opts.parserOpts } } + + this.log = this.opts.parserOpts.logger } /** diff --git a/src/builder/default-resolvers.ts b/src/builder/default-resolvers.ts index e80ee7e..2568e63 100644 --- a/src/builder/default-resolvers.ts +++ b/src/builder/default-resolvers.ts @@ -3,7 +3,7 @@ import { StoredParserOpts } from '../opts' import { Resolver } from './resolver' export class EnvironmentResolver extends Resolver { - async keyExists (key: string, opts: StoredParserOpts): Promise { + async keyExists (key: string, _: boolean, opts: StoredParserOpts): Promise { const envKey = `${opts.environmentPrefix}_${key.toUpperCase()}` const platform = currentPlatform() return platform.getEnv(envKey) !== undefined diff --git a/src/builder/resolver.ts b/src/builder/resolver.ts index 559ca7a..87fd1d3 100644 --- a/src/builder/resolver.ts +++ b/src/builder/resolver.ts @@ -13,9 +13,10 @@ export abstract class Resolver { /** * Determine whether this resolver can resolve the provided key. * @param key - The key to check + * @param userDidPassArg - Whether the user provided an argument or not * @param opts - The parser opts */ - abstract keyExists (key: string, opts: StoredParserOpts): Promise + abstract keyExists (key: string, userDidPassArg: boolean, opts: StoredParserOpts): Promise /** * Resolve the provided key to its string value. * diff --git a/src/internal/parse/coerce.ts b/src/internal/parse/coerce.ts index b66a733..24c6ea6 100644 --- a/src/internal/parse/coerce.ts +++ b/src/internal/parse/coerce.ts @@ -90,7 +90,7 @@ async function resolveArgumentDefault ( const key = argument.type === 'flag' ? argument.longFlag : argument.key for (const resolver of resolvers) { - if (await resolver.keyExists(key, opts)) { + if (await resolver.keyExists(key, false, opts)) { const value = await resolver.resolveKey(key, opts) if (!value) { diff --git a/src/internal/parse/schematic-validation.ts b/src/internal/parse/schematic-validation.ts index ad9889f..d6884fe 100644 --- a/src/internal/parse/schematic-validation.ts +++ b/src/internal/parse/schematic-validation.ts @@ -37,6 +37,8 @@ export async function validateFlagSchematically ( } } + const userDidProvideArgs = (foundFlags ?? []).length > 0 + let { resolveDefault, optional, dependencies, conflicts, exclusive, requiredUnlessPresent } = argument.inner._state const [specifiedDefault, unspecifiedDefault] = await Promise.all([resolveDefault('specified'), resolveDefault('unspecified')]) @@ -44,7 +46,7 @@ export async function validateFlagSchematically ( let resolversHaveValue = false for (const resolver of resolvers) { - if (await resolver.keyExists(argument.longFlag, opts)) { + if (await resolver.keyExists(argument.longFlag, userDidProvideArgs, opts)) { resolversHaveValue = true } } @@ -110,7 +112,7 @@ export async function validatePositionalSchematically ( let resolversHaveValue = false for (const middleware of resolvers) { - if (await middleware.keyExists(argument.key, opts)) { + if (await middleware.keyExists(argument.key, foundFlag !== undefined, opts)) { resolversHaveValue = true } } diff --git a/src/internal/util.ts b/src/internal/util.ts index e742a06..581e715 100644 --- a/src/internal/util.ts +++ b/src/internal/util.ts @@ -17,11 +17,11 @@ export function getAliasDenotion (alias: FlagAlias): string { } } -const flagValidationRegex = /-+(?:[a-z]+)/ +const flagValidationRegex = /-+(?:[a-zA-Z]+)/ export function internaliseFlagString (flag: string): ['long' | 'short', string] { if (!flagValidationRegex.test(flag)) { - throw new SchemaError(`flags must match '--abcdef...' or '-abcdef' got '${flag}'`) + throw new SchemaError(`flags must match '--abcdefABCDEF' or '-abcdefABCDEF' got '${flag}'`) } // Long flag diff --git a/src/util/help.ts b/src/util/help.ts index 70043b2..f113a91 100644 --- a/src/util/help.ts +++ b/src/util/help.ts @@ -21,9 +21,9 @@ export function generateHelp (parser: Args<{}>): string { if (value.aliases.length) { if (isMultiType) { - return `[--${value.longFlag}${value.aliases.map(getAliasDenotion).join(' | ')}<${value.inner.type}...>]` + return `[--${value.longFlag} | ${value.aliases.map(getAliasDenotion).join(' | ')} <${value.inner.type}...>]` } - return `[--${value.longFlag}${value.aliases.map(getAliasDenotion).join(' | ')}<${value.inner.type}>]` + return `[--${value.longFlag} | ${value.aliases.map(getAliasDenotion).join(' | ')} <${value.inner.type}>]` } return `[--${value.longFlag} <${value.inner.type}>]` } else { @@ -36,9 +36,9 @@ export function generateHelp (parser: Args<{}>): string { if (value.aliases.length) { if (isMultiType) { - return `(--${value.longFlag}${value.aliases.map(getAliasDenotion).join(' | ')}<${value.inner.type}...>)` + return `(--${value.longFlag} | ${value.aliases.map(getAliasDenotion).join(' | ')} <${value.inner.type}...>)` } - return `(--${value.longFlag}${value.aliases.map(getAliasDenotion).join(' | ')}<${value.inner.type}>)` + return `(--${value.longFlag} | ${value.aliases.map(getAliasDenotion).join(' | ')} <${value.inner.type}>)` } return `(--${value.longFlag} <${value.inner.type}>)` diff --git a/src/util/logging.ts b/src/util/logging.ts index 619bec1..40c5f74 100644 --- a/src/util/logging.ts +++ b/src/util/logging.ts @@ -2,6 +2,7 @@ interface Stringifiable { toString: () => string } type LoggingFunction = (...args: Stringifiable[]) => T const LEVEL_TO_CONSOLE: Record (...args: unknown[]) => void> = { + internal: () => console.trace, trace: () => console.trace, debug: () => console.debug, info: () => console.log, @@ -11,7 +12,8 @@ const LEVEL_TO_CONSOLE: Record (...args: unknown[]) => void> = { } const LEVEL_TO_NUMBER: Record = { - trace: 0, + internal: 0, + trace: 1, debug: 10, info: 20, warn: 30, @@ -22,13 +24,14 @@ const LEVEL_TO_NUMBER: Record = { /** * The levels which a {@link Logger} can operate at. */ -export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' +export type LogLevel = 'internal' | 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' /** * The logging class used internally to (configurably) inform users about library behaviour. * This is a thin wrapper around the {@link console}, and should generally be set to something above 'info' in production. */ export class Logger { + internal = this.makeLevelFunc('internal', false) trace = this.makeLevelFunc('trace', false) debug = this.makeLevelFunc('debug', false) info = this.makeLevelFunc('info', false) @@ -51,12 +54,12 @@ export class Logger { const ourLevel = LEVEL_TO_NUMBER[this.level] const targetLevel = LEVEL_TO_NUMBER[level] - if (ourLevel >= targetLevel) { + if (ourLevel > targetLevel) { return } - const fn = LEVEL_TO_CONSOLE[this.level]() - fn(`[${this.name}]`, new Date().toISOString(), ':', ...args) + const fn = LEVEL_TO_CONSOLE[level]() + fn(`[${level.toUpperCase()}]`.padEnd(7), `[${this.name}]`, new Date().toISOString(), ':', ...args) if (exit) { process.exit() diff --git a/test/integrations/resolver.test.ts b/test/integrations/resolver.test.ts index 8b5afc8..153b2f5 100644 --- a/test/integrations/resolver.test.ts +++ b/test/integrations/resolver.test.ts @@ -5,8 +5,8 @@ import { runArgsExecution } from './utils' class MockResolver extends Resolver { constructor ( - public readonly keyExists: (key: string) => boolean, - public readonly resolveKey: (key: string) => string, + public readonly keyExists: (key: string) => Promise, + public readonly resolveKey: (key: string) => Promise, id = 'mock' ) { super(id) @@ -15,8 +15,8 @@ class MockResolver extends Resolver { describe('Resolver tests', () => { it('calls for resolver when resolving arguments', async () => { - const existsFn = jest.fn((key: string) => key === 'ware') - const valueFn = jest.fn(() => 'value') + const existsFn = jest.fn(async (key: string) => key === 'ware') + const valueFn = jest.fn(async () => 'value') const parser = new Args(parserOpts) .arg(['--ware'], a.string()) @@ -29,8 +29,8 @@ describe('Resolver tests', () => { }) it('rejects invalid resolver values', async () => { - const existsFn = jest.fn((key: string) => key === 'ware') - const valueFn = jest.fn(() => 'value') + const existsFn = jest.fn(async (key: string) => key === 'ware') + const valueFn = jest.fn(async () => 'value') const parser = new Args(parserOpts) .arg(['--ware'], a.decimal()) diff --git a/test/parsing/utils.ts b/test/parsing/utils.ts index ab946f6..3874939 100644 --- a/test/parsing/utils.ts +++ b/test/parsing/utils.ts @@ -1,6 +1,6 @@ import assert from 'assert' -import { ArgsState, MinimalArgument, StoredParserOpts, defaultCommandOpts } from '../../src' +import { ArgsState, Command, MinimalArgument, StoredParserOpts, defaultCommandOpts, defaultParserOpts } from '../../src' import { CoercedArguments, coerce } from '../../src/internal/parse/coerce' import { tokenise } from '../../src/internal/parse/lexer' import { ParsedArguments, parse } from '../../src/internal/parse/parser' @@ -20,17 +20,18 @@ export function makeInternalCommand ( aliases: aliases ?? [], isBase: true, inner: { + log: defaultParserOpts.logger, _subcommands: subcommands ?? {}, - args: p => p, + args: (p: any) => p, opts: { description: description ?? `${name} command description`, parserOpts: opts, ...defaultCommandOpts }, - run: p => p, - runner: p => p, - subcommand: p => ({} as any) - }, + run: (p: any) => p, + runner: (p: any) => p, + subcommand: (p: any) => ({} as any) + } as unknown as Command, parser: ({} as any) } } diff --git a/test/schema/validation.test.ts b/test/schema/validation.test.ts index a914d8c..e90b20e 100644 --- a/test/schema/validation.test.ts +++ b/test/schema/validation.test.ts @@ -30,7 +30,7 @@ describe('Schema validation', () => { expect(() => { // @ts-expect-error we are testing runtime validation, for JS users, or people who dont like playing by the rules parser.arg(['-1'], a.string()) - }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdef...' or '-abcdef' got '-1'"`) + }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdefABCDEF' or '-abcdefABCDEF' got '-1'"`) }) it('rejects positionals not prefixed by <', () => { @@ -57,7 +57,7 @@ describe('Schema validation', () => { expect(() => { // @ts-expect-error we are testing runtime validation, for JS users, or people who dont like playing by the rules parser.arg(['--flag', '1'], a.string()) - }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdef...' or '-abcdef' got '1'"`) + }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdefABCDEF' or '-abcdefABCDEF' got '1'"`) }) it('rejects long flags that do not have a valid ID', () => { @@ -65,7 +65,7 @@ describe('Schema validation', () => { expect(() => { parser.arg(['--1'], a.string()) - }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdef...' or '-abcdef' got '--1'"`) + }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdefABCDEF' or '-abcdefABCDEF' got '--1'"`) }) it('rejects short flags that do not have a valid ID', () => { @@ -73,7 +73,7 @@ describe('Schema validation', () => { expect(() => { parser.arg(['--flag', '-1'], a.string()) - }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdef...' or '-abcdef' got '-1'"`) + }).toThrowErrorMatchingInlineSnapshot(`"flags must match '--abcdefABCDEF' or '-abcdefABCDEF' got '-1'"`) }) it('rejects duplicate long flags', () => { diff --git a/test/util.test.ts b/test/util.test.ts index d4166ff..89541f4 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -135,7 +135,7 @@ describe('Help generation utils', () => { expect(util.generateHelp(parser)).toMatchInlineSnapshot(` "program-name - program description -Usage: program-name [--flag-f] [--opt-multi-o] (--opt-req-r) (--enum-e) (--long ) [--long-optional ] [] +Usage: program-name [--flag | -f ] [--opt-multi | -o ] (--opt-req | -r ) (--enum | -e ) (--long ) [--long-optional ] [] Commands: program-name [help, nohelp] (--cmd-arg )