You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
479 lines
12 KiB
479 lines
12 KiB
const abbrev = require('abbrev') |
|
const debug = require('./debug') |
|
const defaultTypeDefs = require('./type-defs') |
|
|
|
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k) |
|
|
|
const getType = (k, { types, dynamicTypes }) => { |
|
let hasType = hasOwn(types, k) |
|
let type = types[k] |
|
if (!hasType && typeof dynamicTypes === 'function') { |
|
const matchedType = dynamicTypes(k) |
|
if (matchedType !== undefined) { |
|
type = matchedType |
|
hasType = true |
|
} |
|
} |
|
return [hasType, type] |
|
} |
|
|
|
const isTypeDef = (type, def) => def && type === def |
|
const hasTypeDef = (type, def) => def && type.indexOf(def) !== -1 |
|
const doesNotHaveTypeDef = (type, def) => def && !hasTypeDef(type, def) |
|
|
|
function nopt (args, { |
|
types, |
|
shorthands, |
|
typeDefs, |
|
invalidHandler, |
|
typeDefault, |
|
dynamicTypes, |
|
} = {}) { |
|
debug(types, shorthands, args, typeDefs) |
|
|
|
const data = {} |
|
const argv = { |
|
remain: [], |
|
cooked: args, |
|
original: args.slice(0), |
|
} |
|
|
|
parse(args, data, argv.remain, { typeDefs, types, dynamicTypes, shorthands }) |
|
|
|
// now data is full |
|
clean(data, { types, dynamicTypes, typeDefs, invalidHandler, typeDefault }) |
|
data.argv = argv |
|
|
|
Object.defineProperty(data.argv, 'toString', { |
|
value: function () { |
|
return this.original.map(JSON.stringify).join(' ') |
|
}, |
|
enumerable: false, |
|
}) |
|
|
|
return data |
|
} |
|
|
|
function clean (data, { |
|
types = {}, |
|
typeDefs = {}, |
|
dynamicTypes, |
|
invalidHandler, |
|
typeDefault, |
|
} = {}) { |
|
const StringType = typeDefs.String?.type |
|
const NumberType = typeDefs.Number?.type |
|
const ArrayType = typeDefs.Array?.type |
|
const BooleanType = typeDefs.Boolean?.type |
|
const DateType = typeDefs.Date?.type |
|
|
|
const hasTypeDefault = typeof typeDefault !== 'undefined' |
|
if (!hasTypeDefault) { |
|
typeDefault = [false, true, null] |
|
if (StringType) { |
|
typeDefault.push(StringType) |
|
} |
|
if (ArrayType) { |
|
typeDefault.push(ArrayType) |
|
} |
|
} |
|
|
|
const remove = {} |
|
|
|
Object.keys(data).forEach((k) => { |
|
if (k === 'argv') { |
|
return |
|
} |
|
let val = data[k] |
|
debug('val=%j', val) |
|
const isArray = Array.isArray(val) |
|
let [hasType, rawType] = getType(k, { types, dynamicTypes }) |
|
let type = rawType |
|
if (!isArray) { |
|
val = [val] |
|
} |
|
if (!type) { |
|
type = typeDefault |
|
} |
|
if (isTypeDef(type, ArrayType)) { |
|
type = typeDefault.concat(ArrayType) |
|
} |
|
if (!Array.isArray(type)) { |
|
type = [type] |
|
} |
|
|
|
debug('val=%j', val) |
|
debug('types=', type) |
|
val = val.map((v) => { |
|
// if it's an unknown value, then parse false/true/null/numbers/dates |
|
if (typeof v === 'string') { |
|
debug('string %j', v) |
|
v = v.trim() |
|
if ((v === 'null' && ~type.indexOf(null)) |
|
|| (v === 'true' && |
|
(~type.indexOf(true) || hasTypeDef(type, BooleanType))) |
|
|| (v === 'false' && |
|
(~type.indexOf(false) || hasTypeDef(type, BooleanType)))) { |
|
v = JSON.parse(v) |
|
debug('jsonable %j', v) |
|
} else if (hasTypeDef(type, NumberType) && !isNaN(v)) { |
|
debug('convert to number', v) |
|
v = +v |
|
} else if (hasTypeDef(type, DateType) && !isNaN(Date.parse(v))) { |
|
debug('convert to date', v) |
|
v = new Date(v) |
|
} |
|
} |
|
|
|
if (!hasType) { |
|
if (!hasTypeDefault) { |
|
return v |
|
} |
|
// if the default type has been passed in then we want to validate the |
|
// unknown data key instead of bailing out earlier. we also set the raw |
|
// type which is passed to the invalid handler so that it can be |
|
// determined if during validation if it is unknown vs invalid |
|
rawType = typeDefault |
|
} |
|
|
|
// allow `--no-blah` to set 'blah' to null if null is allowed |
|
if (v === false && ~type.indexOf(null) && |
|
!(~type.indexOf(false) || hasTypeDef(type, BooleanType))) { |
|
v = null |
|
} |
|
|
|
const d = {} |
|
d[k] = v |
|
debug('prevalidated val', d, v, rawType) |
|
if (!validate(d, k, v, rawType, { typeDefs })) { |
|
if (invalidHandler) { |
|
invalidHandler(k, v, rawType, data) |
|
} else if (invalidHandler !== false) { |
|
debug('invalid: ' + k + '=' + v, rawType) |
|
} |
|
return remove |
|
} |
|
debug('validated v', d, v, rawType) |
|
return d[k] |
|
}).filter((v) => v !== remove) |
|
|
|
// if we allow Array specifically, then an empty array is how we |
|
// express 'no value here', not null. Allow it. |
|
if (!val.length && doesNotHaveTypeDef(type, ArrayType)) { |
|
debug('VAL HAS NO LENGTH, DELETE IT', val, k, type.indexOf(ArrayType)) |
|
delete data[k] |
|
} else if (isArray) { |
|
debug(isArray, data[k], val) |
|
data[k] = val |
|
} else { |
|
data[k] = val[0] |
|
} |
|
|
|
debug('k=%s val=%j', k, val, data[k]) |
|
}) |
|
} |
|
|
|
function validate (data, k, val, type, { typeDefs } = {}) { |
|
const ArrayType = typeDefs?.Array?.type |
|
// arrays are lists of types. |
|
if (Array.isArray(type)) { |
|
for (let i = 0, l = type.length; i < l; i++) { |
|
if (isTypeDef(type[i], ArrayType)) { |
|
continue |
|
} |
|
if (validate(data, k, val, type[i], { typeDefs })) { |
|
return true |
|
} |
|
} |
|
delete data[k] |
|
return false |
|
} |
|
|
|
// an array of anything? |
|
if (isTypeDef(type, ArrayType)) { |
|
return true |
|
} |
|
|
|
// Original comment: |
|
// NaN is poisonous. Means that something is not allowed. |
|
// New comment: Changing this to an isNaN check breaks a lot of tests. |
|
// Something is being assumed here that is not actually what happens in |
|
// practice. Fixing it is outside the scope of getting linting to pass in |
|
// this repo. Leaving as-is for now. |
|
/* eslint-disable-next-line no-self-compare */ |
|
if (type !== type) { |
|
debug('Poison NaN', k, val, type) |
|
delete data[k] |
|
return false |
|
} |
|
|
|
// explicit list of values |
|
if (val === type) { |
|
debug('Explicitly allowed %j', val) |
|
data[k] = val |
|
return true |
|
} |
|
|
|
// now go through the list of typeDefs, validate against each one. |
|
let ok = false |
|
const types = Object.keys(typeDefs) |
|
for (let i = 0, l = types.length; i < l; i++) { |
|
debug('test type %j %j %j', k, val, types[i]) |
|
const t = typeDefs[types[i]] |
|
if (t && ( |
|
(type && type.name && t.type && t.type.name) ? |
|
(type.name === t.type.name) : |
|
(type === t.type) |
|
)) { |
|
const d = {} |
|
ok = t.validate(d, k, val) !== false |
|
val = d[k] |
|
if (ok) { |
|
data[k] = val |
|
break |
|
} |
|
} |
|
} |
|
debug('OK? %j (%j %j %j)', ok, k, val, types[types.length - 1]) |
|
|
|
if (!ok) { |
|
delete data[k] |
|
} |
|
return ok |
|
} |
|
|
|
function parse (args, data, remain, { |
|
types = {}, |
|
typeDefs = {}, |
|
shorthands = {}, |
|
dynamicTypes, |
|
} = {}) { |
|
const StringType = typeDefs.String?.type |
|
const NumberType = typeDefs.Number?.type |
|
const ArrayType = typeDefs.Array?.type |
|
const BooleanType = typeDefs.Boolean?.type |
|
|
|
debug('parse', args, data, remain) |
|
|
|
const abbrevs = abbrev(Object.keys(types)) |
|
debug('abbrevs=%j', abbrevs) |
|
const shortAbbr = abbrev(Object.keys(shorthands)) |
|
|
|
for (let i = 0; i < args.length; i++) { |
|
let arg = args[i] |
|
debug('arg', arg) |
|
|
|
if (arg.match(/^-{2,}$/)) { |
|
// done with keys. |
|
// the rest are args. |
|
remain.push.apply(remain, args.slice(i + 1)) |
|
args[i] = '--' |
|
break |
|
} |
|
let hadEq = false |
|
if (arg.charAt(0) === '-' && arg.length > 1) { |
|
const at = arg.indexOf('=') |
|
if (at > -1) { |
|
hadEq = true |
|
const v = arg.slice(at + 1) |
|
arg = arg.slice(0, at) |
|
args.splice(i, 1, arg, v) |
|
} |
|
|
|
// see if it's a shorthand |
|
// if so, splice and back up to re-parse it. |
|
const shRes = resolveShort(arg, shortAbbr, abbrevs, { shorthands }) |
|
debug('arg=%j shRes=%j', arg, shRes) |
|
if (shRes) { |
|
args.splice.apply(args, [i, 1].concat(shRes)) |
|
if (arg !== shRes[0]) { |
|
i-- |
|
continue |
|
} |
|
} |
|
arg = arg.replace(/^-+/, '') |
|
let no = null |
|
while (arg.toLowerCase().indexOf('no-') === 0) { |
|
no = !no |
|
arg = arg.slice(3) |
|
} |
|
|
|
if (abbrevs[arg]) { |
|
arg = abbrevs[arg] |
|
} |
|
|
|
let [hasType, argType] = getType(arg, { types, dynamicTypes }) |
|
let isTypeArray = Array.isArray(argType) |
|
if (isTypeArray && argType.length === 1) { |
|
isTypeArray = false |
|
argType = argType[0] |
|
} |
|
|
|
let isArray = isTypeDef(argType, ArrayType) || |
|
isTypeArray && hasTypeDef(argType, ArrayType) |
|
|
|
// allow unknown things to be arrays if specified multiple times. |
|
if (!hasType && hasOwn(data, arg)) { |
|
if (!Array.isArray(data[arg])) { |
|
data[arg] = [data[arg]] |
|
} |
|
isArray = true |
|
} |
|
|
|
let val |
|
let la = args[i + 1] |
|
|
|
const isBool = typeof no === 'boolean' || |
|
isTypeDef(argType, BooleanType) || |
|
isTypeArray && hasTypeDef(argType, BooleanType) || |
|
(typeof argType === 'undefined' && !hadEq) || |
|
(la === 'false' && |
|
(argType === null || |
|
isTypeArray && ~argType.indexOf(null))) |
|
|
|
if (isBool) { |
|
// just set and move along |
|
val = !no |
|
// however, also support --bool true or --bool false |
|
if (la === 'true' || la === 'false') { |
|
val = JSON.parse(la) |
|
la = null |
|
if (no) { |
|
val = !val |
|
} |
|
i++ |
|
} |
|
|
|
// also support "foo":[Boolean, "bar"] and "--foo bar" |
|
if (isTypeArray && la) { |
|
if (~argType.indexOf(la)) { |
|
// an explicit type |
|
val = la |
|
i++ |
|
} else if (la === 'null' && ~argType.indexOf(null)) { |
|
// null allowed |
|
val = null |
|
i++ |
|
} else if (!la.match(/^-{2,}[^-]/) && |
|
!isNaN(la) && |
|
hasTypeDef(argType, NumberType)) { |
|
// number |
|
val = +la |
|
i++ |
|
} else if (!la.match(/^-[^-]/) && hasTypeDef(argType, StringType)) { |
|
// string |
|
val = la |
|
i++ |
|
} |
|
} |
|
|
|
if (isArray) { |
|
(data[arg] = data[arg] || []).push(val) |
|
} else { |
|
data[arg] = val |
|
} |
|
|
|
continue |
|
} |
|
|
|
if (isTypeDef(argType, StringType)) { |
|
if (la === undefined) { |
|
la = '' |
|
} else if (la.match(/^-{1,2}[^-]+/)) { |
|
la = '' |
|
i-- |
|
} |
|
} |
|
|
|
if (la && la.match(/^-{2,}$/)) { |
|
la = undefined |
|
i-- |
|
} |
|
|
|
val = la === undefined ? true : la |
|
if (isArray) { |
|
(data[arg] = data[arg] || []).push(val) |
|
} else { |
|
data[arg] = val |
|
} |
|
|
|
i++ |
|
continue |
|
} |
|
remain.push(arg) |
|
} |
|
} |
|
|
|
const SINGLES = Symbol('singles') |
|
const singleCharacters = (arg, shorthands) => { |
|
let singles = shorthands[SINGLES] |
|
if (!singles) { |
|
singles = Object.keys(shorthands).filter((s) => s.length === 1).reduce((l, r) => { |
|
l[r] = true |
|
return l |
|
}, {}) |
|
shorthands[SINGLES] = singles |
|
debug('shorthand singles', singles) |
|
} |
|
const chrs = arg.split('').filter((c) => singles[c]) |
|
return chrs.join('') === arg ? chrs : null |
|
} |
|
|
|
function resolveShort (arg, ...rest) { |
|
const { types = {}, shorthands = {} } = rest.length ? rest.pop() : {} |
|
const shortAbbr = rest[0] ?? abbrev(Object.keys(shorthands)) |
|
const abbrevs = rest[1] ?? abbrev(Object.keys(types)) |
|
|
|
// handle single-char shorthands glommed together, like |
|
// npm ls -glp, but only if there is one dash, and only if |
|
// all of the chars are single-char shorthands, and it's |
|
// not a match to some other abbrev. |
|
arg = arg.replace(/^-+/, '') |
|
|
|
// if it's an exact known option, then don't go any further |
|
if (abbrevs[arg] === arg) { |
|
return null |
|
} |
|
|
|
// if it's an exact known shortopt, same deal |
|
if (shorthands[arg]) { |
|
// make it an array, if it's a list of words |
|
if (shorthands[arg] && !Array.isArray(shorthands[arg])) { |
|
shorthands[arg] = shorthands[arg].split(/\s+/) |
|
} |
|
|
|
return shorthands[arg] |
|
} |
|
|
|
// first check to see if this arg is a set of single-char shorthands |
|
const chrs = singleCharacters(arg, shorthands) |
|
if (chrs) { |
|
return chrs.map((c) => shorthands[c]).reduce((l, r) => l.concat(r), []) |
|
} |
|
|
|
// if it's an arg abbrev, and not a literal shorthand, then prefer the arg |
|
if (abbrevs[arg] && !shorthands[arg]) { |
|
return null |
|
} |
|
|
|
// if it's an abbr for a shorthand, then use that |
|
if (shortAbbr[arg]) { |
|
arg = shortAbbr[arg] |
|
} |
|
|
|
// make it an array, if it's a list of words |
|
if (shorthands[arg] && !Array.isArray(shorthands[arg])) { |
|
shorthands[arg] = shorthands[arg].split(/\s+/) |
|
} |
|
|
|
return shorthands[arg] |
|
} |
|
|
|
module.exports = { |
|
nopt, |
|
clean, |
|
parse, |
|
validate, |
|
resolveShort, |
|
typeDefs: defaultTypeDefs, |
|
}
|
|
|