action_container.js (15055B)
1 /** internal 2 * class ActionContainer 3 * 4 * Action container. Parent for [[ArgumentParser]] and [[ArgumentGroup]] 5 **/ 6 7 'use strict'; 8 9 var format = require('util').format; 10 11 // Constants 12 var c = require('./const'); 13 14 var $$ = require('./utils'); 15 16 //Actions 17 var ActionHelp = require('./action/help'); 18 var ActionAppend = require('./action/append'); 19 var ActionAppendConstant = require('./action/append/constant'); 20 var ActionCount = require('./action/count'); 21 var ActionStore = require('./action/store'); 22 var ActionStoreConstant = require('./action/store/constant'); 23 var ActionStoreTrue = require('./action/store/true'); 24 var ActionStoreFalse = require('./action/store/false'); 25 var ActionVersion = require('./action/version'); 26 var ActionSubparsers = require('./action/subparsers'); 27 28 // Errors 29 var argumentErrorHelper = require('./argument/error'); 30 31 /** 32 * new ActionContainer(options) 33 * 34 * Action container. Parent for [[ArgumentParser]] and [[ArgumentGroup]] 35 * 36 * ##### Options: 37 * 38 * - `description` -- A description of what the program does 39 * - `prefixChars` -- Characters that prefix optional arguments 40 * - `argumentDefault` -- The default value for all arguments 41 * - `conflictHandler` -- The conflict handler to use for duplicate arguments 42 **/ 43 var ActionContainer = module.exports = function ActionContainer(options) { 44 options = options || {}; 45 46 this.description = options.description; 47 this.argumentDefault = options.argumentDefault; 48 this.prefixChars = options.prefixChars || ''; 49 this.conflictHandler = options.conflictHandler; 50 51 // set up registries 52 this._registries = {}; 53 54 // register actions 55 this.register('action', null, ActionStore); 56 this.register('action', 'store', ActionStore); 57 this.register('action', 'storeConst', ActionStoreConstant); 58 this.register('action', 'storeTrue', ActionStoreTrue); 59 this.register('action', 'storeFalse', ActionStoreFalse); 60 this.register('action', 'append', ActionAppend); 61 this.register('action', 'appendConst', ActionAppendConstant); 62 this.register('action', 'count', ActionCount); 63 this.register('action', 'help', ActionHelp); 64 this.register('action', 'version', ActionVersion); 65 this.register('action', 'parsers', ActionSubparsers); 66 67 // raise an exception if the conflict handler is invalid 68 this._getHandler(); 69 70 // action storage 71 this._actions = []; 72 this._optionStringActions = {}; 73 74 // groups 75 this._actionGroups = []; 76 this._mutuallyExclusiveGroups = []; 77 78 // defaults storage 79 this._defaults = {}; 80 81 // determines whether an "option" looks like a negative number 82 // -1, -1.5 -5e+4 83 this._regexpNegativeNumber = new RegExp('^[-]?[0-9]*\\.?[0-9]+([eE][-+]?[0-9]+)?$'); 84 85 // whether or not there are any optionals that look like negative 86 // numbers -- uses a list so it can be shared and edited 87 this._hasNegativeNumberOptionals = []; 88 }; 89 90 // Groups must be required, then ActionContainer already defined 91 var ArgumentGroup = require('./argument/group'); 92 var MutuallyExclusiveGroup = require('./argument/exclusive'); 93 94 // 95 // Registration methods 96 // 97 98 /** 99 * ActionContainer#register(registryName, value, object) -> Void 100 * - registryName (String) : object type action|type 101 * - value (string) : keyword 102 * - object (Object|Function) : handler 103 * 104 * Register handlers 105 **/ 106 ActionContainer.prototype.register = function (registryName, value, object) { 107 this._registries[registryName] = this._registries[registryName] || {}; 108 this._registries[registryName][value] = object; 109 }; 110 111 ActionContainer.prototype._registryGet = function (registryName, value, defaultValue) { 112 if (arguments.length < 3) { 113 defaultValue = null; 114 } 115 return this._registries[registryName][value] || defaultValue; 116 }; 117 118 // 119 // Namespace default accessor methods 120 // 121 122 /** 123 * ActionContainer#setDefaults(options) -> Void 124 * - options (object):hash of options see [[Action.new]] 125 * 126 * Set defaults 127 **/ 128 ActionContainer.prototype.setDefaults = function (options) { 129 options = options || {}; 130 for (var property in options) { 131 if ($$.has(options, property)) { 132 this._defaults[property] = options[property]; 133 } 134 } 135 136 // if these defaults match any existing arguments, replace the previous 137 // default on the object with the new one 138 this._actions.forEach(function (action) { 139 if ($$.has(options, action.dest)) { 140 action.defaultValue = options[action.dest]; 141 } 142 }); 143 }; 144 145 /** 146 * ActionContainer#getDefault(dest) -> Mixed 147 * - dest (string): action destination 148 * 149 * Return action default value 150 **/ 151 ActionContainer.prototype.getDefault = function (dest) { 152 var result = $$.has(this._defaults, dest) ? this._defaults[dest] : null; 153 154 this._actions.forEach(function (action) { 155 if (action.dest === dest && $$.has(action, 'defaultValue')) { 156 result = action.defaultValue; 157 } 158 }); 159 160 return result; 161 }; 162 // 163 // Adding argument actions 164 // 165 166 /** 167 * ActionContainer#addArgument(args, options) -> Object 168 * - args (String|Array): argument key, or array of argument keys 169 * - options (Object): action objects see [[Action.new]] 170 * 171 * #### Examples 172 * - addArgument([ '-f', '--foo' ], { action: 'store', defaultValue: 1, ... }) 173 * - addArgument([ 'bar' ], { action: 'store', nargs: 1, ... }) 174 * - addArgument('--baz', { action: 'store', nargs: 1, ... }) 175 **/ 176 ActionContainer.prototype.addArgument = function (args, options) { 177 args = args; 178 options = options || {}; 179 180 if (typeof args === 'string') { 181 args = [ args ]; 182 } 183 if (!Array.isArray(args)) { 184 throw new TypeError('addArgument first argument should be a string or an array'); 185 } 186 if (typeof options !== 'object' || Array.isArray(options)) { 187 throw new TypeError('addArgument second argument should be a hash'); 188 } 189 190 // if no positional args are supplied or only one is supplied and 191 // it doesn't look like an option string, parse a positional argument 192 if (!args || args.length === 1 && this.prefixChars.indexOf(args[0][0]) < 0) { 193 if (args && !!options.dest) { 194 throw new Error('dest supplied twice for positional argument'); 195 } 196 options = this._getPositional(args, options); 197 198 // otherwise, we're adding an optional argument 199 } else { 200 options = this._getOptional(args, options); 201 } 202 203 // if no default was supplied, use the parser-level default 204 if (typeof options.defaultValue === 'undefined') { 205 var dest = options.dest; 206 if ($$.has(this._defaults, dest)) { 207 options.defaultValue = this._defaults[dest]; 208 } else if (typeof this.argumentDefault !== 'undefined') { 209 options.defaultValue = this.argumentDefault; 210 } 211 } 212 213 // create the action object, and add it to the parser 214 var ActionClass = this._popActionClass(options); 215 if (typeof ActionClass !== 'function') { 216 throw new Error(format('Unknown action "%s".', ActionClass)); 217 } 218 var action = new ActionClass(options); 219 220 // throw an error if the action type is not callable 221 var typeFunction = this._registryGet('type', action.type, action.type); 222 if (typeof typeFunction !== 'function') { 223 throw new Error(format('"%s" is not callable', typeFunction)); 224 } 225 226 return this._addAction(action); 227 }; 228 229 /** 230 * ActionContainer#addArgumentGroup(options) -> ArgumentGroup 231 * - options (Object): hash of options see [[ArgumentGroup.new]] 232 * 233 * Create new arguments groups 234 **/ 235 ActionContainer.prototype.addArgumentGroup = function (options) { 236 var group = new ArgumentGroup(this, options); 237 this._actionGroups.push(group); 238 return group; 239 }; 240 241 /** 242 * ActionContainer#addMutuallyExclusiveGroup(options) -> ArgumentGroup 243 * - options (Object): {required: false} 244 * 245 * Create new mutual exclusive groups 246 **/ 247 ActionContainer.prototype.addMutuallyExclusiveGroup = function (options) { 248 var group = new MutuallyExclusiveGroup(this, options); 249 this._mutuallyExclusiveGroups.push(group); 250 return group; 251 }; 252 253 ActionContainer.prototype._addAction = function (action) { 254 var self = this; 255 256 // resolve any conflicts 257 this._checkConflict(action); 258 259 // add to actions list 260 this._actions.push(action); 261 action.container = this; 262 263 // index the action by any option strings it has 264 action.optionStrings.forEach(function (optionString) { 265 self._optionStringActions[optionString] = action; 266 }); 267 268 // set the flag if any option strings look like negative numbers 269 action.optionStrings.forEach(function (optionString) { 270 if (optionString.match(self._regexpNegativeNumber)) { 271 if (!self._hasNegativeNumberOptionals.some(Boolean)) { 272 self._hasNegativeNumberOptionals.push(true); 273 } 274 } 275 }); 276 277 // return the created action 278 return action; 279 }; 280 281 ActionContainer.prototype._removeAction = function (action) { 282 var actionIndex = this._actions.indexOf(action); 283 if (actionIndex >= 0) { 284 this._actions.splice(actionIndex, 1); 285 } 286 }; 287 288 ActionContainer.prototype._addContainerActions = function (container) { 289 // collect groups by titles 290 var titleGroupMap = {}; 291 this._actionGroups.forEach(function (group) { 292 if (titleGroupMap[group.title]) { 293 throw new Error(format('Cannot merge actions - two groups are named "%s".', group.title)); 294 } 295 titleGroupMap[group.title] = group; 296 }); 297 298 // map each action to its group 299 var groupMap = {}; 300 function actionHash(action) { 301 // unique (hopefully?) string suitable as dictionary key 302 return action.getName(); 303 } 304 container._actionGroups.forEach(function (group) { 305 // if a group with the title exists, use that, otherwise 306 // create a new group matching the container's group 307 if (!titleGroupMap[group.title]) { 308 titleGroupMap[group.title] = this.addArgumentGroup({ 309 title: group.title, 310 description: group.description 311 }); 312 } 313 314 // map the actions to their new group 315 group._groupActions.forEach(function (action) { 316 groupMap[actionHash(action)] = titleGroupMap[group.title]; 317 }); 318 }, this); 319 320 // add container's mutually exclusive groups 321 // NOTE: if add_mutually_exclusive_group ever gains title= and 322 // description= then this code will need to be expanded as above 323 var mutexGroup; 324 container._mutuallyExclusiveGroups.forEach(function (group) { 325 mutexGroup = this.addMutuallyExclusiveGroup({ 326 required: group.required 327 }); 328 // map the actions to their new mutex group 329 group._groupActions.forEach(function (action) { 330 groupMap[actionHash(action)] = mutexGroup; 331 }); 332 }, this); // forEach takes a 'this' argument 333 334 // add all actions to this container or their group 335 container._actions.forEach(function (action) { 336 var key = actionHash(action); 337 if (groupMap[key]) { 338 groupMap[key]._addAction(action); 339 } else { 340 this._addAction(action); 341 } 342 }); 343 }; 344 345 ActionContainer.prototype._getPositional = function (dest, options) { 346 if (Array.isArray(dest)) { 347 dest = dest[0]; 348 } 349 // make sure required is not specified 350 if (options.required) { 351 throw new Error('"required" is an invalid argument for positionals.'); 352 } 353 354 // mark positional arguments as required if at least one is 355 // always required 356 if (options.nargs !== c.OPTIONAL && options.nargs !== c.ZERO_OR_MORE) { 357 options.required = true; 358 } 359 if (options.nargs === c.ZERO_OR_MORE && typeof options.defaultValue === 'undefined') { 360 options.required = true; 361 } 362 363 // return the keyword arguments with no option strings 364 options.dest = dest; 365 options.optionStrings = []; 366 return options; 367 }; 368 369 ActionContainer.prototype._getOptional = function (args, options) { 370 var prefixChars = this.prefixChars; 371 var optionStrings = []; 372 var optionStringsLong = []; 373 374 // determine short and long option strings 375 args.forEach(function (optionString) { 376 // error on strings that don't start with an appropriate prefix 377 if (prefixChars.indexOf(optionString[0]) < 0) { 378 throw new Error(format('Invalid option string "%s": must start with a "%s".', 379 optionString, 380 prefixChars 381 )); 382 } 383 384 // strings starting with two prefix characters are long options 385 optionStrings.push(optionString); 386 if (optionString.length > 1 && prefixChars.indexOf(optionString[1]) >= 0) { 387 optionStringsLong.push(optionString); 388 } 389 }); 390 391 // infer dest, '--foo-bar' -> 'foo_bar' and '-x' -> 'x' 392 var dest = options.dest || null; 393 delete options.dest; 394 395 if (!dest) { 396 var optionStringDest = optionStringsLong.length ? optionStringsLong[0] : optionStrings[0]; 397 dest = $$.trimChars(optionStringDest, this.prefixChars); 398 399 if (dest.length === 0) { 400 throw new Error( 401 format('dest= is required for options like "%s"', optionStrings.join(', ')) 402 ); 403 } 404 dest = dest.replace(/-/g, '_'); 405 } 406 407 // return the updated keyword arguments 408 options.dest = dest; 409 options.optionStrings = optionStrings; 410 411 return options; 412 }; 413 414 ActionContainer.prototype._popActionClass = function (options, defaultValue) { 415 defaultValue = defaultValue || null; 416 417 var action = (options.action || defaultValue); 418 delete options.action; 419 420 var actionClass = this._registryGet('action', action, action); 421 return actionClass; 422 }; 423 424 ActionContainer.prototype._getHandler = function () { 425 var handlerString = this.conflictHandler; 426 var handlerFuncName = '_handleConflict' + $$.capitalize(handlerString); 427 var func = this[handlerFuncName]; 428 if (typeof func === 'undefined') { 429 var msg = 'invalid conflict resolution value: ' + handlerString; 430 throw new Error(msg); 431 } else { 432 return func; 433 } 434 }; 435 436 ActionContainer.prototype._checkConflict = function (action) { 437 var optionStringActions = this._optionStringActions; 438 var conflictOptionals = []; 439 440 // find all options that conflict with this option 441 // collect pairs, the string, and an existing action that it conflicts with 442 action.optionStrings.forEach(function (optionString) { 443 var conflOptional = optionStringActions[optionString]; 444 if (typeof conflOptional !== 'undefined') { 445 conflictOptionals.push([ optionString, conflOptional ]); 446 } 447 }); 448 449 if (conflictOptionals.length > 0) { 450 var conflictHandler = this._getHandler(); 451 conflictHandler.call(this, action, conflictOptionals); 452 } 453 }; 454 455 ActionContainer.prototype._handleConflictError = function (action, conflOptionals) { 456 var conflicts = conflOptionals.map(function (pair) { return pair[0]; }); 457 conflicts = conflicts.join(', '); 458 throw argumentErrorHelper( 459 action, 460 format('Conflicting option string(s): %s', conflicts) 461 ); 462 }; 463 464 ActionContainer.prototype._handleConflictResolve = function (action, conflOptionals) { 465 // remove all conflicting options 466 var self = this; 467 conflOptionals.forEach(function (pair) { 468 var optionString = pair[0]; 469 var conflictingAction = pair[1]; 470 // remove the conflicting option string 471 var i = conflictingAction.optionStrings.indexOf(optionString); 472 if (i >= 0) { 473 conflictingAction.optionStrings.splice(i, 1); 474 } 475 delete self._optionStringActions[optionString]; 476 // if the option now has no option string, remove it from the 477 // container holding it 478 if (conflictingAction.optionStrings.length === 0) { 479 conflictingAction.container._removeAction(conflictingAction); 480 } 481 }); 482 };