From a05a8bc27cdd4d61e49f80ccc7c851e721beca47 Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 14 Sep 2020 20:36:39 +1200 Subject: [PATCH] Enhance Option class to allow hiding help, specifying choices, and change how default value displayed in help (#1331) * Add .addOption and use as bottleneck. Make all Option properties private. * Desription for object is optional * Add support for hidden options * Try setFoo for booleans so less ambiguous what no parameter means * Renamed method * Restore Option property names to reduce churn * Try more fluent names for non-property methods * Avoid renaming existing members of Option * Add default description for help * Fix return JSDoc * First cut at choices * Throw CommanderError for easier detection * Add catch for tidy coercion failure handing * Add tests for Option.choices * Rename custom option processing property * Add run script for TypeScript checkJS * Add tests for chaining routines * More consistent name for custom option arg processing * Add choices to help * .default() now expects parameter * Fixed return type * Separate out argumentRejected for possible reuse * Add back support for RegExp which accidentally dropped. * Add test for obsolete regexp * Add TypeScript definitions for new Option properties * Switch from obsolete to deprecated, clearer meaning * Fix left-over edit * Add comment * Simplify the comments * Add README and example file * Remove example covered elsewhere * Restore example, leave change for a separate PR * Fix example output to match changed code * Add language to code blocks * Rename getFullDescription, not using get much * Add chaining test for addHelpText * Describe as legacy rather than deprecated in comments, add @deprecated for editor feedback to discourage use * Do not have to have both should and long flags these days * Eliminate duplicate code using internal knowledge * Rename parseArgWith to argParser * Improve JSDoc for help * Make code and declarations consistent to pass tsc checks * Match up write signature to fix linting error * Restore "deprecated", it is the right word --- Readme.md | 28 +++ examples/options-extra.js | 20 +++ index.js | 224 +++++++++++++++++++----- package.json | 3 +- tests/command.chain.test.js | 132 ++++++++++++++ tests/command.exitOverride.test.js | 17 ++ tests/command.help.test.js | 45 ++++- tests/deprecated.test.js | 31 ++++ tests/option.chain.test.js | 33 ++++ tests/options.custom-processing.test.js | 10 ++ tests/options.regex.test.js | 41 ----- tests/program.test.js | 2 +- typings/commander-tests.ts | 43 ++++- typings/index.d.ts | 78 ++++++++- 14 files changed, 603 insertions(+), 104 deletions(-) create mode 100644 examples/options-extra.js create mode 100644 tests/command.chain.test.js create mode 100644 tests/deprecated.test.js create mode 100644 tests/option.chain.test.js delete mode 100644 tests/options.regex.test.js diff --git a/Readme.md b/Readme.md index e8807f9cd..2e0577fca 100644 --- a/Readme.md +++ b/Readme.md @@ -16,6 +16,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [Common option types, boolean and value](#common-option-types-boolean-and-value) - [Default option value](#default-option-value) - [Other option types, negatable boolean and flag|value](#other-option-types-negatable-boolean-and-flagvalue) + - [Extra option features](#extra-option-features) - [Custom option processing](#custom-option-processing) - [Required option](#required-option) - [Variadic option](#variadic-option) @@ -202,6 +203,33 @@ $ pizza-options --cheese mozzarella add cheese type mozzarella ``` +### Extra option features + +You can add most options using the `.option()` method, but there are some additional features available +by constructing an `Option` explicitly for less common cases. + +Example file: [options-extra.js](./examples/options-extra.js) + +```js +program + .addOption(new Option('-s, --secret').hideHelp()) + .addOption(new Option('-t, --timeout ', 'timeout in seconds').default(60, 'one minute')) + .addOption(new Option('-d, --drink ', 'drink size').choices(['small', 'medium', 'large'])); +``` + +```bash +$ extra --help +Usage: help [options] + +Options: + -t, --timeout timeout in seconds (default: one minute) + -d, --drink drink cup size (choices: "small", "medium", "large") + -h, --help display help for command + +$ extra --drink huge +error: option '-d, --drink ' argument of 'huge' not in allowed choices: small, medium, large +``` + ### Custom option processing You may specify a function to do custom processing of option values. The callback function receives two parameters, the user specified value and the diff --git a/examples/options-extra.js b/examples/options-extra.js new file mode 100644 index 000000000..404b3e86a --- /dev/null +++ b/examples/options-extra.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +// This is used as an example in the README for extra option features. + +// const commander = require('commander'); // (normal include) +const commander = require('../'); // include commander in git clone of commander repo +const program = new commander.Command(); + +program + .addOption(new commander.Option('-s, --secret').hideHelp()) + .addOption(new commander.Option('-t, --timeout ', 'timeout in seconds').default(60, 'one minute')) + .addOption(new commander.Option('-d, --drink ', 'drink cup size').choices(['small', 'medium', 'large'])); + +program.parse(); + +console.log('Options: ', program.opts()); + +// Try the following: +// node options-extra.js --help +// node options-extra.js --drink huge diff --git a/index.js b/index.js index b367dadaf..b60acf0af 100644 --- a/index.js +++ b/index.js @@ -14,12 +14,14 @@ class Option { * Initialize a new `Option` with the given `flags` and `description`. * * @param {string} flags - * @param {string} description + * @param {string} [description] * @api public */ constructor(flags, description) { this.flags = flags; + this.description = description || ''; + this.required = flags.includes('<'); // A value must be supplied when the option is specified. this.optional = flags.includes('['); // A value is optional when the option is specified. // variadic test ignores et al which might be used to describe custom splitting of single argument @@ -32,15 +34,128 @@ class Option { if (this.long) { this.negate = this.long.startsWith('--no-'); } - this.description = description || ''; this.defaultValue = undefined; + this.defaultValueDescription = undefined; + this.parseArg = undefined; + this.hidden = false; + this.argChoices = undefined; } + /** + * Set the default value, and optionally supply the description to be displayed in the help. + * + * @param {any} value + * @param {string} [description] + * @return {Option} + * @api public + */ + + default(value, description) { + this.defaultValue = value; + this.defaultValueDescription = description; + return this; + }; + + /** + * Calculate the full description, including defaultValue etc. + * + * @return {string} + * @api public + */ + + fullDescription() { + if (this.negate) { + return this.description; + } + const extraInfo = []; + if (this.argChoices) { + extraInfo.push( + // use stringify to match the display of the default value + `choices: ${this.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`); + } + if (this.defaultValue !== undefined) { + extraInfo.push(`default: ${this.defaultValueDescription || JSON.stringify(this.defaultValue)}`); + } + if (extraInfo.length > 0) { + return `${this.description} (${extraInfo.join(', ')})`; + } + return this.description; + }; + + /** + * Set the custom handler for processing CLI option arguments into option values. + * + * @param {Function} [fn] + * @return {Option} + * @api public + */ + + argParser(fn) { + this.parseArg = fn; + return this; + }; + + /** + * Whether the option is mandatory and must have a value after parsing. + * + * @param {boolean} [value] + * @return {Option} + * @api public + */ + + makeOptionMandatory(value) { + this.mandatory = (value === undefined) || value; + return this; + }; + + /** + * Hide option in help. + * + * @param {boolean} [value] + * @return {Option} + * @api public + */ + + hideHelp(value) { + this.hidden = (value === undefined) || value; + return this; + }; + + /** + * Validation of option argument failed. + * Intended for use from custom argument processing functions. + * + * @param {string} message + * @api public + */ + argumentRejected(message) { + throw new CommanderError(1, 'commander.optionArgumentRejected', message); + } + + /** + * Only allow option value to be one of choices. + * + * @param {string[]} values + * @return {Option} + * @api public + */ + + choices(values) { + this.argChoices = values; + this.parseArg = (arg) => { + if (!values.includes(arg)) { + this.argumentRejected(`error: option '${this.flags}' argument of '${arg}' not in allowed choices: ${values.join(', ')}`); + } + return arg; + }; + return this; + }; + /** * Return option name. * * @return {string} - * @api private + * @api public */ name() { @@ -479,40 +594,18 @@ Read more on https://git.io/JJc0W`); }; /** - * Internal implementation shared by .option() and .requiredOption() + * Add an option. * - * @param {Object} config - * @param {string} flags - * @param {string} description - * @param {Function|*} [fn] - custom option processing function or default value - * @param {*} [defaultValue] + * @param {Option} option * @return {Command} `this` command for chaining - * @api private */ - - _optionEx(config, flags, description, fn, defaultValue) { - const option = new Option(flags, description); + addOption(option) { const oname = option.name(); const name = option.attributeName(); - option.mandatory = !!config.mandatory; this._checkForOptionNameClash(option); - // default as 3rd arg - if (typeof fn !== 'function') { - if (fn instanceof RegExp) { - // This is a bit simplistic (especially no error messages), and probably better handled by caller using custom option processing. - // No longer documented in README, but still present for backwards compatibility. - const regex = fn; - fn = (val, def) => { - const m = regex.exec(val); - return m ? m[0] : def; - }; - } else { - defaultValue = fn; - fn = null; - } - } + let defaultValue = option.defaultValue; // preassign default value for --no-*, [optional], , or plain flag if boolean value if (option.negate || option.optional || option.required || typeof defaultValue === 'boolean') { @@ -524,7 +617,6 @@ Read more on https://git.io/JJc0W`); // preassign only if we have a default if (defaultValue !== undefined) { this._setOptionValue(name, defaultValue); - option.defaultValue = defaultValue; } } @@ -537,8 +629,16 @@ Read more on https://git.io/JJc0W`); const oldValue = this._getOptionValue(name); // custom processing - if (val !== null && fn) { - val = fn(val, oldValue === undefined ? defaultValue : oldValue); + if (val !== null && option.parseArg) { + try { + val = option.parseArg(val, oldValue === undefined ? defaultValue : oldValue); + } catch (err) { + if (err.code === 'commander.optionArgumentRejected') { + console.error(err.message); + this._exit(err.exitCode, err.code, err.message); + } + throw err; + } } else if (val !== null && option.variadic) { if (oldValue === defaultValue || !Array.isArray(oldValue)) { val = [val]; @@ -564,13 +664,13 @@ Read more on https://git.io/JJc0W`); }); return this; - }; + } /** * Define option with `flags`, `description` and optional * coercion `fn`. * - * The `flags` string should contain both the short and long flags, + * The `flags` string contains the short and/or long flags, * separated by comma, a pipe or space. The following are all valid * all will output this way when `--help` is used. * @@ -619,14 +719,29 @@ Read more on https://git.io/JJc0W`); */ option(flags, description, fn, defaultValue) { - return this._optionEx({}, flags, description, fn, defaultValue); + const option = new Option(flags, description); + if (typeof fn === 'function') { + option.default(defaultValue).argParser(fn); + } else if (fn instanceof RegExp) { + // deprecated + const regex = fn; + fn = (val, def) => { + const m = regex.exec(val); + return m ? m[0] : def; + }; + option.default(defaultValue).argParser(fn); + } else { + option.default(fn); + } + + return this.addOption(option); }; /** * Add a required option which must have a value after parsing. This usually means * the option must be specified on the command line. (Otherwise the same as .option().) * - * The `flags` string should contain both the short and long flags, separated by comma, a pipe or space. + * The `flags` string contains the short and/or long flags, separated by comma, a pipe or space. * * @param {string} flags * @param {string} description @@ -637,7 +752,9 @@ Read more on https://git.io/JJc0W`); */ requiredOption(flags, description, fn, defaultValue) { - return this._optionEx({ mandatory: true }, flags, description, fn, defaultValue); + this.option(flags, description, fn, defaultValue); + this.options[this.options.length - 1].makeOptionMandatory(); + return this; }; /** @@ -790,7 +907,9 @@ Read more on https://git.io/JJc0W`); default: throw new Error(`unexpected parse option { from: '${parseOptions.from}' }`); } + // @ts-ignore if (!this._scriptPath && process.mainModule) { + // @ts-ignore this._scriptPath = process.mainModule.filename; } @@ -846,7 +965,9 @@ Read more on https://git.io/JJc0W`); // Want the entry script as the reference for command name and directory for searching for other files. let scriptPath = this._scriptPath; // Fallback in case not set, due to how Command created or called. + // @ts-ignore if (!scriptPath && process.mainModule) { + // @ts-ignore scriptPath = process.mainModule.filename; } @@ -1491,6 +1612,16 @@ Read more on https://git.io/JJc0W`); return width; }; + /** + * Any visible options? + * + * @return {boolean} + * @api private + */ + _hasVisibleOptions() { + return this._hasHelpOption || this.options.some((option) => !option.hidden); + } + /** * Return help for options. * @@ -1507,10 +1638,9 @@ Read more on https://git.io/JJc0W`); }; // Explicit options (including version) - const help = this.options.map((option) => { - const fullDesc = option.description + - ((!option.negate && option.defaultValue !== undefined) ? ' (default: ' + JSON.stringify(option.defaultValue) + ')' : ''); - return padOptionDetails(option.flags, fullDesc); + const visibleOptions = this.options.filter((option) => !option.hidden); + const help = visibleOptions.map((option) => { + return padOptionDetails(option.flags, option.fullDescription()); }); // Implicit help @@ -1602,7 +1732,7 @@ Read more on https://git.io/JJc0W`); if (commandHelp) cmds = [commandHelp]; let options = []; - if (this._hasHelpOption || this.options.length > 0) { + if (this._hasVisibleOptions()) { options = [ 'Options:', '' + this.optionHelp().replace(/^/gm, ' '), @@ -1626,9 +1756,9 @@ Read more on https://git.io/JJc0W`); const context = { error: !!contextOptions.error }; let write; if (context.error) { - write = (...args) => process.stderr.write(...args); + write = (arg, ...args) => process.stderr.write(arg, ...args); } else { - write = (...args) => process.stdout.write(...args); + write = (arg, ...args) => process.stdout.write(arg, ...args); } context.write = contextOptions.write || write; context.command = this; @@ -1640,15 +1770,15 @@ Read more on https://git.io/JJc0W`); * * Outputs built-in help, and custom text added using `.addHelpText()`. * + * @param {{ error: boolean } | Function} [contextOptions] - pass {error:true} to write to stderr instead of stdout * @api public - * @param {Object} [contextOptions] - Can optionally pass in `{ error: true }` to write to stderr */ outputHelp(contextOptions) { let deprecatedCallback; if (typeof contextOptions === 'function') { deprecatedCallback = contextOptions; - contextOptions = {}; + contextOptions = undefined; } const context = this._getHelpContext(contextOptions); @@ -1707,7 +1837,7 @@ Read more on https://git.io/JJc0W`); * * Outputs built-in help, and custom text added using `.addHelpText()`. * - * @param {Object} [contextOptions] - optionally pass in `{ error: true }` to write to stderr + * @param {{ error: boolean }} [contextOptions] - pass {error:true} to write to stderr instead of stdout * @api public */ diff --git a/package.json b/package.json index d31b5c5bb..131826b97 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "lint": "eslint index.js \"tests/**/*.js\"", "typescript-lint": "eslint typings/*.ts", "test": "jest && npm run test-typings", - "test-typings": "tsc -p tsconfig.json" + "test-typings": "tsc -p tsconfig.json", + "typescript-checkJS": "tsc --allowJS --checkJS index.js --noEmit" }, "main": "index", "files": [ diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js new file mode 100644 index 000000000..6a94a37c5 --- /dev/null +++ b/tests/command.chain.test.js @@ -0,0 +1,132 @@ +const { Command, Option } = require('../'); + +// Testing the functions which should chain. +// parse and parseAsync are tested in command.parse.test.js + +describe('Command methods that should return this for chaining', () => { + test('when call .command() with description for stand-alone executable then returns this', () => { + const program = new Command(); + const result = program.command('foo', 'foo description'); + expect(result).toBe(program); + }); + + test('when call .addCommand() then returns this', () => { + const program = new Command(); + const result = program.addCommand(new Command('name')); + expect(result).toBe(program); + }); + + test('when set .arguments() then returns this', () => { + const program = new Command(); + const result = program.arguments(''); + expect(result).toBe(program); + }); + + test('when call .addHelpCommand() then returns this', () => { + const program = new Command(); + const result = program.addHelpCommand(false); + expect(result).toBe(program); + }); + + test('when call .exitOverride() then returns this', () => { + const program = new Command(); + const result = program.exitOverride(() => { }); + expect(result).toBe(program); + }); + + test('when call .action() then returns this', () => { + const program = new Command(); + const result = program.action(() => { }); + expect(result).toBe(program); + }); + + test('when call .addOption() then returns this', () => { + const program = new Command(); + const result = program.addOption(new Option('-e')); + expect(result).toBe(program); + }); + + test('when call .option() then returns this', () => { + const program = new Command(); + const result = program.option('-e'); + expect(result).toBe(program); + }); + + test('when call .requiredOption() then returns this', () => { + const program = new Command(); + const result = program.requiredOption('-r'); + expect(result).toBe(program); + }); + + test('when call .combineFlagAndOptionalValue() then returns this', () => { + const program = new Command(); + const result = program.combineFlagAndOptionalValue(); + expect(result).toBe(program); + }); + + test('when call .allowUnknownOption() then returns this', () => { + const program = new Command(); + const result = program.allowUnknownOption(); + expect(result).toBe(program); + }); + + test('when call .storeOptionsAsProperties() then returns this', () => { + const program = new Command(); + const result = program.storeOptionsAsProperties(); + expect(result).toBe(program); + }); + + test('when call .passCommandToAction() then returns this', () => { + const program = new Command(); + const result = program.passCommandToAction(); + expect(result).toBe(program); + }); + + test('when call .version() then returns this', () => { + const program = new Command(); + const result = program.version('1.2.3'); + expect(result).toBe(program); + }); + + test('when set .description() then returns this', () => { + const program = new Command(); + const result = program.description('description'); + expect(result).toBe(program); + }); + + test('when set .alias() then returns this', () => { + const program = new Command(); + const result = program.alias('alias'); + expect(result).toBe(program); + }); + + test('when set .aliases() then returns this', () => { + const program = new Command(); + const result = program.aliases(['foo', 'bar']); + expect(result).toBe(program); + }); + + test('when set .usage() then returns this', () => { + const program = new Command(); + const result = program.usage('[options]'); + expect(result).toBe(program); + }); + + test('when set .name() then returns this', () => { + const program = new Command(); + const result = program.name('easy'); + expect(result).toBe(program); + }); + + test('when call .helpOption() then returns this', () => { + const program = new Command(); + const result = program.helpOption(false); + expect(result).toBe(program); + }); + + test('when call .addHelpText() then returns this', () => { + const program = new Command(); + const result = program.addHelpText('before', 'example'); + expect(result).toBe(program); + }); +}); diff --git a/tests/command.exitOverride.test.js b/tests/command.exitOverride.test.js index 62bfdf6e4..962384a71 100644 --- a/tests/command.exitOverride.test.js +++ b/tests/command.exitOverride.test.js @@ -205,4 +205,21 @@ describe('.exitOverride and error details', () => { expectCommanderError(caughtErr, 1, 'commander.missingMandatoryOptionValue', `error: required option '${optionFlags}' not specified`); }); + + test('when option argument not in choices then throw CommanderError', () => { + const optionFlags = '--colour '; + const program = new commander.Command(); + program + .exitOverride() + .addOption(new commander.Option(optionFlags).choices(['red', 'blue'])); + + let caughtErr; + try { + program.parse(['--colour', 'green'], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + + expectCommanderError(caughtErr, 1, 'commander.optionArgumentRejected', `error: option '${optionFlags}' argument of 'green' not in allowed choices: red, blue`); + }); }); diff --git a/tests/command.help.test.js b/tests/command.help.test.js index 965886fbf..2572dbd3b 100644 --- a/tests/command.help.test.js +++ b/tests/command.help.test.js @@ -88,8 +88,7 @@ test('when call outputHelp(cb) then display cb output', () => { writeSpy.mockClear(); }); -// noHelp is now named hidden, not officially deprecated yet -test('when command sets noHelp then not displayed in helpInformation', () => { +test('when command sets deprecated noHelp then not displayed in helpInformation', () => { const program = new commander.Command(); program .command('secret', 'secret description', { noHelp: true }); @@ -155,7 +154,7 @@ test('when call .help with { error: true } then output on stderr', () => { writeSpy.mockClear(); }); -test('when no options then Options not includes in helpInformation', () => { +test('when no options then Options not included in helpInformation', () => { const program = new commander.Command(); // No custom options, no version option, no help option program @@ -163,3 +162,43 @@ test('when no options then Options not includes in helpInformation', () => { const helpInformation = program.helpInformation(); expect(helpInformation).not.toMatch('Options'); }); + +test('when option hidden then option not included in helpInformation', () => { + const program = new commander.Command(); + program + .addOption(new commander.Option('-s,--secret', 'secret option').hideHelp()); + const helpInformation = program.helpInformation(); + expect(helpInformation).not.toMatch('secret'); +}); + +test('when option has default value then default included in helpInformation', () => { + const program = new commander.Command(); + program + .option('-p, --port ', 'port number', 80); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('(default: 80)'); +}); + +test('when option has default value description then default description included in helpInformation', () => { + const program = new commander.Command(); + program + .addOption(new commander.Option('-a, --address ', 'ip address').default('127.0.0.1', 'home')); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('(default: home)'); +}); + +test('when option has choices then choices included in helpInformation', () => { + const program = new commander.Command(); + program + .addOption(new commander.Option('-c, --colour ').choices(['red', 'blue'])); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('(choices: "red", "blue")'); +}); + +test('when option has choices and default then both included in helpInformation', () => { + const program = new commander.Command(); + program + .addOption(new commander.Option('-c, --colour ').choices(['red', 'blue']).default('red')); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('(choices: "red", "blue", default: "red")'); +}); diff --git a/tests/deprecated.test.js b/tests/deprecated.test.js new file mode 100644 index 000000000..ec08fd9e2 --- /dev/null +++ b/tests/deprecated.test.js @@ -0,0 +1,31 @@ +const commander = require('../'); + +// Test for backwards compatible behaviour of deprecated features that don't fit in elsewhere. +// We keep deprecated features working (when not too difficult) to avoid breaking existing code +// and reduce barriers to updating to latest version of Commander. + +describe('option with regular expression instead of custom processing function', () => { + test('when option not given then value is default', () => { + const program = new commander.Command(); + program + .option('--cheese ', 'cheese type', /mild|tasty/, 'mild'); + program.parse([], { from: 'user' }); + expect(program.cheese).toEqual('mild'); + }); + + test('when argument matches regexp then value is as specified', () => { + const program = new commander.Command(); + program + .option('--cheese ', 'cheese type', /mild|tasty/, 'mild'); + program.parse(['--cheese', 'tasty'], { from: 'user' }); + expect(program.cheese).toEqual('tasty'); + }); + + test('when argument does mot matches regexp then value is default', () => { + const program = new commander.Command(); + program + .option('--cheese ', 'cheese type', /mild|tasty/, 'mild'); + program.parse(['--cheese', 'other'], { from: 'user' }); + expect(program.cheese).toEqual('mild'); + }); +}); diff --git a/tests/option.chain.test.js b/tests/option.chain.test.js new file mode 100644 index 000000000..700984d15 --- /dev/null +++ b/tests/option.chain.test.js @@ -0,0 +1,33 @@ +const { Option } = require('../'); + +describe('Option methods that should return this for chaining', () => { + test('when call .default() then returns this', () => { + const option = new Option('-e,--example '); + const result = option.default(3); + expect(result).toBe(option); + }); + + test('when call .argParser() then returns this', () => { + const option = new Option('-e,--example '); + const result = option.argParser(() => { }); + expect(result).toBe(option); + }); + + test('when call .makeOptionMandatory() then returns this', () => { + const option = new Option('-e,--example '); + const result = option.makeOptionMandatory(); + expect(result).toBe(option); + }); + + test('when call .hideHelp() then returns this', () => { + const option = new Option('-e,--example '); + const result = option.hideHelp(); + expect(result).toBe(option); + }); + + test('when call .choices() then returns this', () => { + const option = new Option('-e,--example '); + const result = option.choices(['a']); + expect(result).toBe(option); + }); +}); diff --git a/tests/options.custom-processing.test.js b/tests/options.custom-processing.test.js index d5a9b98b8..8c04f2ee4 100644 --- a/tests/options.custom-processing.test.js +++ b/tests/options.custom-processing.test.js @@ -98,6 +98,16 @@ test('when option specified multiple times then callback called with value and p expect(mockCoercion).toHaveBeenNthCalledWith(2, '2', 'callback'); }); +// this is the happy path, testing failure case in command.exitOverride.test.js +test('when option argument in choices then option set', () => { + const program = new commander.Command(); + program + .exitOverride() + .addOption(new commander.Option('--colour ').choices(['red', 'blue'])); + program.parse(['--colour', 'red'], { from: 'user' }); + expect(program.colour).toBe('red'); +}); + // Now some functional tests like the examples in the README! test('when parseFloat "1e2" then value is 100', () => { diff --git a/tests/options.regex.test.js b/tests/options.regex.test.js deleted file mode 100644 index 566f72c49..000000000 --- a/tests/options.regex.test.js +++ /dev/null @@ -1,41 +0,0 @@ -const commander = require('../'); - -// NB: regex support is deprecated, and deliberately not documented in README. - -describe('option with optional value and regex', () => { - test('when option regex fails then value is true [sic]', () => { - const program = new commander.Command(); - program - .option('-d, --drink [drink]', 'Drink', /^(Water|Wine)$/i); - program.parse(['node', 'test', '--drink', 'does-not-match']); - expect(program.drink).toBe(true); - }); - - test('when option regex matches then value is as specified', () => { - const drinkValue = 'water'; - const program = new commander.Command(); - program - .option('-d, --drink [drink]', 'Drink', /^(Water|Wine)$/i); - program.parse(['node', 'test', '--drink', drinkValue]); - expect(program.drink).toBe(drinkValue); - }); -}); - -describe('option with required value and regex', () => { - test('when option regex fails then value is true [sic]', () => { - const program = new commander.Command(); - program - .option('-d, --drink ', 'Drink', /^(Water|Wine)$/i); - program.parse(['node', 'test', '--drink', 'does-not-match']); - expect(program.drink).toBe(true); - }); - - test('when option regex matches then value is as specified', () => { - const drinkValue = 'water'; - const program = new commander.Command(); - program - .option('-d, --drink [drink]', 'Drink', /^(Water|Wine)$/i); - program.parse(['node', 'test', '--drink', drinkValue]); - expect(program.drink).toBe(drinkValue); - }); -}); diff --git a/tests/program.test.js b/tests/program.test.js index a529ee27f..3d5faaf82 100644 --- a/tests/program.test.js +++ b/tests/program.test.js @@ -3,7 +3,7 @@ const commander = require('../'); // Do some testing of the default export(s). test('when require commander then is a Command (default export of global)', () => { - // Legacy global command + // Deprecated global command const program = commander; expect(program.constructor.name).toBe('Command'); }); diff --git a/typings/commander-tests.ts b/typings/commander-tests.ts index 16abd3dfa..130c18994 100644 --- a/typings/commander-tests.ts +++ b/typings/commander-tests.ts @@ -81,6 +81,7 @@ const optionThis1: commander.Command = program.option('-a,--alpha'); const optionThis2: commander.Command = program.option('-p, --peppers', 'Add peppers'); const optionThis3: commander.Command = program.option('-s, --string [value]', 'default string', 'value'); const optionThis4: commander.Command = program.option('-b, --boolean', 'default boolean', false); +program.option('--drink ', 'float argument', parseFloat); const requiredOptionThis6: commander.Command = program.requiredOption('-f, --float ', 'float argument', parseFloat, 3.2); @@ -126,6 +128,9 @@ const requiredOptionThis9: commander.Command = program.requiredOption('-v, --ver const requiredOptionThis10: commander.Command = program.requiredOption('-c, --collect ', 'repeatable value', collect, []); const requiredOptionThis11: commander.Command = program.requiredOption('-l, --list ', 'comma separated list', commaSeparatedList); +// addOption +const addOptionThis: commander.Command = program.addOption(new commander.Option('-s,--simple')); + // storeOptionsAsProperties const storeOptionsAsPropertiesThis1: commander.Command = program.storeOptionsAsProperties(); const storeOptionsAsPropertiesThis2: commander.Command = program.storeOptionsAsProperties(false); @@ -187,12 +192,12 @@ const nameValue: string = program.name(); // outputHelp program.outputHelp(); -program.outputHelp((str: string) => { return str; }); // deprecated +program.outputHelp((str: string) => { return str; }); program.outputHelp({ error: true }); // help program.help(); -program.help((str: string) => { return str; }); // Deprecated +program.help((str: string) => { return str; }); program.help({ error: true }); // helpInformation @@ -238,3 +243,37 @@ const myProgram = new MyCommand(); myProgram.myFunction(); const mySub = myProgram.command('sub'); mySub.myFunction(); + +// Option methods + +const baseOption = new commander.Option('-f,--foo', 'foo description'); + +// default +const myOptionThis1: commander.Option = baseOption.default(3); +const myOptionThis2: commander.Option = baseOption.default(60, 'one minute'); + +// fullDescription +const optionDescription: string = baseOption.fullDescription(); + +// argParser +const myOptionThis3: commander.Option = baseOption.argParser((value: string) => parseInt(value)); +const myOptionThis4: commander.Option = baseOption.argParser((value: string, previous: string[]) => { return previous.concat(value); }); + +// makeOptionMandatory +const myOptionThis5: commander.Option = baseOption.makeOptionMandatory(); +const myOptionThis6: commander.Option = baseOption.makeOptionMandatory(true); + +// hideHelp +const myOptionThis7: commander.Option = baseOption.hideHelp(); +const myOptionThis8: commander.Option = baseOption.hideHelp(true); + +// argumentRejected +function goodbye(): never { + return baseOption.argumentRejected('failed'); +}; + +// choices +const myOptionThis9: commander.Option = baseOption.choices(['a', 'b']); + +// name +const optionName: string = baseOption.name(); diff --git a/typings/index.d.ts b/typings/index.d.ts index 700c51cdf..09156352b 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -13,13 +13,62 @@ declare namespace commander { interface Option { flags: string; + description: string; + required: boolean; // A value must be supplied when the option is specified. optional: boolean; // A value is optional when the option is specified. + variadic: boolean; mandatory: boolean; // The option must have a value after parsing, which usually means it must be specified on command line. - bool: boolean; + optionFlags: string; short?: string; - long: string; - description: string; + long?: string; + negate: boolean; + defaultValue?: any; + defaultValueDescription?: string; + parseArg?: (value: string, previous: T) => T; + hidden: boolean; + argChoices?: string[]; + + /** + * Set the default value, and optionally supply the description to be displayed in the help. + */ + default(value: any, description?: string): this; + + /** + * Calculate the full description, including defaultValue etc. + */ + fullDescription(): string; + + /** + * Set the custom handler for processing CLI option arguments into option values. + */ + argParser(fn: (value: string, previous: T) => T): this; + + /** + * Whether the option is mandatory and must have a value after parsing. + */ + makeOptionMandatory(value?: boolean): this; + + /** + * Hide option in help. + */ + hideHelp(value?: boolean): this; + + /** + * Validation of option argument failed. + * Intended for use from custom argument processing functions. + */ + argumentRejected(messsage: string): never; + + /** + * Only allow option value to be one of choices. + */ + choices(values: string[]): this; + + /** + * Return option name. + */ + name(): string; } type OptionConstructor = new (flags: string, description?: string) => Option; @@ -142,7 +191,7 @@ declare namespace commander { * Define option with `flags`, `description` and optional * coercion `fn`. * - * The `flags` string should contain both the short and long flags, + * The `flags` string contains the short and/or long flags, * separated by comma, a pipe or space. The following are all valid * all will output this way when `--help` is used. * @@ -181,18 +230,27 @@ declare namespace commander { * @returns `this` command for chaining */ option(flags: string, description?: string, defaultValue?: string | boolean): this; - option(flags: string, description: string, regexp: RegExp, defaultValue?: string | boolean): this; option(flags: string, description: string, fn: (value: string, previous: T) => T, defaultValue?: T): this; + /** @deprecated since v7, instead use choices or a custom function */ + option(flags: string, description: string, regexp: RegExp, defaultValue?: string | boolean): this; /** * Define a required option, which must have a value after parsing. This usually means * the option must be specified on the command line. (Otherwise the same as .option().) * - * The `flags` string should contain both the short and long flags, separated by comma, a pipe or space. + * The `flags` string contains the short and/or long flags, separated by comma, a pipe or space. */ requiredOption(flags: string, description?: string, defaultValue?: string | boolean): this; - requiredOption(flags: string, description: string, regexp: RegExp, defaultValue?: string | boolean): this; requiredOption(flags: string, description: string, fn: (value: string, previous: T) => T, defaultValue?: T): this; + /** @deprecated since v7, instead use choices or a custom function */ + requiredOption(flags: string, description: string, regexp: RegExp, defaultValue?: string | boolean): this; + + /** + * Add a prepared Option. + * + * See .option() and .requiredOption() for creating and attaching an option in a single call. + */ + addOption(option: Option): this; /** * Whether to store option values as properties on command object, @@ -348,7 +406,8 @@ declare namespace commander { * */ outputHelp(context?: HelpContext): void; - outputHelp(cb?: (str: string) => string): void; // callback deprecated + /** @deprecated since v7 */ + outputHelp(cb?: (str: string) => string): void; /** * Return command help documentation. @@ -368,7 +427,8 @@ declare namespace commander { * Outputs built-in help, and custom text added using `.addHelpText()`. */ help(context?: HelpContext): never; - help(cb?: (str: string) => string): never; // callback deprecated + /** @deprecated since v7 */ + help(cb?: (str: string) => string): never; /** * Add additional text to be displayed with the built-in help.