From a087cc95a60e058e37242fda37098a13c05c2e08 Mon Sep 17 00:00:00 2001 From: Mickael Daniel Date: Mon, 2 May 2016 02:00:19 +0200 Subject: [PATCH] completion: Add completion based on env.getGeneratorsMeta() and --help - Prevent completion install on CI environments - review: check in code review updates - Refactor into Completer class, adding in a few tests - spawn "node yo" instead of yo - dont test generators on stdout, just options - skip test on user installed generators --- lib/completion/completer.js | 106 ++++++++++++++++++++++++++ lib/completion/index.js | 18 +++++ package.json | 24 +++++- test/cli.js | 6 +- test/completion.js | 144 ++++++++++++++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 lib/completion/completer.js create mode 100755 lib/completion/index.js create mode 100644 test/completion.js diff --git a/lib/completion/completer.js b/lib/completion/completer.js new file mode 100644 index 00000000..7930dae8 --- /dev/null +++ b/lib/completion/completer.js @@ -0,0 +1,106 @@ +'use strict'; + +var path = require('path'); +var execFile = require('child_process').execFile; +var parseHelp = require('parse-help'); + +/** + * The Completer is in charge of handling `yo-complete` behavior. + * @constructor + * @param {Environment} env A yeoman environment instance + */ +var Completer = module.exports = function (env) { + this.env = env; +}; + +/** + * Completion event done + * + * @param {String} data Environment object as parsed by tabtab + * @param {Function} done Callback to invoke with completion results + */ +Completer.prototype.complete = function (data, done) { + if (data.last !== 'yo' && !/^-/.test(data.last)) { + return this.generator(data, done); + } + + this.env.lookup(function (err) { + if (err) { + return done(err); + } + + var meta = this.env.getGeneratorsMeta(); + var results = Object.keys(meta).map(this.item('yo'), this); + done(null, results); + }.bind(this)); +}; + + +/** + * Generator completion event done + * + * @param {String} data Environment object as parsed by tabtab + * @param {Function} done Callback to invoke with completion results + */ +Completer.prototype.generator = function (data, done) { + var last = data.last; + var bin = path.join(__dirname, '../cli.js'); + + execFile('node', [bin, last, '--help'], function (err, out) { + if (err) { + return done(err); + } + + var results = this.parseHelp(last, out); + done(null, results); + }.bind(this)); +}; + +/** + * Helper to format completion results into { name, description } objects + * + * @param {String} data Environment object as parsed by tabtab + * @param {Function} done Callback to invoke with completion results + */ +Completer.prototype.item = function (desc, prefix) { + prefix = prefix || ''; + return function (item) { + var name = typeof item === 'string' ? item : item.name; + desc = typeof item !== 'string' && item.description ? item.description : desc; + desc = desc.replace(/^#?\s*/g, ''); + desc = desc.replace(/:/g, '->'); + desc = desc.replace(/'/g, ' '); + + return { + name: prefix + name, + description: desc + }; + }; +}; + +/** + * parse-help wrapper. Invokes parse-help with stdout result, returning the + * list of completion items for flags / alias. + * + * @param {String} last Last word in COMP_LINE (completed line in command line) + * @param {String} out Help output + */ +Completer.prototype.parseHelp = function (last, out) { + var help = parseHelp(out); + var alias = []; + var results = Object.keys(help.flags).map(function (key) { + var flag = help.flags[key]; + if (flag.alias) { + alias.push(Object.assign({}, flag, { name: flag.alias })); + } + flag.name = key; + return flag; + }).map(this.item(last, '--'), this); + + results = results.concat(alias.map(this.item(last.replace(':', '_'), '-'), this)); + results = results.filter(function (r) { + return r.name !== '--help' && r.name !== '-h'; + }); + + return results; +}; diff --git a/lib/completion/index.js b/lib/completion/index.js new file mode 100755 index 00000000..d2b262ee --- /dev/null +++ b/lib/completion/index.js @@ -0,0 +1,18 @@ +#! /usr/bin/env node +'use strict'; + +var env = require('yeoman-environment').createEnv(); +var Completer = require('./completer'); + +var tabtab = require('tabtab')({ + name: 'yo' +}); + +var completer = new Completer(env); + +// Lookup installed generator in yeoman environment, respond completion results +// with each generator. +tabtab.on('yo', completer.complete.bind(completer)); + +// Register complete command +tabtab.start(); diff --git a/package.json b/package.json index 9e2505d3..00434406 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,20 @@ ], "license": "BSD-2-Clause", "repository": "yeoman/yo", - "bin": "lib/cli.js", + "bin": { + "yo": "lib/cli.js", + "yo-complete": "lib/completion/index.js" + }, "engines": { "node": ">=0.12.0" }, "scripts": { "test": "gulp", - "postinstall": "yodoctor", + "postinstall": "yodoctor && npm run tabtab", "postupdate": "yodoctor", - "prepublish": "gulp prepublish" + "prepublish": "gulp prepublish", + "tabtab": "[ $CI ] || tabtab install --name=yo --completer=yo-complete", + "test-completion": "export cmd=\"yo\" && DEBUG=\"tabtab*\" COMP_POINT=\"4\" COMP_LINE=\"$cmd\" COMP_CWORD=\"$cmd\" yo-complete completion -- yo $cmd" }, "dependencies": { "async": "^1.0.0", @@ -54,11 +59,13 @@ "npm-keyword": "^4.1.0", "opn": "^3.0.2", "package-json": "^2.1.0", + "parse-help": "^0.1.1", "read-pkg-up": "^1.0.1", "repeating": "^2.0.0", "root-check": "^1.0.0", "sort-on": "^1.0.0", "string-length": "^1.0.0", + "tabtab": "^1.1.1", "titleize": "^1.0.0", "update-notifier": "^0.6.0", "user-home": "^2.0.0", @@ -83,5 +90,16 @@ "gulp-plumber": "^1.0.0", "gulp-nsp": "^2.1.0", "gulp-coveralls": "^0.1.0" + }, + "tabtab": { + "yo": [ + "-f", + "--force", + "--version", + "--no-color", + "--no-insight", + "--insight", + "--generators" + ] } } diff --git a/test/cli.js b/test/cli.js index 532a2d18..5c9f09c8 100644 --- a/test/cli.js +++ b/test/cli.js @@ -42,7 +42,7 @@ describe('bin', function () { done(); }; - process.argv = ['node', path.join(__dirname, '../', pkg.bin), 'notexisting']; + process.argv = ['node', path.join(__dirname, '../', pkg.bin.yo), 'notexisting']; this.env.lookup = function (cb) { cb(); @@ -53,7 +53,7 @@ describe('bin', function () { }); it('should return the version', function (cb) { - var cp = execFile('node', [path.join(__dirname, '../', pkg.bin), '--version', '--no-insight', '--no-update-notifier']); + var cp = execFile('node', [path.join(__dirname, '../', pkg.bin.yo), '--version', '--no-insight', '--no-update-notifier']); var expected = pkg.version; cp.stdout.on('data', function (data) { @@ -63,7 +63,7 @@ describe('bin', function () { }); it('should output available generators when `--generators` flag is supplied', function (cb) { - var cp = execFile('node', [path.join(__dirname, '../', pkg.bin), '--generators', '--no-insight', '--no-update-notifier']); + var cp = execFile('node', [path.join(__dirname, '../', pkg.bin.yo), '--generators', '--no-insight', '--no-update-notifier']); cp.stdout.once('data', function (data) { assert(data.length > 0); diff --git a/test/completion.js b/test/completion.js new file mode 100644 index 00000000..ba8e31a6 --- /dev/null +++ b/test/completion.js @@ -0,0 +1,144 @@ +'use strict'; + +var path = require('path'); +var assert = require('assert'); +var Completer = require('../lib/completion/completer'); +var execFile = require('child_process').execFile; + +var help = [ + ' Usage:', + ' yo backbone:app [options] []', + '', + ' Options:', + ' -h, --help # Print the generator\'s options and usage', + ' --skip-cache # Do not remember prompt answers Default: false', + ' --skip-install # Do not automatically install dependencies Default: false', + ' --appPath # Name of application directory Default: app', + ' --requirejs # Support requirejs Default: false', + ' --template-framework # Choose template framework. lodash/handlebars/mustache Default: lodash', + ' --test-framework # Choose test framework. mocha/jasmine Default: mocha', + '', + ' Arguments:', + ' app_name Type: String Required: false' +].join('\n'); + +describe('Completion', function () { + + before(function () { + this.env = require('yeoman-environment').createEnv(); + }); + + describe('Test completion STDOUT output', function () { + it('Returns the completion candidates for both options and installed generators', function (done) { + var yocomplete = path.join(__dirname, '../lib/completion/index.js'); + var yo = path.join(__dirname, '../lib/cli'); + + var cmd = 'export cmd=\"yo\" && DEBUG=\"tabtab*\" COMP_POINT=\"4\" COMP_LINE=\"$cmd\" COMP_CWORD=\"$cmd\"'; + cmd += 'node ' + yocomplete + ' completion -- ' + yo + ' $cmd'; + + execFile('bash', ['-c', cmd], function (err, out) { + if (err) { + return done(err); + } + + assert.ok(/-f/.test(out)); + assert.ok(/--force/.test(out)); + assert.ok(/--version/.test(out)); + assert.ok(/--no-color/.test(out)); + assert.ok(/--no-insight/.test(out)); + assert.ok(/--insight/.test(out)); + assert.ok(/--generators/.test(out)); + + done(); + }); + }); + }); + + describe('Completer', function () { + beforeEach(function () { + this.completer = new Completer(this.env); + }); + + describe('#parseHelp', function () { + it('Returns completion items based on help output', function () { + var results = this.completer.parseHelp('backbone:app', help); + var first = results[0]; + + assert.equal(results.length, 6); + assert.deepEqual(first, { + name: '--skip-cache', + description: 'Do not remember prompt answers Default-> false' + }); + }); + }); + + describe('#item', function () { + it('Format results into { name, description }', function () { + var list = ['foo', 'bar']; + var results = list.map(this.completer.item('yo!', '--')); + assert.deepEqual(results, [{ + name: '--foo', + description: 'yo!' + }, { + name: '--bar', + description: 'yo!' + }]); + }); + + it('Escapes certain characters before consumption by shell scripts', function () { + var list = ['foo']; + + var desc = '# yo I\'m a very subtle description, with chars that likely will break your Shell: yeah I\'m mean'; + var expected = 'yo I m a very subtle description, with chars that likely will break your Shell-> yeah I m mean'; + var results = list.map(this.completer.item(desc, '-')); + + assert.equal(results[0].description, expected); + }); + }); + + describe('#generator', function () { + it('Returns completion candidates from generator help output', function (done) { + // Here we test against yo --help (could use dummy:yo --help) + this.completer.generator({ last: '' }, function (err, results) { + if (err) { + return done(err); + } + + /* eslint no-multi-spaces: 0 */ + assert.deepEqual(results, [ + { name: '--force', description: 'Overwrite files that already exist' }, + { name: '--version', description: 'Print version' }, + { name: '--no-color', description: 'Disable colors' }, + { name: '-f', description: 'Overwrite files that already exist' } + ]); + + done(); + }); + }); + }); + + describe('#complete', function () { + // SKipping on CI right now, otherwise might introduce an `npm install + // generator-dummy` as a pretest script + + it.skip('Returns the list of user installed generators as completion candidates', function (done) { + this.completer.complete({ last: 'yo' }, function (err, results) { + if (err) { + return done(err); + } + + console.log(results); + var dummy = results.find(function (result) { + return result.name === 'dummy:yo'; + }); + + assert.equal(dummy.name, 'dummy:yo'); + assert.equal(dummy.description, 'yo'); + + done(); + }); + }); + }); + }); + +});