/********************************************************************** * * argv.js * * A simple argv parser * * Motivation: * I needed a new argv parser for a quick and dirty project I was working * on and evaluating and selecting the proper existing parser and then * learning its API, quirks and adapting the architecture to it seemed * to be more complicated, require more effort and far less fun than * putting together a trivial parser myself in a couple of hours. * This code is an evolution of that parser. * * Repo and docs: * https://github.com/flynx/argv.js * * **********************************************************************/ ((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define) (function(require){ var module={} // make module AMD/node compatible... /*********************************************************************/ var object = require('ig-object') //--------------------------------------------------------------------- module.STOP = {doc: 'stop option processing'} module.ERROR = {doc: 'option processing error'} //--------------------------------------------------------------------- // helpers... // XXX do we need to remove handlers??? // XXX does this need to be an event constructor??? var afterCallback = function(name){ var attr = '__after_'+ name return function(func){ var that = this var args = [...arguments] ;(args.length == 1 && typeof(func) == 'function') ? // add handler... (this[attr] = this[attr] || []).push(func) // call handlers... : (this[attr] || []) .forEach(function(func){ func.call(that, ...args) }) return this } } //--------------------------------------------------------------------- // Basic argv parser... // // // Parser(spec) // -> parser // // // spec format: // { // // option alias... // '-v': '-verbose', // // options handler (basic)... // '-verbose': function(opts, key, value){ // ... // }, // // // option handler (full)... // // NOTE: the same attributes (except for .handler) can be set on // // the function handler above to same effect... // '-t': '-test', // '-test': { // doc: 'test option.', // arg: 'VALUE', // env: 'VALUE', // handler: function(opts, key, value){ // ... // }}, // // // command... // // // // NOTE: commands are the same as options in every way other than // // call syntax. // // NOTE: it is possible to alias options to commands and vice-versa... // '@command': ... , // // // // nested parsers... // // // // NOTE: the nested parser behaves the same as if it was root and // // can consume as many argv elements as it needs, effectively // // rendering the relevant options as context sensitive, e.g.: // // cmd -h # get root help... // // cmd nest -h # get help for @nest command... // // NOTE: a nested parser can be either an option or a command... // @nest: new Parser({ // doc: 'nested parser', // // '-nested-option': { // ... // }, // }) // .then(function(){ // ... // }), // // ... // } // // // It is recommended not to do any processing with side-effects in // option/command handlers directly, prepare for the execution and to // the actual work in the .then(..) callback. The reason being that the // option handlers are called while parsing options and thus may not // yet know of any error or stop conditions triggered later in the argv. // // // XXX handle .required options... // XXX handle option types??? // XXX add support for outputting strings instead of console.log(..) // XXX --help should work for any command and not just for the nested // parser commands... (???) // ...not sure how to implement this... // .....or should it be the responsibility of the user defining // the command??? // XXX should we handle - script calls??? // XXX should .options(..), .commands(..) and .handler(..) be: // .getOptions(..), .getCommands(..) and .getHandler(..) respectively??? var Parser = module.Parser = object.Constructor('Parser', { // config... splitOptions: true, optionPrefix: '-', commandPrefix: '@', // NOTE: we only care about differentiating an option from a command // here by design... optionInputPattern: /^--?(.*)$/, commandInputPattern: /^([a-zA-Z].*)$/, // instance stuff... argv: null, pre_argv: null, rest: null, scriptNmae: null, scriptPath: null, // Handler API... // // Format: // [ // [, , , ], // ... // ] // options: function(...prefix){ var that = this prefix = prefix.length == 0 ? [this.optionPrefix] : prefix return prefix .map(function(prefix){ var handlers = {} object.deepKeys(that, Parser.prototype) .forEach(function(opt){ if(!opt.startsWith(prefix)){ return } var [k, h] = that.handler(opt) h !== undefined && (handlers[k] ? handlers[k][0].push(opt) : (handlers[k] = [ [opt], h.arg, h.doc || k.slice(1), h ])) }) return Object.values(handlers) }) .flat(1) .map(function(e, i){ return [e, i] }) .sort(function([a, ai], [b, bi]){ a = a[3].priority b = b[3].priority return a != null && b != null ? b - a // positive priority above order, negative below... : (a > 0 || b < 0) ? -1 : (b < 0 || a > 0) ? 1 : ai - bi }) .map(function([e, _]){ return e }) }, envOptions: function(){ return this.options() .filter(function([k, a, d, handler]){ return !!handler.env }) }, commands: function(){ return this.options(this.commandPrefix) }, isCommand: function(str){ return this.commandInputPattern.test(str) && (this.commandPrefix + str) in this }, // NOTE: this ignores any arguments values present in the key... // NOTE: this ignores options forming alias loops and dead-end // options... handler: function(key){ // clear arg value... key = key.split(/=/).shift() // option or command? key = this.optionInputPattern.test(key) ? key.replace(this.optionInputPattern, this.optionPrefix+'$1') : key.replace(this.commandInputPattern, this.commandPrefix+'$1') var seen = new Set([key]) while(key in this && typeof(this[key]) == typeof('str')){ key = this[key] // check for loops... if(seen.has(key)){ return [key, undefined, // report loop... 'loop', [...seen, key]] } //throw new Error('Option loop detected: '+ ([...seen, key].join(' -> '))) } seen.add(key) } return [key, this[key], // report dead-end if this[key] is undefined... ...(this[key] ? [] : ['dead-end'])] }, // XXX need to test option definitions... (???) // i.e. report loops and dead ends... // doc stuff... helpColumnOffset: 3, helpColumnPrefix: '- ', // doc sections... version: undefined, license: undefined, usage: '$SCRIPTNAME [OPTIONS]', doc: undefined, // XXX test this with string value... examples: undefined, // XXX add license and version info... //footer: '$SCRIPTNAME v:$VERSION', footer: undefined, // XXX should wrap long lines... alignColumns: function(a, b, ...rest){ var opts_width = this.helpColumnOffset || 4 var prefix = this.helpColumnPrefix || '' b = [b, ...rest].join('\n'+ ('\t'.repeat(opts_width+1) + ' '.repeat(prefix.length))) return b ? (a.strip.length < opts_width*8 ? [a +'\t'.repeat(opts_width - Math.floor(a.strip.length/8))+ prefix + b] : [a, '\t'.repeat(opts_width)+ prefix + b]) : [a] }, expandTextVars: function(text){ return text .replace(/\$LICENSE/g, this.license || '') .replace(/\$VERSION/g, this.version || '0.0.0') .replace(/\$SCRIPTNAME/g, this.scriptName) }, // Builtin options/commands... // XXX might be a good idea to keep short options in a separate column... '-h': '-help', '-help': { doc: 'print this message and exit', priority: 99, handler: function(argv, key, value){ var that = this var expandVars = this.expandTextVars.bind(this) var formDoc = function(doc, env){ return [doc, ...(env ? [`(default value: \$${env})`] : [])] } var getValue = function(name){ return that[name] ? ['', typeof(that[name]) == 'function' ? that[name]() : that[name]] : [] } var section = function(title, items){ items = items instanceof Array ? items : [items] return items.length > 0 ? ['', title +':', ...items] : [] } console.log( expandVars([ `Usage: ${ getValue('usage').join('') }`, // doc (optional)... ...getValue('doc'), // options... // XXX add option groups... ...section('Options', this.options() .map(function([opts, arg, doc, handler]){ return [ // XXX might be a good idea to keep // short options in a separate // column... opts .sort(function(a, b){ return a.length - b.length}) .join(' | -') +' '+ (arg || ''), ...formDoc(doc, handler.env) ] })), // dynamic options... ...section('Dynamic options', this.handleArgument ? this.handleArgument('doc') || [] : []), // commands (optional)... ...section('Commands', this.commands() .map(function([cmd, arg, doc, handler]){ return [ cmd .map(function(cmd){ return cmd.slice(1)}) .join(' | ') +' '+ (arg || ''), ...formDoc(doc, handler.env) ] })), // examples (optional)... ...section('Examples', this.examples instanceof Array ? this.examples .map(function(e){ return e instanceof Array ? e : [e] }) : getValue('examples') ), // footer (optional)... ...getValue('footer') ] // expand/align columns... .map(function(e){ return e instanceof Array ? // NOTE: we need to expandVars(..) here so as to // be able to calculate actual widths... that.alignColumns(...e.map(expandVars)) .map(function(s){ return '\t'+ s }) : e }) .flat() .join('\n'))) return module.STOP }}, '-v': '-version', '-version': { doc: 'show $SCRIPTNAME verion and exit', priority: 99, handler: function(){ console.log(this.version || '0.0.0') return module.STOP }, }, /*/ XXX do we actually need this??? // doing this is trivial, need to give a practical example... // XXX would be nice to be able to take the rest of the options and // pass them to .then(..)... '-': { doc: 'stop processing arguments', handler: function(){ return module.STOP }, }, //*/ // common short-hands... // // NOTE: defining this as a loop will enable the user to define any // of the aliases as the handler and thus breaking the loop... // NOTE: unless the loop is broken this set of options is not usable. //'-v': '-verbose', //'-verbose': '-v', // Handle arguments with no explicit handlers found... // // Handle dynamic/unknown argument... // .handleArgument(args, arg) // -> module.ERROR // -> module.STOP // -> result // // Get dynamic argument doc... // .handleArgument('doc') // -> undefined // -> doc // // // doc format: // [ // [, ], // ... // ] // // // NOTE: this is mainly needed to handle dynamic arguments or print // error on unknown options (default)... handleArgument: function(_, key){ // doc handler... if(arguments.length == 1 && arguments[0] == 'doc'){ return undefined } console.error('Unknown '+ (key.startsWith('-') ? 'option:' : 'command:'), key) return module.ERROR }, // Handle argument value conversion... // // If this is false/undefined value is passed to the handler as-is... // // Example: // typeHandler: { // int: parseInt, // float: parseFloat, // number: function(v){ return new Number(v) }, // string: function(v){ return v.toString() }, // ... // }, // handleArgumentValue: function(handler, value){ // var convert = typeof(handler.type) == 'function' ? // handler.type // : this.typeHandler[handler.type] // return convert ? // convert(value) // : value }, // // XXX should we define a handler.type handler??? handleArgumentValue: false, // Handle error exit... // // If this is set to false Parser will not call process.exit(..) on // error... handleErrorExit: function(arg){ typeof(process) != 'unhandled' && process.exit(1) }, // post parsing callbacks... // // .then(callback(unhandleed, root_value, rest)) // // .stop(callback(arg, rest)) // .error(callback(arg, rest)) // then: afterCallback('parsing'), stop: afterCallback('stop'), error: afterCallback('error'), // // parser(argv) // -> parser // // parser(argv, main) // -> parser // // NOTE: this (i.e. parser) can be used as a nested command/option // handler... __call__: function(context, argv, main, root_value){ var that = this var nested = false var rest = this.rest = argv == null ? (typeof(process) != 'unhandled' ? process.argv : []) : argv argv = rest.slice() main = main || require.main.filename // nested command handler... if(context instanceof Parser){ nested = true main = context.scriptName +' '+ main rest.unshift(main) } // normalize the argv... if(main != null){ this.pre_argv = rest.splice(0, rest.indexOf(main)) rest.includes(main) || rest.unshift(main) } this.script = rest[0] this.scriptName = rest.shift().split(/[\\\/]/).pop() this.scriptPath = this.script.slice(0, this.script.length - this.scriptName.length) var opt_pattern = this.optionInputPattern // helpers... var runHandler = function(handler, arg, rest){ var [arg, value] = arg.split(/=/) // get option value... value = value || ((handler.arg && !opt_pattern.test(rest[0])) ? rest.shift() : (typeof(process) != 'unhandled' && handler.env) ? process.env[handler.env] : undefined) // value conversion... value = (value && that.handleArgumentValue) ? that.handleArgumentValue(handler, value) : value // run handler... var res = (typeof(handler) == 'function' ? handler : handler.handler) .call(that, rest, arg, ...(value ? [value] : [])) // handle .STOP / .ERROR if(res === module.STOP || res === module.ERROR){ that[res === module.STOP ? 'stop' : 'error'](arg, rest) res === module.ERROR && that.handleErrorExit && that.handleErrorExit(arg) } return res } // NOTE: if successful this needs to modify the arg, thus it // returns both the new first arg and the handler... var splitArgs = function(arg, rest){ var [arg, value] = arg.split(/=/) // skip single letter unknown options or '--' options... if(arg.length <= 2 || arg.startsWith(that.optionPrefix.repeat(2))){ return undefined } // split and normalize... var [a, ...r] = [...arg.slice(1)] .map(function(e){ return '-'+ e }) // push the value to the last arg... value !== undefined && r.push(r.pop() +'='+ value) // push new options back to option "stack"... rest.splice(0, 0, ...r) var handler = that.handler(a)[1] return handler && [a, handler] } var env = new Set() var unhandled = [] while(rest.length > 0){ var arg = rest.shift() var type = opt_pattern.test(arg) ? 'opt' : this.isCommand(arg) ? 'cmd' : 'unhandled' // options / commands... if(type != 'unhandled'){ // get handler... // XXX revise -- arg replacement feels clunky... var handler = this.handler(arg)[1] // handle merged options // NOTE: if successful returns array... || (type == 'opt' && this.splitOptions && splitArgs(arg, rest)) // dynamic or error... || this.handleArgument // normalize output of splitArgs(..) ;[arg, handler] = handler instanceof Array ? handler : [arg, handler] // env handler called... handler.env && env.add(handler) var res = runHandler(handler, arg, rest) // handle stop conditions... if(res === module.STOP || res === module.ERROR){ return nested ? res : this } continue } // unhandled... arg && unhandled.push(arg) } // call env handlers that were not explicitly called yet... typeof(process) != 'unhandled' && this.envOptions() .forEach(function([k, a, d, handler]){ env.has(handler) || (handler.env in process.env && runHandler(handler, a, null, rest)) }) // post handlers... root_value = root_value && this.handleArgumentValue ? this.handleArgumentValue(this, root_value) : root_value return this.then(unhandled, root_value, rest) }, // NOTE: see general doc... __init__: function(spec){ Object.assign(this, spec) }, }) /********************************************************************** * vim:set ts=4 sw=4 nowrap : */ return module })