From 9dc1cf6a6e095653fed6c79c4896c71af8af1953 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Sun, 28 Jul 2013 17:35:36 -0700 Subject: [PATCH] feat: basic bash/zsh completion --- bin/karma | 4 +- karma-completion.sh | 50 ++++++++++ lib/cli.js | 20 +++- lib/completion.js | 160 +++++++++++++++++++++++++++++++ test/unit/completion.spec.coffee | 59 ++++++++++++ 5 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 karma-completion.sh create mode 100644 lib/completion.js create mode 100644 test/unit/completion.spec.coffee diff --git a/bin/karma b/bin/karma index 67ac9d6ce..5c55decf9 100755 --- a/bin/karma +++ b/bin/karma @@ -12,7 +12,6 @@ if (!fs.existsSync(dir)) { } var cli = require(path.join(dir, 'cli')); - var config = cli.process(); switch (config.cmd) { @@ -25,4 +24,7 @@ switch (config.cmd) { case 'init': require(path.join(dir, 'init')).init(config); break; + case 'completion': + require(path.join(dir, 'completion')).completion(config); + break; } diff --git a/karma-completion.sh b/karma-completion.sh new file mode 100644 index 000000000..fc683540d --- /dev/null +++ b/karma-completion.sh @@ -0,0 +1,50 @@ +###-begin-karma-completion-### +# +# karma command completion script +# This is stolen from NPM. Thanks @isaac! +# +# Installation: karma completion >> ~/.bashrc (or ~/.zshrc) +# Or, maybe: karma completion > /usr/local/etc/bash_completion.d/npm +# + +if type complete &>/dev/null; then + __karma_completion () { + local si="$IFS" + IFS=$'\n' COMPREPLY=($(COMP_CWORD="$COMP_CWORD" \ + COMP_LINE="$COMP_LINE" \ + COMP_POINT="$COMP_POINT" \ + karma completion -- "${COMP_WORDS[@]}" \ + 2>/dev/null)) || return $? + IFS="$si" + } + complete -F __karma_completion karma +elif type compdef &>/dev/null; then + __karma_completion() { + si=$IFS + compadd -- $(COMP_CWORD=$((CURRENT-1)) \ + COMP_LINE=$BUFFER \ + COMP_POINT=0 \ + karma completion -- "${words[@]}" \ + 2>/dev/null) + IFS=$si + } + compdef __karma_completion karma +elif type compctl &>/dev/null; then + __karma_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" \ + karma completion -- "${words[@]}" \ + 2>/dev/null)) || return $? + IFS="$si" + } + compctl -K __karma_completion karma +fi +###-end-karma-completion-### diff --git a/lib/cli.js b/lib/cli.js index c536b9ede..4f0ffa3ae 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -76,7 +76,8 @@ var describeShared = function() { 'Commands:\n' + ' start [] [] Start the server / do single run.\n' + ' init [] Initialize a config file.\n' + - ' run [] [ -- ] Trigger a test run.\n\n' + + ' run [] [ -- ] Trigger a test run.\n' + + ' completion Shell completion for karma.\n\n' + 'Run --help with particular command to see its description and available options.') .describe('help', 'Print usage and options.') .describe('version', 'Print current version.'); @@ -93,7 +94,6 @@ var describeInit = function() { .describe('colors', 'Use colors when reporting and printing logs.') .describe('no-colors', 'Do not use colors when reporting or printing logs.') .describe('help', 'Print usage and options.') - .describe('version', 'Print current version.'); }; @@ -116,7 +116,6 @@ var describeStart = function() { .describe('no-single-run', 'Disable single-run.') .describe('report-slower-than', ' Report tests that are slower than given time [ms].') .describe('help', 'Print usage and options.') - .describe('version', 'Print current version.'); }; @@ -128,7 +127,16 @@ var describeRun = function() { ' $0 run []') .describe('port', ' Port where the server is listening.') .describe('help', 'Print usage.') - .describe('version', 'Print current version.'); +}; + + +var describeCompletion = function() { + optimist + .usage('Karma - Spectacular Test Runner for JavaScript.\n\n' + + 'COMPLETION - Bash/ZSH completion for karma.\n\n' + + 'Installation:\n' + + ' $0 completion >> ~/.bashrc\n') + .describe('help', 'Print usage.') }; @@ -153,6 +161,10 @@ exports.process = function() { describeInit(); break; + case 'completion': + describeCompletion(); + break; + default: describeShared(); if (!options.cmd) { diff --git a/lib/completion.js b/lib/completion.js new file mode 100644 index 000000000..62ad65994 --- /dev/null +++ b/lib/completion.js @@ -0,0 +1,160 @@ +var CUSTOM = ['']; +var BOOLEAN = false; + +var options = { + start: { + '--port': CUSTOM, + '--auto-watch': BOOLEAN, + '--no-auto-watch': BOOLEAN, + '--log-level': ['disable', 'debug', 'info', 'warn', 'error'], + '--colors': BOOLEAN, + '--no-colors': BOOLEAN, + '--reporters': ['dots', 'progress'], + '--no-reporters': BOOLEAN, + '--browsers': ['Chrome', 'ChromeCanary', 'Firefox', 'PhantomJS', 'Safari', 'Opera'], + '--no-browsers': BOOLEAN, + '--single-run': BOOLEAN, + '--no-single-run': BOOLEAN, + '--help': BOOLEAN + }, + init: { + '--colors': BOOLEAN, + '--no-colors': BOOLEAN, + '--help': BOOLEAN + }, + run: { + '--port': CUSTOM, + '--help': BOOLEAN + } +}; + +var parseEnv = function(argv, env) { + var words = argv.slice(5); + + return { + words: words, + count: parseInt(env.COMP_CWORD, 10), + last: words[words.length - 1], + prev: words[words.length - 2] + }; +}; + +var opositeWord = function(word) { + if (word.charAt(0) !== '-') { + return null; + } + + return word.substr(0, 5) === '--no-' ? '--' + word.substr(5) : '--no-' + word.substr(2); +}; + +var sendCompletion = function(possibleWords, env) { + var regexp = new RegExp('^' + env.last); + var filteredWords = possibleWords.filter(function(word) { + return regexp.test(word) && env.words.indexOf(word) === -1 && env.words.indexOf(opositeWord(word)) === -1; + }); + + if (!filteredWords.length) { + return sendCompletionNoOptions(env); + } + + filteredWords.forEach(function(word) { + console.log(word); + }); +}; + + +var glob = require('glob'); +var globOpts = { + mark: true, + nocase: true +}; + +var sendCompletionFiles = function(env) { + glob(env.last + '*', globOpts, function(err, files) { + if (files.length === 1 && files[0].charAt(files[0].length - 1) === '/') { + sendCompletionFiles({last: files[0]}); + } else { + console.log(files.join('\n')); + } + }); +}; + +var sendCompletionConfirmLast = function(env) { + console.log(env.last); +}; + +var sendCompletionNoOptions = function() {}; + +var complete = function(env) { + if (env.count === 1) { + if (env.words[0].charAt(0) === '-') { + return sendCompletion(['--help', '--version'], env); + } + + return sendCompletion(Object.keys(options), env); + } + + if (env.count === 2 && env.words[1].charAt(0) !== '-') { + // complete files (probably karma.conf.js) + return sendCompletionFiles(env); + } + + var cmdOptions = options[env.words[0]]; + var previousOption = cmdOptions[env.prev]; + + if (!cmdOptions) { + // no completion, wrong command + return sendCompletionNoOptions(); + } + + if (previousOption === CUSTOM && env.last) { + // custom value with already filled something + return sendCompletionConfirmLast(env); + } + + if (previousOption) { + // custom options + return sendCompletion(previousOption, env); + } + + return sendCompletion(Object.keys(cmdOptions), env); +}; + + +var completion = function() { + if (process.argv[3] == '--') { + return complete(parseEnv(process.argv, process.env)); + } + + // just print out the karma-completion.sh + var fs = require('fs'); + var path = require('path'); + + fs.readFile(path.resolve(__dirname, '../karma-completion.sh'), 'utf8', function (err, data) { + process.stdout.write(data); + process.stdout.on('error', function (error) { + // Darwin is a real dick sometimes. + // + // This is necessary because the "source" or "." program in + // bash on OS X closes its file argument before reading + // from it, meaning that you get exactly 1 write, which will + // work most of the time, and will always raise an EPIPE. + // + // Really, one should not be tossing away EPIPE errors, or any + // errors, so casually. But, without this, `. <(karma completion)` + // can never ever work on OS X. + if (error.errno === 'EPIPE') { + error = null; + } + }); + }); +}; + + +// PUBLIC API +exports.completion = completion; + +// for testing +exports.opositeWord = opositeWord; +exports.sendCompletion = sendCompletion; +exports.complete = complete; diff --git a/test/unit/completion.spec.coffee b/test/unit/completion.spec.coffee new file mode 100644 index 000000000..e5a822bcb --- /dev/null +++ b/test/unit/completion.spec.coffee @@ -0,0 +1,59 @@ +#============================================================================== +# lib/completion.js module +#============================================================================== +describe 'completion', -> + c = require '../../lib/completion' + completion = null + + mockEnv = (line) -> + words = line.split ' ' + + words: words + count: words.length + last: words[words.length - 1] + prev: words[words.length - 2] + + beforeEach -> + sinon.stub console, 'log', (msg) -> completion.push msg + completion = [] + + describe 'opositeWord', -> + + it 'should handle --no-x args', -> + expect(c.opositeWord '--no-single-run').to.equal '--single-run' + + + it 'should handle --x args', -> + expect(c.opositeWord '--browsers').to.equal '--no-browsers' + + + it 'should ignore args without --', -> + expect(c.opositeWord 'start').to.equal null + + + describe 'sendCompletion', -> + + it 'should filter only words matching last typed partial', -> + c.sendCompletion ['start', 'init', 'run'], mockEnv 'in' + expect(completion).to.deep.equal ['init'] + + + it 'should filter out already used words/args', -> + c.sendCompletion ['--single-run', '--port', '--xxx'], mockEnv 'start --single-run ' + expect(completion).to.deep.equal ['--port', '--xxx'] + + + it 'should filter out already used oposite words', -> + c.sendCompletion ['--auto-watch', '--port'], mockEnv 'start --no-auto-watch ' + expect(completion).to.deep.equal ['--port'] + + + describe 'complete', -> + + it 'should complete the basic commands', -> + c.complete mockEnv '' + expect(completion).to.deep.equal ['start', 'init', 'run'] + + completion.length = 0 # reset + c.complete mockEnv 's' + expect(completion).to.deep.equal ['start']