diff --git a/GNUmakefile b/GNUmakefile index d07f03b..bee42e2 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -1,7 +1,11 @@ - +.PHONY: all all: lib lib: $(foreach s,$(wildcard src/*.coffee),$(patsubst src/%.coffee,lib/%.js,$s)) lib/%.js: src/%.coffee coffee -cb -o $(@D) $< + +.PHONY: test +test: lib + ./node_modules/.bin/vows --spec diff --git a/README.md b/README.md index 64acabb..9559f8f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ COA is a yet another parser for command line options. You can choose one of the [existing modules](https://github.com/joyent/node/wiki/modules#wiki-parsers-commandline), or write your own like me. +## Features + +* Shell completion (experimantal) + ## Examples ````javascript @@ -97,10 +101,24 @@ Apply function with arguments in context of command instance.
**@param** *Array* `args`
**@returns** *COA.Cmd* `this` instance (for chainability) +#### Cmd.comp +Set custom additional completion for current command.
+**@param** *Function* `fn` completion generation function, + invoked in the context of command instance. + Accepts parameters:
+ - *Object* `opts` completion options
+ It can return promise or any other value treated as result.
+**@returns** *COA.Cmd* `this` instance (for chainability) + #### Cmd.helpful Make command "helpful", i.e. add -h --help flags for print usage.
**@returns** *COA.Cmd* `this` instance (for chainability) +#### Cmd.completable +Adds shell completion to command, adds "completion" subcommand, that makes all the magic.
+Must be called only on root command.
+**@returns** *COA.Cmd* `this` instance (for chainability) + #### Cmd.usage Build full usage text for current command instance.
**@returns** *String* `usage` text @@ -138,7 +156,6 @@ Set a long description for option to be used anywhere in text messages.
**@param** *String* `_title` option title
**@returns** *COA.Opt* `this` instance (for chainability) - #### Opt.short Set a short key for option to be used with one hyphen from command line.
**@param** *String* `_short`
@@ -201,6 +218,15 @@ is present in parsed options (with any value).
or any other value treated as result.
**@returns** *COA.Opt* `this` instance (for chainability) +#### Opt.comp +Set custom additional completion for current option.
+**@param** *Function* `fn` completion generation function, + invoked in the context of command instance. + Accepts parameters:
+ - *Object* `opts` completion options
+ It can return promise or any other value treated as result.
+**@returns** *COA.Opt* `this` instance (for chainability) + #### Opt.end Finish chain for current option and return parent command instance.
**@returns** *COA.Cmd* `parent` command @@ -249,6 +275,15 @@ Make argument value outputing stream.
It's add useful validation and shortcut for STDOUT.
**@returns** *COA.Arg* `this` instance (for chainability) +#### Opt.comp +Set custom additional completion for current argument.
+**@param** *Function* `fn` completion generation function, + invoked in the context of command instance. + Accepts parameters:
+ - *Object* `opts` completion options
+ It can return promise or any other value treated as result.
+**@returns** *COA.Arg* `this` instance (for chainability) + #### Arg.end Finish chain for current option and return parent command instance.
**@returns** *COA.Cmd* `parent` command @@ -256,7 +291,6 @@ Finish chain for current option and return parent command instance.
## TODO * Program API for use COA-covered programs as modules -* Shell completion * Localization * Shell-mode * Configs diff --git a/lib/arg.js b/lib/arg.js index ef8a82f..f05d16d 100644 --- a/lib/arg.js +++ b/lib/arg.js @@ -3,7 +3,8 @@ Color = require('./color').Color; Cmd = require('./cmd').Cmd; Opt = require('./opt').Opt; /** -## Argument +Argument + Unnamed entity. From command line arguments passed as list of unnamed values. @namespace @class Presents argument @@ -57,6 +58,16 @@ exports.Arg = Arg = (function() { */ Arg.prototype.def = Opt.prototype.def; /** + Set custom additional completion for current argument. + @param {Function} completion generation function, + invoked in the context of argument instance. + Accepts parameters: + - {Object} opts completion options + It can return promise or any other value treated as result. + @returns {COA.Arg} this instance (for chainability) + */ + Arg.prototype.comp = Cmd.prototype.comp; + /** Make argument value inputting stream. It's add useful validation and shortcut for STDIN. @returns {COA.Arg} this instance (for chainability) diff --git a/lib/cmd.js b/lib/cmd.js index 351ff3c..0cb0172 100644 --- a/lib/cmd.js +++ b/lib/cmd.js @@ -5,7 +5,8 @@ path = require('path'); Color = require('./color').Color; Q = require('q'); /** -## Command +Command + Top level entity. Commands may have options and arguments. @namespace @class Presents command @@ -39,7 +40,10 @@ exports.Cmd = Cmd = (function() { */ Cmd.prototype.name = function(_name) { this._name = _name; - return this._cmd._cmdsByName[_name] = this; + if (this._cmd !== this) { + this._cmd._cmdsByName[_name] = this; + } + return this; }; /** Set a long description for command to be used anywhere in text messages. @@ -101,6 +105,19 @@ exports.Cmd = Cmd = (function() { return this; }; /** + Set custom additional completion for current command. + @param {Function} completion generation function, + invoked in the context of command instance. + Accepts parameters: + - {Object} opts completion options + It can return promise or any other value treated as result. + @returns {COA.Cmd} this instance (for chainability) + */ + Cmd.prototype.comp = function(_comp) { + this._comp = _comp; + return this; + }; + /** Apply function with arguments in context of command instance. @param {Function} fn @param {Array} args @@ -121,6 +138,15 @@ exports.Cmd = Cmd = (function() { return this.usage(); }).end(); }; + /** + Adds shell completion to command, adds "completion" subcommand, + that makes all the magic. + Must be called only on root command. + @returns {COA.Cmd} this instance (for chainability) + */ + Cmd.prototype.completable = function() { + return this.cmd().name('completion').apply(require('./completion')).end(); + }; Cmd.prototype._exit = function(msg, code) { if (msg) { sys.error(msg); @@ -178,18 +204,50 @@ exports.Cmd = Cmd = (function() { } } }; - Cmd.prototype._parseArr = function(argv, opts, args) { - var arg, cmd, hitOnly, i, m, nonParsed, nonParsedArgs, nonParsedOpts, opt, res, _i, _len, _ref; - if (opts == null) { - opts = {}; + Cmd.prototype._checkRequired = function(opts, args) { + var all, i, _results; + if (!(this._opts.filter(function(o) { + return o._only && o._name in opts; + })).length) { + all = this._opts.concat(this._args); + _results = []; + while (i = all.shift()) { + if (i._req && i._checkParsed(opts, args)) { + return this.reject(i._requiredText()); + } + } + return _results; } - if (args == null) { - args = {}; + }; + Cmd.prototype._parseCmd = function(argv, unparsed) { + var cmd, i, optSeen; + if (unparsed == null) { + unparsed = []; } - nonParsedOpts = this._opts.concat(); + argv = argv.concat(); + optSeen = false; while (i = argv.shift()) { if (!i.indexOf('-')) { - nonParsedArgs || (nonParsedArgs = this._args.concat()); + optSeen = true; + } + if (!optSeen && /^\w[\w-_]*$/.test(i) && (cmd = this._cmdsByName[i])) { + return cmd._parseCmd(argv, unparsed); + } + unparsed.push(i); + } + return { + cmd: this, + argv: unparsed + }; + }; + Cmd.prototype._parseOptsAndArgs = function(argv) { + var a, arg, args, i, m, nonParsed, nonParsedArgs, nonParsedOpts, opt, opts, res; + opts = {}; + args = {}; + nonParsedOpts = this._opts.concat(); + nonParsedArgs = this._args.concat(); + while (i = argv.shift()) { + if (i !== '--' && !i.indexOf('-')) { if (m = i.match(/^(--\w[\w-_]*)=(.*)$/)) { i = m[1]; argv.unshift(m[2]); @@ -201,79 +259,63 @@ exports.Cmd = Cmd = (function() { } else { return this.reject("Unknown option: " + i); } - } else if (!nonParsedArgs && /^\w[\w-_]*$/.test(i)) { - cmd = this._cmdsByName[i]; - if (cmd) { - return cmd._parseArr(argv, opts, args); - } else { - nonParsedArgs = this._args.concat(); - argv.unshift(i); - } } else { - if (arg = (nonParsedArgs || (nonParsedArgs = this._args.concat())).shift()) { - if (arg._arr) { - nonParsedArgs.unshift(arg); - } - if (Q.isPromise(res = arg._parse(i, args))) { - return res; + if (i === '--') { + i = argv.splice(0); + } + i = Array.isArray(i) ? i : [i]; + while (a = i.shift()) { + if (arg = nonParsedArgs.shift()) { + if (arg._arr) { + nonParsedArgs.unshift(arg); + } + if (Q.isPromise(res = arg._parse(a, args))) { + return res; + } + } else { + return this.reject("Unknown argument: " + a); } - } else { - return this.reject("Unknown argument: " + i); } } } - nonParsedArgs || (nonParsedArgs = this._args.concat()); - hitOnly = false; - _ref = this._opts; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - opt = _ref[_i]; - if (opt._only && opt._name in opts) { - hitOnly = true; - } - } - if (!hitOnly) { - nonParsed = nonParsedOpts.concat(nonParsedArgs); - while (i = nonParsed.shift()) { - if (i._req && i._checkParsed(opts, args)) { - return this.reject(i._requiredText()); - } - if ('_def' in i) { - i._saveVal(opts, i._def); - } + nonParsed = nonParsedOpts.concat(nonParsedArgs); + while (i = nonParsed.shift()) { + if ('_def' in i) { + i._saveVal(opts, i._def); } } return { - cmd: this, opts: opts, args: args }; }; + Cmd.prototype._parseArr = function(argv) { + var cmd, res, _ref; + _ref = this._parseCmd(argv), cmd = _ref.cmd, argv = _ref.argv; + if (Q.isPromise(res = cmd._parseOptsAndArgs(argv))) { + return res; + } + return { + cmd: cmd, + opts: res.opts, + args: res.args + }; + }; Cmd.prototype._do = function(input, succ, err) { - var cmd, defer, parsed, _ref; + var cmd, defer, parsed; defer = Q.defer(); parsed = this._parseArr(input); cmd = parsed.cmd || this; - if ((_ref = cmd._act) != null) { - _ref.reduce(__bind(function(res, act) { - return res.then(__bind(function(params) { - var actRes, _ref2; - actRes = act.call(cmd, params.opts, params.args, params.res); - if (Q.isPromise(actRes)) { - return actRes; - } else { - if ((_ref2 = params.res) == null) { - params.res = actRes; - } - return params; - } - }, this)); - }, this), defer.promise).fail(__bind(function(res) { - return err.call(cmd, res); - }, this)).then(__bind(function(res) { - return succ.call(cmd, res.res); + [this._checkRequired].concat(cmd._act || []).reduce(__bind(function(res, act) { + return res.then(__bind(function(res) { + return act.call(cmd, parsed.opts, parsed.args, res); }, this)); - } - return defer.resolve(parsed); + }, this), defer.promise).fail(__bind(function(res) { + return err.call(cmd, res); + }, this)).then(__bind(function(res) { + return succ.call(cmd, res); + }, this)); + return defer.resolve(Q.isPromise(parsed) ? parsed : void 0); }; /** Parse arguments from simple format like NodeJS process.argv @@ -288,8 +330,8 @@ exports.Cmd = Cmd = (function() { } cb = function(code) { return function(res) { - var _ref; - return this._exit(res.toString(), (_ref = res.exitCode) != null ? _ref : code); + var _ref, _ref2; + return this._exit((_ref = res.stack) != null ? _ref : res.toString(), (_ref2 = res.exitCode) != null ? _ref2 : code); }; }; this._do(argv, cb(0), cb(1)); diff --git a/lib/completion.js b/lib/completion.js new file mode 100644 index 0000000..5ad10df --- /dev/null +++ b/lib/completion.js @@ -0,0 +1,125 @@ +/** +Most of the code adopted from the npm package shell completion code. +See https://github.com/isaacs/npm/blob/master/lib/completion.js +*/ +var Q, complete, dumpScript, escape, getOpts, unescape; +Q = require('q'); +escape = require('./shell').escape; +unescape = require('./shell').unescape; +module.exports = function() { + return this.title('Shell completion').helpful().arg().name('raw').title('Completion words').arr().end().act(function(opts, args) { + var argv, cmd, e, _ref; + if (process.platform === 'win32') { + e = new Error('shell completion not supported on windows'); + e.code = 'ENOTSUP'; + e.errno = require('constants').ENOTSUP; + return this.reject(e); + } + if (!(process.env.COMP_CWORD != null) || !(process.env.COMP_LINE != null) || !(process.env.COMP_POINT != null)) { + return dumpScript(this._cmd._name); + } + console.error('COMP_LINE: %s', process.env.COMP_LINE); + console.error('COMP_CWORD: %s', process.env.COMP_CWORD); + console.error('COMP_POINT: %s', process.env.COMP_POINT); + console.error('args: %j', args.raw); + opts = getOpts(args.raw); + _ref = this._cmd._parseCmd(opts.partialWords), cmd = _ref.cmd, argv = _ref.argv; + return Q.when(complete(cmd, opts), function(compls) { + console.error('filtered: %j', compls); + return console.log(compls.map(escape).join('\n')); + }); + }); +}; +dumpScript = function(name) { + var defer, fs, path; + fs = require('fs'); + path = require('path'); + defer = Q.defer(); + fs.readFile(path.resolve(__dirname, 'completion.sh'), 'utf8', function(err, d) { + var onError; + if (err) { + return defer.reject(err); + } + d = d.replace(/{{cmd}}/g, path.basename(name)).replace(/^\#\!.*?\n/, ''); + onError = function(err) { + if (err.errno === require('constants').EPIPE) { + process.stdout.removeListener('error', onError); + return defer.resolve(); + } else { + return defer.reject(err); + } + }; + process.stdout.on('error', onError); + return process.stdout.write(d, function() { + return defer.resolve(); + }); + }); + return defer.promise; +}; +getOpts = function(argv) { + var i, line, partialLine, partialWord, partialWords, point, w, word, words; + line = process.env.COMP_LINE; + w = +process.env.COMP_CWORD; + point = +process.env.COMP_POINT; + words = argv.map(unescape); + word = words[w]; + partialLine = line.substr(0, point); + partialWords = words.slice(0, w); + partialWord = argv[w] || ''; + i = partialWord.length; + while (partialWord.substr(0, i) !== partialLine.substr(-1 * i) && i > 0) { + i--; + } + partialWord = unescape(partialWord.substr(0, i)); + if (partialWord) { + partialWords.push(partialWord); + } + return { + line: line, + w: w, + point: point, + words: words, + word: word, + partialLine: partialLine, + partialWords: partialWords, + partialWord: partialWord + }; +}; +complete = function(cmd, opts) { + var compls, m, o, opt, optPrefix, optWord; + compls = []; + if (opts.partialWord.indexOf('-')) { + compls = Object.keys(cmd._cmdsByName); + } else { + if (m = opts.partialWord.match(/^(--\w[\w-_]*)=(.*)$/)) { + optWord = m[1]; + optPrefix = optWord + '='; + } else { + compls = Object.keys(cmd._optsByKey); + } + } + if (!(o = opts.partialWords[opts.w - 1]).indexOf('-')) { + optWord = o; + } + if (optWord && (opt = cmd._optsByKey[optWord])) { + if (!opt._flag && opt._comp) { + compls = Q.join(compls, Q.when(opt._comp(opts), function(c, o) { + return c.concat(o.map(function(v) { + return (optPrefix || '') + v; + })); + })); + } + } + if (cmd._comp) { + compls = Q.join(compls, Q.when(cmd._comp(opts)), function(c, o) { + return c.concat(o); + }); + } + return Q.when(compls, function(compls) { + console.error('partialWord: %s', opts.partialWord); + console.error('compls: %j', compls); + return compls.filter(function(c) { + return c.indexOf(opts.partialWord) === 0; + }); + }); +}; \ No newline at end of file diff --git a/lib/completion.sh b/lib/completion.sh new file mode 100644 index 0000000..6d15c87 --- /dev/null +++ b/lib/completion.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +###-begin-{{cmd}}-completion-### +# +# {{cmd}} command completion script +# +# Installation: {{cmd}} completion >> ~/.bashrc (or ~/.zshrc) +# Or, maybe: {{cmd}} completion > /usr/local/etc/bash_completion.d/{{cmd}} +# + +COMP_WORDBREAKS=${COMP_WORDBREAKS/=/} +COMP_WORDBREAKS=${COMP_WORDBREAKS/@/} +export COMP_WORDBREAKS + +if complete &>/dev/null; then + _{{cmd}}_completion () { + local si="$IFS" + IFS=$'\n' COMPREPLY=($(COMP_CWORD="$COMP_CWORD" \ + COMP_LINE="$COMP_LINE" \ + COMP_POINT="$COMP_POINT" \ + {{cmd}} completion -- "${COMP_WORDS[@]}" \ + 2>/dev/null)) || return $? + IFS="$si" + } + complete -F _{{cmd}}_completion {{cmd}} +elif compctl &>/dev/null; then + _{{cmd}}_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" \ + {{cmd}} completion -- "${words[@]}" \ + 2>/dev/null)) || return $? + IFS="$si" + } + compctl -K _{{cmd}}_completion {{cmd}} +fi +###-end-{{cmd}}-completion-### diff --git a/lib/opt.js b/lib/opt.js index 87f45fa..b4508df 100644 --- a/lib/opt.js +++ b/lib/opt.js @@ -1,9 +1,12 @@ -var Cmd, Color, Opt, fs; +var Cmd, Color, Opt, Q, fs; +var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; fs = require('fs'); +Q = require('q'); Color = require('./color').Color; Cmd = require('./cmd').Cmd; /** -## Option +Option + Named entity. Options may have short and long keys for use from command line. @namespace @class Presents option @@ -172,12 +175,14 @@ exports.Opt = Opt = (function() { if (name in opts) { res = act.apply(this, arguments); if (opt._only) { - return this.reject({ - toString: function() { - return res; - }, - exitCode: 0 - }); + return Q.when(res, __bind(function(res) { + return this.reject({ + toString: function() { + return res.toString(); + }, + exitCode: 0 + }); + }, this)); } else { return res; } @@ -185,6 +190,16 @@ exports.Opt = Opt = (function() { }); return this; }; + /** + Set custom additional completion for current option. + @param {Function} completion generation function, + invoked in the context of option instance. + Accepts parameters: + - {Object} opts completion options + It can return promise or any other value treated as result. + @returns {COA.Opt} this instance (for chainability) + */ + Opt.prototype.comp = Cmd.prototype.comp; Opt.prototype._saveVal = function(opts, val) { var _name; if (this._val) { diff --git a/lib/shell.js b/lib/shell.js new file mode 100644 index 0000000..dfd8c42 --- /dev/null +++ b/lib/shell.js @@ -0,0 +1,12 @@ +exports.unescape = function(w) { + w = w.charAt(0) === '"' ? w.replace(/^"|([^\\])"$/g, '$1') : w.replace(/\\ /g, ' '); + return w.replace(/\\("|'|\$|`|\\)/g, '$1'); +}; +exports.escape = function(w) { + w = w.replace(/(["'$`\\])/g, '\\$1'); + if (w.match(/\s+/)) { + return '"' + w + '"'; + } else { + return w; + } +}; \ No newline at end of file diff --git a/package.json b/package.json index 15c7772..02d43b1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "dependencies" : { "q": ">=0.7.1" }, + "devDependencies" : { + "vows": ">=0.5.12" + }, "engines" : [ "node ~0.4.0" ], "licenses" : [ { "type" : "AS IS" } ] } diff --git a/src/arg.coffee b/src/arg.coffee index 604f3f1..1620937 100644 --- a/src/arg.coffee +++ b/src/arg.coffee @@ -3,7 +3,8 @@ Cmd = require('./cmd').Cmd Opt = require('./opt').Opt ###* -## Argument +Argument + Unnamed entity. From command line arguments passed as list of unnamed values. @namespace @class Presents argument @@ -62,6 +63,17 @@ exports.Arg = class Arg ### def: Opt::def + ###* + Set custom additional completion for current argument. + @param {Function} completion generation function, + invoked in the context of argument instance. + Accepts parameters: + - {Object} opts completion options + It can return promise or any other value treated as result. + @returns {COA.Arg} this instance (for chainability) + ### + comp: Cmd::comp + ###* Make argument value inputting stream. It's add useful validation and shortcut for STDIN. diff --git a/src/cmd.coffee b/src/cmd.coffee index a2db43b..31f006b 100644 --- a/src/cmd.coffee +++ b/src/cmd.coffee @@ -6,7 +6,8 @@ Q = require('q') #inspect = require('eyes').inspector { maxLength: 99999, stream: process.stderr } ###* -## Command +Command + Top level entity. Commands may have options and arguments. @namespace @class Presents command @@ -41,7 +42,9 @@ exports.Cmd = class Cmd @param {String} _name command name @returns {COA.Cmd} this instance (for chainability) ### - name: (@_name) -> @_cmd._cmdsByName[_name] = @ + name: (@_name) -> + if @_cmd isnt @ then @_cmd._cmdsByName[_name] = @ + @ ###* Set a long description for command to be used anywhere in text messages. @@ -94,6 +97,17 @@ exports.Cmd = class Cmd @ + ###* + Set custom additional completion for current command. + @param {Function} completion generation function, + invoked in the context of command instance. + Accepts parameters: + - {Object} opts completion options + It can return promise or any other value treated as result. + @returns {COA.Cmd} this instance (for chainability) + ### + comp: (@_comp) -> @ + ###* Apply function with arguments in context of command instance. @param {Function} fn @@ -118,6 +132,17 @@ exports.Cmd = class Cmd return @usage() .end() + ###* + Adds shell completion to command, adds "completion" subcommand, + that makes all the magic. + Must be called only on root command. + @returns {COA.Cmd} this instance (for chainability) + ### + completable: -> + @cmd() + .name('completion') + .apply(require './completion') + .end() _exit: (msg, code) -> if msg then sys.error msg @@ -175,14 +200,36 @@ exports.Cmd = class Cmd else opts.splice(pos, 1)[0] - _parseArr: (argv, opts = {}, args = {}) -> - nonParsedOpts = @_opts.concat() + _checkRequired: (opts, args) -> + if not (@_opts.filter (o) -> o._only and o._name of opts).length + all = @_opts.concat @_args + while i = all.shift() + if i._req and i._checkParsed opts, args + return @reject i._requiredText() + _parseCmd: (argv, unparsed = []) -> + argv = argv.concat() + optSeen = false while i = argv.shift() - # opt if not i.indexOf '-' + optSeen = true + if not optSeen and /^\w[\w-_]*$/.test(i) and cmd = @_cmdsByName[i] + return cmd._parseCmd argv, unparsed + + unparsed.push i + + { cmd: @, argv: unparsed } - nonParsedArgs or= @_args.concat() + _parseOptsAndArgs: (argv) -> + opts = {} + args = {} + + nonParsedOpts = @_opts.concat() + nonParsedArgs = @_args.concat() + + while i = argv.shift() + # opt + if i isnt '--' and not i.indexOf '-' if m = i.match /^(--\w[\w-_]*)=(.*)$/ i = m[1] @@ -194,65 +241,52 @@ exports.Cmd = class Cmd else return @reject "Unknown option: #{ i }" - # cmd - else if not nonParsedArgs and /^\w[\w-_]*$/.test i - cmd = @_cmdsByName[i] - if cmd - return cmd._parseArr argv, opts, args - else - nonParsedArgs = @_args.concat() - argv.unshift i - # arg else - if arg = (nonParsedArgs or= @_args.concat()).shift() - if arg._arr then nonParsedArgs.unshift arg - if Q.isPromise res = arg._parse i, args - return res - else - return @reject "Unknown argument: #{ i }" + if i is '--' + i = argv.splice(0) - nonParsedArgs or= @_args.concat() + i = if Array.isArray(i) then i else [i] - hitOnly = false - for opt in @_opts - if opt._only and opt._name of opts - hitOnly = true + while a = i.shift() + if arg = nonParsedArgs.shift() + if arg._arr then nonParsedArgs.unshift arg + if Q.isPromise res = arg._parse a, args + return res + else + return @reject "Unknown argument: #{ a }" - if not hitOnly - nonParsed = nonParsedOpts.concat nonParsedArgs - while i = nonParsed.shift() - if i._req and i._checkParsed opts, args - return @reject i._requiredText() - if '_def' of i - i._saveVal opts, i._def + # defaults + nonParsed = nonParsedOpts.concat nonParsedArgs + while i = nonParsed.shift() + if '_def' of i then i._saveVal opts, i._def - { cmd: @, opts: opts, args: args } + { opts: opts, args: args } + + _parseArr: (argv) -> + { cmd, argv } = @_parseCmd argv + if Q.isPromise res = cmd._parseOptsAndArgs argv + return res + { cmd: cmd, opts: res.opts, args: res.args } _do: (input, succ, err) -> defer = Q.defer() parsed = @_parseArr input cmd = parsed.cmd or @ - cmd._act?.reduce( + [@_checkRequired].concat(cmd._act or []).reduce( (res, act) => - res.then (params) => - actRes = act.call( + res.then (res) => + act.call( cmd - params.opts - params.args - params.res) - - if Q.isPromise actRes - actRes - else - params.res ?= actRes - params + parsed.opts + parsed.args + res) defer.promise ) .fail((res) => err.call cmd, res) - .then((res) => succ.call cmd, res.res) + .then((res) => succ.call cmd, res) - defer.resolve parsed + defer.resolve(if Q.isPromise parsed then parsed) ###* Parse arguments from simple format like NodeJS process.argv @@ -261,7 +295,7 @@ exports.Cmd = class Cmd @returns {COA.Cmd} this instance (for chainability) ### run: (argv = process.argv.slice(2)) -> - cb = (code) -> (res) -> @_exit res.toString(), res.exitCode ? code + cb = (code) -> (res) -> @_exit res.stack ? res.toString(), res.exitCode ? code @_do argv, cb(0), cb(1) @ diff --git a/src/completion.coffee b/src/completion.coffee new file mode 100644 index 0000000..2c1e358 --- /dev/null +++ b/src/completion.coffee @@ -0,0 +1,156 @@ +###* +Most of the code adopted from the npm package shell completion code. +See https://github.com/isaacs/npm/blob/master/lib/completion.js +### + +Q = require 'q' +escape = require('./shell').escape +unescape = require('./shell').unescape + +module.exports = -> + @title('Shell completion') + .helpful() + .arg() + .name('raw') + .title('Completion words') + .arr() + .end() + .act (opts, args) -> + if process.platform == 'win32' + e = new Error 'shell completion not supported on windows' + e.code = 'ENOTSUP' + e.errno = require('constants').ENOTSUP + return @reject(e) + + # if the COMP_* isn't in the env, then just dump the script + if !process.env.COMP_CWORD? or !process.env.COMP_LINE? or !process.env.COMP_POINT? + return dumpScript(@_cmd._name) + + console.error 'COMP_LINE: %s', process.env.COMP_LINE + console.error 'COMP_CWORD: %s', process.env.COMP_CWORD + console.error 'COMP_POINT: %s', process.env.COMP_POINT + console.error 'args: %j', args.raw + + # completion opts + opts = getOpts args.raw + + # cmd + { cmd, argv } = @_cmd._parseCmd opts.partialWords + Q.when complete(cmd, opts), (compls) -> + console.error 'filtered: %j', compls + console.log compls.map(escape).join('\n') + + +dumpScript = (name) -> + fs = require 'fs' + path = require 'path' + defer = Q.defer() + + fs.readFile path.resolve(__dirname, 'completion.sh'), 'utf8', (err, d) -> + if err then return defer.reject err + d = d.replace(/{{cmd}}/g, path.basename name).replace(/^\#\!.*?\n/, '') + + onError = (err) -> + # 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, `. <(cmd completion)` + # can never ever work on OS X. + if err.errno == require('constants').EPIPE + process.stdout.removeListener 'error', onError + defer.resolve() + else + defer.reject(err) + + process.stdout.on 'error', onError + process.stdout.write d, -> defer.resolve() + + defer.promise + + +getOpts = (argv) -> + # get the partial line and partial word, if the point isn't at the end + # ie, tabbing at: cmd foo b|ar + line = process.env.COMP_LINE + w = +process.env.COMP_CWORD + point = +process.env.COMP_POINT + words = argv.map unescape + word = words[w] + partialLine = line.substr 0, point + partialWords = words.slice 0, w + + # figure out where in that last word the point is + partialWord = argv[w] or '' + i = partialWord.length + while partialWord.substr(0, i) isnt partialLine.substr(-1 * i) and i > 0 + i-- + partialWord = unescape partialWord.substr 0, i + if partialWord then partialWords.push partialWord + + { + line: line + w: w + point: point + words: words + word: word + partialLine: partialLine + partialWords: partialWords + partialWord: partialWord + } + + +complete = (cmd, opts) -> + compls = [] + + # complete on cmds + if opts.partialWord.indexOf('-') + compls = Object.keys(cmd._cmdsByName) + # Complete on required opts without '-' in last partial word + # (if required not already specified) + # + # Commented out because of uselessness: + # -b, --block suggest results in '-' on cmd line; + # next completion suggest all options, because of '-' + #.concat Object.keys(cmd._optsByKey).filter (v) -> cmd._optsByKey[v]._req + else + # complete on opt values: --opt=| case + if m = opts.partialWord.match /^(--\w[\w-_]*)=(.*)$/ + optWord = m[1] + optPrefix = optWord + '=' + else + # complete on opts + # don't complete on opts in case of --opt=val completion + # TODO: don't complete on opts in case of unknown arg after commands + # TODO: complete only on opts with arr() or not already used + # TODO: complete only on full opts? + compls = Object.keys cmd._optsByKey + + # complete on opt values: next arg case + if not (o = opts.partialWords[opts.w - 1]).indexOf '-' + optWord = o + + # complete on opt values: completion + if optWord and opt = cmd._optsByKey[optWord] + if not opt._flag and opt._comp + compls = Q.join compls, Q.when opt._comp(opts), (c, o) -> + c.concat o.map (v) -> (optPrefix or '') + v + + # TODO: complete on args values (context aware, custom completion?) + + # custom completion on cmds + if cmd._comp + compls = Q.join compls, Q.when(cmd._comp(opts)), (c, o) -> + c.concat o + + # TODO: context aware custom completion on cmds, opts and args + # (can depend on already entered values, especially options) + + Q.when compls, (compls) -> + console.error 'partialWord: %s', opts.partialWord + console.error 'compls: %j', compls + compls.filter (c) -> c.indexOf(opts.partialWord) is 0 diff --git a/src/opt.coffee b/src/opt.coffee index fe56163..f23c919 100644 --- a/src/opt.coffee +++ b/src/opt.coffee @@ -1,9 +1,11 @@ fs = require 'fs' +Q = require 'q' Color = require('./color').Color Cmd = require('./cmd').Cmd ###* -## Option +Option + Named entity. Options may have short and long keys for use from command line. @namespace @class Presents option @@ -152,14 +154,26 @@ exports.Opt = class Opt if name of opts res = act.apply @, arguments if opt._only - @reject { - toString: -> res - exitCode: 0 - } + Q.when res, (res) => + @reject { + toString: -> res.toString() + exitCode: 0 + } else res @ + ###* + Set custom additional completion for current option. + @param {Function} completion generation function, + invoked in the context of option instance. + Accepts parameters: + - {Object} opts completion options + It can return promise or any other value treated as result. + @returns {COA.Opt} this instance (for chainability) + ### + comp: Cmd::comp + _saveVal: (opts, val) -> if @_val then val = @_val val if @_arr diff --git a/src/shell.coffee b/src/shell.coffee new file mode 100644 index 0000000..efad108 --- /dev/null +++ b/src/shell.coffee @@ -0,0 +1,10 @@ +exports.unescape = (w) -> + w = if w.charAt(0) is '"' + w.replace(/^"|([^\\])"$/g, '$1') + else + w.replace(/\\ /g, ' ') + w.replace(/\\("|'|\$|`|\\)/g, '$1') + +exports.escape = (w) -> + w = w.replace(/(["'$`\\])/g,'\\$1') + if w.match(/\s+/) then '"' + w + '"' else w diff --git a/test/shell-test.js b/test/shell-test.js new file mode 100644 index 0000000..72cc0c3 --- /dev/null +++ b/test/shell-test.js @@ -0,0 +1,59 @@ +var vows = require('vows'), + assert = require('assert'), + shell = require('../lib/shell'); + +vows.describe('coa/shell').addBatch({ + + 'The shell module': { + + '`escape`': { + + topic: function() { + return shell.escape; + }, + + 'Should wrap values with spaces in double quotes': function(escape) { + assert.equal(escape('asd abc'), '"asd abc"'); + }, + + 'Should escape double quote "': function(escape) { + assert.equal(escape('"asd'), '\\"asd'); + }, + + "Should escape single quote '": function(escape) { + assert.equal(escape("'asd"), "\\'asd"); + }, + + 'Should escape backslash \\': function(escape) { + assert.equal(escape('\\asd'), '\\\\asd'); + }, + + 'Should escape dollar $': function(escape) { + assert.equal(escape('$asd'), '\\$asd'); + }, + + 'Should escape backtick `': function(escape) { + assert.equal(escape('`asd'), '\\`asd'); + } + + }, + + '`unescape`': { + + topic: function() { + return shell.unescape; + }, + + 'Should strip double quotes at the both ends': function(unescape) { + assert.equal(unescape('"asd"'), 'asd'); + }, + + 'Should not strip escaped double quotes at the both ends': function(unescape) { + assert.equal(unescape('\\"asd\\"'), '"asd"'); + } + + } + + } + +}).export(module); diff --git a/tests/args.js b/tests/args.js new file mode 100644 index 0000000..9670ace --- /dev/null +++ b/tests/args.js @@ -0,0 +1,21 @@ +var argv = process.argv.slice(2); +require('../lib/coa').Cmd() + .name('arg') + .title('Args test') + .helpful() + .opt() + .name('opt').title('Option') + .long('opt').short('o') + .end() + .arg() + .name('arg1').title('First arg') + .end() + .arg() + .name('arg2').title('Second array arg') + .arr() + .end() + .act(function(opts, args) { + console.log(opts); + console.log(args); + }) + .run(argv.length? argv : ['-o', 'value', 'value', 'value 1', 'value 2']); diff --git a/tests/rarg.js b/tests/rarg.js new file mode 100644 index 0000000..3523c00 --- /dev/null +++ b/tests/rarg.js @@ -0,0 +1,13 @@ +var argv = process.argv.slice(2); +require('../lib/coa').Cmd() + .name('rarg') + .title('Raw arg test') + .helpful() + .arg() + .name('raw').title('Raw arg') + .arr() + .end() + .act(function(opts, args) { + console.log(args); + }) + .run(argv.length? argv : ['--', 'raw', 'arg', 'values']);