diff --git a/Readme.md b/Readme.md index 7e09c21ec..3d813e65a 100644 --- a/Readme.md +++ b/Readme.md @@ -254,9 +254,44 @@ You can enable `--harmony` option in two ways: * Use `#! /usr/bin/env node --harmony` in the sub-commands scripts. Note some os version don’t support this pattern. * Use the `--harmony` option when call the command, like `node --harmony examples/pm publish`. The `--harmony` option will be preserved when spawning sub-command process. +## Autocomplete + +commander has autocomplete capability builtin, and you can enable it by the following steps: + +1. Declare the candidate values for each option and arg. The candidate value can be an array or a function that receives typedArgs (an array of already typed command line string split by empty space) and returns an array of candidates. + +```js +program + .arguments(' ') + .option('--verbose', 'verbose') + .option('-n, --name ', 'specify name') + .option('--description ', 'specify description') + .complete({ + options: { + '--name': function(typedArgs) { return ['kate', 'jim']; }, + '--description': ['desc1', 'desc2'] + }, + arguments: { + a: function(typedArgs) { return ['a-1', 'a-2']; }, + b: ['b-1', 'b-2'] + } + }); +``` + +2. Ask your command line user to enable autocompletion for current session by executing the following command. +For persistent support, we can recommend adding the command to their shell initialization file such as .bashrc or .zshrc etc. + +``` +# for zsh or bash +eval "$( --completion)" + +# for fish shell + --completion-fish | source +``` + ## Automated --help - The help information is auto-generated based on the information commander already knows about your program, so the following `--help` info is for free: +The help information is auto-generated based on the information commander already knows about your program, so the following `--help` info is for free: ``` $ ./examples/pizza --help diff --git a/Readme_zh-CN.md b/Readme_zh-CN.md index f91d2d5c6..221b403af 100644 --- a/Readme_zh-CN.md +++ b/Readme_zh-CN.md @@ -181,6 +181,40 @@ Commander 将会尝试在入口脚本(例如 `./examples/pm`)的目录中搜 * 在子命令脚本中加上 `#!/usr/bin/env node --harmony`。注意一些系统版本不支持此模式。 * 在指令调用时加上 `--harmony` 参数,例如 `node --harmony examples/pm publish`。`--harmony` 选项在开启子进程时会被保留。 +## 自动补全 + +commander 已自带命令行自动补全功能。您可以按照以下步骤启用它: + +1. 声明对应每个选项和参数的候选值。候选值可以是一个数组或者是一个 接受当前已经输入值的数组然后返回一个候选数组的function。 + +```js +program + .arguments(' ') + .option('--verbose', 'verbose') + .option('-n, --name ', 'specify name') + .option('--description ', 'specify description') + .complete({ + options: { + '--name': function(typedArgs) { return ['kate', 'jim']; }, + '--description': ['desc1', 'desc2'] + }, + arguments: { + a: function(typedArgs) { return ['a-1', 'a-2']; }, + b: ['b-1', 'b-2'] + } + }); +``` + +2. 邀请你的命令行用户开启自动补全功能。他们可以直接在shell执行以下指令,或是把其加入到个人shell对应的.bashrc或.zshrc等文件中,就可以获得长期的自动补全支持。 + +``` +# for zsh or bash +eval "$( --completion)" + +# for fish shell + --completion-fish | source +``` + ## 自动化帮助信息 --help 帮助信息是 commander 基于你的程序自动生成的,下面是 `--help` 生成的帮助信息: diff --git a/examples/autocomplete b/examples/autocomplete new file mode 100755 index 000000000..b83b27b13 --- /dev/null +++ b/examples/autocomplete @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +/** + * To simulate real use cases please set the example path into PATH variable + * export PATH=".":$PATH + * + * Then please execute the following line to enable completion for your shell + * eval "$(autocomplete --completion)" + * + * And then you can try use tab completion after auto-complete command + */ + +var program = require('..'); + +program + .arguments(' ') + .option('--verbose', 'verbose') + .option('-n, --name ', 'specify name') + .option('--description ', 'specify description') + .complete({ + options: { + '--name': function() { return ['kate', 'jim']; }, + '--description': ['desc1', 'desc2'] + }, + arguments: { + a: function() { return ['a-1', 'a-2']; }, + b: ['b-1', 'b-2'] + } + }) + .parse(process.argv); diff --git a/examples/autocomplete-sub b/examples/autocomplete-sub new file mode 100755 index 000000000..1f442f296 --- /dev/null +++ b/examples/autocomplete-sub @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/** + * To simulate real use cases please set the example path into PATH variable + * export PATH=".":$PATH + * + * Then please execute the following line to enable completion for your shell + * eval "$(autocomplete-sub --completion)" + */ + +var program = require('..'); + +program + .command('sub1 ') + .description('sub command 1') + .option('--verbose', 'verbose') + .option('-n, --name ', 'specify name') + .option('--description ', 'specify description') + .complete({ + options: { + '--name': function() { return ['kate', 'jim']; }, + '--description': ['d1', 'd2'] + }, + arguments: { + arg1: function() { return ['a-1', 'a-2', 'a-3']; }, + arg2: ['b-1', 'b-2'] + } + }); + +program + .command('sub2 ') + .description('sub command 2') + .option('--verbose', 'verbose') + .option('-n, --name ', 'specify name') + .option('--description ', 'specify description') + .complete({ + options: { + '--name': function() { return ['lucy', 'linda']; }, + '--description': ['db1', 'db2'] + }, + arguments: { + arg1: function() { return ['a-11', 'a-12']; }, + } + }); + +program.parse(process.argv); diff --git a/index.js b/index.js index 84436d8ea..90ade5fed 100644 --- a/index.js +++ b/index.js @@ -89,6 +89,18 @@ Option.prototype.is = function(arg) { return this.short === arg || this.long === arg; }; +/** + * Returns number of args that are expected by the option. + * Can only be 0 or 1. + * + * @return {Number} + * @api private + */ + +Option.prototype.arity = function() { + return (this.required || this.optional) ? 1 : 0; +}; + /** * Initialize a new `Command`. * @@ -103,6 +115,10 @@ function Command(name) { this._allowUnknownOption = false; this._args = []; this._name = name || ''; + this._completionRules = { + options: {}, + args: {} + }; } /** @@ -443,6 +459,251 @@ Command.prototype.allowUnknownOption = function(arg) { return this; }; +/** + * Define completionRules which will later be used by autocomplete to generate appropriate response + * + * @param {Object} completion rules + * @api public + */ + +Command.prototype.complete = function(rules) { + // merge options + // this should ensure this._completionRules are always in shape + if (rules.options) { + this._completionRules.options = rules.options; + } + + // support both arguments or args as key + if (rules.arguments) { + this._completionRules.args = rules.arguments; + } else if (rules.args) { + this._completionRules.args = rules.args; + } + + return this; +}; + +/** + * Test if any complete rules has been defined for current command or its subcommands. + * + * @return {Boolean} + * @api private + */ + +Command.prototype.hasCompletionRules = function() { + function isEmptyRule(rules) { + return ( + Object.keys(rules.options).length === 0 && + Object.keys(rules.args).length === 0 + ); + } + + return !( + isEmptyRule(this._completionRules) && + this.commands.every(function(command) { + return isEmptyRule(command._completionRules); + }) + ); +}; + +/** + * Handle autocomplete if command args starts with special options. + * It will exit current process after successful processing. + * + * @param {Array} process.argv + */ + +Command.prototype.autocomplete = function(argv) { + var RESERVED_STARTING_KEYWORDS = [ + '--completion', + '--completion-fish', + '--compzsh', + '--compbash', + '--compfish' + ]; + var firstArg = argv[2]; + + if (RESERVED_STARTING_KEYWORDS.includes(firstArg)) { + // lazy require + var omelette = require('omelette'); + var executableName = basename(argv[1], '.js'); + var self = this; + + var completion = omelette(executableName); + + completion.on('complete', function(f, event) { + self.autocompleteHandleEvent(event); + }); + + // omelette will call process.exit(0) + completion.init(); + } + + return this; +}; + +/** + * Handle omelette complete event + * + * @param {Object} omelette event which contains fragment, line, reply info + * + * @api private + */ +Command.prototype.autocompleteHandleEvent = function(event) { + if (this.commands.length > 0) { + // sub command style + if (event.fragment === 1) { + // for sub command first complete should return command + var commands = this.commands.map(function(c) { return c.name(); }); + + event.reply(commands.concat(['--help'])); + } else { + var elements = event.line.split(' '); + var commandName = elements[1]; + var commandArgs = elements.slice(2, event.fragment); + var currentCommand = this.commands.find(function(c) { + return c.name() === commandName; + }); + + if (currentCommand) { + event.reply( + currentCommand.autocompleteCandidates(commandArgs) + ); + } else { + event.reply([]); + } + } + } else { + // single command style + var singleCommandArgs = event.line.split(' ').slice(1, event.fragment); + + if (event.fragment === 1) { + // offer --help for the first complete only + event.reply( + this.autocompleteCandidates(singleCommandArgs).concat(['--help']) + ); + } else { + event.reply( + this.autocompleteCandidates(singleCommandArgs) + ); + } + } +}; + +/** + * Return candidates base on current line input and completionRules. + * This is the core of smart logic of autocompletion + * + * @param {Array} typed args + * @return {Array} auto complete candidates + * @api private + */ + +Command.prototype.autocompleteCandidates = function(typedArgs) { + var completionRules = this.autocompleteNormalizeRules(); + var activeOption = autocompleteActiveOption( + completionRules.options, typedArgs + ); + + if (activeOption) { + // if current typedArgs suggests it's filling an option + // next value would be the possible values for that option + var reply = activeOption.reply; + + if (typeof reply === 'function') { + return (reply(typedArgs) || []); + } else if (Array.isArray(reply)) { + return reply; + } else { + return []; + } + } else { + // otherwise + // next value would be one of the unused option names + var optionNames = Object + .keys(completionRules.options) + .filter(function(name) { + var option = completionRules.options[name]; + + if (option.sibling) { + // remove both option and its sibling form + return ( + !typedArgs.includes(name) && + !typedArgs.includes(option.sibling) + ); + } else { + return !typedArgs.includes(name); + } + }); + + // or possible values for next arguments + var activeArg = autocompleteActiveArg( + completionRules.options, + completionRules.args, + typedArgs + ); + + if (typeof activeArg === 'function') { + return optionNames.concat(activeArg(typedArgs) || []); + } else if (Array.isArray(activeArg)) { + return optionNames.concat(activeArg); + } else { + return optionNames; + } + } +}; + +/** + * For the ease of processing, + * the internal presentation of completion rules is quite different from user input. + * + * @return {Object} normalized rules + * @api private + */ + +Command.prototype.autocompleteNormalizeRules = function() { + // supplement with important information including + // option arity and sibling + var rawRules = this._completionRules; + var options = this.options; + var args = this._args; + var normalizedRules = { options: {}, args: [] }; + + options.forEach(function(option) { + if (option.short) { + var reply = ( + rawRules.options[option.long] || + rawRules.options[option.short] || + [] + ); + + normalizedRules.options[option.short] = { + arity: option.arity(), + sibling: option.long, + reply: reply + }; + + normalizedRules.options[option.long] = { + arity: option.arity(), + sibling: option.short, + reply: reply + }; + } else { + normalizedRules.options[option.long] = { + arity: option.arity(), + sibling: null, + reply: rawRules.options[option.long] || [] + }; + } + }); + + args.forEach(function(arg) { + normalizedRules.args.push(rawRules.args[arg.name] || []); + }); + + return normalizedRules; +}; + /** * Parse `argv`, settings options and invoking commands when defined. * @@ -452,6 +713,11 @@ Command.prototype.allowUnknownOption = function(arg) { */ Command.prototype.parse = function(argv) { + // trigger autocomplete first if some completion rules have been defined + if (this.hasCompletionRules()) { + this.autocomplete(argv); + } + // implicit help if (this.executables) this.addImplicitHelpCommand(); @@ -1225,3 +1491,69 @@ function exists(file) { return false; } } + +/** + * Detect whether current command line input infers an option. + * + * @param {Object} normalized option rules + * @param {Array} typed args + * @return {Object} active option if found, otherwise false + * @api private + */ + +function autocompleteActiveOption(optionRules, typedArgs) { + if (typedArgs.length === 0) { + return false; + } + + var lastArg = typedArgs[typedArgs.length - 1]; + + if (!optionRules[lastArg]) { + return false; + } + + var option = optionRules[lastArg]; + + if (option.arity === 0) { + return false; + } + + return option; +} + +/** + * Detect whether current command line input infers an arg. + * + * @param {Object} normalized option rules + * @param {Array} normalized arg rules + * @param {Array} typed args + * @return {Object} active arg if found, otherwise false + * @api private + */ + +function autocompleteActiveArg(optionRules, argRules, typedArgs) { + if (argRules.length === 0) { + return false; + } + + // find out how many args have already been typed + var count = 0; + var curr = 0; + + while (curr < typedArgs.length) { + var currStr = typedArgs[curr]; + + if (optionRules[currStr]) { + curr += optionRules[currStr].arity + 1; + } else { + count += 1; + curr += 1; + } + } + + if (argRules.length > count) { + return argRules[count]; + } else { + return false; + } +} diff --git a/package-lock.json b/package-lock.json index 54f6aa992..dcb74bb81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1311,6 +1311,11 @@ "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==", "dev": true }, + "omelette": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/omelette/-/omelette-0.4.12.tgz", + "integrity": "sha512-f/GsNOZIxHnNo9hHguQ8bNWXogfTebmvXR5Jg1apRakWO/PDSUbCOFlp9Rv4IPnS59sjZTPqkHZ2seFx8opJXA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index a68384294..54e976f23 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "index.js", "typings/index.d.ts" ], - "dependencies": {}, + "dependencies": { + "omelette": "^0.4.12" + }, "devDependencies": { "@types/node": "^10.11.3", "eslint": "^5.6.1", diff --git a/test/test.command.autocomplete.single.js b/test/test.command.autocomplete.single.js new file mode 100644 index 000000000..d0838a95b --- /dev/null +++ b/test/test.command.autocomplete.single.js @@ -0,0 +1,139 @@ +var program = require('../') + , should = require('should'); + +program.hasCompletionRules().should.be.false(); + +program + .arguments('') + .option('--verbose', 'verbose') + .option('-o, --output ', 'output') + .option('--debug-level ', 'debug level') + .option('-m ', 'mode') + .complete({ + options: { + '--output': function() { return ['file1', 'file2'] }, + '--debug-level': ['info', 'error'], + '-m': function(typedArgs) { return typedArgs; } + }, + arguments: { + filename: ['file1.c', 'file2.c'] + } + }); + +program.hasCompletionRules().should.be.true(); + +program.autocompleteNormalizeRules().should.deepEqual({ + options: { + '--verbose': { + arity: 0, + sibling: null, + reply: [] + }, + '-o': { + arity: 1, + sibling: '--output', + reply: program._completionRules.options['--output'] + }, + '--output': { + arity: 1, + sibling: '-o', + reply: program._completionRules.options['--output'] + }, + '--debug-level': { + arity: 1, + sibling: null, + reply: ['info', 'error'] + }, + '-m': { + arity: 1, + sibling: null, + reply: program._completionRules.options['-m'] + } + }, + args: [ + ['file1.c', 'file2.c'] + ] +}); + +program.autocompleteCandidates([]).should.deepEqual([ + '--verbose', + '-o', + '--output', + '--debug-level', + '-m', + 'file1.c', + 'file2.c' +]); + +program.autocompleteCandidates(['--verbose']).should.deepEqual([ + '-o', + '--output', + '--debug-level', + '-m', + 'file1.c', + 'file2.c' +]); + +program.autocompleteCandidates(['-o']).should.deepEqual([ + 'file1', + 'file2' +]); + +program.autocompleteCandidates(['--output']).should.deepEqual([ + 'file1', + 'file2' +]); + +program.autocompleteCandidates(['--debug-level']).should.deepEqual([ + 'info', + 'error' +]); + +program.autocompleteCandidates(['-m']).should.deepEqual([ + '-m' +]); + +program.autocompleteCandidates(['--verbose', '-m']).should.deepEqual([ + '--verbose', + '-m' +]); + +program.autocompleteCandidates([ + '--verbose', + '-o', 'file1', + '--debug-level', 'info', + '-m', 'production' +]).should.deepEqual([ + 'file1.c', + 'file2.c' +]); + +// nothing to complete +program.autocompleteCandidates([ + '--verbose', + '-o', 'file1', + '--debug-level', 'info', + '-m', 'production', + 'file1.c' +]).should.deepEqual([]); + +// place arguments in different position +program.autocompleteCandidates([ + 'file1.c', + '-o', 'file1', + '--debug-level', 'info', + '-m', 'production' +]).should.deepEqual([ + '--verbose' +]); + +// should handle the case +// when provide more args than expected +program.autocompleteCandidates([ + 'file1.c', + 'file2.c', + '--verbose', + '-o', 'file1', + '--debug-level', 'info', + '-m', 'production' +]).should.deepEqual([]); diff --git a/test/test.command.autocomplete.subcommand.js b/test/test.command.autocomplete.subcommand.js new file mode 100644 index 000000000..8dbcb1e65 --- /dev/null +++ b/test/test.command.autocomplete.subcommand.js @@ -0,0 +1,107 @@ +var program = require('../') + , sinon = require('sinon').sandbox.create() + , should = require('should'); + +program + .command('clone ') + .option('--debug-level ', 'debug level') + .complete({ + options: { + '--debug-level': ['info', 'error'], + }, + arguments: { + url: ['https://github.com/1', 'https://github.com/2'] + } + }); + +program + .command('add ') + .option('-A', 'add all files') + .option('--debug-level ', 'debug level') + .complete({ + options: { + '--debug-level': ['info', 'error'], + }, + arguments: { + file1: ['file1.c', 'file11.c'], + file2: ['file2.c', 'file21.c'] + } + }); + +program.hasCompletionRules().should.be.true(); + +var rootReply = sinon.spy(); + +program.autocompleteHandleEvent({ + reply: rootReply, + fragment: 1, + line: "git", +}); + +rootReply.calledOnce.should.be.true(); +rootReply.getCall(0).args[0].should.deepEqual([ + 'clone', + 'add', + '--help' +]); + +var cloneReply = sinon.spy(); + +program.autocompleteHandleEvent({ + reply: cloneReply, + fragment: 2, + line: "git clone", +}); + +cloneReply.calledOnce.should.be.true(); +cloneReply.getCall(0).args[0].should.deepEqual([ + '--debug-level', + 'https://github.com/1', + 'https://github.com/2' +]); + +var cloneWithOptionReply = sinon.spy(); + +program.autocompleteHandleEvent({ + reply: cloneWithOptionReply, + fragment: 3, + line: "git clone --debug-level", +}); + +cloneWithOptionReply.calledOnce.should.be.true(); +cloneWithOptionReply.getCall(0).args[0].should.deepEqual([ + 'info', + 'error' +]); + +var addReply = sinon.spy(); + +program.autocompleteHandleEvent({ + reply: addReply, + fragment: 2, + line: "git add", +}); + +addReply.calledOnce.should.be.true(); +addReply.getCall(0).args[0].should.deepEqual([ + '-A', + '--debug-level', + 'file1.c', + 'file11.c' +]); + +var addWithArgReply = sinon.spy(); + +program.autocompleteHandleEvent({ + reply: addWithArgReply, + fragment: 3, + line: "git add file1.c", +}); + +addWithArgReply.calledOnce.should.be.true(); +addWithArgReply.getCall(0).args[0].should.deepEqual([ + '-A', + '--debug-level', + 'file2.c', + 'file21.c', +]);