diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f7d665 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +tab_width = 2 +# trim_trailing_whitespace = true diff --git a/index.js b/index.js index cf73e7c..56da055 100644 --- a/index.js +++ b/index.js @@ -2,10 +2,9 @@ const { ArrayPrototypeConcat, - ArrayPrototypeFind, ArrayPrototypeForEach, + ArrayPrototypeShift, ArrayPrototypeSlice, - ArrayPrototypeSplice, ArrayPrototypePush, ObjectHasOwn, ObjectEntries, @@ -13,7 +12,6 @@ const { StringPrototypeIncludes, StringPrototypeIndexOf, StringPrototypeSlice, - StringPrototypeStartsWith, } = require('./primordials'); const { @@ -24,6 +22,16 @@ const { validateBoolean, } = require('./validators'); +const { + findLongOptionForShort, + isLoneLongOption, + isLoneShortOption, + isLongOptionAndValue, + isOptionValue, + isShortOptionAndValue, + isShortOptionGroup +} = require('./utils'); + function getMainArgs() { // This function is a placeholder for proposed process.mainArgs. // Work out where to slice process.argv for user supplied arguments. @@ -116,86 +124,89 @@ const parseArgs = ({ positionals: [] }; - let pos = 0; - while (pos < args.length) { - let arg = args[pos]; - - if (StringPrototypeStartsWith(arg, '-')) { - if (arg === '-') { - // '-' commonly used to represent stdin/stdout, treat as positional - result.positionals = ArrayPrototypeConcat(result.positionals, '-'); - ++pos; - continue; - } else if (arg === '--') { - // Everything after a bare '--' is considered a positional argument - // and is returned verbatim - result.positionals = ArrayPrototypeConcat( - result.positionals, - ArrayPrototypeSlice(args, ++pos) - ); - return result; - } else if (StringPrototypeCharAt(arg, 1) !== '-') { - // Look for shortcodes: -fXzy and expand them to -f -X -z -y: - if (arg.length > 2) { - for (let i = 2; i < arg.length; i++) { - const shortOption = StringPrototypeCharAt(arg, i); - // Add 'i' to 'pos' such that short options are parsed in order - // of definition: - ArrayPrototypeSplice(args, pos + (i - 1), 0, `-${shortOption}`); - } - } + let remainingArgs = ArrayPrototypeSlice(args); + while (remainingArgs.length > 0) { + const arg = ArrayPrototypeShift(remainingArgs); + const nextArg = remainingArgs[0]; + + // Check if `arg` is an options terminator. + // Guideline 10 in https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html + if (arg === '--') { + // Everything after a bare '--' is considered a positional argument. + result.positionals = ArrayPrototypeConcat( + result.positionals, + remainingArgs + ); + break; // Finished processing args, leave while loop. + } - arg = StringPrototypeCharAt(arg, 1); // short + if (isLoneShortOption(arg)) { + // e.g. '-f' + const shortOption = StringPrototypeCharAt(arg, 1); + const longOption = findLongOptionForShort(shortOption, options); + let optionValue; + if (options[longOption]?.type === 'string' && isOptionValue(nextArg)) { + // e.g. '-f', 'bar' + optionValue = ArrayPrototypeShift(remainingArgs); + } + storeOptionValue(options, longOption, optionValue, result); + continue; + } - const [longOption] = ArrayPrototypeFind( - ObjectEntries(options), - ([, optionConfig]) => optionConfig.short === arg - ) || []; + if (isShortOptionGroup(arg, options)) { + // Expand -fXzy to -f -X -z -y + const expanded = []; + for (let index = 1; index < arg.length; index++) { + const shortOption = StringPrototypeCharAt(arg, index); + const longOption = findLongOptionForShort(shortOption, options); + if (options[longOption]?.type !== 'string' || + index === arg.length - 1) { + // Boolean option, or last short in group. Well formed. + ArrayPrototypePush(expanded, `-${shortOption}`); + } else { + // String option in middle. Yuck. + // ToDo: if strict then throw + // Expand -abfFILE to -a -b -fFILE + ArrayPrototypePush(expanded, `-${StringPrototypeSlice(arg, index)}`); + break; // finished short group + } + } + remainingArgs = ArrayPrototypeConcat(expanded, remainingArgs); + continue; + } - arg = longOption ?? arg; + if (isShortOptionAndValue(arg, options)) { + // e.g. -fFILE + const shortOption = StringPrototypeCharAt(arg, 1); + const longOption = findLongOptionForShort(shortOption, options); + const optionValue = StringPrototypeSlice(arg, 2); + storeOptionValue(options, longOption, optionValue, result); + continue; + } - // ToDo: later code tests for `=` in arg and wrong for shorts - } else { - arg = StringPrototypeSlice(arg, 2); // remove leading -- + if (isLoneLongOption(arg)) { + // e.g. '--foo' + const longOption = StringPrototypeSlice(arg, 2); + let optionValue; + if (options[longOption]?.type === 'string' && isOptionValue(nextArg)) { + // e.g. '--foo', 'bar' + optionValue = ArrayPrototypeShift(remainingArgs); } + storeOptionValue(options, longOption, optionValue, result); + continue; + } - if (StringPrototypeIncludes(arg, '=')) { - // Store option=value same way independent of `type: "string"` as: - // - looks like a value, store as a value - // - match the intention of the user - // - preserve information for author to process further - const index = StringPrototypeIndexOf(arg, '='); - storeOptionValue( - options, - StringPrototypeSlice(arg, 0, index), - StringPrototypeSlice(arg, index + 1), - result); - } else if (pos + 1 < args.length && - !StringPrototypeStartsWith(args[pos + 1], '-') - ) { - // `type: "string"` option should also support setting values when '=' - // isn't used ie. both --foo=b and --foo b should work - - // If `type: "string"` option is specified, take next position argument - // as value and then increment pos so that we don't re-evaluate that - // arg, else set value as undefined ie. --foo b --bar c, after setting - // b as the value for foo, evaluate --bar next and skip 'b' - const val = options[arg] && options[arg].type === 'string' ? - args[++pos] : - undefined; - storeOptionValue(options, arg, val, result); - } else { - // Cases when an arg is specified without a value, example - // '--foo --bar' <- 'foo' and 'bar' flags should be set to true and - // save value as undefined - storeOptionValue(options, arg, undefined, result); - } - } else { - // Arguments without a dash prefix are considered "positional" - ArrayPrototypePush(result.positionals, arg); + if (isLongOptionAndValue(arg)) { + // e.g. --foo=bar + const index = StringPrototypeIndexOf(arg, '='); + const longOption = StringPrototypeSlice(arg, 2, index); + const optionValue = StringPrototypeSlice(arg, index + 1); + storeOptionValue(options, longOption, optionValue, result); + continue; } - pos++; + // Anything left is a positional + ArrayPrototypePush(result.positionals, arg); } return result; diff --git a/package.json b/package.json index fbef77d..a54bb82 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,13 @@ "version": "0.3.0", "description": "Polyfill of future proposal for `util.parseArgs()`", "main": "index.js", + "exports": { + ".": "./index.js", + "./package.json": "./package.json" + }, "scripts": { - "coverage": "c8 --check-coverage node test/index.js", - "test": "c8 node test/index.js", + "coverage": "c8 --check-coverage tape 'test/*.js'", + "test": "c8 tape 'test/*.js'", "posttest": "eslint .", "fix": "npm run posttest -- --fix" }, diff --git a/test/dash.js b/test/dash.js new file mode 100644 index 0000000..c727e0a --- /dev/null +++ b/test/dash.js @@ -0,0 +1,34 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { parseArgs } = require('../index.js'); + +// The use of `-` as a positional is specifically mentioned in the Open Group Utility Conventions. +// The interpretation is up to the utility, and for a file positional (operand) the examples are +// '-' may stand for standard input (or standard output), or for a file named -. +// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html +// +// A different usage and example is `git switch -` to switch back to the previous branch. + +test("dash: when args include '-' used as positional then result has '-' in positionals", (t) => { + const passedArgs = ['-']; + const expected = { flags: {}, values: {}, positionals: ['-'] }; + + const result = parseArgs({ args: passedArgs }); + + t.deepEqual(result, expected); + t.end(); +}); + +// If '-' is a valid positional, it is symmetrical to allow it as an option value too. +test("dash: when args include '-' used as space-separated option value then result has '-' in option value", (t) => { + const passedArgs = ['-v', '-']; + const passedOptions = { v: { type: 'string' } }; + const expected = { flags: { v: true }, values: { v: '-' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); diff --git a/test/find-long-option-for-short.js b/test/find-long-option-for-short.js new file mode 100644 index 0000000..97b4603 --- /dev/null +++ b/test/find-long-option-for-short.js @@ -0,0 +1,20 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { findLongOptionForShort } = require('../utils.js'); + +test('findLongOptionForShort: when passed empty options then returns short', (t) => { + t.equal(findLongOptionForShort('a', {}), 'a'); + t.end(); +}); + +test('findLongOptionForShort: when passed short not present in options then returns short', (t) => { + t.equal(findLongOptionForShort('a', { foo: { short: 'f', type: 'string' } }), 'a'); + t.end(); +}); + +test('findLongOptionForShort: when passed short present in options then returns long', (t) => { + t.equal(findLongOptionForShort('a', { alpha: { short: 'a' } }), 'alpha'); + t.end(); +}); diff --git a/test/is-lone-long-option.js b/test/is-lone-long-option.js new file mode 100644 index 0000000..deb95e8 --- /dev/null +++ b/test/is-lone-long-option.js @@ -0,0 +1,62 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { isLoneLongOption } = require('../utils.js'); + +test('isLoneLongOption: when passed short option then returns false', (t) => { + t.false(isLoneLongOption('-s')); + t.end(); +}); + +test('isLoneLongOption: when passed short option group then returns false', (t) => { + t.false(isLoneLongOption('-abc')); + t.end(); +}); + +test('isLoneLongOption: when passed lone long option then returns true', (t) => { + t.true(isLoneLongOption('--foo')); + t.end(); +}); + +test('isLoneLongOption: when passed single character long option then returns true', (t) => { + t.true(isLoneLongOption('--f')); + t.end(); +}); + +test('isLoneLongOption: when passed long option and value then returns false', (t) => { + t.false(isLoneLongOption('--foo=bar')); + t.end(); +}); + +test('isLoneLongOption: when passed empty string then returns false', (t) => { + t.false(isLoneLongOption('')); + t.end(); +}); + +test('isLoneLongOption: when passed plain text then returns false', (t) => { + t.false(isLoneLongOption('foo')); + t.end(); +}); + +test('isLoneLongOption: when passed single dash then returns false', (t) => { + t.false(isLoneLongOption('-')); + t.end(); +}); + +test('isLoneLongOption: when passed double dash then returns false', (t) => { + t.false(isLoneLongOption('--')); + t.end(); +}); + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test('isLoneLongOption: when passed arg starting with triple dash then returns true', (t) => { + t.true(isLoneLongOption('---foo')); + t.end(); +}); + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test("isLoneLongOption: when passed '--=' then returns true", (t) => { + t.true(isLoneLongOption('--=')); + t.end(); +}); diff --git a/test/is-lone-short-option.js b/test/is-lone-short-option.js new file mode 100644 index 0000000..baea02b --- /dev/null +++ b/test/is-lone-short-option.js @@ -0,0 +1,45 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { isLoneShortOption } = require('../utils.js'); + +test('isLoneShortOption: when passed short option then returns true', (t) => { + t.true(isLoneShortOption('-s')); + t.end(); +}); + +test('isLoneShortOption: when passed short option group (or might be short and value) then returns false', (t) => { + t.false(isLoneShortOption('-abc')); + t.end(); +}); + +test('isLoneShortOption: when passed long option then returns false', (t) => { + t.false(isLoneShortOption('--foo')); + t.end(); +}); + +test('isLoneShortOption: when passed long option with value then returns false', (t) => { + t.false(isLoneShortOption('--foo=bar')); + t.end(); +}); + +test('isLoneShortOption: when passed empty string then returns false', (t) => { + t.false(isLoneShortOption('')); + t.end(); +}); + +test('isLoneShortOption: when passed plain text then returns false', (t) => { + t.false(isLoneShortOption('foo')); + t.end(); +}); + +test('isLoneShortOption: when passed single dash then returns false', (t) => { + t.false(isLoneShortOption('-')); + t.end(); +}); + +test('isLoneShortOption: when passed double dash then returns false', (t) => { + t.false(isLoneShortOption('--')); + t.end(); +}); diff --git a/test/is-long-option-and-value.js b/test/is-long-option-and-value.js new file mode 100644 index 0000000..a4c9e1d --- /dev/null +++ b/test/is-long-option-and-value.js @@ -0,0 +1,62 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { isLongOptionAndValue } = require('../utils.js'); + +test('isLongOptionAndValue: when passed short option then returns false', (t) => { + t.false(isLongOptionAndValue('-s')); + t.end(); +}); + +test('isLongOptionAndValue: when passed short option group then returns false', (t) => { + t.false(isLongOptionAndValue('-abc')); + t.end(); +}); + +test('isLongOptionAndValue: when passed lone long option then returns false', (t) => { + t.false(isLongOptionAndValue('--foo')); + t.end(); +}); + +test('isLongOptionAndValue: when passed long option and value then returns true', (t) => { + t.true(isLongOptionAndValue('--foo=bar')); + t.end(); +}); + +test('isLongOptionAndValue: when passed single character long option and value then returns true', (t) => { + t.true(isLongOptionAndValue('--f=bar')); + t.end(); +}); + +test('isLongOptionAndValue: when passed empty string then returns false', (t) => { + t.false(isLongOptionAndValue('')); + t.end(); +}); + +test('isLongOptionAndValue: when passed plain text then returns false', (t) => { + t.false(isLongOptionAndValue('foo')); + t.end(); +}); + +test('isLongOptionAndValue: when passed single dash then returns false', (t) => { + t.false(isLongOptionAndValue('-')); + t.end(); +}); + +test('isLongOptionAndValue: when passed double dash then returns false', (t) => { + t.false(isLongOptionAndValue('--')); + t.end(); +}); + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test('isLongOptionAndValue: when passed arg starting with triple dash and value then returns true', (t) => { + t.true(isLongOptionAndValue('---foo=bar')); + t.end(); +}); + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test("isLongOptionAndValue: when passed '--=' then returns false", (t) => { + t.false(isLongOptionAndValue('--=')); + t.end(); +}); diff --git a/test/is-option-value.js b/test/is-option-value.js new file mode 100644 index 0000000..199bf30 --- /dev/null +++ b/test/is-option-value.js @@ -0,0 +1,52 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { isOptionValue } = require('../utils.js'); + +test('isOptionValue: when passed plain text then returns true', (t) => { + t.true(isOptionValue('abc')); + t.end(); +}); + +test('isOptionValue: when passed digits then returns true', (t) => { + t.true(isOptionValue(123)); + t.end(); +}); + +test('isOptionValue: when passed empty string then returns true', (t) => { + t.true(isOptionValue('')); + t.end(); +}); + +// Special case, used as stdin/stdout et al and not reason to reject +test('isOptionValue: when passed dash then returns true', (t) => { + t.true(isOptionValue('-')); + t.end(); +}); + +// Supporting undefined so can pass element off end of array without checking +test('isOptionValue: when passed undefined then returns false', (t) => { + t.false(isOptionValue(undefined)); + t.end(); +}); + +test('isOptionValue: when passed short option then returns false', (t) => { + t.false(isOptionValue('-a')); + t.end(); +}); + +test('isOptionValue: when passed short option group of short option with value then returns false', (t) => { + t.false(isOptionValue('-abd')); + t.end(); +}); + +test('isOptionValue: when passed long option then returns false', (t) => { + t.false(isOptionValue('--foo')); + t.end(); +}); + +test('isOptionValue: when passed long option with value then returns false', (t) => { + t.false(isOptionValue('--foo=bar')); + t.end(); +}); diff --git a/test/is-short-option-and-value.js b/test/is-short-option-and-value.js new file mode 100644 index 0000000..9b43b20 --- /dev/null +++ b/test/is-short-option-and-value.js @@ -0,0 +1,60 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { isShortOptionAndValue } = require('../utils.js'); + +test('isShortOptionAndValue: when passed lone short option then returns false', (t) => { + t.false(isShortOptionAndValue('-s', {})); + t.end(); +}); + +test('isShortOptionAndValue: when passed group with leading zero-config boolean then returns false', (t) => { + t.false(isShortOptionAndValue('-ab', {})); + t.end(); +}); + +test('isShortOptionAndValue: when passed group with leading configured implicit boolean then returns false', (t) => { + t.false(isShortOptionAndValue('-ab', { aaa: { short: 'a' } })); + t.end(); +}); + +test('isShortOptionAndValue: when passed group with leading configured explicit boolean then returns false', (t) => { + t.false(isShortOptionAndValue('-ab', { aaa: { short: 'a', type: 'boolean' } })); + t.end(); +}); + +test('isShortOptionAndValue: when passed group with leading configured string then returns true', (t) => { + t.true(isShortOptionAndValue('-ab', { aaa: { short: 'a', type: 'string' } })); + t.end(); +}); + +test('isShortOptionAndValue: when passed long option then returns false', (t) => { + t.false(isShortOptionAndValue('--foo', {})); + t.end(); +}); + +test('isShortOptionAndValue: when passed long option with value then returns false', (t) => { + t.false(isShortOptionAndValue('--foo=bar', {})); + t.end(); +}); + +test('isShortOptionAndValue: when passed empty string then returns false', (t) => { + t.false(isShortOptionAndValue('', {})); + t.end(); +}); + +test('isShortOptionAndValue: when passed plain text then returns false', (t) => { + t.false(isShortOptionAndValue('foo', {})); + t.end(); +}); + +test('isShortOptionAndValue: when passed single dash then returns false', (t) => { + t.false(isShortOptionAndValue('-', {})); + t.end(); +}); + +test('isShortOptionAndValue: when passed double dash then returns false', (t) => { + t.false(isShortOptionAndValue('--', {})); + t.end(); +}); diff --git a/test/is-short-option-group.js b/test/is-short-option-group.js new file mode 100644 index 0000000..56a5e00 --- /dev/null +++ b/test/is-short-option-group.js @@ -0,0 +1,71 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { isShortOptionGroup } = require('../utils.js'); + +test('isShortOptionGroup: when passed lone short option then returns false', (t) => { + t.false(isShortOptionGroup('-s', {})); + t.end(); +}); + +test('isShortOptionGroup: when passed group with leading zero-config boolean then returns true', (t) => { + t.true(isShortOptionGroup('-ab', {})); + t.end(); +}); + +test('isShortOptionGroup: when passed group with leading configured implicit boolean then returns true', (t) => { + t.true(isShortOptionGroup('-ab', { aaa: { short: 'a' } })); + t.end(); +}); + +test('isShortOptionGroup: when passed group with leading configured explicit boolean then returns true', (t) => { + t.true(isShortOptionGroup('-ab', { aaa: { short: 'a', type: 'boolean' } })); + t.end(); +}); + +test('isShortOptionGroup: when passed group with leading configured string then returns false', (t) => { + t.false(isShortOptionGroup('-ab', { aaa: { short: 'a', type: 'string' } })); + t.end(); +}); + +test('isShortOptionGroup: when passed group with trailing configured string then returns true', (t) => { + t.true(isShortOptionGroup('-ab', { bbb: { short: 'b', type: 'string' } })); + t.end(); +}); + +// This one is dubious, but leave it to caller to handle. +test('isShortOptionGroup: when passed group with middle configured string then returns true', (t) => { + t.true(isShortOptionGroup('-abc', { bbb: { short: 'b', type: 'string' } })); + t.end(); +}); + +test('isShortOptionGroup: when passed long option then returns false', (t) => { + t.false(isShortOptionGroup('--foo', {})); + t.end(); +}); + +test('isShortOptionGroup: when passed long option with value then returns false', (t) => { + t.false(isShortOptionGroup('--foo=bar', {})); + t.end(); +}); + +test('isShortOptionGroup: when passed empty string then returns false', (t) => { + t.false(isShortOptionGroup('', {})); + t.end(); +}); + +test('isShortOptionGroup: when passed plain text then returns false', (t) => { + t.false(isShortOptionGroup('foo', {})); + t.end(); +}); + +test('isShortOptionGroup: when passed single dash then returns false', (t) => { + t.false(isShortOptionGroup('-', {})); + t.end(); +}); + +test('isShortOptionGroup: when passed double dash then returns false', (t) => { + t.false(isShortOptionGroup('--', {})); + t.end(); +}); diff --git a/test/short-option-combined-with-value.js b/test/short-option-combined-with-value.js new file mode 100644 index 0000000..66fb5d2 --- /dev/null +++ b/test/short-option-combined-with-value.js @@ -0,0 +1,83 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { parseArgs } = require('../index.js'); + +test('when combine string short with plain text then parsed as value', (t) => { + const passedArgs = ['-aHELLO']; + const passedOptions = { alpha: { short: 'a', type: 'string' } }; + const expected = { flags: { alpha: true }, values: { alpha: 'HELLO' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when combine low-config string short with plain text then parsed as value', (t) => { + const passedArgs = ['-aHELLO']; + const passedOptions = { a: { type: 'string' } }; + const expected = { flags: { a: true }, values: { a: 'HELLO' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when combine string short with value like short option then parsed as value', (t) => { + const passedArgs = ['-a-b']; + const passedOptions = { alpha: { short: 'a', type: 'string' } }; + const expected = { flags: { alpha: true }, values: { alpha: '-b' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when combine string short with value like long option then parsed as value', (t) => { + const passedArgs = ['-a--bar']; + const passedOptions = { alpha: { short: 'a', type: 'string' } }; + const expected = { flags: { alpha: true }, values: { alpha: '--bar' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when combine string short with value like negative number then parsed as value', (t) => { + const passedArgs = ['-a-5']; + const passedOptions = { alpha: { short: 'a', type: 'string' } }; + const expected = { flags: { alpha: true }, values: { alpha: '-5' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + + +test('when combine string short with value which matches configured flag then parsed as value', (t) => { + const passedArgs = ['-af']; + const passedOptions = { alpha: { short: 'a', type: 'string' }, file: { short: 'f' } }; + const expected = { flags: { alpha: true }, values: { alpha: 'f' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when combine string short with value including equals then parsed with equals in value', (t) => { + const passedArgs = ['-a=5']; + const passedOptions = { alpha: { short: 'a', type: 'string' } }; + const expected = { flags: { alpha: true }, values: { alpha: '=5' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); diff --git a/test/short-option-groups.js b/test/short-option-groups.js new file mode 100644 index 0000000..f849b50 --- /dev/null +++ b/test/short-option-groups.js @@ -0,0 +1,71 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { parseArgs } = require('../index.js'); + +test('when pass zero-config group of booleans then parsed as booleans', (t) => { + const passedArgs = ['-rf', 'p']; + const passedOptions = { }; + const expected = { flags: { r: true, f: true }, values: { r: undefined, f: undefined }, positionals: ['p'] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when pass low-config group of booleans then parsed as booleans', (t) => { + const passedArgs = ['-rf', 'p']; + const passedOptions = { r: {}, f: {} }; + const expected = { flags: { r: true, f: true }, values: { r: undefined, f: undefined }, positionals: ['p'] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when pass full-config group of booleans then parsed as booleans', (t) => { + const passedArgs = ['-rf', 'p']; + const passedOptions = { r: { type: 'boolean' }, f: { type: 'boolean' } }; + const expected = { flags: { r: true, f: true }, values: { r: undefined, f: undefined }, positionals: ['p'] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when pass group with string option on end then parsed as booleans and string option', (t) => { + const passedArgs = ['-rf', 'p']; + const passedOptions = { r: { type: 'boolean' }, f: { type: 'string' } }; + const expected = { flags: { r: true, f: true }, values: { r: undefined, f: 'p' }, positionals: [] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions }); + + t.deepEqual(result, expected); + t.end(); +}); + +test('when pass group with string option in middle and strict:false then parsed as booleans and string option with trailing value', (t) => { + const passedArgs = ['-afb', 'p']; + const passedOptions = { f: { type: 'string' } }; + const expected = { flags: { a: true, f: true }, values: { a: undefined, f: 'b' }, positionals: ['p'] }; + + const result = parseArgs({ args: passedArgs, options: passedOptions, strict: false }); + + t.deepEqual(result, expected); + t.end(); +}); + +// Hopefully coming: +// test('when pass group with string option in middle and strict:true then error', (t) => { +// const passedArgs = ['-afb', 'p']; +// const passedOptions = { f: { type: 'string' } }; +// +// t.throws(() => { +// parseArgs({ args: passedArgs, options: passedOptions, strict: true }); +// }); +// t.end(); +// }); diff --git a/test/store-user-intent.js b/test/store-user-intent.js new file mode 100644 index 0000000..d5340a9 --- /dev/null +++ b/test/store-user-intent.js @@ -0,0 +1,53 @@ +'use strict'; +/* eslint max-len: 0 */ + +const test = require('tape'); +const { parseArgs } = require('../index.js'); + + +// Rationale +// +// John Gee: +// - Looks like a boolean option, stored like a boolean option. +// - Looks like a string option, stored like a string option. +// No loss of information. No new pattern to learn in result. +// +// Jordan Harband: In other words, the way they're stored matches the intention of the user, +// not the configurer, which will ensure the configurer can most accurately respond to the +// user's intentions. + +test('when use string short option used as boolean then result as if boolean', (t) => { + const passedArgs = ['-o']; + const stringOptions = { opt: { short: 'o', type: 'string' } }; + const booleanOptions = { opt: { short: 'o', type: 'boolean' } }; + + const stringConfigResult = parseArgs({ args: passedArgs, options: stringOptions, strict: false }); + const booleanConfigResult = parseArgs({ args: passedArgs, options: booleanOptions, strict: false }); + + t.deepEqual(stringConfigResult, booleanConfigResult); + t.end(); +}); + +test('when use string long option used as boolean then result as if boolean', (t) => { + const passedArgs = ['--opt']; + const stringOptions = { opt: { short: 'o', type: 'string' } }; + const booleanOptions = { opt: { short: 'o', type: 'boolean' } }; + + const stringConfigResult = parseArgs({ args: passedArgs, options: stringOptions, strict: false }); + const booleanConfigResult = parseArgs({ args: passedArgs, options: booleanOptions, strict: false }); + + t.deepEqual(stringConfigResult, booleanConfigResult); + t.end(); +}); + +test('when use boolean long option used as string then result as if string', (t) => { + const passedArgs = ['--bool=OOPS']; + const stringOptions = { bool: { type: 'string' } }; + const booleanOptions = { bool: { type: 'boolean' } }; + + const stringConfigResult = parseArgs({ args: passedArgs, options: stringOptions, strict: false }); + const booleanConfigResult = parseArgs({ args: passedArgs, options: booleanOptions, strict: false }); + + t.deepEqual(booleanConfigResult, stringConfigResult); + t.end(); +}); diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..eca8711 --- /dev/null +++ b/utils.js @@ -0,0 +1,155 @@ +'use strict'; + +const { + ArrayPrototypeFind, + ObjectEntries, + StringPrototypeCharAt, + StringPrototypeIncludes, + StringPrototypeSlice, + StringPrototypeStartsWith, +} = require('./primordials'); + +// 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 +// unit testing, although that is not essential (this could be rolled into +// main file and just tested implicitly via API). +// +// These routines are for internal use, not for export to client. + +/** + * Determines if the argument may be used as an option value. + * NB: We are choosing not to accept option-ish arguments. + * @example + * isOptionValue('V']) // returns true + * isOptionValue('-v') // returns false + * isOptionValue('--foo') // returns false + * isOptionValue(undefined) // returns false + */ +function isOptionValue(value) { + if (value == null) return false; + if (value === '-') return true; // e.g. representing stdin/stdout for file + + // Open Group Utility Conventions are that an option-argument + // is the argument after the option, and may start with a dash. + // However, we are currently rejecting these and prioritising the + // option-like appearance of the argument. Rejection allows more error + // detection for strict:true, but comes at the cost of rejecting intended + // values starting with a dash, especially negative numbers. + return !StringPrototypeStartsWith(value, '-'); +} + +/** + * Determines if `arg` is a just a short option. + * @example '-f' + */ +function isLoneShortOption(arg) { + return arg.length === 2 && + StringPrototypeCharAt(arg, 0) === '-' && + StringPrototypeCharAt(arg, 1) !== '-'; +} + +/** + * Determines if `arg` is a lone long option. + * @example + * isLoneLongOption('a') // returns false + * isLoneLongOption('-a') // returns false + * isLoneLongOption('--foo) // returns true + * isLoneLongOption('--foo=bar) // returns false + */ +function isLoneLongOption(arg) { + return arg.length > 2 && + StringPrototypeStartsWith(arg, '--') && + !StringPrototypeIncludes(StringPrototypeSlice(arg, 3), '='); +} + +/** + * Determines if `arg` is a long option and value in same argument. + * @example + * isLongOptionAndValue('--foo) // returns false + * isLongOptionAndValue('--foo=bar) // returns true + */ +function isLongOptionAndValue(arg) { + return arg.length > 2 && + StringPrototypeStartsWith(arg, '--') && + StringPrototypeIncludes(StringPrototypeSlice(arg, 3), '='); +} + +/** + * Determines if `arg` is a short option group. + * + * See Guideline 5 of the [Open Group Utility Conventions](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html). + * One or more options without option-arguments, followed by at most one + * option that takes an option-argument, should be accepted when grouped + * behind one '-' delimiter. + * @example + * isShortOptionGroup('-a', {}) // returns false + * isShortOptionGroup('-ab', {}) // returns true + * // -fb is an option and a value, not a short option group + * isShortOptionGroup('-fb', { + * options: { f: { type: 'string' }} + * }) // returns false + * isShortOptionGroup('-bf', { + * options: { f: { type: 'string' }} + * }) // returns true + * // -bfb is an edge case, return true and caller sorts it out + * isShortOptionGroup('-bfb', { + * options: { f: { type: 'string' }} + * }) // returns true + */ +function isShortOptionGroup(arg, options) { + if (arg.length <= 2) return false; + if (StringPrototypeCharAt(arg, 0) !== '-') return false; + if (StringPrototypeCharAt(arg, 1) === '-') return false; + + const firstShort = StringPrototypeCharAt(arg, 1); + const longOption = findLongOptionForShort(firstShort, options); + return options[longOption]?.type !== 'string'; +} + +/** + * Determine is arg is a short string option followed by its value. + * @example + * isShortOptionAndValue('-a, {}); // returns false + * isShortOptionAndValue('-ab, {}); // returns false + * isShortOptionAndValue('-fFILE', { + * options: { foo: { short: 'f', type: 'string' }} + * }) // returns true + */ +function isShortOptionAndValue(arg, options) { + if (!options) throw new Error('Internal error, missing options argument'); + if (arg.length <= 2) return false; + if (StringPrototypeCharAt(arg, 0) !== '-') return false; + if (StringPrototypeCharAt(arg, 1) === '-') return false; + + const shortOption = StringPrototypeCharAt(arg, 1); + const longOption = findLongOptionForShort(shortOption, options); + return options[longOption]?.type === 'string'; +} + +/** + * Find the long option associated with a short option. Looks for a configured + * `short` and returns the short option itself if long option not found. + * @example + * findLongOptionForShort('a', {}) // returns 'a' + * findLongOptionForShort('b', { + * options: { bar: { short: 'b' }} + * }) // returns 'bar' + */ +function findLongOptionForShort(shortOption, options) { + if (!options) throw new Error('Internal error, missing options argument'); + const [longOption] = ArrayPrototypeFind( + ObjectEntries(options), + ([, optionConfig]) => optionConfig.short === shortOption + ) || []; + return longOption || shortOption; +} + +module.exports = { + findLongOptionForShort, + isLoneLongOption, + isLoneShortOption, + isLongOptionAndValue, + isOptionValue, + isShortOptionAndValue, + isShortOptionGroup +};