diff --git a/.editorconfig b/.editorconfig index 8f7d665..b140163 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,10 +3,12 @@ # top-most EditorConfig file root = true +# Copied from Node.js to ease compatibility in PR. [*] +charset = utf-8 end_of_line = lf -insert_final_newline = true -indent_style = space indent_size = 2 -tab_width = 2 -# trim_trailing_whitespace = true +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +quote_type = single diff --git a/README.md b/README.md index 7eb8ae8..20ba8c7 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,17 @@ [![Coverage][coverage-image]][coverage-url] -Polyfill of proposal for `util.parseArgs()` +Polyfill of `util.parseArgs()` ## `util.parseArgs([config])` > Stability: 1 - Experimental @@ -25,18 +30,24 @@ added: REPLACEME times. If `true`, all values will be collected in an array. If `false`, values for the option are last-wins. **Default:** `false`. * `short` {string} A single character alias for the option. - * `strict`: {boolean} Should an error be thrown when unknown arguments + * `strict` {boolean} Should an error be thrown when unknown arguments are encountered, or when arguments are passed that do not match the `type` configured in `options`. **Default:** `true`. - * `allowPositionals`: {boolean} Whether this command accepts positional + * `allowPositionals` {boolean} Whether this command accepts positional arguments. **Default:** `false` if `strict` is `true`, otherwise `true`. + * `tokens` {boolean} Return the parsed tokens. This is useful for extending + the built-in behavior, from adding additional checks through to reprocessing + the tokens in different ways. + **Default:** `false`. * Returns: {Object} The parsed command line arguments: * `values` {Object} A mapping of parsed option names with their {string} or {boolean} values. * `positionals` {string\[]} Positional arguments. + * `tokens` {Object\[] | undefined} See [parseArgs tokens](#parseargs-tokens) + section. Only returned if `config` includes `tokens: true`. Provides a higher level API for command-line argument parsing than interacting with `process.argv` directly. Takes a specification for the expected arguments @@ -79,12 +90,120 @@ const { positionals } = parseArgs({ args, options }); console.log(values, positionals); -// Prints: [Object: null prototype] { foo: true, bar: 'b' } []ss +// Prints: [Object: null prototype] { foo: true, bar: 'b' } [] ``` `util.parseArgs` is experimental and behavior may change. Join the conversation in [pkgjs/parseargs][] to contribute to the design. +### `parseArgs` `tokens` + +Detailed parse information is available for adding custom behaviours by +specifying `tokens: true` in the configuration. +The returned tokens have properties describing: + +* all tokens + * `kind` {string} One of 'option', 'positional', or 'option-terminator'. + * `index` {number} Index of element in `args` containing token. So the + source argument for a token is `args[token.index]`. +* option tokens + * `name` {string} Long name of option. + * `rawName` {string} How option used in args, like `-f` of `--foo`. + * `value` {string | undefined} Option value specified in args. + Undefined for boolean options. + * `inlineValue` {boolean | undefined} Whether option value specified inline, + like `--foo=bar`. +* positional tokens + * `value` {string} The value of the positional argument in args (i.e. `args[index]`). +* option-terminator token + +The returned tokens are in the order encountered in the input args. Options +that appear more than once in args produce a token for each use. Short option +groups like `-xy` expand to a token for each option. So `-xxx` produces +three tokens. + +For example to use the returned tokens to add support for a negated option +like `--no-color`, the tokens can be reprocessed to change the value stored +for the negated option. + +```mjs +import { parseArgs } from 'node:util'; + +const options = { + 'color': { type: 'boolean' }, + 'no-color': { type: 'boolean' }, + 'logfile': { type: 'string' }, + 'no-logfile': { type: 'boolean' }, +}; +const { values, tokens } = parseArgs({ options, tokens: true }); + +// Reprocess the option tokens and overwrite the returned values. +tokens + .filter((token) => token.kind === 'option') + .forEach((token) => { + if (token.name.startsWith('no-')) { + // Store foo:false for --no-foo + const positiveName = token.name.slice(3); + values[positiveName] = false; + delete values[token.name]; + } else { + // Resave value so last one wins if both --foo and --no-foo. + values[token.name] = token.value ?? true; + } + }); + +const color = values.color; +const logfile = values.logfile ?? 'default.log'; + +console.log({ logfile, color }); +``` + +```cjs +const { parseArgs } = require('node:util'); + +const options = { + 'color': { type: 'boolean' }, + 'no-color': { type: 'boolean' }, + 'logfile': { type: 'string' }, + 'no-logfile': { type: 'boolean' }, +}; +const { values, tokens } = parseArgs({ options, tokens: true }); + +// Reprocess the option tokens and overwrite the returned values. +tokens + .filter((token) => token.kind === 'option') + .forEach((token) => { + if (token.name.startsWith('no-')) { + // Store foo:false for --no-foo + const positiveName = token.name.slice(3); + values[positiveName] = false; + delete values[token.name]; + } else { + // Resave value so last one wins if both --foo and --no-foo. + values[token.name] = token.value ?? true; + } + }); + +const color = values.color; +const logfile = values.logfile ?? 'default.log'; + +console.log({ logfile, color }); +``` + +Example usage showing negated options, and when an option is used +multiple ways then last one wins. + +```console +$ node negate.js +{ logfile: 'default.log', color: undefined } +$ node negate.js --no-logfile --no-color +{ logfile: false, color: false } +$ node negate.js --logfile=test.log --color +{ logfile: 'test.log', color: true } +$ node negate.js --no-logfile --logfile=test.log --color --no-color +{ logfile: 'test.log', color: false } +``` + ----- diff --git a/examples/negate.js b/examples/negate.js new file mode 100644 index 0000000..b663469 --- /dev/null +++ b/examples/negate.js @@ -0,0 +1,43 @@ +'use strict'; + +// This example is used in the documentation. + +// How might I add my own support for --no-foo? + +// 1. const { parseArgs } = require('node:util'); // from node +// 2. const { parseArgs } = require('@pkgjs/parseargs'); // from package +const { parseArgs } = require('..'); // in repo + +const options = { + 'color': { type: 'boolean' }, + 'no-color': { type: 'boolean' }, + 'logfile': { type: 'string' }, + 'no-logfile': { type: 'boolean' }, +}; +const { values, tokens } = parseArgs({ options, tokens: true }); + +// Reprocess the option tokens and overwrite the returned values. +tokens + .filter((token) => token.kind === 'option') + .forEach((token) => { + if (token.name.startsWith('no-')) { + // Store foo:false for --no-foo + const positiveName = token.name.slice(3); + values[positiveName] = false; + delete values[token.name]; + } else { + // Resave value so last one wins if both --foo and --no-foo. + values[token.name] = token.value ?? true; + } + }); + +const color = values.color; +const logfile = values.logfile ?? 'default.log'; + +console.log({ logfile, color }); + +// Try the following: +// node negate.js +// node negate.js --no-logfile --no-color +// negate.js --logfile=test.log --color +// node negate.js --no-logfile --logfile=test.log --color --no-color diff --git a/index.js b/index.js index aa8b763..fb0a10e 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,7 @@ const { ArrayPrototypeForEach, ArrayPrototypeIncludes, + ArrayPrototypeMap, ArrayPrototypePush, ArrayPrototypePushApply, ArrayPrototypeShift, @@ -13,7 +14,8 @@ const { StringPrototypeCharAt, StringPrototypeIndexOf, StringPrototypeSlice, -} = require('./primordials'); + StringPrototypeStartsWith, +} = require('./internal/primordials'); const { validateArray, @@ -21,7 +23,11 @@ const { validateObject, validateString, validateUnion, -} = require('./validators'); +} = require('./internal/validators'); + +const { + kEmptyObject, +} = require('./internal/util'); const { findLongOptionForShort, @@ -43,7 +49,7 @@ const { ERR_PARSE_ARGS_UNKNOWN_OPTION, ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL, }, -} = require('./errors'); +} = require('./internal/errors'); function getMainArgs() { // Work out where to slice process.argv for user supplied arguments. @@ -64,19 +70,16 @@ function getMainArgs() { /** * In strict mode, throw for possible usage errors like --foo --bar * - * @param {string} longOption - long option name e.g. 'foo' - * @param {string|undefined} optionValue - value from user args - * @param {string} shortOrLong - option used, with dashes e.g. `-l` or `--long` - * @param {boolean} strict - show errors, from parseArgs({ strict }) + * @param {object} token - from tokens as available from parseArgs */ -function checkOptionLikeValue(longOption, optionValue, shortOrLong, strict) { - if (strict && isOptionLikeValue(optionValue)) { +function checkOptionLikeValue(token) { + if (!token.inlineValue && isOptionLikeValue(token.value)) { // Only show short example if user used short option. - const example = (shortOrLong.length === 2) ? - `'--${longOption}=-XYZ' or '${shortOrLong}-XYZ'` : - `'--${longOption}=-XYZ'`; - const errorMessage = `Option '${shortOrLong}' argument is ambiguous. -Did you forget to specify the option argument for '${shortOrLong}'? + const example = StringPrototypeStartsWith(token.rawName, '--') ? + `'${token.rawName}=-XYZ'` : + `'--${token.name}=-XYZ' or '${token.rawName}-XYZ'`; + const errorMessage = `Option '${token.rawName}' argument is ambiguous. +Did you forget to specify the option argument for '${token.rawName}'? To specify an option argument starting with a dash use ${example}.`; throw new ERR_PARSE_ARGS_INVALID_OPTION_VALUE(errorMessage); } @@ -85,34 +88,28 @@ To specify an option argument starting with a dash use ${example}.`; /** * In strict mode, throw for usage errors. * - * @param {string} longOption - long option name e.g. 'foo' - * @param {string|undefined} optionValue - value from user args - * @param {object} options - option configs, from parseArgs({ options }) - * @param {string} shortOrLong - option used, with dashes e.g. `-l` or `--long` - * @param {boolean} strict - show errors, from parseArgs({ strict }) - * @param {boolean} allowPositionals - from parseArgs({ allowPositionals }) + * @param {object} config - from config passed to parseArgs + * @param {object} token - from tokens as available from parseArgs */ -function checkOptionUsage(longOption, optionValue, options, - shortOrLong, strict, allowPositionals) { - // Strict and options are used from local context. - if (!strict) return; - - if (!ObjectHasOwn(options, longOption)) { - throw new ERR_PARSE_ARGS_UNKNOWN_OPTION(shortOrLong, allowPositionals); +function checkOptionUsage(config, token) { + if (!ObjectHasOwn(config.options, token.name)) { + throw new ERR_PARSE_ARGS_UNKNOWN_OPTION( + token.rawName, config.allowPositionals); } - const short = optionsGetOwn(options, longOption, 'short'); - const shortAndLong = short ? `-${short}, --${longOption}` : `--${longOption}`; - const type = optionsGetOwn(options, longOption, 'type'); - if (type === 'string' && typeof optionValue !== 'string') { + const short = optionsGetOwn(config.options, token.name, 'short'); + const shortAndLong = `${short ? `-${short}, ` : ''}--${token.name}`; + const type = optionsGetOwn(config.options, token.name, 'type'); + if (type === 'string' && typeof token.value !== 'string') { throw new ERR_PARSE_ARGS_INVALID_OPTION_VALUE(`Option '${shortAndLong} ' argument missing`); } // (Idiomatic test for undefined||null, expecting undefined.) - if (type === 'boolean' && optionValue != null) { + if (type === 'boolean' && token.value != null) { throw new ERR_PARSE_ARGS_INVALID_OPTION_VALUE(`Option '${shortAndLong}' does not take an argument`); } } + /** * Store the option value in `values`. * @@ -145,64 +142,38 @@ function storeOption(longOption, optionValue, options, values) { } } -const parseArgs = (config = { __proto__: null }) => { - const args = objectGetOwn(config, 'args') ?? getMainArgs(); - const strict = objectGetOwn(config, 'strict') ?? true; - const allowPositionals = objectGetOwn(config, 'allowPositionals') ?? !strict; - const options = objectGetOwn(config, 'options') ?? { __proto__: null }; - - // Validate input configuration. - validateArray(args, 'args'); - validateBoolean(strict, 'strict'); - validateBoolean(allowPositionals, 'allowPositionals'); - validateObject(options, 'options'); - ArrayPrototypeForEach( - ObjectEntries(options), - ({ 0: longOption, 1: optionConfig }) => { - validateObject(optionConfig, `options.${longOption}`); - - // type is required - validateUnion(objectGetOwn(optionConfig, 'type'), `options.${longOption}.type`, ['string', 'boolean']); - - if (ObjectHasOwn(optionConfig, 'short')) { - const shortOption = optionConfig.short; - validateString(shortOption, `options.${longOption}.short`); - if (shortOption.length !== 1) { - throw new ERR_INVALID_ARG_VALUE( - `options.${longOption}.short`, - shortOption, - 'must be a single character' - ); - } - } - - if (ObjectHasOwn(optionConfig, 'multiple')) { - validateBoolean(optionConfig.multiple, `options.${longOption}.multiple`); - } - } - ); - - const result = { - values: { __proto__: null }, - positionals: [] - }; +/** + * Process args and turn into identified tokens: + * - option (along with value, if any) + * - positional + * - option-terminator + * + * @param {string[]} args - from parseArgs({ args }) or mainArgs + * @param {object} options - option configs, from parseArgs({ options }) + */ +function argsToTokens(args, options) { + const tokens = []; + let index = -1; + let groupCount = 0; const remainingArgs = ArrayPrototypeSlice(args); while (remainingArgs.length > 0) { const arg = ArrayPrototypeShift(remainingArgs); const nextArg = remainingArgs[0]; + if (groupCount > 0) + groupCount--; + else + index++; // Check if `arg` is an options terminator. // Guideline 10 in https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html if (arg === '--') { - if (!allowPositionals && remainingArgs.length > 0) { - throw new ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL(nextArg); - } - // Everything after a bare '--' is considered a positional argument. + ArrayPrototypePush(tokens, { kind: 'option-terminator', index }); ArrayPrototypePushApply( - result.positionals, - remainingArgs + tokens, ArrayPrototypeMap(remainingArgs, (arg) => { + return { kind: 'positional', index: ++index, value: arg }; + }) ); break; // Finished processing args, leave while loop. } @@ -211,16 +182,19 @@ const parseArgs = (config = { __proto__: null }) => { // e.g. '-f' const shortOption = StringPrototypeCharAt(arg, 1); const longOption = findLongOptionForShort(shortOption, options); - let optionValue; + let value; + let inlineValue; if (optionsGetOwn(options, longOption, 'type') === 'string' && isOptionValue(nextArg)) { // e.g. '-f', 'bar' - optionValue = ArrayPrototypeShift(remainingArgs); - checkOptionLikeValue(longOption, optionValue, arg, strict); + value = ArrayPrototypeShift(remainingArgs); + inlineValue = false; } - checkOptionUsage(longOption, optionValue, options, - arg, strict, allowPositionals); - storeOption(longOption, optionValue, options, result.values); + ArrayPrototypePush( + tokens, + { kind: 'option', name: longOption, rawName: arg, + index, value, inlineValue }); + if (value != null) ++index; continue; } @@ -242,6 +216,7 @@ const parseArgs = (config = { __proto__: null }) => { } } ArrayPrototypeUnshiftApply(remainingArgs, expanded); + groupCount = expanded.length; continue; } @@ -249,45 +224,116 @@ const parseArgs = (config = { __proto__: null }) => { // e.g. -fFILE const shortOption = StringPrototypeCharAt(arg, 1); const longOption = findLongOptionForShort(shortOption, options); - const optionValue = StringPrototypeSlice(arg, 2); - checkOptionUsage(longOption, optionValue, options, `-${shortOption}`, strict, allowPositionals); - storeOption(longOption, optionValue, options, result.values); + const value = StringPrototypeSlice(arg, 2); + ArrayPrototypePush( + tokens, + { kind: 'option', name: longOption, rawName: `-${shortOption}`, + index, value, inlineValue: true }); continue; } if (isLoneLongOption(arg)) { // e.g. '--foo' const longOption = StringPrototypeSlice(arg, 2); - let optionValue; + let value; + let inlineValue; if (optionsGetOwn(options, longOption, 'type') === 'string' && isOptionValue(nextArg)) { // e.g. '--foo', 'bar' - optionValue = ArrayPrototypeShift(remainingArgs); - checkOptionLikeValue(longOption, optionValue, arg, strict); + value = ArrayPrototypeShift(remainingArgs); + inlineValue = false; } - checkOptionUsage(longOption, optionValue, options, - arg, strict, allowPositionals); - storeOption(longOption, optionValue, options, result.values); + ArrayPrototypePush( + tokens, + { kind: 'option', name: longOption, rawName: arg, + index, value, inlineValue }); + if (value != null) ++index; continue; } if (isLongOptionAndValue(arg)) { // e.g. --foo=bar - const index = StringPrototypeIndexOf(arg, '='); - const longOption = StringPrototypeSlice(arg, 2, index); - const optionValue = StringPrototypeSlice(arg, index + 1); - checkOptionUsage(longOption, optionValue, options, `--${longOption}`, strict, allowPositionals); - storeOption(longOption, optionValue, options, result.values); + const equalIndex = StringPrototypeIndexOf(arg, '='); + const longOption = StringPrototypeSlice(arg, 2, equalIndex); + const value = StringPrototypeSlice(arg, equalIndex + 1); + ArrayPrototypePush( + tokens, + { kind: 'option', name: longOption, rawName: `--${longOption}`, + index, value, inlineValue: true }); continue; } - // Anything left is a positional - if (!allowPositionals) { - throw new ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL(arg); + ArrayPrototypePush(tokens, { kind: 'positional', index, value: arg }); + } + return tokens; +} + +const parseArgs = (config = kEmptyObject) => { + const args = objectGetOwn(config, 'args') ?? getMainArgs(); + const strict = objectGetOwn(config, 'strict') ?? true; + const allowPositionals = objectGetOwn(config, 'allowPositionals') ?? !strict; + const returnTokens = objectGetOwn(config, 'tokens') ?? false; + const options = objectGetOwn(config, 'options') ?? { __proto__: null }; + // Bundle these up for passing to strict-mode checks. + const parseConfig = { args, strict, options, allowPositionals }; + + // Validate input configuration. + validateArray(args, 'args'); + validateBoolean(strict, 'strict'); + validateBoolean(allowPositionals, 'allowPositionals'); + validateBoolean(returnTokens, 'tokens'); + validateObject(options, 'options'); + ArrayPrototypeForEach( + ObjectEntries(options), + ({ 0: longOption, 1: optionConfig }) => { + validateObject(optionConfig, `options.${longOption}`); + + // type is required + validateUnion(objectGetOwn(optionConfig, 'type'), `options.${longOption}.type`, ['string', 'boolean']); + + if (ObjectHasOwn(optionConfig, 'short')) { + const shortOption = optionConfig.short; + validateString(shortOption, `options.${longOption}.short`); + if (shortOption.length !== 1) { + throw new ERR_INVALID_ARG_VALUE( + `options.${longOption}.short`, + shortOption, + 'must be a single character' + ); + } + } + + if (ObjectHasOwn(optionConfig, 'multiple')) { + validateBoolean(optionConfig.multiple, `options.${longOption}.multiple`); + } } + ); + + // Phase 1: identify tokens + const tokens = argsToTokens(args, options); - ArrayPrototypePush(result.positionals, arg); + // Phase 2: process tokens into parsed option values and positionals + const result = { + values: { __proto__: null }, + positionals: [], + }; + if (returnTokens) { + result.tokens = tokens; } + ArrayPrototypeForEach(tokens, (token) => { + if (token.kind === 'option') { + if (strict) { + checkOptionUsage(parseConfig, token); + checkOptionLikeValue(token); + } + storeOption(token.name, token.value, options, result.values); + } else if (token.kind === 'positional') { + if (!allowPositionals) { + throw new ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL(token.value); + } + ArrayPrototypePush(result.positionals, token.value); + } + }); return result; }; diff --git a/errors.js b/internal/errors.js similarity index 100% rename from errors.js rename to internal/errors.js diff --git a/primordials.js b/internal/primordials.js similarity index 100% rename from primordials.js rename to internal/primordials.js diff --git a/internal/util.js b/internal/util.js new file mode 100644 index 0000000..b9b8fe5 --- /dev/null +++ b/internal/util.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a placeholder for util.js in node.js land. + +const { + ObjectCreate, + ObjectFreeze, +} = require('./primordials'); + +const kEmptyObject = ObjectFreeze(ObjectCreate(null)); + +module.exports = { + kEmptyObject, +}; diff --git a/validators.js b/internal/validators.js similarity index 100% rename from validators.js rename to internal/validators.js diff --git a/test/index.js b/test/index.js index 044b61a..a24bf53 100644 --- a/test/index.js +++ b/test/index.js @@ -107,21 +107,21 @@ test('Everything after a bare `--` is considered a positional argument', () => { const args = ['--', 'barepositionals', 'mopositionals']; const expected = { values: { __proto__: null }, positionals: ['barepositionals', 'mopositionals'] }; const result = parseArgs({ allowPositionals: true, args }); - assert.deepStrictEqual(result, expected, Error('testing bare positionals')); + assert.deepStrictEqual(result, expected); }); test('args are true', () => { const args = ['--foo', '--bar']; const expected = { values: { __proto__: null, foo: true, bar: true }, positionals: [] }; const result = parseArgs({ strict: false, args }); - assert.deepStrictEqual(result, expected, Error('args are true')); + assert.deepStrictEqual(result, expected); }); test('arg is true and positional is identified', () => { const args = ['--foo=a', '--foo', 'b']; const expected = { values: { __proto__: null, foo: true }, positionals: ['b'] }; const result = parseArgs({ strict: false, args }); - assert.deepStrictEqual(result, expected, Error('arg is true and positional is identified')); + assert.deepStrictEqual(result, expected); }); test('args equals are passed `type: "string"`', () => { @@ -129,7 +129,7 @@ test('args equals are passed `type: "string"`', () => { const options = { so: { type: 'string' } }; const expected = { values: { __proto__: null, so: 'wat' }, positionals: [] }; const result = parseArgs({ args, options }); - assert.deepStrictEqual(result, expected, Error('arg value is passed')); + assert.deepStrictEqual(result, expected); }); test('when args include single dash then result stores dash as positional', () => { @@ -144,7 +144,7 @@ test('zero config args equals are parsed as if `type: "string"`', () => { const options = { }; const expected = { values: { __proto__: null, so: 'wat' }, positionals: [] }; const result = parseArgs({ strict: false, args, options }); - assert.deepStrictEqual(result, expected, Error('arg value is passed')); + assert.deepStrictEqual(result, expected); }); test('same arg is passed twice `type: "string"` and last value is recorded', () => { @@ -152,7 +152,7 @@ test('same arg is passed twice `type: "string"` and last value is recorded', () const options = { foo: { type: 'string' } }; const expected = { values: { __proto__: null, foo: 'b' }, positionals: [] }; const result = parseArgs({ args, options }); - assert.deepStrictEqual(result, expected, Error('last arg value is passed')); + assert.deepStrictEqual(result, expected); }); test('args equals pass string including more equals', () => { @@ -160,7 +160,7 @@ test('args equals pass string including more equals', () => { const options = { so: { type: 'string' } }; const expected = { values: { __proto__: null, so: 'wat=bing' }, positionals: [] }; const result = parseArgs({ args, options }); - assert.deepStrictEqual(result, expected, Error('arg value is passed')); + assert.deepStrictEqual(result, expected); }); test('first arg passed for `type: "string"` and "multiple" is in array', () => { @@ -168,7 +168,7 @@ test('first arg passed for `type: "string"` and "multiple" is in array', () => { const options = { foo: { type: 'string', multiple: true } }; const expected = { values: { __proto__: null, foo: ['a'] }, positionals: [] }; const result = parseArgs({ args, options }); - assert.deepStrictEqual(result, expected, Error('first multiple in array')); + assert.deepStrictEqual(result, expected); }); test('args are passed `type: "string"` and "multiple"', () => { @@ -181,7 +181,7 @@ test('args are passed `type: "string"` and "multiple"', () => { }; const expected = { values: { __proto__: null, foo: ['a', 'b'] }, positionals: [] }; const result = parseArgs({ args, options }); - assert.deepStrictEqual(result, expected, Error('both arg values are passed')); + assert.deepStrictEqual(result, expected); }); test('when expecting `multiple:true` boolean option and option used multiple times then result includes array of ' + @@ -203,16 +203,10 @@ test('order of option and positional does not matter (per README)', () => { const args2 = ['baz', '--foo=bar']; const options = { foo: { type: 'string' } }; const expected = { values: { __proto__: null, foo: 'bar' }, positionals: ['baz'] }; - assert.deepStrictEqual( - parseArgs({ allowPositionals: true, args: args1, options }), - expected, - Error('option then positional') - ); - assert.deepStrictEqual( - parseArgs({ allowPositionals: true, args: args2, options }), - expected, - Error('positional then option') - ); + let result = parseArgs({ allowPositionals: true, args: args1, options }); + assert.deepStrictEqual(result, expected, Error('option then positional')); + result = parseArgs({ allowPositionals: true, args: args2, options }); + assert.deepStrictEqual(result, expected, Error('positional then option')); }); test('correct default args when use node -p', () => { @@ -293,7 +287,7 @@ test('excess leading dashes on options are retained', () => { positionals: [] }; const result = parseArgs({ strict: false, args, options }); - assert.deepStrictEqual(result, expected, Error('excess option dashes are retained')); + assert.deepStrictEqual(result, expected); }); test('positional arguments are allowed by default in strict:false', () => { @@ -576,3 +570,248 @@ test('strict: when long option and suspect value then throws with whole expected }, /To specify an option argument starting with a dash use '--with=-XYZ'/ ); }); + +test('tokens: positional', () => { + const args = ['one']; + const expectedTokens = [ + { kind: 'positional', index: 0, value: 'one' }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: -- followed by option-like', () => { + const args = ['--', '--foo']; + const expectedTokens = [ + { kind: 'option-terminator', index: 0 }, + { kind: 'positional', index: 1, value: '--foo' }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true boolean short', () => { + const args = ['-f']; + const options = { + file: { short: 'f', type: 'boolean' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '-f', + index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true boolean long', () => { + const args = ['--file']; + const options = { + file: { short: 'f', type: 'boolean' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '--file', + index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false boolean short', () => { + const args = ['-f']; + const expectedTokens = [ + { kind: 'option', name: 'f', rawName: '-f', + index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false boolean long', () => { + const args = ['--file']; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '--file', + index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false boolean option group', () => { + const args = ['-ab']; + const expectedTokens = [ + { kind: 'option', name: 'a', rawName: '-a', + index: 0, value: undefined, inlineValue: undefined }, + { kind: 'option', name: 'b', rawName: '-b', + index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false boolean option group with repeated option', () => { + // Also positional to check index correct after grouop + const args = ['-aa', 'pos']; + const expectedTokens = [ + { kind: 'option', name: 'a', rawName: '-a', + index: 0, value: undefined, inlineValue: undefined }, + { kind: 'option', name: 'a', rawName: '-a', + index: 0, value: undefined, inlineValue: undefined }, + { kind: 'positional', index: 1, value: 'pos' }, + ]; + const { tokens } = parseArgs({ strict: false, allowPositionals: true, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true string short with value after space', () => { + // Also positional to check index correct after out-of-line. + const args = ['-f', 'bar', 'ppp']; + const options = { + file: { short: 'f', type: 'string' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '-f', + index: 0, value: 'bar', inlineValue: false }, + { kind: 'positional', index: 2, value: 'ppp' }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true string short with value inline', () => { + const args = ['-fBAR']; + const options = { + file: { short: 'f', type: 'string' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '-f', + index: 0, value: 'BAR', inlineValue: true }, + ]; + const { tokens } = parseArgs({ strict: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false string short missing value', () => { + const args = ['-f']; + const options = { + file: { short: 'f', type: 'string' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '-f', + index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true string long with value after space', () => { + // Also positional to check index correct after out-of-line. + const args = ['--file', 'bar', 'ppp']; + const options = { + file: { short: 'f', type: 'string' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '--file', + index: 0, value: 'bar', inlineValue: false }, + { kind: 'positional', index: 2, value: 'ppp' }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true string long with value inline', () => { + // Also positional to check index correct after out-of-line. + const args = ['--file=bar', 'pos']; + const options = { + file: { short: 'f', type: 'string' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '--file', + index: 0, value: 'bar', inlineValue: true }, + { kind: 'positional', index: 1, value: 'pos' }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false string long with value inline', () => { + const args = ['--file=bar']; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '--file', + index: 0, value: 'bar', inlineValue: true }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false string long missing value', () => { + const args = ['--file']; + const options = { + file: { short: 'f', type: 'string' } + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '--file', + index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true complex option group with value after space', () => { + // Also positional to check index correct afterwards. + const args = ['-ab', 'c', 'pos']; + const options = { + alpha: { short: 'a', type: 'boolean' }, + beta: { short: 'b', type: 'string' }, + }; + const expectedTokens = [ + { kind: 'option', name: 'alpha', rawName: '-a', + index: 0, value: undefined, inlineValue: undefined }, + { kind: 'option', name: 'beta', rawName: '-b', + index: 0, value: 'c', inlineValue: false }, + { kind: 'positional', index: 2, value: 'pos' }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:true complex option group with inline value', () => { + // Also positional to check index correct afterwards. + const args = ['-abc', 'pos']; + const options = { + alpha: { short: 'a', type: 'boolean' }, + beta: { short: 'b', type: 'string' }, + }; + const expectedTokens = [ + { kind: 'option', name: 'alpha', rawName: '-a', + index: 0, value: undefined, inlineValue: undefined }, + { kind: 'option', name: 'beta', rawName: '-b', + index: 0, value: 'c', inlineValue: true }, + { kind: 'positional', index: 1, value: 'pos' }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false with single dashes', () => { + const args = ['--file', '-', '-']; + const options = { + file: { short: 'f', type: 'string' }, + }; + const expectedTokens = [ + { kind: 'option', name: 'file', rawName: '--file', + index: 0, value: '-', inlineValue: false }, + { kind: 'positional', index: 2, value: '-' }, + ]; + const { tokens } = parseArgs({ strict: false, args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens: strict:false with -- --', () => { + const args = ['--', '--']; + const expectedTokens = [ + { kind: 'option-terminator', index: 0 }, + { kind: 'positional', index: 1, value: '--' }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); diff --git a/utils.js b/utils.js index b7ab034..a89fb6f 100644 --- a/utils.js +++ b/utils.js @@ -7,11 +7,11 @@ const { StringPrototypeCharAt, StringPrototypeIncludes, StringPrototypeStartsWith, -} = require('./primordials'); +} = require('./internal/primordials'); const { validateObject, -} = require('./validators'); +} = require('./internal/validators'); // These are internal utilities to make the parsing logic easier to read, and // add lots of detail for the curious. They are in a separate file to allow