-
Notifications
You must be signed in to change notification settings - Fork 10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor parsing for readability and future expansion, and tighten short option group parsing #68
Changes from 8 commits
2065a95
bdd8530
0cfbe0f
6b918bf
91646b9
52507d3
3aa6ddb
b0e0e92
42103c6
381e356
9fd16fe
80eae79
75b2fbe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -3,6 +3,7 @@ | |||||
const { | ||||||
ArrayPrototypeConcat, | ||||||
ArrayPrototypeIncludes, | ||||||
ArrayPrototypeMap, | ||||||
ArrayPrototypeSlice, | ||||||
ArrayPrototypeSplice, | ||||||
ArrayPrototypePush, | ||||||
|
@@ -53,7 +54,7 @@ function getMainArgs() { | |||||
return ArrayPrototypeSlice(process.argv, 2); | ||||||
} | ||||||
|
||||||
function storeOptionValue(parseOptions, option, value, result) { | ||||||
function storeOptionValue(option, value, parseOptions, result) { | ||||||
const multiple = parseOptions.multiples && | ||||||
ArrayPrototypeIncludes(parseOptions.multiples, option); | ||||||
|
||||||
|
@@ -97,84 +98,164 @@ const parseArgs = ( | |||||
|
||||||
let pos = 0; | ||||||
while (pos < argv.length) { | ||||||
let arg = argv[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(argv, ++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 short = StringPrototypeCharAt(arg, i); | ||||||
// Add 'i' to 'pos' such that short options are parsed in order | ||||||
// of definition: | ||||||
ArrayPrototypeSplice(argv, pos + (i - 1), 0, `-${short}`); | ||||||
} | ||||||
} | ||||||
const arg = argv[pos]; | ||||||
const nextArg = argv[pos + 1]; | ||||||
|
||||||
arg = StringPrototypeCharAt(arg, 1); // short | ||||||
if (options.short && options.short[arg]) | ||||||
arg = options.short[arg]; // now long! | ||||||
// ToDo: later code tests for `=` in arg and wrong for shorts | ||||||
} else { | ||||||
arg = StringPrototypeSlice(arg, 2); // remove leading -- | ||||||
// 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, | ||||||
ArrayPrototypeSlice(argv, pos + 1) | ||||||
); | ||||||
break; // Finished processing argv, leave while loop. | ||||||
} | ||||||
|
||||||
if (isLoneShortOption(arg)) { | ||||||
// e.g. '-f' | ||||||
const optionKey = getOptionKey(StringPrototypeCharAt(arg, 1), options); | ||||||
let optionValue; | ||||||
if (isExpectingValue(optionKey, options) && isOptionValue(nextArg)) { | ||||||
// e.g. '-f' 'bar' | ||||||
optionValue = nextArg; | ||||||
pos++; | ||||||
} | ||||||
storeOptionValue(optionKey, optionValue, options, result); | ||||||
pos++; | ||||||
continue; | ||||||
} | ||||||
|
||||||
if (isShortOptionGroup(arg, options)) { | ||||||
// Expand -fXzy to -f -X -z -y | ||||||
const expanded = ArrayPrototypeMap(StringPrototypeSlice(arg, 1), (char) => `-${char}`); | ||||||
// Replace group with expansion. | ||||||
ArrayPrototypeSplice(argv, pos, 1, ...expanded); | ||||||
ljharb marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
continue; | ||||||
} | ||||||
|
||||||
if (isLongOption(arg)) { | ||||||
let optionKey; | ||||||
let optionValue; | ||||||
if (StringPrototypeIncludes(arg, '=')) { | ||||||
// Store option=value same way independent of `withValue` as: | ||||||
// - looks like a value, store as a value | ||||||
// - match the intention of the user | ||||||
// - preserve information for author to process further | ||||||
// e.g. '--foo=bar' | ||||||
const index = StringPrototypeIndexOf(arg, '='); | ||||||
storeOptionValue( | ||||||
options, | ||||||
StringPrototypeSlice(arg, 0, index), | ||||||
StringPrototypeSlice(arg, index + 1), | ||||||
result); | ||||||
} else if (pos + 1 < argv.length && | ||||||
!StringPrototypeStartsWith(argv[pos + 1], '-') | ||||||
) { | ||||||
// withValue option should also support setting values when '= | ||||||
// isn't used ie. both --foo=b and --foo b should work | ||||||
|
||||||
// If withValue 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.withValue && | ||||||
ArrayPrototypeIncludes(options.withValue, arg) ? argv[++pos] : | ||||||
undefined; | ||||||
storeOptionValue(options, arg, val, result); | ||||||
optionKey = StringPrototypeSlice(arg, 2, index); | ||||||
optionValue = StringPrototypeSlice(arg, index + 1); | ||||||
} 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); | ||||||
// e.g. '--foo' | ||||||
optionKey = StringPrototypeSlice(arg, 2); | ||||||
if (isExpectingValue(optionKey, options) && isOptionValue(nextArg)) { | ||||||
// e.g. '--foo' 'bar' | ||||||
optionValue = nextArg; | ||||||
pos++; | ||||||
} | ||||||
} | ||||||
|
||||||
} else { | ||||||
// Arguments without a dash prefix are considered "positional" | ||||||
ArrayPrototypePush(result.positionals, arg); | ||||||
storeOptionValue(optionKey, optionValue, options, result); | ||||||
pos++; | ||||||
continue; | ||||||
} | ||||||
|
||||||
// Anything that did not get handled above is a positional. | ||||||
ArrayPrototypePush(result.positionals, arg); | ||||||
pos++; | ||||||
} | ||||||
|
||||||
return result; | ||||||
}; | ||||||
|
||||||
/** | ||||||
* 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 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 | ||||||
* isShortOptionGroup('-fb', { withValue: ['f'] }) // returns false | ||||||
* isShortOptionGroup('-bf', { withValue: ['f'] }) // 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 onlyFlags = StringPrototypeSlice(arg, 1, -1); | ||||||
for (let index = 0; index < onlyFlags.length; index++) { | ||||||
const optionKey = getOptionKey(StringPrototypeCharAt(onlyFlags, index)); | ||||||
if (isExpectingValue(optionKey, options)) { | ||||||
return false; | ||||||
} | ||||||
} | ||||||
return true; | ||||||
ljharb marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
} | ||||||
|
||||||
/** | ||||||
* Determines if `arg` is a long option, which may have a trailing value. | ||||||
* @example | ||||||
* isLongOption('-a) // returns false | ||||||
* isLongOption('--foo) // returns true | ||||||
* isLongOption('--foo=bar) // returns true | ||||||
*/ | ||||||
function isLongOption(arg) { | ||||||
return arg.length > 2 && StringPrototypeStartsWith(arg, '--'); | ||||||
Comment on lines
+209
to
+215
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Or simply add There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes, this is an edge case but is as intended. See discussion in #7. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Roger roger, thanks for the resource! Skimming the issue I'm in agreement it makes sense we limit the assumption here 👍 |
||||||
} | ||||||
|
||||||
/** | ||||||
* Expand known short options into long, otherwise return original. | ||||||
* @example | ||||||
* getOptionKey('f', { short: { f: 'file'}}) // returns 'file' | ||||||
* getOptionKey('b', {}) // returns 'b' | ||||||
* getOptionKey('long-option', {}) // returns 'long-option' | ||||||
*/ | ||||||
function getOptionKey(option, options) { | ||||||
if (option.length === 1 && options?.short?.[option]) { | ||||||
return options.short[option]; // long option | ||||||
} | ||||||
return option; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Determines if the option is expecting a value. | ||||||
*/ | ||||||
function isExpectingValue(optionKey, options) { | ||||||
return options && options.withValue && | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
otherwise a truthy non-array could cause problems There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we're sure it's an array when present, why would There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
ArrayPrototypeIncludes(options.withValue, optionKey); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Determines if the argument can 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 === undefined) return false; | ||||||
if (value === '-') return true; // e.g. representing stdin/stdout for file | ||||||
|
||||||
// Open Group Utility Conventions are that an option-argument may start | ||||||
// with a dash, but we are currentlly rejecting these and prioritising the | ||||||
// option-like appearance of the argument. Rejection allows error detection | ||||||
// if strict:true, but comes at the cost of rejecting intended values starting | ||||||
// with a dash, especially negative numbers. | ||||||
return !StringPrototypeStartsWith(value, '-'); | ||||||
} | ||||||
|
||||||
module.exports = { | ||||||
parseArgs | ||||||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
'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 | ||
test("when args include '-' used as positional then result has '-' in positionals", function(t) { | ||
shadowspawn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const passedArgs = ['-']; | ||
|
||
const args = parseArgs(passedArgs); | ||
const expected = { flags: {}, values: {}, positionals: ['-'] }; | ||
t.deepEqual(args, expected); | ||
|
||
t.end(); | ||
}); | ||
|
||
// If '-' is a valid positional, it is symmetrical to allow it as an option value too. | ||
test("when args include '-' used as space-separated option value then result has '-' in option value", function(t) { | ||
shadowspawn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const passedArgs = ['-v', '-']; | ||
const options = { withValue: ['v'] }; | ||
|
||
const args = parseArgs(passedArgs, options); | ||
const expected = { flags: { v: true }, values: { v: '-' }, positionals: [] }; | ||
t.deepEqual(args, expected); | ||
|
||
t.end(); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like if we’re validating such that we know this is an array, why isn’t it always an array? Meaning, have it be an empty array here, rather than falsy/missing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(We currently validate, but not modify. Might be revamping configuration: #63)