From 8263b7f098983fac7545fe7f0c61be2d90b2b53a Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 13 Jan 2025 17:30:14 +1300 Subject: [PATCH] Add support for dual long options when no short option (#2312) --- CHANGELOG.md | 1 + Readme.md | 10 ++++++- docs/deprecated.md | 17 +++++++----- lib/command.js | 2 +- lib/option.js | 49 +++++++++++++++++++++++----------- tests/option.bad-flags.test.js | 8 +++--- typings/index.d.ts | 2 +- 7 files changed, 61 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b4687962..f8ab4410d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - *Breaking*: excess command-arguments cause an error by default, see migration tips ([#2223]) - *Breaking*: throw during Option construction for unsupported option flags, like multiple characters after single `-` ([#2270]) + - note: support for dual long option flags added in Commander 13.1 - *Breaking*: throw on multiple calls to `.parse()` if `storeOptionsAsProperties: true` ([#2299]) - TypeScript: include implicit `this` in parameters for action handler callback ([#2197]) diff --git a/Readme.md b/Readme.md index 6cf55910f..ca6fb27ff 100644 --- a/Readme.md +++ b/Readme.md @@ -175,7 +175,15 @@ const program = new Command(); ## Options -Options are defined with the `.option()` method, also serving as documentation for the options. Each option can have a short flag (single character) and a long name, separated by a comma or space or vertical bar ('|'). +Options are defined with the `.option()` method, also serving as documentation for the options. Each option can have a short flag (single character) and a long name, separated by a comma or space or vertical bar ('|'). To allow a wider range of short-ish flags than just +single characters, you may also have two long options. Examples: + +```js +program + .option('-p, --port ', 'server port number') + .option('--trace', 'add extra debugging output') + .option('--ws, --workspace ', 'use a custom workspace') +``` The parsed options can be accessed by calling `.opts()` on a `Command` object, and are passed to the action handler. diff --git a/docs/deprecated.md b/docs/deprecated.md index e114c7207..09e13cf50 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -12,11 +12,11 @@ They are currently still available for backwards compatibility, but should not b - [.command('\*')](#command) - [cmd.description(cmdDescription, argDescriptions)](#cmddescriptioncmddescription-argdescriptions) - [InvalidOptionArgumentError](#invalidoptionargumenterror) - - [Short option flag longer than a single character](#short-option-flag-longer-than-a-single-character) - [Import from `commander/esm.mjs`](#import-from-commanderesmmjs) - [cmd.\_args](#cmd_args) - [.addHelpCommand(string|boolean|undefined)](#addhelpcommandstringbooleanundefined) - [Removed](#removed) + - [Short option flag longer than a single character](#short-option-flag-longer-than-a-single-character) - [Default import of global Command object](#default-import-of-global-command-object) ### RegExp .option() parameter @@ -168,12 +168,6 @@ function myParseInt(value, dummyPrevious) { Deprecated from Commander v8. -### Short option flag longer than a single character - -Short option flags like `-ws` were never supported, but the old README did not make this clear. The README now states that short options are a single character. - -README updated in Commander v3. Deprecated from Commander v9. - ### Import from `commander/esm.mjs` The first support for named imports required an explicit entry file. @@ -232,6 +226,15 @@ program.addHelpCommand(new Command('assist').argument('[command]').description(' ## Removed +### Short option flag longer than a single character + +Short option flags like `-ws` were never supported, but the old README did not make this clear. The README now states that short options are a single character. + +- README updated in Commander v3. +- Deprecated from Commander v9. +- Throws an exception in Commander v13. Deprecated and gone! +- Replacement added in Commander v13.1 with support for dual long options, like `--ws, --workspace`. + ### Default import of global Command object The default import was a global Command object. diff --git a/lib/command.js b/lib/command.js index 858e13e29..efbb8f614 100644 --- a/lib/command.js +++ b/lib/command.js @@ -756,7 +756,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * @example * program * .option('-p, --pepper', 'add pepper') - * .option('-p, --pizza-type ', 'type of pizza') // required option-argument + * .option('--pt, --pizza-type ', 'type of pizza') // required option-argument * .option('-c, --cheese [CHEESE]', 'add extra cheese', 'mozzarella') // optional option-argument with default * .option('-t, --tip ', 'add tip to purchase cost', parseFloat) // custom parse function * diff --git a/lib/option.js b/lib/option.js index 708295407..5d43dfbd7 100644 --- a/lib/option.js +++ b/lib/option.js @@ -18,7 +18,7 @@ class Option { this.variadic = /\w\.\.\.[>\]]$/.test(flags); // The option can take multiple values. this.mandatory = false; // The option must have a value after parsing, which usually means it must be specified on command line. const optionFlags = splitOptionFlags(flags); - this.short = optionFlags.shortFlag; + this.short = optionFlags.shortFlag; // May be a short flag, undefined, or even a long flag (if option has two long flags). this.long = optionFlags.longFlag; this.negate = false; if (this.long) { @@ -321,25 +321,44 @@ function splitOptionFlags(flags) { const longFlagExp = /^--[^-]/; const flagParts = flags.split(/[ |,]+/).concat('guard'); + // Normal is short and/or long. if (shortFlagExp.test(flagParts[0])) shortFlag = flagParts.shift(); if (longFlagExp.test(flagParts[0])) longFlag = flagParts.shift(); + // Long then short. Rarely used but fine. + if (!shortFlag && shortFlagExp.test(flagParts[0])) + shortFlag = flagParts.shift(); + // Allow two long flags, like '--ws, --workspace' + // This is the supported way to have a shortish option flag. + if (!shortFlag && longFlagExp.test(flagParts[0])) { + shortFlag = longFlag; + longFlag = flagParts.shift(); + } - // Check for some unsupported flags that people try. - if (/^-[^-][^-]/.test(flagParts[0])) - throw new Error( - `invalid Option flags, short option is dash and single character: '${flags}'`, - ); - if (shortFlag && shortFlagExp.test(flagParts[0])) - throw new Error( - `invalid Option flags, more than one short flag: '${flags}'`, - ); - if (longFlag && longFlagExp.test(flagParts[0])) + // Check for unprocessed flag. Fail noisily rather than silently ignore. + if (flagParts[0].startsWith('-')) { + const unsupportedFlag = flagParts[0]; + const baseError = `option creation failed due to '${unsupportedFlag}' in option flags '${flags}'`; + if (/^-[^-][^-]/.test(unsupportedFlag)) + throw new Error( + `${baseError} +- a short flag is a single dash and a single character + - either use a single dash and a single character (for a short flag) + - or use a double dash for a long option (and can have two, like '--ws, --workspace')`, + ); + if (shortFlagExp.test(unsupportedFlag)) + throw new Error(`${baseError} +- too many short flags`); + if (longFlagExp.test(unsupportedFlag)) + throw new Error(`${baseError} +- too many long flags`); + + throw new Error(`${baseError} +- unrecognised flag format`); + } + if (shortFlag === undefined && longFlag === undefined) throw new Error( - `invalid Option flags, more than one long flag: '${flags}'`, + `option creation failed due to no flags found in '${flags}'.`, ); - // Generic error if failed to find a flag or an unexpected flag left over. - if (!(shortFlag || longFlag) || flagParts[0].startsWith('-')) - throw new Error(`invalid Option flags: '${flags}'`); return { shortFlag, longFlag }; } diff --git a/tests/option.bad-flags.test.js b/tests/option.bad-flags.test.js index 814de52a4..c641895fc 100644 --- a/tests/option.bad-flags.test.js +++ b/tests/option.bad-flags.test.js @@ -5,9 +5,9 @@ test.each([ { flags: '-a, -b' }, // too many short flags { flags: '-a, -b ' }, { flags: '-a, -b, --long' }, - { flags: '--one, --two' }, // too many long flags - { flags: '--one, --two [value]' }, + { flags: '--one, --two, --three' }, // too many long flags { flags: '-ws' }, // short flag with more than one character + { flags: '---triple' }, // double dash not followed by a non-dash { flags: 'sdkjhskjh' }, // oops, no flags { flags: '-a,-b' }, // try all the separators { flags: '-a|-b' }, @@ -15,7 +15,7 @@ test.each([ ])('when construct Option with flags %p then throw', ({ flags }) => { expect(() => { new Option(flags); - }).toThrow(/^invalid Option flags/); + }).toThrow(/^option creation failed/); }); // Check that supported flags do not throw. @@ -23,6 +23,8 @@ test.each([ { flags: '-s' }, // single short { flags: '--long' }, // single long { flags: '-b, --both' }, // short and long + { flags: '--both, -b' }, // long and short + { flags: '--ws, --workspace' }, // two long (morally shortish and long) { flags: '-b,--both ' }, { flags: '-b|--both ' }, { flags: '-b --both [space]' }, diff --git a/typings/index.d.ts b/typings/index.d.ts index b31fad7c5..f92ee19e1 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -617,7 +617,7 @@ export class Command { * ```js * program * .option('-p, --pepper', 'add pepper') - * .option('-p, --pizza-type ', 'type of pizza') // required option-argument + * .option('--pt, --pizza-type ', 'type of pizza') // required option-argument * .option('-c, --cheese [CHEESE]', 'add extra cheese', 'mozzarella') // optional option-argument with default * .option('-t, --tip ', 'add tip to purchase cost', parseFloat) // custom parse function * ```