ox-argv-parser/index.js
Oxtaly 5533706b30 Additions & fixes, mainly filtering options & internal refactors
Additions:
    Filtering!:
        opts.shouldStopParsingFunc(): more information in commit's types.d.ts;
        opts.shouldStopParsingStr: more information in commit's types.d.ts;
        opts.parseFilterFunc(): more information in commit's types.d.ts;
        (tweaked (lookingForFlagVal -> naturalFlagAssignment) to include the flag's type to add onto new argMeta for filtering functions);
    isTypeAssignable()
    assureValidFlagConfigState(): Now guarantees that flag.config is assigned right after runAutomaticBooleanFlagNegation() in the flag branch of arg handling, skipping the need for other flagConfig lookups (as it would be required in one step or another regardless);
    runFlagAssignedValueAssignment(): extracted main code of meta.hasAssignedValue branch into this function (also adjusted to account for flag.isNegated);
    internalFlag.isNegated: set by runAutomaticBooleanFlagNegation(), for refactored boolean flag negation handling;
    internalFlag.type: extracted flagType assignments into guaranteed

Removals:
    internalFlag.lookedupFlagConfig: flag.config is now assured with assureValidFlagConfigState() after runAutomaticBooleanFlagNegation(), and is instead tested against flag.config === null within assureValidFlagConfigState() to avoid repeating getting flag;

Fixes:
    expectType(): Removed unused (and broken) null handling from;
    validateOrDefaultIfUnset(): Fixed passing through to expectType() when setting the default, breaking if the default type isn't the 'required' type (ie: null value defaults);
    opts.lowerCaseFlags now correctly applies to shorthand flags;
    flags with an explicit string type now correctly do not apply opts.resolveFlagValueTypes even if enabled;
    defined boolean type flags that undergo automatic boolean negation (ie: --no-flag) now correctly resolve if there is an input value after it (bug with a defined flag: `['--no-flag', 'hi'] -> { 'no-flag': 'hi' }`);
    Fixed some spelling issues (eg: 2x otps -> opts);

Other:
    Copied StringConsumer from OxLib into index.js
    Moved stage of runAutomaticBooleanFlagNegation() from beginning of flag post arg handling -> pre flag assignments in arg handling, with a small refactor;
        -> Addition of internalFlag.isNegated
    Cleaned up and clarified the purpose of isAssignedValueTypeAssignable() -> assignedValueUncastableTypeError() to externalize the check thanks to new isTypeAssignable();
    Cleaned up and clarified the purpose of unknownFlag() -> unknownFlagError() to externalize the check;
    Main arg handling 'loop' is not a for loop instead of a foreach to allow for a break statement for the new opts.shouldStopParsing* options;

-Note: Other small unmentioned things, and big commit, probably forgot some note-worthy things, mb;
2025-05-13 02:12:12 +02:00

1058 lines
38 KiB
JavaScript

/** 'bundling' my own dependencies */
class StringConsumer {
/** @readonly */
static VERSION = 3;
/**
* @param {string} str
*/
constructor(str) {
/** @private */
this.base = str;
/** @private */
this.i = 0;
}
/**
* Returns the index of the next character in line
* If getPointer() == getFullStr().length, hasNext() = false
*/
getPointer() {
return this.i;
}
getFullStr() {
return this.base;
}
/**
* Get the next characters while consuming them
* @param {number} [num]
*/
next(num) {
if(num < 1)
throw new RangeError(`Num ${num} < 1! Num must be between 1 and fullStr length.`)
if(typeof num !== 'number')
num = 1;
if(this.i == this.base.length)
return null;
let retStr = this.base.slice(this.i, this.i + num);
this.i += num;
if(this.i >= this.base.length)
this.i = this.base.length;
return retStr;
}
/**
* Get the next characters without consuming them
* @param {number} [num]
*/
peek(num) {
if(num < 1)
throw new RangeError(`Num ${num} < 1! Num must be between 1 and fullStr length.`)
if(typeof num !== 'number')
num = 1;
if(this.i == this.base.length)
return null;
let retStr = this.base.slice(this.i, this.i + num);
return retStr;
}
reset() {
this.i = 0;
}
end() {
return this.next(this.base.length-this.i);
}
/**
* @param {number} [num]
*/
hasNext(num) {
if(num < 1)
throw new RangeError(`Num ${num} < 1! Num must be between 1 and fullStr length.`)
if(typeof num !== 'number')
num = 1;
return this.i + num <= this.base.length;
}
}
const { ok } = require('node:assert');
/**
* @interface
* @template {FlagsI} Flags
* @typedef {import('./types.d.ts').ParserOpts<Flags>} ParserOpts
*/
/**
* @typedef {import('./types.d.ts').DefaultParserOptsType} DefaultParserOptsType
*/
/**
* @template T
* @template Z
* @typedef {import('./types.d.ts').FlagT<T, Z>} FlagT
*/
/**
* @interface
* @typedef {import('./types.d.ts').FlagsI} FlagsI
*/
/**
* @typedef {import('./types.d.ts').JSTypes} JSTypes
*/
/**
* @typedef {import('./types.d.ts').FlagAny} FlagAny
*/
/**
* @typedef {import('./types.d.ts').argMeta} argMeta
*/
class InputError extends Error {
context = null;
};
class UnsetFlagValueType {}
const UNSET_FLAG_VALUE = new UnsetFlagValueType();
/**
* @param {string} key
* @param {JSTypes} type
* @param {any} value
* @param {boolean} [acceptsUndefined=false]
*/
function expectType(key, type, value, acceptsUndefined) {
if(typeof acceptsUndefined !== 'boolean')
acceptsUndefined = false;
if(typeof value === 'undefined') {
if(acceptsUndefined)
return
else
throw new TypeError(`Required type ${type} for ${key} is undefined!`);
}
if(typeof value !== type)
throw new TypeError(`Expected ${key} to be ${type}, received ${typeof value}!`);
}
/**
* @template {Object} obj
* @param {obj} obj
* @param {keyof obj} keyName
* @param {JSTypes} keyType
* @param {any} defaultVal
* @param {string} [objName='opts']
*/
function validateOrDefaultIfUnset(obj, keyName, keyType, defaultVal, objName) {
if(typeof objName === 'undefined')
objName = 'opts';
if(typeof obj[keyName] === 'undefined')
return obj[keyName] = defaultVal;
return expectType(`${objName}.${String(keyName)}`, keyType, obj[keyName], true)
}
/**
* @template {Object} obj
* @param {string} objName
* @param {obj} obj
* @param {keyof obj} keyName
* @param {JSTypes} keyType
* @param {any} defaultVal
*/
function namedValidateOrDefaultIfUnset(objName, obj, keyName, keyType, defaultVal) {
return validateOrDefaultIfUnset(obj, keyName, keyType, defaultVal, objName);
}
/**
* @authorsComments: Not my prettiest work but it works so hey
* @throws
* @param {DefaultParserOptsType} opts
*/
function validateAndFillDefaults(opts) {
const allowedTypeofDefaultType = ["string", "number", "bigint", "boolean"];
const validFlagTypes = ['any', ...allowedTypeofDefaultType];
const allowedBehaviorOnInputErrorTypes = ['throw', 'log', 'log&exit', 'ignore'];
validateOrDefaultIfUnset(opts, 'warningLogger', 'function', console.log);
validateOrDefaultIfUnset(opts, 'behaviorOnInputError', 'string', 'throw');
if(!allowedBehaviorOnInputErrorTypes.includes(opts.behaviorOnInputError))
throw new TypeError(`Expected opts.behaviorOnInputError === ${allowedBehaviorOnInputErrorTypes.map((type) => `"${type}"`).join(" | ")} | undefined, received "${opts.behaviorOnInputError}"!`);
validateOrDefaultIfUnset(opts, 'defaultFlagType', 'string', 'any');
if(!validFlagTypes.includes(opts.defaultFlagType))
throw new TypeError(`Expected opts.defaultFlagType === ${validFlagTypes.map((type) => `"${type}"`).join(" | ")} | undefined, received "${opts.defaultFlagType}"!`);
/** Category: @default false */
validateOrDefaultIfUnset(opts, 'allowSingularDashLongFlags', 'boolean', false);
validateOrDefaultIfUnset(opts, 'lowerCaseFlagValues', 'boolean', false);
validateOrDefaultIfUnset(opts, 'lowerCaseInputValues', 'boolean', false);
if(opts.shouldStopParsingFunc !== null)
validateOrDefaultIfUnset(opts, 'shouldStopParsingFunc', 'function', null);
if(opts.shouldStopParsingStr !== null)
validateOrDefaultIfUnset(opts, 'shouldStopParsingStr', 'string', null);
if(opts.shouldStopParsingStr !== null && opts.shouldStopParsingFunc !== null) {
opts.warningLogger(`warn: opts.shouldStopParsingStr and opts.shouldStopParsingFunc are both present, opts.shouldStopParsingStr will be ignored!`);
opts.shouldStopParsingStr = null;
}
if(opts.parseFilterFunc !== null)
validateOrDefaultIfUnset(opts, 'parseFilterFunc', 'function', null);
/** Category: @default true */
validateOrDefaultIfUnset(opts, 'allowUnknownFlags', 'boolean', true);
validateOrDefaultIfUnset(opts, 'lowerCaseFlags', 'boolean', true);
validateOrDefaultIfUnset(opts, 'resolveFlagValueTypes', 'boolean', true);
validateOrDefaultIfUnset(opts, 'allowNullDefaultFlagValues', 'boolean', true);
validateOrDefaultIfUnset(opts, 'handleBackslashesForSpecialFlagChars', 'boolean', true);
validateOrDefaultIfUnset(opts, 'addFlagShorthandsAsAliases', 'boolean', true);
validateOrDefaultIfUnset(opts, 'automaticBooleanFlagNegation', 'object', {});
namedValidateOrDefaultIfUnset('opts.automaticBooleanFlagNegation', opts.automaticBooleanFlagNegation, 'allowUnspacedNegatedFlags', 'boolean', true);
namedValidateOrDefaultIfUnset('opts.automaticBooleanFlagNegation', opts.automaticBooleanFlagNegation, 'enabled', 'boolean', true);
namedValidateOrDefaultIfUnset('opts.automaticBooleanFlagNegation', opts.automaticBooleanFlagNegation, 'shorthandNegation', 'boolean', true);
validateOrDefaultIfUnset(opts, 'flags', "object", {});
Object.entries(opts.flags).forEach(/** @param {[string, FlagAny]} value */ ([flagName, flagData]) => {
// namedValidateOrDefaultIfUnset('flag', flagData, 'default')
expectType(`flag["${flagName}"]`, 'object', flagData, false);
if(typeof flagData.type === 'undefined') {
opts.warningLogger(`warn: flag["${flagName}"].type is undefined, defaulting to '${opts.defaultFlagType}'`);
flagData.type = opts.defaultFlagType;
}
if(!validFlagTypes.includes(flagData.type)) {
throw new TypeError(`Expected flag["${flagName}"].type === ${validFlagTypes.map((type) => `"${type}"`).join(" | ")}, received "${flagData.type}"!`);
}
/** Type of flagData.default should be consistent with flagData.type */
if(flagData.default !== undefined) {
if(flagData.default !== null && opts.allowNullDefaultFlagValues) {
if(flagData.type !== 'any' && typeof flagData.default !== flagData.type)
throw new TypeError(`Expected typeof flag["${flagName}"].default === flag["${flagName}"].type, received "${typeof flagData.default}"!`);
/** @type {JSTypes[]} */
if(flagData.type === 'any' && !allowedTypeofDefaultType.includes(typeof flagData.default))
throw new TypeError(`Expected typeof flag["${flagName}"].default === ${allowedTypeofDefaultType.map((type) => `"${type}"`).join(" | ")}, received "${typeof flagData.default}"!`);
}
}
if(typeof flagData.shorthands === 'string')
flagData.shorthands = [flagData.shorthands];
if(typeof flagData.shorthands === 'undefined')
flagData.shorthands = [];
if(!Array.isArray(flagData.shorthands) || flagData.shorthands.some((shorthand) => typeof shorthand !== 'string'))
throw new TypeError(`flag["${flagName}"].shorthands must be type string | string[] | undefined!`);
if(typeof flagData.aliases === 'undefined')
flagData.aliases = [];
if(typeof flagData.aliases !== 'undefined' && (!Array.isArray(flagData.aliases) || flagData.aliases.some((alias) => typeof alias !== 'string')))
throw new TypeError(`flag["${flagName}"].aliases must be type string[]!`);
//#region I hate that, setting useless properties that echo a value that I should supposedly already have, but it would require a non trivial refactoring in a lot of places so this is easier
//its also wrongly typed because obviously flag.name doesn't actually exist outside of me parsing the options, so it 's type is actually in `ReturnType<typeof getFlag()>`
//#endregion
///@ts-expect-error
if(typeof flagData.name !== 'undefined' && flagData.name !== flagName) {
throw new TypeError(`internal flag["${flagName}"].name must be set to flagName:"${flagName}" or undefined!`);
}
///@ts-expect-error
flagData.name = flagName;
if(typeof flagData.requiredValue === 'undefined')
flagData.requiredValue = false;
if(typeof flagData.requiredValue !== 'boolean')
throw new TypeError(`flag["${flagName}"].requiredValue must be type boolean | undefined!`);
})
}
//#region findFlagFunctions
/**
* @param {DefaultParserOptsType} opts
* @param {string} flagKeyInput
* @param {boolean} [isShorthand=false]
*/
function hasFlag(opts, flagKeyInput, isShorthand) {
if(typeof isShorthand !== 'boolean')
isShorthand = false;
/** @type {string} @readonly */
/// @ts-ignore
let flagKey = flagKeyInput
const definedFlagKeys = [];
Object.entries(opts.flags).forEach(([flagKey, flag]) => {
if(isShorthand) {
if(flag.shorthands.length >= 1)
definedFlagKeys.push(...flag.shorthands);
} else {
definedFlagKeys.push(flagKey);
if(flag.aliases.length >= 1)
definedFlagKeys.push(...flag.aliases);
}
});
if(opts.lowerCaseFlags) {
flagKey = flagKey.toLowerCase();
definedFlagKeys.forEach((flagKey) => flagKey = flagKey.toLowerCase());
}
if(definedFlagKeys.includes(flagKey))
return true;
return false;
}
/**
* @param {DefaultParserOptsType} opts
* @param {string} flagKeyInput
* @returns {boolean}
*/
function hasShorthandFlag(opts, flagKeyInput) {
return hasFlag(opts, flagKeyInput, true);
}
/**
* @param {DefaultParserOptsType} opts
* @param {string} flagKeyInput
* @returns {boolean}
*/
function hasFullFlag(opts, flagKeyInput) {
return hasFlag(opts, flagKeyInput, false);
}
/**
* @param {DefaultParserOptsType} opts
* @param {string} flagKeyInput
* @param {boolean} [isShorthand=false]
* @returns {undefined | (FlagAny & { name: string })}
*/
function getFlag(opts, flagKeyInput, isShorthand) {
if(typeof isShorthand !== 'boolean')
isShorthand = false;
/** @type {string} @readonly */
let flagKey = flagKeyInput
/** @type {{ flagKey: string, ref: FlagAny }[]} */
const definedFlagKeys = [];
/** For correct priorities in cases of similar aliases or shorthands, ordering by definition order (or at least trying) */
Object.entries(opts.flags).forEach(([flagKey, flag]) => {
if(isShorthand) {
///@ts-expect-error
flag.shorthands.forEach((shorthand) => definedFlagKeys.push({ flagKey: shorthand, ref: flag }));
} else {
definedFlagKeys.push({ flagKey, ref: flag });
flag.aliases.forEach((alias) => definedFlagKeys.push({ flagKey: alias, ref: flag }));
}
});
if(opts.lowerCaseFlags) {
flagKey = flagKey.toLowerCase();
definedFlagKeys.forEach((definedFlagKeyRef) => definedFlagKeyRef.flagKey = definedFlagKeyRef.flagKey.toLowerCase());
}
const foundFlag = definedFlagKeys.find((definedFlagKeyRef) => definedFlagKeyRef.flagKey === flagKey);
if(foundFlag !== undefined)
/// @ts-ignore The type is correct
return foundFlag.ref;
return undefined;
}
/**
* @param {DefaultParserOptsType} opts
* @param {string} flagKeyInput
* @returns {ReturnType<typeof getFlag>}
*/
function getFullFlag(opts, flagKeyInput) {
return getFlag(opts, flagKeyInput, false);
}
/**
* @param {DefaultParserOptsType} opts
* @param {string} flagKeyInput
* @returns {ReturnType<typeof getFlag>}
*/
function getShorthandFlag(opts, flagKeyInput) {
return getFlag(opts, flagKeyInput, true);
}
/**
* @param {DefaultParserOptsType} opts
* @param {InternalFlagsFlagObj} flag
*/
function assureValidFlagConfigState(opts, flag) {
if(flag.config === null) {
if(flag.isShorthand)
flag.config = getShorthandFlag(opts, flag.key);
else
flag.config = getFullFlag(opts, flag.key);
}
}
//#endregion findFlagFunctions
//#region end findFlagFunctions
//#endregion end findFlagFunctions
/**
* @related runFlagValueTypeResolving,isTypeAssignable
* @param {string} input
* @returns {"number"|"bigint"|"boolean"|"string"}
*/
function getCastableType(input) {
if(input.toLowerCase() === 'true' || input.toLowerCase() === 'false')
return 'boolean';
/// @ts-ignore
if(input.length >= 2 && input.endsWith('n') && !isNaN(input.slice(0, -1)))
return 'bigint';
/// @ts-ignore
if(!isNaN(input))
return 'number';
return 'string';
}
/**
* @related runFlagValueTypeResolving,getCastableType
* @param {FlagAny['type']} baseType
* @param {FlagAny['type']} comparedType
*/
function isTypeAssignable(baseType, comparedType) {
if(baseType === comparedType)
return true;
if(baseType === 'any' || baseType === 'string')
return true;
if(baseType === 'bigint' && comparedType === 'number')
return true;
return false
}
/**
* @param {DefaultParserOptsType} opts
* @param {{ error: InputError | Error, message: string, exitCode: number }} errorObj
*/
function giveError(opts, errorObj) {
if(opts.behaviorOnInputError === 'throw') {
throw errorObj.error;
} else if(opts.behaviorOnInputError === 'log' || opts.behaviorOnInputError === 'log&exit') {
opts.warningLogger(errorObj.message);
}
if(opts.behaviorOnInputError === 'log&exit') {
process.exit(errorObj.exitCode || 0);
}
}
/**
* @throws if opts.behaviorOnInputError == 'throw' && flag value isn't assignable to it's required type
* @param {DefaultParserOptsType} opts
* @param {ReturnType<typeof parseArg>} meta
* @param {InternalFlagsFlagObj} flag
* @param {string[]} argv - For the error context
*/
function assignedValueUncastableTypeError(opts, meta, flag, argv) {
const castableType = getCastableType(meta.flagAssignedValue);
let valDisplay = `"${meta.flagAssignedValue}"`
if(typeof meta.flagAssignedValue !== 'string')
valDisplay = meta.flagAssignedValue;
const message = `Value ${valDisplay} (castable:"${castableType}") is not assignable to required type "${flag.type}".`;
const error = new InputError(message);
error.context = { flag: flag.config, flagType: flag.type, flagMatch: meta.flagKey, value: meta.flagAssignedValue, assignableType: castableType, argv };
giveError(opts, {
error,
message: message + `\n - Flag: "${flag.config.name}", match: "${meta.flagKey}", value: ${valDisplay}, assignableType: "${castableType}", requiredType: "${flag.type}"`,
exitCode: 1
});
}
/**
* @throws if behaviorOnInputError == 'throw'
* @param {DefaultParserOptsType} opts
* @param {string} flagKey
* @param {any} flagVal
* @param {string[]} argv - For the error context
*/
function unknownFlagError(opts, flagKey, flagVal, argv) {
const message = `Received unknown flag "${flagKey}", but opts.allowUnknownFlags is disabled!`;
const error = new InputError(message);
error.context = { flagKey, flagVal, argv };
let valDisplay = `"${flagVal}"`
if(typeof flagVal !== 'string')
valDisplay = flagVal
giveError(opts, {
error,
message: message + `\n - Flag: <not found>, match: "${flagKey}", value: ${valDisplay}`,
exitCode: 2
});
}
//#region SubworkFunctions
/**
* @param {DefaultParserOptsType} opts
* @returns {string[]}
*/
function getAllShorthandFlags(opts) {
const allShorthandFlags = []
Object.values(opts.flags).forEach((flag) => {
allShorthandFlags.push(...flag.shorthands);
if(opts.automaticBooleanFlagNegation.shorthandNegation && flag.type === 'boolean') {
// For typescript
ok(Array.isArray(flag.shorthands), 'Array.isArray(flag.shorthands): Should have been parsed by validateAndFillDefaults and made into an array!');
allShorthandFlags.push(...flag.shorthands.map((shorthand) => "n" + shorthand));
}
});
return allShorthandFlags;
}
/**
* Can (only if negated flag) @modify flag.key && flag.config
* ## To be ran before any flag value assignments: flag.value always expected to be string
* @param {DefaultParserOptsType} opts
* @param {InternalFlagsFlagObj} flag
*/
function runAutomaticBooleanFlagNegation(opts, flag) {
if(opts.automaticBooleanFlagNegation.enabled !== true)
return false;
if(flag.isShorthand && opts.automaticBooleanFlagNegation.shorthandNegation !== true)
return false;
/** @readonly */
let newKey = null;
/** @readonly */
let flagConfig = null;
let negated = false;
if(!flag.isShorthand) {
/** Make sure flag isn't set in our vals */
if(!hasFullFlag(opts, flag.key) && flag.key.length > 2) {
if(flag.key.length > 3 && flag.key.toLowerCase().startsWith('no-')) {
newKey = flag.key.slice(3);
negated = true;
flagConfig = getFullFlag(opts, newKey);
}
else if(opts.automaticBooleanFlagNegation.allowUnspacedNegatedFlags && flag.key.toLowerCase().startsWith('no')) {
newKey = flag.key.slice(2);
negated = true;
flagConfig = getFullFlag(opts, newKey);
}
}
} else {
/** Make sure flag isn't set in our vals */
if(!hasShorthandFlag(opts, flag.key) && flag.key.length >= 2) {
if(flag.key.toLowerCase().startsWith('n')) {
newKey = flag.key.slice(1);
negated = true;
flagConfig = getShorthandFlag(opts, newKey);
}
}
}
if(negated) {
/** Cannot use flag.type as it's not yet initialized, and useless to set it as it will always be done right after this function call */
const flagType = flagConfig?.type || opts.defaultFlagType;
/** @type {ReturnType<typeof getCastableType>} */
let castableType = 'boolean';
if(flag.value !== UNSET_FLAG_VALUE)
/// @ts-expect-error - flag.value is always string at this moment in time
castableType = getCastableType(flag.value);
if(castableType === 'boolean' && isTypeAssignable(flagType, castableType)) {
flag.key = newKey;
flag.config = flagConfig;
flag.isNegated = true;
return true;
}
}
return false;
}
/**
* @related getCastableType,isTypeAssignable
* @param {DefaultParserOptsType} opts
* @param {string} flagVal
* @param {FlagAny['type']} [expectedType]
*/
function runFlagValueTypeResolving(opts, flagVal, expectedType) {
/** @type {any} */
let resolvedFlagVal = flagVal;
if(expectedType !== undefined && expectedType === 'string')
return flagVal;
/** boolean checking */
if(flagVal.toLowerCase() === 'true')
resolvedFlagVal = true;
else if(flagVal.toLowerCase() === 'false')
resolvedFlagVal = false;
else if(opts.resolveFlagValueTypes) {
/** bigint checking */
/// @ts-expect-error - isNaN on string
if(flagVal.length >= 2 && flagVal.endsWith('n') && !isNaN(flagVal.slice(0, -1)))
resolvedFlagVal = BigInt(flagVal.slice(0, -1));
/** number & bigint checking */
/// @ts-expect-error - isNaN on string
else if(!isNaN(flagVal)) {
if(expectedType === undefined || expectedType === 'number' || expectedType === 'any')
resolvedFlagVal = Number(flagVal);
else if (expectedType !== undefined && expectedType === 'bigint')
resolvedFlagVal = BigInt(flagVal);
}
}
return resolvedFlagVal;
}
/**
* @related getCastableType,isTypeAssignable
* @param {DefaultParserOptsType} opts
* @param {ReturnType<typeof parseArg>} meta
* @param {InternalFlagsFlagObj} flag
* @param {string[]} argv - For the error context
*/
function runFlagAssignedValueAssignment(opts, meta, flag, argv) {
if(flag.config !== undefined && !isTypeAssignable(flag.type, getCastableType(meta.flagAssignedValue))) {
assignedValueUncastableTypeError(opts, meta, flag, argv);
return false;
}
const flagVal = runFlagValueTypeResolving(opts, meta.flagAssignedValue, flag.type);
if(flag.isNegated && typeof flagVal === 'boolean' && isTypeAssignable(flag.type, 'boolean')) {
flag.value = !flagVal
} else {
flag.value = flagVal;
}
return true;
}
//#endregion SubworkFunctions
//#region SecondaryParsers
/**
* @param {string} arg
* @param {DefaultParserOptsType} opts
*/
function parseArg(arg, opts) {
const argsStr = new StringConsumer(arg);
/** return meta flags */
let isFlag = false;
/** @type {string} */
let flagKey = null;
let minusSignCount = 0;
let unescapedEqualSignPos = -1;
let hasFlagAssignedValue = false;
//TODO: Add support for comma separated args into arr for flag values
/** @type {string} */
let flagAssignedValue = null;
/** parser character flags */
let isBackslashed = false;
let isPotentialFlag = false;
let hasPotentialFlagAssignedValue = false;
let readingMinusSignCount = true;
while(argsStr.hasNext()) {
let char = argsStr.next();
/** !isBackslashed check redundant here, readingMinusSignCount will always be false if previous character was not a - */
if(char === '-' && readingMinusSignCount) {
isPotentialFlag = true;
minusSignCount++;
continue;
}
/** Must not be a minus sign */
readingMinusSignCount = false;
/** Flag has an assigned value in the arg*/
if(char === "=" && isFlag) {
if(!isBackslashed) {
if(!hasPotentialFlagAssignedValue) {
hasPotentialFlagAssignedValue = true;
unescapedEqualSignPos = argsStr.getPointer()-1;
continue;
}
} else {
if(hasFlagAssignedValue) {
/** Consume the backslash */
flagAssignedValue = flagAssignedValue.slice(0, -1)
} else {
/** Consume the backslash */
flagKey = flagKey.slice(0, -1)
}
isBackslashed = false;
}
}
/** Making sure there is indeed something after the initial - */
if(isPotentialFlag && !isFlag) {
isFlag = true;
flagKey = '';
}
/** Making sure there is indeed something after the = */
if(hasPotentialFlagAssignedValue && !hasFlagAssignedValue) {
hasFlagAssignedValue = true;
flagAssignedValue = '';
}
if(isFlag) {
/** If is still during flag phase, add flagKey */
if(!hasFlagAssignedValue) {
flagKey += char;
}
/** Else if it has a flagAssignedValue, add value */
else {
flagAssignedValue += char;
}
}
// Weird and gross handling of backslashes, but explicit, 'defined' (in ./types.d.ts -> ParserOpts.handleBackslashesForSpecialFlagChars) and toggleable
if(char === '\\') {
if(opts.handleBackslashesForSpecialFlagChars) {
if(!isBackslashed) {
if(isFlag && argsStr.hasNext() && argsStr.peek() === '-') {
/** Consume the backslash */
flagKey = flagKey.slice(0, -1)
}
else
isBackslashed = true;
}
}
}
else if(isBackslashed)
isBackslashed = false;
}
return { isFlag, minusSignCount, flagKey, hasFlagAssignedValue, flagAssignedValue, unescapedEqualSignPos };
}
/**
* @param {string[]} shorthandFlags
* @param {string} shorthandString
*/
function parseShorthandFlags(shorthandFlags, shorthandString) {
shorthandFlags = shorthandFlags.concat();
const shorthandStr = new StringConsumer(shorthandString);
let foundFlags = [];
let currStr = '';
while(shorthandStr.hasNext()) {
let char = shorthandStr.next();
currStr += char;
let perfectMath = shorthandFlags.findIndex((shorthand) => shorthand === currStr);
let lastIsmMatch = shorthandFlags.findIndex((shorthand) => shorthand === currStr.slice(0, -1));
/** Has shorthand that can start with the currentStr but is missing some letters */
if(shorthandStr.hasNext() && shorthandFlags.some((shorthand) => shorthand.startsWith(currStr) && currStr !== shorthand)) {
continue;
} else if(perfectMath !== -1) {
foundFlags.push(currStr);
shorthandFlags.splice(perfectMath, 1);
} else if(lastIsmMatch !== -1) {
foundFlags.push(currStr.slice(0, -1));
shorthandFlags.splice(lastIsmMatch, 1);
if(shorthandStr.hasNext()) {
currStr = char;
continue;
}
else {
foundFlags.push(char);
}
} else {
foundFlags.push(...currStr.split(''))
}
currStr = '';
}
return foundFlags;
}
//#endregion SecondaryParsers
//#region end SecondaryParsers
//#endregion end SecondaryParsers
/**
* @interface
* @typedef {{ key: string, value: typeof UNSET_FLAG_VALUE | number | boolean | string, isShorthand: boolean, config: ReturnType<typeof getFlag>, isNegated: boolean, type: FlagAny['type'] }} InternalFlagsFlagObj
*/
// TODO: Support multiple flag as a option = overload input
// TODO: ^ Add a flag so that if the previous thing is turned off, determine which flag gets picked first (last or first instance, current=last)
// TODO: Export a function to list all the flags and their values, with a description property on the flags
// TODO: Add an option for custom flag setting character, and wether they're stackable or not (so something to allow '/flag' to work)
// TODO: Fix typescript types of returned flags to include opts.resolveFlagValueTypes into it as well as checking if it has a default and if not make it an optional?, and type never if it's not in the flags object
/**
* @template {FlagsI} Flags
* @template {{ [K in keyof Flags]: Flags[K]['type'] extends 'boolean' ? boolean : Flags[K]['type'] extends 'number' ? number : Flags[K]['type'] extends 'string' ? string : string | boolean | number }} FlagsReturn
* @param {string[]} argv
* @param {ParserOpts<Flags>} opts
* @returns {{ input: string[], flags: FlagsReturn, unparsed: string[] } }}
*/
function parser(argv, opts) {
if(!Array.isArray(argv) || argv.some((e) => typeof e !== 'string'))
throw new TypeError(`Argv must be type string[]!`);
expectType('opts', 'object', opts, false);
/** @throwable Will throw if anything is wrong */
validateAndFillDefaults(opts);
if(opts.addFlagShorthandsAsAliases) {
Object.values(opts.flags).forEach((flagConfig) => {
if(flagConfig.shorthands.length >= 1)
/// @ts-ignore
flagConfig.aliases.push(...flagConfig.shorthands)
})
}
const allShorthandFlags = getAllShorthandFlags(opts);
/** @type {InternalFlagsFlagObj[]} */
const flags = [];
/** @type {string[]} */
const input = [];
/** @type {string[]} */
const unparsed = [];
/** @readonly Internal value for keeping track previous flag being available for a natural flag value assignment (ie: ['--flag', 'hi'] -> { "flag": 'hi' }) */
let naturalFlagAssignment = {
isLookingForValue: false,
/** @type {FlagAny['type']} */
requiredType: null
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const meta = parseArg(arg, opts);
/** @type {argMeta} */
const argMeta = {
...meta,
isShorthand: meta.minusSignCount === 1,
isInput: !meta.isFlag,
isFlagNaturalValue: false
}
if(!meta.isFlag && naturalFlagAssignment.isLookingForValue && isTypeAssignable(naturalFlagAssignment.requiredType, getCastableType(arg))) {
argMeta.isFlagNaturalValue = true;
argMeta.isInput = false;
}
//#region Parsing filters
if(typeof opts.shouldStopParsingFunc === 'function') {
let shouldStop = false;
try {
shouldStop = opts.shouldStopParsingFunc(arg, i, argv, argMeta, input);
} catch (stopParsingFuncError) {
const error = new EvalError(`Function opts.shouldStopParsingFunc threw an error!`);
error.cause = stopParsingFuncError;
/// @ts-ignore
error.context = { arg, index: i, argMeta, stopParsingFunc: opts.shouldStopParsingFunc, argv };
throw error;
}
if(shouldStop) {
unparsed.push(...argv.slice(i));
break;
}
}
if(typeof opts.shouldStopParsingStr === 'string' && opts.shouldStopParsingStr === arg) {
unparsed.push(...argv.slice(i))
break;
}
if(typeof opts.parseFilterFunc === 'function') {
let filtered = false;
try {
filtered = opts.parseFilterFunc(arg, i, argv, argMeta, input);
} catch (parseFilterError) {
const error = new EvalError(`Function opts.parseFilterFunc threw an error!`);
error.cause = parseFilterError;
/// @ts-ignore
error.context = { arg, index: i, argMeta, parseFilterFunc: opts.parseFilterFunc, argv };
throw error;
}
if(filtered) {
continue;
}
}
//#endregion Parsing filters
if(meta.isFlag) {
/** If allow singular dashlong flags, just fall through to the full flag handling */
/** Redundant hasFullFlag given later might getFullFlag but yk easier implementation like this */
if(argMeta.isShorthand && !(opts.allowSingularDashLongFlags && hasFullFlag(opts, meta.flagKey))) {
parseShorthandFlags(allShorthandFlags, meta.flagKey)
.forEach((shorthandKey) => {
/** @readonly */
let flagKey = shorthandKey;
if(opts.lowerCaseFlags)
flagKey = flagKey.toLowerCase();
/** @type {InternalFlagsFlagObj} */
const flag = { key: flagKey, value: UNSET_FLAG_VALUE, isShorthand: true, config: null, isNegated: false, type: opts.defaultFlagType };
runAutomaticBooleanFlagNegation(opts, flag);
assureValidFlagConfigState(opts, flag);
if(flag.config !== undefined)
flag.type = flag.config.type;
flags.push(flag);
});
const flag = flags.at(-1);
if(meta.hasFlagAssignedValue) {
naturalFlagAssignment = { isLookingForValue: false, requiredType: null };
runFlagAssignedValueAssignment(opts, meta, flag, argv);
continue;
}
naturalFlagAssignment = { isLookingForValue: true, requiredType: flag.type };
continue;
}
/** @readonly I like dark blue, plus if is better than a ? assignment from a const */
let flagKey = meta.flagKey;
if(opts.lowerCaseFlags)
flagKey = flagKey.toLowerCase();
/** @type {InternalFlagsFlagObj} */
const flag = { key: flagKey, value: UNSET_FLAG_VALUE, isShorthand: false, config: null, isNegated: false, type: opts.defaultFlagType };
runAutomaticBooleanFlagNegation(opts, flag);
assureValidFlagConfigState(opts, flag);
if(flag.config !== undefined)
flag.type = flag.config.type;
if(meta.hasFlagAssignedValue) {
naturalFlagAssignment = { isLookingForValue: false, requiredType: null };
const succeeded = runFlagAssignedValueAssignment(opts, meta, flag, argv);
if(succeeded)
flags.push(flag);
continue;
}
flags.push(flag);
naturalFlagAssignment = { isLookingForValue: true, requiredType: flag.type };
continue;
}
else {
let inputVal = arg;
// Hardcoded edge case, not a fan, but if it has more than one backslash, it was never gonna be a flag anyways
if(opts.handleBackslashesForSpecialFlagChars && inputVal.startsWith('\\-'))
inputVal = inputVal.slice(1);
if(!naturalFlagAssignment.isLookingForValue) {
if(opts.lowerCaseInputValues)
inputVal = inputVal.toLowerCase();
input.push(inputVal);
continue
}
const flag = flags.at(-1);
if(isTypeAssignable(flag.type, getCastableType(inputVal))) {
const flagVal = runFlagValueTypeResolving(opts, inputVal, flag.type);
if(flag.isNegated && typeof flagVal === 'boolean' && isTypeAssignable(flag.type, 'boolean')) {
flag.value = !flagVal
} else {
flag.value = flagVal;
}
}
else {
if(opts.lowerCaseInputValues)
inputVal = inputVal.toLowerCase();
input.push(inputVal);
}
naturalFlagAssignment = { isLookingForValue: false, requiredType: null };
continue;
}
}
const flagReturnObj = {};
/** Some (not all, some is run in previous loop) Flag processing (like checking types, which we can't run in the argv.forEach) and return object building */
flags.forEach((flag) => {
if(flag.value === UNSET_FLAG_VALUE) {
if(flag.config !== undefined) {
if(flag.config.requiredValue === true) {
const message = `Flag "${flag.config.name}" is missing a required value of type "${flag.config.type}"!`;
const error = new InputError(message);
error.context = { flag: flag.config, flagType: flag.config.type, flagMatch: flag.key, value: UNSET_FLAG_VALUE, argv };
return giveError(opts, {
error,
message: message + `\n - Flag: "${flag.config.name}", match: "${flag.key}", value: UNSET_FLAG_VALUE, type: "${flag.config.type}"`,
exitCode: 1
});
}
/** Set default except booleans (since this function only runs if the flag is present, and a boolean flag being present is by itself a true value (unless negated)) */
if(typeof flag.config.default !== 'undefined' && flag.config.type !== 'boolean')
flag.value = flag.config.default;
}
if(flag.value === UNSET_FLAG_VALUE) {
switch(flag.type) {
case 'any':
case 'boolean':
/** ProCoder: flag.value = !flag.isNegated */
flag.value = true;
if(flag.isNegated)
flag.value = false;
break;
case "bigint":
case "number":
case "string":
flag.value = null;
break;
}
}
} else {
if(typeof flag.value === 'string')
flag.value = runFlagValueTypeResolving(opts, flag.value, flag.type);
if(typeof flag.value === 'string' && opts.lowerCaseFlagValues)
flag.value = flag.value.toLowerCase()
}
if(flag.config === undefined && !opts.allowUnknownFlags)
return unknownFlagError(opts, flag.key, flag.value, argv);
const flagName = flag.config?.name || flag.key;
flagReturnObj[flagName] = flag.value;
})
/** Setting missing flags */
Object.entries(opts.flags).forEach(([flagName, flagConfig]) => {
if(typeof flagReturnObj[flagName] !== 'undefined')
return;
/** Not a needed check as you can tell, but present to explicitly highlight that behavior in the code */
if(typeof flagConfig.default === 'undefined')
return flagReturnObj[flagName] = undefined;
flagReturnObj[flagName] = flagConfig.default;
});
/// @ts-ignore - falsly wrong flags type
return { flags: flagReturnObj, input, unparsed };
}
module.exports = { parser, InputError };