From 31765cbdce812ee5c16aaae70ab523a2c7e0fcec Mon Sep 17 00:00:00 2001 From: Yurii H Date: Wed, 17 Feb 2021 03:16:42 +0200 Subject: [PATCH] feat: allow calling standard completion function from custom one (#1855) --- lib/completion.ts | 335 ++++++++++++++++++++++++++++---------------- test/completion.cjs | 50 ++++++- 2 files changed, 266 insertions(+), 119 deletions(-) diff --git a/lib/completion.ts b/lib/completion.ts index c249d83e7..207028831 100644 --- a/lib/completion.ts +++ b/lib/completion.ts @@ -9,86 +9,83 @@ import {Arguments, DetailedArguments} from './typings/yargs-parser-types.js'; // add bash completions to your // yargs-powered applications. -export function completion( - yargs: YargsInstance, - usage: UsageInstance, - command: CommandInstance, - shim: PlatformShim -) { - const self: CompletionInstance = { - completionKey: 'get-yargs-completions', - } as CompletionInstance; - - let aliases: DetailedArguments['aliases']; - self.setParsed = function setParsed(parsed) { - aliases = parsed.aliases; - }; - - const zshShell = - (shim.getEnv('SHELL') && shim.getEnv('SHELL')!.indexOf('zsh') !== -1) || - (shim.getEnv('ZSH_NAME') && shim.getEnv('ZSH_NAME')!.indexOf('zsh') !== -1); - // get a list of completion commands. - // 'args' is the array of strings from the line to be completed - self.getCompletion = function getCompletion(args, done): any { - const completions: string[] = []; - const current = args.length ? args[args.length - 1] : ''; - const argv = yargs.parse(args, true); - const parentCommands = yargs.getContext().commands; - // a custom completion function can be provided - // to completion(). - function runCompletionFunction(argv: Arguments) { - assertNotStrictEqual(completionFunction, null, shim); - - if (isSyncCompletionFunction(completionFunction)) { - const result = completionFunction(current, argv); - - // promise based completion function. - if (isPromise(result)) { - return result - .then(list => { - shim.process.nextTick(() => { - done(null, list); - }); - }) - .catch(err => { - shim.process.nextTick(() => { - done(err, undefined); - }); - }); - } - // synchronous completion function. - return done(null, result); - } else { - // asynchronous completion function - return completionFunction(current, argv, completions => { - done(null, completions); - }); - } - } - if (completionFunction) { - return isPromise(argv) - ? argv.then(runCompletionFunction) - : runCompletionFunction(argv); - } - const handlers = command.getCommandHandlers(); + +type CompletionCallback = ( + err: Error | null, + completions: string[] | undefined +) => void; + +/** Instance of the completion module. */ +export interface CompletionInstance { + completionKey: string; + generateCompletionScript($0: string, cmd: string): string; + getCompletion( + args: string[], + done: (err: Error | null, completions: string[] | undefined) => void + ): any; + registerFunction(fn: CompletionFunction): void; + setParsed(parsed: DetailedArguments): void; +} + +export class Completion implements CompletionInstance { + completionKey = 'get-yargs-completions'; + + private aliases: DetailedArguments['aliases'] | null = null; + private customCompletionFunction: CompletionFunction | null = null; + private readonly zshShell: boolean; + + constructor( + private readonly yargs: YargsInstance, + private readonly usage: UsageInstance, + private readonly command: CommandInstance, + private readonly shim: PlatformShim + ) { + this.zshShell = + (this.shim.getEnv('SHELL')?.includes('zsh') || + this.shim.getEnv('ZSH_NAME')?.includes('zsh')) ?? + false; + } + + private defaultCompletion( + args: string[], + argv: Arguments, + current: string, + done: CompletionCallback + ): Arguments | void { + const handlers = this.command.getCommandHandlers(); for (let i = 0, ii = args.length; i < ii; ++i) { if (handlers[args[i]] && handlers[args[i]].builder) { const builder = handlers[args[i]].builder; if (isCommandBuilderCallback(builder)) { - const y = yargs.reset(); + const y = this.yargs.reset(); builder(y); return y.argv; } } } + + const completions: string[] = []; + + this.commandCompletions(completions, args, current); + this.optionCompletions(completions, args, argv, current); + done(null, completions); + } + + // Default completions for commands + private commandCompletions( + completions: string[], + args: string[], + current: string + ) { + const parentCommands = this.yargs.getContext().commands; if ( !current.match(/^-/) && parentCommands[parentCommands.length - 1] !== current ) { - usage.getCommands().forEach(usageCommand => { + this.usage.getCommands().forEach(usageCommand => { const commandName = parseCommand(usageCommand[0]).cmd; if (args.indexOf(commandName) === -1) { - if (!zshShell) { + if (!this.zshShell) { completions.push(commandName); } else { const desc = usageCommand[1] || ''; @@ -97,55 +94,142 @@ export function completion( } }); } + } + + // Default completions for - and -- options + private optionCompletions( + completions: string[], + args: string[], + argv: Arguments, + current: string + ) { if (current.match(/^-/) || (current === '' && completions.length === 0)) { - const descs = usage.getDescriptions(); - const options = yargs.getOptions(); + const options = this.yargs.getOptions(); Object.keys(options.key).forEach(key => { const negable = !!options.configuration['boolean-negation'] && options.boolean.includes(key); + // If the key and its aliases aren't in 'args', add the key to 'completions' - let keyAndAliases = [key].concat(aliases[key] || []); - if (negable) - keyAndAliases = keyAndAliases.concat( - keyAndAliases.map(key => `no-${key}`) - ); - function completeOptionKey(key: string) { - const notInArgs = keyAndAliases.every( - val => args.indexOf(`--${val}`) === -1 - ); - if (notInArgs) { - const startsByTwoDashes = (s: string) => /^--/.test(s); - const isShortOption = (s: string) => /^[^0-9]$/.test(s); - const dashes = - !startsByTwoDashes(current) && isShortOption(key) ? '-' : '--'; - if (!zshShell) { - completions.push(dashes + key); - } else { - const desc = descs[key] || ''; - completions.push( - dashes + - `${key.replace(/:/g, '\\:')}:${desc.replace( - '__yargsString__:', - '' - )}` - ); - } - } + if (!this.argsContainKey(args, argv, key, negable)) { + this.completeOptionKey(key, completions, current); + if (negable && !!options.default[key]) + this.completeOptionKey(`no-${key}`, completions, current); } - completeOptionKey(key); - if (negable && !!options.default[key]) completeOptionKey(`no-${key}`); }); } - done(null, completions); - }; + } + + private argsContainKey( + args: string[], + argv: Arguments, + key: string, + negable: boolean + ): boolean { + if (args.indexOf(`--${key}`) !== -1) return true; + if (negable && args.indexOf(`--no-${key}`) !== -1) return true; + if (this.aliases) { + // search for aliases in parsed argv + // can't do the same thing for main option names because argv can contain default values + for (const alias of this.aliases[key]) { + if (argv[alias] !== undefined) return true; + } + } + return false; + } + + // Add completion for a single - or -- option + private completeOptionKey( + key: string, + completions: string[], + current: string + ) { + const descs = this.usage.getDescriptions(); + const startsByTwoDashes = (s: string) => /^--/.test(s); + const isShortOption = (s: string) => /^[^0-9]$/.test(s); + const dashes = + !startsByTwoDashes(current) && isShortOption(key) ? '-' : '--'; + if (!this.zshShell) { + completions.push(dashes + key); + } else { + const desc = descs[key] || ''; + completions.push( + dashes + + `${key.replace(/:/g, '\\:')}:${desc.replace('__yargsString__:', '')}` + ); + } + } + + // a custom completion function can be provided + // to completion(). + private customCompletion( + args: string[], + argv: Arguments, + current: string, + done: CompletionCallback + ) { + assertNotStrictEqual(this.customCompletionFunction, null, this.shim); + + if (isSyncCompletionFunction(this.customCompletionFunction)) { + const result = this.customCompletionFunction(current, argv); + + // promise based completion function. + if (isPromise(result)) { + return result + .then(list => { + this.shim.process.nextTick(() => { + done(null, list); + }); + }) + .catch(err => { + this.shim.process.nextTick(() => { + done(err, undefined); + }); + }); + } + // synchronous completion function. + return done(null, result); + } else if (isFallbackCompletionFunction(this.customCompletionFunction)) { + return (this.customCompletionFunction as FallbackCompletionFunction)( + current, + argv, + () => this.defaultCompletion(args, argv, current, done), + completions => { + done(null, completions); + } + ); + } else { + return (this.customCompletionFunction as AsyncCompletionFunction)( + current, + argv, + completions => { + done(null, completions); + } + ); + } + } + + // get a list of completion commands. + // 'args' is the array of strings from the line to be completed + getCompletion(args: string[], done: CompletionCallback): any { + const current = args.length ? args[args.length - 1] : ''; + const argv = this.yargs.parse(args, true); + + const completionFunction = this.customCompletionFunction + ? (argv: Arguments) => this.customCompletion(args, argv, current, done) + : (argv: Arguments) => this.defaultCompletion(args, argv, current, done); + + return isPromise(argv) + ? argv.then(completionFunction) + : completionFunction(argv); + } // generate the completion script to add to your .bashrc. - self.generateCompletionScript = function generateCompletionScript($0, cmd) { - let script = zshShell + generateCompletionScript($0: string, cmd: string): string { + let script = this.zshShell ? templates.completionZshTemplate : templates.completionShTemplate; - const name = shim.path.basename($0); + const name = this.shim.path.basename($0); // add ./to applications not yet installed as bin. if ($0.match(/\.js$/)) $0 = `./${$0}`; @@ -153,34 +237,34 @@ export function completion( script = script.replace(/{{app_name}}/g, name); script = script.replace(/{{completion_command}}/g, cmd); return script.replace(/{{app_path}}/g, $0); - }; + } // register a function to perform your own custom // completions., this function can be either // synchrnous or asynchronous. - let completionFunction: CompletionFunction | null = null; - self.registerFunction = fn => { - completionFunction = fn; - }; + registerFunction(fn: CompletionFunction) { + this.customCompletionFunction = fn; + } - return self; + setParsed(parsed: DetailedArguments) { + this.aliases = parsed.aliases; + } } -/** Instance of the completion module. */ -export interface CompletionInstance { - completionKey: string; - generateCompletionScript($0: string, cmd: string): string; - getCompletion( - args: string[], - done: (err: Error | null, completions: string[] | undefined) => void - ): any; - registerFunction(fn: CompletionFunction): void; - setParsed(parsed: DetailedArguments): void; +// For backwards compatibility +export function completion( + yargs: YargsInstance, + usage: UsageInstance, + command: CommandInstance, + shim: PlatformShim +): CompletionInstance { + return new Completion(yargs, usage, command, shim); } export type CompletionFunction = | SyncCompletionFunction - | AsyncCompletionFunction; + | AsyncCompletionFunction + | FallbackCompletionFunction; interface SyncCompletionFunction { (current: string, argv: Arguments): string[] | Promise; @@ -190,8 +274,23 @@ interface AsyncCompletionFunction { (current: string, argv: Arguments, done: (completions: string[]) => any): any; } +interface FallbackCompletionFunction { + ( + current: string, + argv: Arguments, + defaultCompletion: () => any, + done: (completions: string[]) => any + ): any; +} + function isSyncCompletionFunction( completionFunction: CompletionFunction ): completionFunction is SyncCompletionFunction { return completionFunction.length < 3; } + +function isFallbackCompletionFunction( + completionFunction: CompletionFunction +): completionFunction is FallbackCompletionFunction { + return completionFunction.length > 3; +} diff --git a/test/completion.cjs b/test/completion.cjs index a37709d75..9fc774bd3 100644 --- a/test/completion.cjs +++ b/test/completion.cjs @@ -57,7 +57,7 @@ describe('Completion', () => { './completion', '--get-yargs-completions', './completion', - '--f', + '-f', '--', ]) .options({ @@ -341,6 +341,54 @@ describe('Completion', () => { r.logs.should.include('success!'); }); + it('allows the custom completion function to use the standard one', done => { + checkUsage( + () => { + yargs(['./completion', '--get-yargs-completions']) + .command('foo', 'bar') + .command('apple', 'banana') + .completion( + 'completion', + (current, argv, defaultCompletion, done) => { + defaultCompletion(); + } + ) + .parse(); + }, + null, + (err, r) => { + if (err) throw err; + r.logs.should.include('apple'); + r.logs.should.include('foo'); + return done(); + } + ); + }); + + it('allows calling callback instead of default completion function', done => { + checkUsage( + () => { + yargs(['./completion', '--get-yargs-completions']) + .command('foo', 'bar') + .command('apple', 'banana') + .completion( + 'completion', + (current, argv, defaultCompletion, done) => { + done(['orange']); + } + ) + .parse(); + }, + null, + (err, r) => { + if (err) throw err; + r.logs.should.include('orange'); + r.logs.should.not.include('foo'); + return done(); + } + ); + }); + it('if a promise is returned, completions can be asynchronous', done => { checkUsage( cb => {