diff --git a/lib/commands/cache-clean.js b/lib/commands/cache-clean.js index f2081491..0db8df96 100644 --- a/lib/commands/cache-clean.js +++ b/lib/commands/cache-clean.js @@ -70,3 +70,13 @@ module.exports.line = function (argv) { if (options.help) return help('cache-clean'); return module.exports(pkgs, options); }; + +module.exports.completion = function (opts, cb) { + glob('./*', { cwd: config.cache }, function (err, dirs) { + if (err) return cb(err); + dirs = dirs.map(function (dir) { + return dir.replace(/^\.\//, ''); + }); + cb(null, dirs); + }); +}; diff --git a/lib/commands/completion.js b/lib/commands/completion.js new file mode 100644 index 00000000..10121e0d --- /dev/null +++ b/lib/commands/completion.js @@ -0,0 +1,102 @@ +// ========================================== +// BOWER: Completion API +// ========================================== +// Copyright 2012 Twitter, Inc +// Licensed under The MIT License +// http://opensource.org/licenses/MIT +// ========================================== +var Emitter = require('events').EventEmitter; +var path = require('path'); +var nopt = require('nopt'); +var mkdirp = require('mkdirp'); + +var template = require('../util/template'); +var complete = require('../util/completion'); +var config = require('../core/config'); +var help = require('./help'); + +var optionTypes = { help: Boolean }; +var shorthand = { 'h': ['--help'] }; + +module.exports = function (argv, env) { + env = env || process.env; + + var emitter = new Emitter; + var commands = require('../commands'); + + // top level flags + var flags = ['--no-color', '--help', '--version']; + + var done = function done() { + process.nextTick(function () { + emitter.emit('end'); + }); + + return emitter; + }; + + // if the COMP_* isn't in the env, then just dump the script. + if (!env.COMP_CWORD) { + template('completion').on('data', emitter.emit.bind(emitter, 'end')); + return emitter; + } + + // parse environment and arguments, returns a hash of completion config. + var opts = complete(argv, env); + + // if only one word, complete the list of command or top level flags + if (opts.w === 1) { + if (opts.word.charAt(0) === '-') complete.log(flags, opts); + else complete.log(Object.keys(commands), opts); + return done(); + } + + // try to find the bower command. first thing after all the configs. + var parsed = opts.conf = nopt({}, {}, opts.partialWords, 0); + var cmd = parsed.argv.remain[0]; + + // unable to find a command, complete the lisf of commands + if (!cmd) { + complete.log(Object.keys(commands), opts); + return done(); + } + + // if words[0] is a bower command, then complete on it. + cmd = commands[cmd]; + + if (cmd && cmd.completion) { + // prior to that, ensure the completion cache directory is created first + mkdirp(path.join(config.completion), function (err) { + if (err) return emitter.emit('error', err); + cmd.completion(opts, function (err, data) { + if (err) return emitter.emit('error', err); + + // completing options, then append top level flags + if (opts.word.charAt(0) === '-') data = data.concat(flags); + + complete.log(data, opts); + + done(); + }); + }); + + return emitter; + } + + // otherwise, do nothing + return emitter; +}; + +module.exports.line = function (argv) { + var emitter = new Emitter; + var options = nopt(optionTypes, shorthand, argv); + + if (options.help) return help('completion'); + + module.exports(options.argv.remain.slice(2), process.env) + .on('data', emitter.emit.bind(emitter, 'data')) + .on('error', emitter.emit.bind(emitter, 'error')) + .on('end', emitter.emit.bind(emitter, 'end')); + + return emitter; +}; diff --git a/lib/commands/help.js b/lib/commands/help.js index e578ac0a..98cd8ba7 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -29,4 +29,8 @@ module.exports.line = function (argv) { var options = nopt({}, {}, argv); var paths = options.argv.remain.slice(1); return module.exports(paths[0]); -}; \ No newline at end of file +}; + +module.exports.completion = function (opts, cb) { + return cb(null, Object.keys(require('../commands'))); +}; diff --git a/lib/commands/index.js b/lib/commands/index.js index 405eb3d0..111e2dad 100644 --- a/lib/commands/index.js +++ b/lib/commands/index.js @@ -18,5 +18,6 @@ module.exports = { 'info': require('./info'), 'register': require('./register'), 'search': require('./search'), - 'cache-clean': require('./cache-clean') -}; \ No newline at end of file + 'cache-clean': require('./cache-clean'), + 'completion': require('./completion') +}; diff --git a/lib/commands/info.js b/lib/commands/info.js index 86274e66..84e0a37a 100644 --- a/lib/commands/info.js +++ b/lib/commands/info.js @@ -10,6 +10,7 @@ var nopt = require('nopt'); var template = require('../util/template'); var source = require('../core/source'); +var install = require('./install'); var help = require('./help'); var optionTypes = { help: Boolean }; @@ -42,4 +43,6 @@ module.exports.line = function (argv) { }); return emitter; -}; \ No newline at end of file +}; + +module.exports.completion = install.completion; diff --git a/lib/commands/install.js b/lib/commands/install.js index 9d40b6ec..65dbd9dd 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -13,8 +13,12 @@ var Emitter = require('events').EventEmitter; var nopt = require('nopt'); +var fs = require('fs'); +var path = require('path'); var Manager = require('../core/manager'); +var config = require('../core/config'); +var source = require('../core/source'); var save = require('../util/save'); var help = require('./help'); @@ -43,3 +47,36 @@ module.exports.line = function (argv) { if (options.help) return help('install'); return module.exports(paths, options); }; + +module.exports.completion = function (opts, cb) { + var word = opts.word; + + // completing options? + if (opts.words[0] === 'install' && word.charAt(0) === '-') { + return cb(null, Object.keys(optionTypes).map(function (option) { + return '--' + option; + })); + } + + var cache = path.join(config.completion, 'install.json'); + var done = function done(err, results) { + if (err) return cb(err); + var names = results.map(function (pkg) { + return pkg.name; + }); + + return cb(null, names); + }; + + fs.readFile(cache, function (err, body) { + if (!err) return done(null, JSON.parse(body)); + + // expected error, do the first request and cache the results + source.all(function (err, results) { + if (err) return cb(err); + fs.writeFile(cache, JSON.stringify(results, null, 2), function (err) { + done(err, results); + }); + }); + }); +}; diff --git a/lib/commands/link.js b/lib/commands/link.js index 4808b4b6..0f65afd9 100644 --- a/lib/commands/link.js +++ b/lib/commands/link.js @@ -108,4 +108,12 @@ module.exports.line = function (argv) { if (options.help) return help('link'); return module.exports(name); -}; \ No newline at end of file +}; + +module.exports.completion = function (opts, cb) { + fs.readdir(config.links, function (err, dirs) { + // ignore ENOENT, ~/.bower/links not created yet + if (err && err.code === 'ENOENT') return cb(null, []); + cb(err, dirs); + }); +}; diff --git a/lib/commands/list.js b/lib/commands/list.js index 47c5f5c1..8c655889 100644 --- a/lib/commands/list.js +++ b/lib/commands/list.js @@ -177,4 +177,14 @@ module.exports.line = function (argv) { var options = nopt(optionTypes, shorthand, argv); if (options.help) return help('list'); return module.exports(options); -}; \ No newline at end of file +}; + +module.exports.completion = function (opts, cb) { + if (!/^-/.test(opts.word)) return cb(null, []); + + var results = Object.keys(optionTypes).map(function (option) { + return '--' + option; + }); + + cb(null, results); +}; diff --git a/lib/commands/lookup.js b/lib/commands/lookup.js index ac60d46f..375583e0 100644 --- a/lib/commands/lookup.js +++ b/lib/commands/lookup.js @@ -11,6 +11,7 @@ var nopt = require('nopt'); var template = require('../util/template'); var source = require('../core/source'); +var install = require('./install'); var help = require('./help'); var optionTypes = { help: Boolean }; @@ -46,4 +47,6 @@ module.exports.line = function (argv) { if (options.help || !names.length) return help('lookup'); return module.exports(names[0]); -}; \ No newline at end of file +}; + +module.exports.completion = install.completion; diff --git a/lib/commands/search.js b/lib/commands/search.js index 4d4f170b..f1cd7d82 100644 --- a/lib/commands/search.js +++ b/lib/commands/search.js @@ -11,6 +11,7 @@ var nopt = require('nopt'); var template = require('../util/template'); var source = require('../core/source'); +var install = require('./install'); var help = require('./help'); var optionTypes = { help: Boolean }; @@ -46,4 +47,6 @@ module.exports.line = function (argv) { if (options.help) return help('search'); return module.exports(names[0]); -}; \ No newline at end of file +}; + +module.exports.completion = install.completion; diff --git a/lib/commands/uninstall.js b/lib/commands/uninstall.js index 707e595e..1cb5d151 100644 --- a/lib/commands/uninstall.js +++ b/lib/commands/uninstall.js @@ -149,4 +149,21 @@ module.exports.line = function (argv) { var names = options.argv.remain.slice(1); return module.exports(names, options); -}; \ No newline at end of file +}; + +module.exports.completion = function (opts, cb) { + var word = opts.word; + + // completing options? + if (opts.words[0] === 'uninstall' && word.charAt(0) === '-') { + return cb(null, Object.keys(optionTypes).map(function (option) { + return '--' + option; + })); + } + + fs.readdir(config.directory, function (err, dirs) { + // ignore ENOENT, ./components not created yet + if (err && err.code === 'ENOENT') return cb(null, []); + cb(err, dirs); + }); +}; diff --git a/lib/commands/update.js b/lib/commands/update.js index 39287299..1098831c 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -11,8 +11,9 @@ var async = require('async'); var nopt = require('nopt'); var _ = require('lodash'); -var Manager = require('../core/manager'); -var help = require('./help'); +var Manager = require('../core/manager'); +var help = require('./help'); +var uninstall = require('./uninstall'); var shorthand = { 'h': ['--help'], 'f': ['--force'] }; var optionTypes = { help: Boolean, force: Boolean }; @@ -75,4 +76,6 @@ module.exports.line = function (argv) { var paths = options.argv.remain.slice(1); return module.exports(paths, options); -}; \ No newline at end of file +}; + +module.exports.completion = uninstall.completion; diff --git a/lib/core/config.js b/lib/core/config.js index f305356e..7dfe7050 100644 --- a/lib/core/config.js +++ b/lib/core/config.js @@ -26,6 +26,7 @@ var folder = process.platform === 'win32' var config = require('rc') ('bower', { cache : path.join(roaming, folder, 'cache'), links : path.join(roaming, folder, 'links'), + completion : path.join(roaming, folder, 'completion'), json : 'component.json', endpoint : 'https://bower.herokuapp.com', directory : 'components' @@ -41,4 +42,4 @@ if (fileExists(localFile)) { // If an uncaught exception occurs, the temporary directories will be deleted nevertheless tmp.setGracefulCleanup(); -module.exports = config; \ No newline at end of file +module.exports = config; diff --git a/lib/util/completion.js b/lib/util/completion.js new file mode 100644 index 00000000..3d222f09 --- /dev/null +++ b/lib/util/completion.js @@ -0,0 +1,65 @@ +// ========================================== +// BOWER: completion +// ========================================== +// Copyright 2012 Twitter, Inc +// Licensed under The MIT License +// http://opensource.org/licenses/MIT +// ========================================== + +// This module exposes a simple helper to parse the environment variables in +// case of a tab completion command. It parses the provided `argv` (nopt's +// remain arguments after `--`) and `env` (should be process.env) +// +// It is inspired and based off Isaac's work on npm. + +module.exports = function (argv, env) { + var opts = {}; + + // w is the words number, based on the cursor position + opts.w = +env.COMP_CWORD; + + // words is the escaped sequence of words following `bower` + opts.words = argv.map(function (word) { + return word.charAt(0) === "\"" ? + word.replace(/^"|"$/g, "") : + word.replace(/\\ /g, " "); + }); + + // word is a shortcut to the last word in the line + opts.word = opts.words[opts.w - 1]; + + // line is the sequence of tab completed words. + opts.line = env.COMP_LINE; + + // point is the cursor position in the line + opts.point = +env.COMP_POINT; + + // length is the whole line's length. + opts.length = opts.line.length; + + // partialLine is the line ignoring the sequence of characters after + // cursor position, ie. tabbing at: bower install j|qu + // gives back a partialLine: bower install j + opts.partialLine = opts.line.slice(0, opts.point); + + // partialWords is only returning the words based on cursor position, + // ie tabbing at: bower install ze|pto backbone + // gives back a partialWords array: ['install', 'zepto'] + opts.partialWords = opts.words.slice(0, opts.w); + + return opts; +}; + +module.exports.log = function (arr, opts, prefix) { + arr = Array.isArray(arr) ? arr : [arr]; + arr.filter(module.exports.abbrev(opts)).forEach(function (word) { + console.log(word); + }); +}; + +module.exports.abbrev = function abbrev(opts) { + var word = opts.word.replace(/\./g, '\\.'); + return function (it) { + return new RegExp('^' + word).test(it); + }; +}; diff --git a/templates/completion.mustache b/templates/completion.mustache new file mode 100644 index 00000000..90e3a65c --- /dev/null +++ b/templates/completion.mustache @@ -0,0 +1,55 @@ +# Credits to npm's. Awesome completion utility. +# +# Bower completion script, based on npm completion script. + +###-begin-bower-completion-### +# +# Installation: bower completion >> ~/.bashrc (or ~/.zshrc) +# Or, maybe: bower completion > /usr/local/etc/bash_completion.d/npm +# + +COMP_WORDBREAKS=${COMP_WORDBREAKS/=/} +COMP_WORDBREAKS=${COMP_WORDBREAKS/@/} +export COMP_WORDBREAKS + +if type complete &>/dev/null; then + _bower_completion () { + local si="$IFS" + IFS=$'\n' COMPREPLY=($(COMP_CWORD="$COMP_CWORD" \ + COMP_LINE="$COMP_LINE" \ + COMP_POINT="$COMP_POINT" \ + bower completion -- "${COMP_WORDS[@]}" \ + 2>/dev/null)) || return $? + IFS="$si" + } + complete -F _bower_completion bower +elif type compdef &>/dev/null; then + _bower_completion() { + si=$IFS + compadd -- $(COMP_CWORD=$((CURRENT-1)) \ + COMP_LINE=$BUFFER \ + COMP_POINT=0 \ + bower completion -- "${words[@]}" \ + 2>/dev/null) + IFS=$si + } + compdef _bower_completion bower +elif type compctl &>/dev/null; then + _bower_completion () { + local cword line point words si + read -Ac words + read -cn cword + let cword-=1 + read -l line + read -ln point + si="$IFS" + IFS=$'\n' reply=($(COMP_CWORD="$cword" \ + COMP_LINE="$line" \ + COMP_POINT="$point" \ + bower completion -- "${words[@]}" \ + 2>/dev/null)) || return $? + IFS="$si" + } + compctl -K _bower_completion bower +fi +###-end-bower-completion-### diff --git a/templates/help-completion.mustache b/templates/help-completion.mustache new file mode 100644 index 00000000..32e058d4 --- /dev/null +++ b/templates/help-completion.mustache @@ -0,0 +1,12 @@ + +Usage: + + {{#cyan}}bower{{/cyan}} completion + +Synopsis: + + . <(bower completion) + +Description: + + Tab Completion for bower diff --git a/test/completion.js b/test/completion.js new file mode 100644 index 00000000..cbe6af74 --- /dev/null +++ b/test/completion.js @@ -0,0 +1,129 @@ +/*jshint plusplus:false*/ + +var fs = require('fs'); +var path = require('path'); +var assert = require('assert'); + +var complete = require('../lib/util/completion'); +var command = require('../lib/commands/completion'); +var commands = require('../lib/commands'); + +describe('completion', function () { + + before(function () { + this.opts = complete(['bower', 'install'], { + COMP_CWORD: '2', + COMP_LINE: 'bower install', + COMP_POINT: '14' + }); + }); + + beforeEach(function() { + this.log = complete.log; + }); + + afterEach(function () { + complete.log = this.log; + }); + + it('parses COMP_* in the env', function () { + assert.deepEqual(this.opts, { + w: 2, + words: [ 'bower', 'install' ], + word: 'install', + line: 'bower install', + point: 14, + length: 13, + partialLine: 'bower install', + partialWords: [ 'bower', 'install' ] + }); + }); + + + it('filters out completion results', function () { + var completed = [ + 'backbone', + 'backbone-forms', + 'backbone-paginator', + 'backbone.forms', + 'backbone.paginator' + ]; + + var all = complete.abbrev({ word: 'ba' }); + var none = complete.abbrev({ word: 'foobar' }); + var form = complete.abbrev({ word: 'backbone-form' }); + var dots = complete.abbrev({ word: 'backbone.' }); + var dashed = complete.abbrev({ word: 'backbone-' }); + + assert.deepEqual(completed.filter(all), completed); + assert.deepEqual(completed.filter(none), []); + assert.deepEqual(completed.filter(form), ['backbone-forms']); + assert.deepEqual(completed.filter(dashed), ['backbone-forms', 'backbone-paginator']); + assert.deepEqual(completed.filter(dots), ['backbone.forms', 'backbone.paginator']); + }); + + + it('dumps the script when COMP_* aren\'t in the env', function (done) { + command().on('end', function (data) { + var script = fs.readFileSync(path.join(__dirname, '../templates/completion.mustache'), 'utf8'); + assert.equal(data, script); + done(); + }); + }); + + it('completes the list of command on first word', function () { + complete.log = function (results, opts) { + assert.deepEqual(results, Object.keys(commands)); + }; + + command([''], { + COMP_CWORD: '1', + COMP_LINE: 'bower ', + COMP_POINT: '6' + }); + }); + + it('completes the list of options on first word', function () { + complete.log = function (results, opts) { + assert.deepEqual(results, ['--no-color', '--help', '--version']); + }; + + command(['-'], { + COMP_CWORD: '1', + COMP_LINE: 'bower -', + COMP_POINT: '7' + }); + }); + + it('completes the list of command on invalid command', function () { + complete.log = function (results, opts) { + assert.deepEqual(results, Object.keys(commands)); + }; + + command(['foobar'], { + COMP_CWORD: '1', + COMP_LINE: 'bower foobar', + COMP_POINT: '12' + }); + }); + + it('delegates to command.completion for each bower command', function (done) { + complete.log = function (results, opts) { + assert.ok(results.length); + + var jq = results.filter(function (res) { + return res === 'jquery'; + }); + + assert.equal(jq.length, 1); + }; + + var cmd = command(['install', 'jquery-'], { + COMP_CWORD: '2', + COMP_LINE: 'bower install jquery-', + COMP_POINT: '14' + }); + + cmd.on('end', done); + }); +});