From 01b2c6a99167d826d3d1e6f6b94f18382a17d47e Mon Sep 17 00:00:00 2001 From: Ansgar Mertens Date: Sun, 5 Sep 2021 22:08:27 +0200 Subject: [PATCH] feat: autocomplete choices for options (#2018) fix: cast error types as TypeScript 4.4 infers them as unknown instead of any (#2016) --- lib/argsert.ts | 2 +- lib/completion.ts | 51 +++++++++++++++++++++- lib/utils/maybe-async-result.ts | 2 +- test/completion.cjs | 77 +++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/lib/argsert.ts b/lib/argsert.ts index 7994d88d1..f2c2351af 100644 --- a/lib/argsert.ts +++ b/lib/argsert.ts @@ -73,7 +73,7 @@ export function argsert( position += 1; }); } catch (err) { - console.warn(err.stack); + console.warn((err as Error).stack); } } diff --git a/lib/completion.ts b/lib/completion.ts index cebd07f4f..60cc486e0 100644 --- a/lib/completion.ts +++ b/lib/completion.ts @@ -68,6 +68,7 @@ export class Completion implements CompletionInstance { this.commandCompletions(completions, args, current); this.optionCompletions(completions, args, argv, current); + this.choicesCompletions(completions, args, argv, current); done(null, completions); } @@ -82,7 +83,8 @@ export class Completion implements CompletionInstance { .getContext().commands; if ( !current.match(/^-/) && - parentCommands[parentCommands.length - 1] !== current + parentCommands[parentCommands.length - 1] !== current && + !this.previousArgHasChoices(args) ) { this.usage.getCommands().forEach(usageCommand => { const commandName = parseCommand(usageCommand[0]).cmd; @@ -105,7 +107,10 @@ export class Completion implements CompletionInstance { argv: Arguments, current: string ) { - if (current.match(/^-/) || (current === '' && completions.length === 0)) { + if ( + (current.match(/^-/) || (current === '' && completions.length === 0)) && + !this.previousArgHasChoices(args) + ) { const options = this.yargs.getOptions(); const positionalKeys = this.yargs.getGroups()[this.usage.getPositionalGroupName()] || []; @@ -129,6 +134,48 @@ export class Completion implements CompletionInstance { } } + private choicesCompletions( + completions: string[], + args: string[], + argv: Arguments, + current: string + ) { + if (this.previousArgHasChoices(args)) { + const choices = this.getPreviousArgChoices(args); + if (choices && choices.length > 0) { + completions.push(...choices); + } + } + } + + private getPreviousArgChoices(args: string[]): string[] | void { + if (args.length < 1) return; // no args + let previousArg = args[args.length - 1]; + let filter = ''; + // use second to last argument if the last one is not an option starting with -- + if (!previousArg.startsWith('--') && args.length > 1) { + filter = previousArg; // use last arg as filter for choices + previousArg = args[args.length - 2]; + } + if (!previousArg.startsWith('--')) return; // still no valid arg, abort + const previousArgKey = previousArg.replace(/-/g, ''); + + const options = this.yargs.getOptions(); + if ( + Object.keys(options.key).some(key => key === previousArgKey) && + Array.isArray(options.choices[previousArgKey]) + ) { + return options.choices[previousArgKey].filter( + choice => !filter || choice.startsWith(filter) + ); + } + } + + private previousArgHasChoices(args: string[]): boolean { + const choices = this.getPreviousArgChoices(args); + return choices !== undefined && choices.length > 0; + } + private argsContainKey( args: string[], argv: Arguments, diff --git a/lib/utils/maybe-async-result.ts b/lib/utils/maybe-async-result.ts index 9c9efc8df..2666de504 100644 --- a/lib/utils/maybe-async-result.ts +++ b/lib/utils/maybe-async-result.ts @@ -19,7 +19,7 @@ export function maybeAsyncResult( ? result.then((result: T) => resultHandler(result)) : resultHandler(result); } catch (err) { - return errorHandler(err); + return errorHandler(err as Error); } } diff --git a/test/completion.cjs b/test/completion.cjs index 001d09edd..df3e07072 100644 --- a/test/completion.cjs +++ b/test/completion.cjs @@ -296,6 +296,83 @@ describe('Completion', () => { r.logs.should.include('--foo'); r.logs.should.not.include('bar'); }); + + it('completes choices if previous option requires a choice', () => { + process.env.SHELL = '/bin/bash'; + const r = checkUsage(() => { + return yargs([ + './completion', + '--get-yargs-completions', + './completion', + '--fruit', + ]) + .options({ + fruit: { + describe: 'fruit option', + choices: ['apple', 'banana', 'pear'], + }, + amount: {describe: 'amount', type: 'number'}, + }) + .completion('completion', false).argv; + }); + + r.logs.should.have.length(3); + r.logs.should.include('apple'); + r.logs.should.include('banana'); + r.logs.should.include('pear'); + }); + + it('completes choices if previous option requires a choice and space has been entered', () => { + process.env.SHELL = '/bin/bash'; + const r = checkUsage(() => { + return yargs([ + './completion', + '--get-yargs-completions', + './completion', + '--fruit', + '', + ]) + .options({ + fruit: { + describe: 'fruit option', + choices: ['apple', 'banana', 'pear'], + }, + amount: {describe: 'amount', type: 'number'}, + }) + .completion('completion', false).argv; + }); + + r.logs.should.have.length(3); + r.logs.should.include('apple'); + r.logs.should.include('banana'); + r.logs.should.include('pear'); + }); + + it('completes choices if previous option requires a choice and a partial choice has been entered', () => { + process.env.SHELL = '/bin/bash'; + const r = checkUsage(() => { + return yargs([ + './completion', + '--get-yargs-completions', + './completion', + '--fruit', + 'ap', + ]) + .options({ + fruit: { + describe: 'fruit option', + choices: ['apple', 'banana', 'pear'], + }, + amount: {describe: 'amount', type: 'number'}, + }) + .completion('completion', false).argv; + }); + + r.logs.should.have.length(1); + r.logs.should.include('apple'); + r.logs.should.not.include('banana'); + r.logs.should.not.include('pear'); + }); }); describe('generateCompletionScript()', () => {