UNPKG

24.2 kBJavaScriptView Raw
1/**
2 * dashdash - A light, featureful and explicit option parsing library for
3 * node.js.
4 */
5// vim: set ts=4 sts=4 sw=4 et:
6
7var p = console.log;
8var format = require('util').format;
9
10var assert = require('assert-plus');
11
12var DEBUG = true;
13if (DEBUG) {
14 var debug = console.warn;
15} else {
16 var debug = function () {};
17}
18
19
20
21// ---- internal support stuff
22
23/**
24 * Return a shallow copy of the given object;
25 */
26function shallowCopy(obj) {
27 if (!obj) {
28 return (obj);
29 }
30 var copy = {};
31 Object.keys(obj).forEach(function (k) {
32 copy[k] = obj[k];
33 });
34 return (copy);
35}
36
37
38function space(n) {
39 var s = '';
40 for (var i = 0; i < n; i++) {
41 s += ' ';
42 }
43 return s;
44}
45
46
47function makeIndent(arg, deflen, name) {
48 if (arg === null || arg === undefined)
49 return space(deflen);
50 else if (typeof (arg) === 'number')
51 return space(arg);
52 else if (typeof (arg) === 'string')
53 return arg;
54 else
55 assert.fail('invalid "' + name + '": not a string or number: ' + arg);
56}
57
58
59/**
60 * Return an array of lines wrapping the given text to the given width.
61 * This splits on whitespace. Single tokens longer than `width` are not
62 * broken up.
63 */
64function textwrap(s, width) {
65 var words = s.trim().split(/\s+/);
66 var lines = [];
67 var line = '';
68 words.forEach(function (w) {
69 var newLength = line.length + w.length;
70 if (line.length > 0)
71 newLength += 1;
72 if (newLength > width) {
73 lines.push(line);
74 line = '';
75 }
76 if (line.length > 0)
77 line += ' ';
78 line += w;
79 });
80 lines.push(line);
81 return lines;
82}
83
84
85/**
86 * Transform an option name to a "key" that is used as the field
87 * on the `opts` object returned from `<parser>.parse()`.
88 *
89 * Transformations:
90 * - '-' -> '_': This allow one to use hyphen in option names (common)
91 * but not have to do silly things like `opt["dry-run"]` to access the
92 * parsed results.
93 */
94function optionKeyFromName(name) {
95 return name.replace(/-/g, '_');
96}
97
98
99
100// ---- Option types
101
102function parseBool(option, optstr, arg) {
103 return Boolean(arg);
104}
105
106function parseString(option, optstr, arg) {
107 assert.string(arg, 'arg');
108 return arg;
109}
110
111function parseNumber(option, optstr, arg) {
112 assert.string(arg, 'arg');
113 var num = Number(arg);
114 if (isNaN(num)) {
115 throw new Error(format('arg for "%s" is not a number: "%s"',
116 optstr, arg));
117 }
118 return num;
119}
120
121function parseInteger(option, optstr, arg) {
122 assert.string(arg, 'arg');
123 var num = Number(arg);
124 if (!/^[0-9-]+$/.test(arg) || isNaN(num)) {
125 throw new Error(format('arg for "%s" is not an integer: "%s"',
126 optstr, arg));
127 }
128 return num;
129}
130
131function parsePositiveInteger(option, optstr, arg) {
132 assert.string(arg, 'arg');
133 var num = Number(arg);
134 if (!/^[0-9]+$/.test(arg) || isNaN(num)) {
135 throw new Error(format('arg for "%s" is not a positive integer: "%s"',
136 optstr, arg));
137 }
138 return num;
139}
140
141/**
142 * Supported date args:
143 * - epoch second times (e.g. 1396031701)
144 * - ISO 8601 format: YYYY-MM-DD[THH:MM:SS[.sss][Z]]
145 * 2014-03-28T18:35:01.489Z
146 * 2014-03-28T18:35:01.489
147 * 2014-03-28T18:35:01Z
148 * 2014-03-28T18:35:01
149 * 2014-03-28
150 */
151function parseDate(option, optstr, arg) {
152 assert.string(arg, 'arg');
153 var date;
154 if (/^\d+$/.test(arg)) {
155 // epoch seconds
156 date = new Date(Number(arg) * 1000);
157 /* JSSTYLED */
158 } else if (/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?Z?)?$/i.test(arg)) {
159 // ISO 8601 format
160 date = new Date(arg);
161 } else {
162 throw new Error(format('arg for "%s" is not a valid date format: "%s"',
163 optstr, arg));
164 }
165 if (date.toString() === 'Invalid Date') {
166 throw new Error(format('arg for "%s" is an invalid date: "%s"',
167 optstr, arg));
168 }
169 return date;
170}
171
172var optionTypes = {
173 bool: {
174 takesArg: false,
175 parseArg: parseBool
176 },
177 string: {
178 takesArg: true,
179 helpArg: 'ARG',
180 parseArg: parseString
181 },
182 number: {
183 takesArg: true,
184 helpArg: 'NUM',
185 parseArg: parseNumber
186 },
187 integer: {
188 takesArg: true,
189 helpArg: 'INT',
190 parseArg: parseInteger
191 },
192 positiveInteger: {
193 takesArg: true,
194 helpArg: 'INT',
195 parseArg: parsePositiveInteger
196 },
197 date: {
198 takesArg: true,
199 helpArg: 'DATE',
200 parseArg: parseDate
201 },
202 arrayOfBool: {
203 takesArg: false,
204 array: true,
205 parseArg: parseBool
206 },
207 arrayOfString: {
208 takesArg: true,
209 helpArg: 'ARG',
210 array: true,
211 parseArg: parseString
212 },
213 arrayOfNumber: {
214 takesArg: true,
215 helpArg: 'NUM',
216 array: true,
217 parseArg: parseNumber
218 },
219 arrayOfInteger: {
220 takesArg: true,
221 helpArg: 'INT',
222 array: true,
223 parseArg: parseInteger
224 },
225 arrayOfPositiveInteger: {
226 takesArg: true,
227 helpArg: 'INT',
228 array: true,
229 parseArg: parsePositiveInteger
230 },
231 arrayOfDate: {
232 takesArg: true,
233 helpArg: 'INT',
234 array: true,
235 parseArg: parseDate
236 },
237};
238
239
240
241// ---- Parser
242
243/**
244 * Parser constructor.
245 *
246 * @param config {Object} The parser configuration
247 * - options {Array} Array of option specs. See the README for how to
248 * specify each option spec.
249 * - allowUnknown {Boolean} Default false. Whether to throw on unknown
250 * options. If false, then unknown args are included in the _args array.
251 * - interspersed {Boolean} Default true. Whether to allow interspersed
252 * arguments (non-options) and options. E.g.:
253 * node tool.js arg1 arg2 -v
254 * '-v' is after some args here. If `interspersed: false` then '-v'
255 * would not be parsed out. Note that regardless of `interspersed`
256 * the presence of '--' will stop option parsing, as all good
257 * option parsers should.
258 */
259function Parser(config) {
260 assert.object(config, 'config');
261 assert.arrayOfObject(config.options, 'config.options');
262 assert.optionalBool(config.interspersed, 'config.interspersed');
263 var self = this;
264
265 // Allow interspersed arguments (true by default).
266 this.interspersed = (config.interspersed !== undefined
267 ? config.interspersed : true);
268
269 // Don't allow unknown flags (true by default).
270 this.allowUnknown = (config.allowUnknown !== undefined
271 ? config.allowUnknown : false);
272
273 this.options = config.options.map(function (o) { return shallowCopy(o); });
274 this.optionFromName = {};
275 this.optionFromEnv = {};
276 for (var i = 0; i < this.options.length; i++) {
277 var o = this.options[i];
278 if (o.group !== undefined && o.group !== null) {
279 assert.optionalString(o.group,
280 format('config.options.%d.group', i));
281 continue;
282 }
283 assert.ok(optionTypes[o.type],
284 format('invalid config.options.%d.type: "%s" in %j',
285 i, o.type, o));
286 assert.optionalString(o.name, format('config.options.%d.name', i));
287 assert.optionalArrayOfString(o.names,
288 format('config.options.%d.names', i));
289 assert.ok((o.name || o.names) && !(o.name && o.names),
290 format('exactly one of "name" or "names" required: %j', o));
291 assert.optionalString(o.help, format('config.options.%d.help', i));
292 var env = o.env || [];
293 if (typeof (env) === 'string') {
294 env = [env];
295 }
296 assert.optionalArrayOfString(env, format('config.options.%d.env', i));
297 assert.optionalString(o.helpGroup,
298 format('config.options.%d.helpGroup', i));
299 assert.optionalBool(o.helpWrap,
300 format('config.options.%d.helpWrap', i));
301
302 if (o.name) {
303 o.names = [o.name];
304 } else {
305 assert.string(o.names[0],
306 format('config.options.%d.names is empty', i));
307 }
308 o.key = optionKeyFromName(o.names[0]);
309 o.names.forEach(function (n) {
310 if (self.optionFromName[n]) {
311 throw new Error(format(
312 'option name collision: "%s" used in %j and %j',
313 n, self.optionFromName[n], o));
314 }
315 self.optionFromName[n] = o;
316 });
317 env.forEach(function (n) {
318 if (self.optionFromEnv[n]) {
319 throw new Error(format(
320 'option env collision: "%s" used in %j and %j',
321 n, self.optionFromEnv[n], o));
322 }
323 self.optionFromEnv[n] = o;
324 });
325 }
326}
327
328Parser.prototype.optionTakesArg = function optionTakesArg(option) {
329 return optionTypes[option.type].takesArg;
330};
331
332/**
333 * Parse options from the given argv.
334 *
335 * @param inputs {Object} Optional.
336 * - argv {Array} Optional. The argv to parse. Defaults to
337 * `process.argv`.
338 * - slice {Number} The index into argv at which options/args begin.
339 * Default is 2, as appropriate for `process.argv`.
340 * - env {Object} Optional. The env to use for 'env' entries in the
341 * option specs. Defaults to `process.env`.
342 * @returns {Object} Parsed `opts`. It has special keys `_args` (the
343 * remaining args from `argv`) and `_order` (gives the order that
344 * options were specified).
345 */
346Parser.prototype.parse = function parse(inputs) {
347 var self = this;
348
349 // Old API was `parse([argv, [slice]])`
350 if (Array.isArray(arguments[0])) {
351 inputs = {argv: arguments[0], slice: arguments[1]};
352 }
353
354 assert.optionalObject(inputs, 'inputs');
355 if (!inputs) {
356 inputs = {};
357 }
358 assert.optionalArrayOfString(inputs.argv, 'inputs.argv');
359 //assert.optionalNumber(slice, 'slice');
360 var argv = inputs.argv || process.argv;
361 var slice = inputs.slice !== undefined ? inputs.slice : 2;
362 var args = argv.slice(slice);
363 var env = inputs.env || process.env;
364 var opts = {};
365 var _order = [];
366
367 function addOpt(option, optstr, key, val, from) {
368 var type = optionTypes[option.type];
369 var parsedVal = type.parseArg(option, optstr, val);
370 if (type.array) {
371 if (!opts[key]) {
372 opts[key] = [];
373 }
374 opts[key].push(parsedVal);
375 } else {
376 opts[key] = parsedVal;
377 }
378 var item = { key: key, value: parsedVal, from: from };
379 _order.push(item);
380 }
381
382 // Parse args.
383 var _args = [];
384 var i = 0;
385 outer: while (i < args.length) {
386 var arg = args[i];
387
388 // End of options marker.
389 if (arg === '--') {
390 i++;
391 break;
392
393 // Long option
394 } else if (arg.slice(0, 2) === '--') {
395 var name = arg.slice(2);
396 var val = null;
397 var idx = name.indexOf('=');
398 if (idx !== -1) {
399 val = name.slice(idx + 1);
400 name = name.slice(0, idx);
401 }
402 var option = this.optionFromName[name];
403 if (!option) {
404 if (!this.allowUnknown)
405 throw new Error(format('unknown option: "--%s"', name));
406 else if (this.interspersed)
407 _args.push(arg);
408 else
409 break outer;
410 } else {
411 var takesArg = this.optionTakesArg(option);
412 if (val !== null && !takesArg) {
413 throw new Error(format('argument given to "--%s" option '
414 + 'that does not take one: "%s"', name, arg));
415 }
416 if (!takesArg) {
417 addOpt(option, '--'+name, option.key, true, 'argv');
418 } else if (val !== null) {
419 addOpt(option, '--'+name, option.key, val, 'argv');
420 } else if (i + 1 >= args.length) {
421 throw new Error(format('do not have enough args for "--%s" '
422 + 'option', name));
423 } else {
424 addOpt(option, '--'+name, option.key, args[i + 1], 'argv');
425 i++;
426 }
427 }
428
429 // Short option
430 } else if (arg[0] === '-' && arg.length > 1) {
431 var j = 1;
432 var allFound = true;
433 while (j < arg.length) {
434 var name = arg[j];
435 // debug('name: %s (val: %s)', name, val)
436 var option = this.optionFromName[name];
437 if (!option) {
438 allFound = false;
439 if (this.allowUnknown) {
440 if (this.interspersed) {
441 _args.push(arg);
442 break;
443 } else
444 break outer;
445 } else if (arg.length > 2) {
446 throw new Error(format(
447 'unknown option: "-%s" in "%s" group',
448 name, arg));
449 } else {
450 throw new Error(format('unknown option: "-%s"', name));
451 }
452 } else if (this.optionTakesArg(option)) {
453 break;
454 }
455 j++;
456 }
457
458 j = 1;
459 while (allFound && j < arg.length) {
460 var name = arg[j];
461 var val = arg.slice(j + 1); // option val if it takes an arg
462 var takesArg = this.optionTakesArg(option);
463 var option = this.optionFromName[name];
464 if (!takesArg) {
465 addOpt(option, '-'+name, option.key, true, 'argv');
466 } else if (val) {
467 addOpt(option, '-'+name, option.key, val, 'argv');
468 break;
469 } else {
470 if (i + 1 >= args.length) {
471 throw new Error(format('do not have enough args '
472 + 'for "-%s" option', name));
473 }
474 addOpt(option, '-'+name, option.key, args[i + 1], 'argv');
475 i++;
476 break;
477 }
478 j++;
479 }
480
481 // An interspersed arg
482 } else if (this.interspersed) {
483 _args.push(arg);
484
485 // An arg and interspersed args are not allowed, so done options.
486 } else {
487 break outer;
488 }
489 i++;
490 }
491 _args = _args.concat(args.slice(i));
492
493 // Parse environment.
494 Object.keys(this.optionFromEnv).forEach(function (envname) {
495 var val = env[envname];
496 if (val === undefined)
497 return;
498 var option = self.optionFromEnv[envname];
499 if (opts[option.key] !== undefined)
500 return;
501 var takesArg = self.optionTakesArg(option);
502 if (takesArg) {
503 addOpt(option, envname, option.key, val, 'env');
504 } else if (val !== '') {
505 // Boolean envvar handling:
506 // - VAR=<empty-string> not set (as if the VAR was not set)
507 // - VAR=0 false
508 // - anything else true
509 addOpt(option, envname, option.key, (val !== '0'), 'env');
510 }
511 });
512
513 // Apply default values.
514 this.options.forEach(function (o) {
515 if (o.default !== undefined && opts[o.key] === undefined) {
516 opts[o.key] = o.default;
517 }
518 });
519
520 opts._order = _order;
521 opts._args = _args;
522 return opts;
523};
524
525
526/**
527 * Return help output for the current options.
528 *
529 * E.g.: if the current options are:
530 * [{names: ['help', 'h'], type: 'bool', help: 'Show help and exit.'}]
531 * then this would return:
532 * ' -h, --help Show help and exit.\n'
533 *
534 * @param config {Object} Config for controlling the option help output.
535 * - indent {Number|String} Default 4. An indent/prefix to use for
536 * each option line.
537 * - nameSort {String} Default is 'length'. By default the names are
538 * sorted to put the short opts first (i.e. '-h, --help' preferred
539 * to '--help, -h'). Set to 'none' to not do this sorting.
540 * - maxCol {Number} Default 80. Note that long tokens in a help string
541 * can go past this.
542 * - helpCol {Number} Set to specify a specific column at which
543 * option help will be aligned. By default this is determined
544 * automatically.
545 * - minHelpCol {Number} Default 20.
546 * - maxHelpCol {Number} Default 40.
547 * - includeEnv {Boolean} Default false.
548 * - helpWrap {Boolean} Default true. Wrap help text in helpCol..maxCol
549 * bounds.
550 * @returns {String}
551 */
552Parser.prototype.help = function help(config) {
553 config = config || {};
554 assert.object(config, 'config');
555
556 var indent = makeIndent(config.indent, 4, 'config.indent');
557 var headingIndent = makeIndent(config.headingIndent,
558 Math.round(indent.length / 2), 'config.headingIndent');
559
560 assert.optionalString(config.nameSort, 'config.nameSort');
561 var nameSort = config.nameSort || 'length';
562 assert.ok(~['length', 'none'].indexOf(nameSort),
563 'invalid "config.nameSort"');
564 assert.optionalNumber(config.maxCol, 'config.maxCol');
565 assert.optionalNumber(config.maxHelpCol, 'config.maxHelpCol');
566 assert.optionalNumber(config.minHelpCol, 'config.minHelpCol');
567 assert.optionalNumber(config.helpCol, 'config.helpCol');
568 assert.optionalBool(config.includeEnv, 'config.includeEnv');
569 assert.optionalBool(config.helpWrap, 'config.helpWrap');
570 var maxCol = config.maxCol || 80;
571 var minHelpCol = config.minHelpCol || 20;
572 var maxHelpCol = config.maxHelpCol || 40;
573
574 var lines = [];
575 var maxWidth = 0;
576 this.options.forEach(function (o) {
577 if (o.group !== undefined && o.group !== null) {
578 // We deal with groups in the next pass
579 lines.push(null);
580 return;
581 }
582 var type = optionTypes[o.type];
583 var arg = o.helpArg || type.helpArg || 'ARG';
584 var line = '';
585 var names = o.names.slice();
586 if (nameSort === 'length') {
587 names.sort(function (a, b) {
588 if (a.length < b.length)
589 return -1;
590 else if (b.length < a.length)
591 return 1;
592 else
593 return 0;
594 })
595 }
596 names.forEach(function (name, i) {
597 if (i > 0)
598 line += ', ';
599 if (name.length === 1) {
600 line += '-' + name
601 if (type.takesArg)
602 line += ' ' + arg;
603 } else {
604 line += '--' + name
605 if (type.takesArg)
606 line += '=' + arg;
607 }
608 });
609 maxWidth = Math.max(maxWidth, line.length);
610 lines.push(line);
611 });
612
613 // Add help strings.
614 var helpCol = config.helpCol;
615 if (!helpCol) {
616 helpCol = maxWidth + indent.length + 2;
617 helpCol = Math.min(Math.max(helpCol, minHelpCol), maxHelpCol);
618 }
619 this.options.forEach(function (o, i) {
620 if (o.group !== undefined && o.group !== null) {
621 if (o.group === '') {
622 // Support a empty string "group" to have a blank line between
623 // sets of options.
624 lines[i] = '';
625 } else {
626 // Render the group heading with the heading-specific indent.
627 lines[i] = (i === 0 ? '' : '\n') + headingIndent +
628 o.group + ':';
629 }
630 return;
631 }
632
633 var line = lines[i] = indent + lines[i];
634 if (!o.help && !(config.includeEnv && o.env)) {
635 return;
636 }
637 var n = helpCol - line.length;
638 if (n >= 0) {
639 line += space(n);
640 } else {
641 line += '\n' + space(helpCol);
642 }
643
644 var helpEnv = '';
645 if (o.env && o.env.length && config.includeEnv) {
646 helpEnv += 'Environment: ';
647 var type = optionTypes[o.type];
648 var arg = o.helpArg || type.helpArg || 'ARG';
649 var envs = (Array.isArray(o.env) ? o.env : [o.env]).map(
650 function (e) {
651 if (type.takesArg) {
652 return e + '=' + arg;
653 } else {
654 return e + '=1';
655 }
656 }
657 );
658 helpEnv += envs.join(', ');
659 }
660 var help = (o.help || '').trim();
661 if (o.helpWrap !== false && config.helpWrap !== false) {
662 // Wrap help description normally.
663 if (help.length && !~'.!?'.indexOf(help.slice(-1))) {
664 help += '.';
665 }
666 if (help.length) {
667 help += ' ';
668 }
669 help += helpEnv;
670 line += textwrap(help, maxCol - helpCol).join(
671 '\n' + space(helpCol));
672 } else {
673 // Do not wrap help description, but indent newlines appropriately.
674 var helpLines = help.split('\n').filter(
675 function (ln) { return ln.length });
676 if (helpEnv !== '') {
677 helpLines.push(helpEnv);
678 }
679 line += helpLines.join('\n' + space(helpCol));
680 }
681
682 lines[i] = line;
683 });
684
685 var rv = '';
686 if (lines.length > 0) {
687 rv = lines.join('\n') + '\n';
688 }
689 return rv;
690};
691
692
693
694// ---- exports
695
696function createParser(config) {
697 return new Parser(config);
698}
699
700/**
701 * Parse argv with the given options.
702 *
703 * @param config {Object} A merge of all the available fields from
704 * `dashdash.Parser` and `dashdash.Parser.parse`: options, interspersed,
705 * argv, env, slice.
706 */
707function parse(config) {
708 assert.object(config, 'config');
709 assert.optionalArrayOfString(config.argv, 'config.argv');
710 assert.optionalObject(config.env, 'config.env');
711 var config = shallowCopy(config);
712 var argv = config.argv;
713 delete config.argv;
714 var env = config.env;
715 delete config.env;
716
717 var parser = new Parser(config);
718 return parser.parse({argv: argv, env: env});
719}
720
721
722/**
723 * Add a new option type.
724 *
725 * @params optionType {Object}:
726 * - name {String} Required.
727 * - takesArg {Boolean} Required. Whether this type of option takes an
728 * argument on process.argv. Typically this is true for all but the
729 * "bool" type.
730 * - helpArg {String} Required iff `takesArg === true`. The string to
731 * show in generated help for options of this type.
732 * - parseArg {Function} Require. `function (option, optstr, arg)` parser
733 * that takes a string argument and returns an instance of the
734 * appropriate type, or throws an error if the arg is invalid.
735 * - array {Boolean} Optional. Set to true if this is an 'arrayOf' type
736 * that collects multiple usages of the option in process.argv and
737 * puts results in an array.
738 */
739function addOptionType(optionType) {
740 assert.object(optionType, 'optionType');
741 assert.string(optionType.name, 'optionType.name');
742 assert.bool(optionType.takesArg, 'optionType.takesArg');
743 if (optionType.takesArg) {
744 assert.string(optionType.helpArg, 'optionType.helpArg');
745 }
746 assert.func(optionType.parseArg, 'optionType.parseArg');
747 assert.optionalBool(optionType.array, 'optionType.array');
748
749 optionTypes[optionType.name] = {
750 takesArg: optionType.takesArg,
751 helpArg: optionType.helpArg,
752 parseArg: optionType.parseArg,
753 array: optionType.array
754 }
755
756
757}
758
759module.exports = {
760 createParser: createParser,
761 Parser: Parser,
762 parse: parse,
763 addOptionType: addOptionType,
764
765 // Export the parseFoo parsers because they might be useful as primitives
766 // for custom option types.
767 parseBool: parseBool,
768 parseString: parseString,
769 parseNumber: parseNumber,
770 parseInteger: parseInteger,
771 parsePositiveInteger: parsePositiveInteger,
772 parseDate: parseDate
773};