Skip to content

Commit

Permalink
feat: async resolution; better logging support in commands
Browse files Browse the repository at this point in the history
  • Loading branch information
nullishamy committed Sep 9, 2023
1 parent 0f0407c commit aacdf16
Show file tree
Hide file tree
Showing 23 changed files with 75 additions and 58 deletions.
2 changes: 1 addition & 1 deletion examples/01-basic-flags/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
2 changes: 1 addition & 1 deletion examples/02-error-handling/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
2 changes: 1 addition & 1 deletion examples/03-simple-commands/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
2 changes: 1 addition & 1 deletion examples/04-package-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
2 changes: 1 addition & 1 deletion examples/05-application-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
4 changes: 2 additions & 2 deletions examples/05-application-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ class UserConfigResolver extends Resolver {
return this
}

keyExists (key: string): boolean {
async keyExists (key: string): Promise<boolean> {
return this.data[key] !== undefined
}

resolveKey (key: string): string {
async resolveKey (key: string): Promise<string> {
const value = this.data[key]

if (value === undefined) {
Expand Down
2 changes: 1 addition & 1 deletion examples/06-builtin-commands/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
2 changes: 1 addition & 1 deletion examples/07-prompting/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
10 changes: 2 additions & 8 deletions examples/07-prompting/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -25,9 +19,9 @@ class UsernamePromptResolver extends Resolver {
})
}

async keyExists (key: string): Promise<boolean> {
async keyExists (key: string, userDidPassArg: boolean): Promise<boolean> {
// We only care about resolving our username argument
return key === 'username'
return key === 'username' && userDidPassArg
}

async resolveKey (): Promise<string> {
Expand Down
8 changes: 4 additions & 4 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
* @param inherit - Whether to inherit arguments from this configuration into the parser
* @returns this
*/
public command<TName extends string, TCommand extends Command> (
[name, ...aliases]: [`${TName}`, ...string[]],
command: TCommand,
public command (
[name, ...aliases]: [string, ...string[]],
command: Command,
inherit = false
): Args<TArgTypes> {
if (this._state.commands.has(name)) {
Expand Down Expand Up @@ -409,7 +409,7 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
* @returns The result of the parse
*/
public async parseToResult (argString: string | string[], executeCommands = false): Promise<Result<ParseSuccess<TArgTypes>, 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)

Expand Down
19 changes: 16 additions & 3 deletions src/builder/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
5 changes: 4 additions & 1 deletion src/builder/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,8 @@ export abstract class Command {
...opts.parserOpts
}
}

this.log = this.opts.parserOpts.logger
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/builder/default-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { StoredParserOpts } from '../opts'
import { Resolver } from './resolver'

export class EnvironmentResolver extends Resolver {
async keyExists (key: string, opts: StoredParserOpts): Promise<boolean> {
async keyExists (key: string, _: boolean, opts: StoredParserOpts): Promise<boolean> {
const envKey = `${opts.environmentPrefix}_${key.toUpperCase()}`
const platform = currentPlatform()
return platform.getEnv(envKey) !== undefined
Expand Down
3 changes: 2 additions & 1 deletion src/builder/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>
abstract keyExists (key: string, userDidPassArg: boolean, opts: StoredParserOpts): Promise<boolean>
/**
* Resolve the provided key to its string value.
*
Expand Down
2 changes: 1 addition & 1 deletion src/internal/parse/coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions src/internal/parse/schematic-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ 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')])

// 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 (await resolver.keyExists(argument.longFlag, opts)) {
if (await resolver.keyExists(argument.longFlag, userDidProvideArgs, opts)) {
resolversHaveValue = true
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/internal/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/util/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}>)`
Expand Down
13 changes: 8 additions & 5 deletions src/util/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ interface Stringifiable { toString: () => string }
type LoggingFunction<T> = (...args: Stringifiable[]) => T

const LEVEL_TO_CONSOLE: Record<LogLevel, () => (...args: unknown[]) => void> = {
internal: () => console.trace,
trace: () => console.trace,
debug: () => console.debug,
info: () => console.log,
Expand All @@ -11,7 +12,8 @@ const LEVEL_TO_CONSOLE: Record<LogLevel, () => (...args: unknown[]) => void> = {
}

const LEVEL_TO_NUMBER: Record<LogLevel, number> = {
trace: 0,
internal: 0,
trace: 1,
debug: 10,
info: 20,
warn: 30,
Expand All @@ -22,13 +24,14 @@ const LEVEL_TO_NUMBER: Record<LogLevel, number> = {
/**
* 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)
Expand All @@ -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()
Expand Down
12 changes: 6 additions & 6 deletions test/integrations/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>,
public readonly resolveKey: (key: string) => Promise<string>,
id = 'mock'
) {
super(id)
Expand All @@ -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())
Expand All @@ -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())
Expand Down
13 changes: 7 additions & 6 deletions test/parsing/utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
}
}
Expand Down
Loading

0 comments on commit aacdf16

Please sign in to comment.