UNPKG

aspargvs

Version:
626 lines (615 loc) 24.2 kB
import * as p from 'peberminta'; import { inspect } from 'node:util'; import { existsSync, readFileSync } from 'node:fs'; import * as pc from 'peberminta/char'; function isJsonArray(value) { return typeof value === 'object' && Array.isArray(value); } function isJsonObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function getType(value) { switch (typeof value) { case 'boolean': return 'boolean'; case 'number': return 'number'; case 'string': return 'string'; case 'object': return (value === null) ? 'null' : (isJsonArray(value)) ? 'array' : 'object'; default: throw new Error(`Expected valid JsonValue, got ${typeof value}`); } } function setJsonArrayItem(arr, value, keyTransform, key0, ...keys) { const i = (Number.isNaN(key0)) ? arr.length : key0; if (keys.length === 0) { arr[i] = value; } else { if (typeof arr[i] === 'undefined') { arr[i] = (typeof keys[0] === 'number') ? [] : {}; } setNested(arr[i], value, keyTransform, keys[0], ...keys.slice(1)); } return arr; } function setJsonObjectItem(obj, value, keyTransform, key0, ...keys) { if (keyTransform) { key0 = keyTransform(key0); } if (keys.length === 0) { if (typeof obj[key0] !== 'undefined') { if (Array.isArray(value) && Array.isArray(obj[key0])) { obj[key0].push(...value); } else { throw new Error(`Trying to set "${key0}" key multiple times.`); } } else { obj[key0] = value; } } else { if (typeof obj[key0] === 'undefined') { obj[key0] = (typeof keys[0] === 'number') ? [] : {}; } setNested(obj[key0], value, keyTransform, keys[0], ...keys.slice(1)); } return obj; } function setNested(nested, value, keyTransform, key1, ...keys) { if (isJsonArray(nested) && typeof key1 === 'number') { setJsonArrayItem(nested, value, keyTransform, key1, ...keys); } else if (isJsonObject(nested) && typeof key1 === 'string') { setJsonObjectItem(nested, value, keyTransform, key1, ...keys); } else { throw new Error(`Trying to access ${typeof key1} key ${JSON.stringify(key1)} of an ${getType(nested)}.`); } } function ciEquals(a, b) { return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0; } function ciIncludes(as, b) { return as.some((a) => ciEquals(a, b)); } function ciGetProperty(obj, key) { for (const k of Object.keys(obj)) { if (ciEquals(k, key)) { return obj[k]; } } return undefined; } function parseCommandName(id, ...aliases) { return p.token((t) => (ciIncludes(aliases, t)) ? id : undefined); } function bareCommandParser(condition, parser) { return p.condition(condition, p.map(parser, (v) => ({ type: 'command', command: v })), p.fail); } const parseVersionCommand = bareCommandParser((data) => !!data.options.handlers?.version, p.eitherOr(parseCommandName('version', 'version', '-v'), p.left(parseCommandName('version', '--version'), p.end))); const parseHelpCommand = bareCommandParser((data) => !!data.options.handlers?.help, p.eitherOr(parseCommandName('help', 'help', '-h'), p.left(parseCommandName('help', '--help'), p.end))); const parseInspectCommand = bareCommandParser((data) => !!data.options.handlers?.inspect, parseCommandName('inspect', 'inspect', '-i')); const parseUnparseCommand = bareCommandParser((data) => !!data.options.handlers?.unparse, parseCommandName('unparse', 'unparse', '-u')); const parseJsonFilename = p.token((fileName) => { if (!existsSync(fileName)) { throw new Error(`File "${fileName}" doesn't exist.`); } const json = JSON.parse(readFileSync(fileName, 'utf8')); if (!isJsonObject(json)) { throw new Error('Only json objects are allowed. Provided file contains an array or a primitive value.'); } return { type: 'data', json: json }; }, () => { throw new Error('Expected one more argument for a json file name.'); }); const parseJsonCommand = p.ab(parseCommandName('json', 'json', '-j'), parseJsonFilename, (ra, rb) => ({ type: 'commandWithData', command: 'json', json: rb.json })); const parsePresetName = p.token((presetName, data) => { const presets = data.options.presets || {}; const preset = ciGetProperty(presets, presetName); if (!preset) { const presetNames = Object.keys(presets).join(', '); throw new Error(`Unknown preset name: "${presetName}".\nKnown presets are: ${presetNames}.`); } return { type: 'data', json: preset.json }; }, () => { throw new Error('Expected one more argument for a preset name.'); }); const parsePresetCommand = p.condition((data) => ((p) => !!p && Object.keys(p).length > 0)(data.options.presets), p.ab(parseCommandName('preset', 'preset', '-p'), parsePresetName, (ra, rb) => ({ type: 'commandWithData', command: 'json', json: rb.json })), p.fail); const parseAnyCommand = p.choice(parseVersionCommand, parseHelpCommand, parseJsonCommand, parsePresetCommand, parseInspectCommand, parseUnparseCommand); function parseAllCommands(data, i) { let acc = {}; let position = i; let commandId = undefined; let tryNextCommand = true; while (tryNextCommand) { const r = parseAnyCommand(data, position); if (!r.matched) { tryNextCommand = false; continue; } switch (r.value.command) { case 'version': case 'help': return r; case 'inspect': case 'unparse': if (commandId && commandId !== r.value.command) { throw new Error(`Can't unparse and inspect json at the same time.`); } commandId = r.value.command; break; case 'json': case 'preset': { const next = (r.value).json; if (!data.options.handlers?.merge) { throw new Error(`Can't use 'json' or 'preset' command without supplying 'merge' handler.`); } acc = data.options.handlers.merge(acc, next); break; } } position = r.position; } return { matched: true, position: position, value: { type: 'commandWithData', command: commandId || 'json', json: acc } }; } function getHelp(options) { const binName = (s => s ? s + ' ' : '')(options.handlers?.bin?.()); let presets = []; if (options.presets && Object.keys(options.presets).length > 0) { const maxKeyLength = Math.max(...Object.keys(options.presets).map((key) => key.length)); presets = Object.entries(options.presets).map(([key, { description }]) => [` ${key.padEnd(maxKeyLength)} : ${description}`, true]); } const lines = [ ['Command line arguments:'], [` ${binName}[commands...] [keys and values...]`], [''], ['Commands are:'], [' version, -v : Print version number and exit', !!options.handlers?.version], [' help, -h : Print this message and exit', !!options.handlers?.help], [' inspect, -i : Pretty print the args object and exit', !!options.handlers?.inspect], [' unparse, -u : Print the args object back as args string and exit', !!options.handlers?.unparse], [' json, -j <file_name> : Merge given JSON file contents into args object', !!options.handlers?.merge], [' preset, -p <preset_name> : Merge given preset into args object', !!options.handlers?.merge && presets.length > 0], ...((presets.length > 0) ? [ [''], ['Presets are:'], ...presets ] : []), [''], ['Key syntax:'], [' --foo : True value'], [' --!foo : False value'], [' --foo=<value> : Value (see below)'], [' --foo=[<value>,...] : Array'], [' --foo[] <value> <value> : Array'], [' --foo[i]=<value> : i-th item in array'], [' --foo[_]=<value> : Next item in array (automatic index)'], [' --foo.bar.[0]=<value> : Nesting with dot chain'], [' --foo{} :bar=<value> :baz : Empty object and it\'s subkeys'], [' --foo[] :[0].bar :[1].bar : Subkeys for objects inside a common array'], [''], ['Value syntax:'], [' null : Null value'], [' true : True value'], [' false : False value'], [' 1.23e+4 : Number'], [' [<value>,...] : Array'], [' {} : Empty object (Use separate args for non-empty objects)'], [' "null" : String (Beware - Node.js strips unescaped double quotes)'], [" 'true' : String (Beware - some shells may strip unescaped quotes)"], [' `false` : String (Beware - some shells may strip unescaped quotes)'], [' anything else : String (Don\'t need quotes unless it is ambiguous)'], [''], ['Escape syntax characters inside keys and arrays with "\\".'], ]; return lines .filter(([, b]) => b !== false) .map(([s,]) => s) .join('\n'); } function isObjectPath(path) { return typeof path.key0 === 'string'; } function isArrayPath(path) { return typeof path.key0 === 'number'; } function escapeDoubleQuotes(str) { return str.replace(/\\([\s\S])|(")/g, '\\$1$2'); } function unescapeString(chars) { return JSON.parse('"' + escapeDoubleQuotes(chars.join('')) + '"'); } function escapedChar(specialChars) { if (!specialChars.includes('\\')) { specialChars += '\\'; } const escapes = [...specialChars] .map((c) => p.map(pc.str('\\' + c), () => c)); return p.choice(...escapes, pc.noneOf(specialChars)); } function stringOfChars(pChar) { return p.map(p.many(pChar), unescapeString); } function quotedString(quoteChar) { return p.middle(pc.char(quoteChar), stringOfChars(escapedChar(quoteChar)), pc.char(quoteChar)); } const btString_ = quotedString('`'); const sqString_ = quotedString("'"); const dqString_ = quotedString('"'); const pathKey_ = stringOfChars(escapedChar('!.=[{')); const arrayString_ = stringOfChars(escapedChar(',]')); const unquotedString_ = stringOfChars(p.any); const nullValue_ = p.map(pc.str('null'), () => null); const trueValue_ = p.map(pc.str('true'), () => true); const falseValue_ = p.map(pc.str('false'), () => false); const emptyObject_ = p.map(pc.str('{}'), () => ({})); const digits_ = p.many1(pc.oneOf('0123456789')); const jsonFloat_ = p.map(pc.concat(p.option(pc.char('-'), ''), digits_, p.option(pc.concat(pc.char('.'), digits_), ''), p.option(pc.concat(pc.oneOf('eE'), p.option(pc.oneOf('+-'), ''), digits_), '')), parseFloat); const unsignedInt_ = p.map(digits_, (chars) => parseInt(chars.join(''))); const primitiveValue_ = p.choice(emptyObject_, nullValue_, trueValue_, falseValue_, jsonFloat_); const array_ = p.choice(p.ab(pc.char('['), pc.char(']'), () => []), p.middle(pc.char('['), p.sepBy1(p.recursive(() => arrayValue_), pc.char(',')), pc.char(']'))); const arrayValue_ = p.choice(primitiveValue_, array_, sqString_, dqString_, btString_, arrayString_); const bareValue_$1 = p.otherwise(p.choice(primitiveValue_, array_, sqString_, dqString_, btString_), unquotedString_); const pathIndex_ = p.middle(pc.char('['), p.eitherOr(unsignedInt_, p.map(pc.char('_'), () => Number.NaN)), pc.char(']')); const pathItem_ = p.eitherOr(p.right(p.option(pc.char('.'), ''), pathIndex_), p.right(pc.char('.'), pathKey_)); const objectPath_ = p.ab(pathKey_, p.many(pathItem_), (head, tail) => ({ key0: head, keys: tail })); const arrayPath_ = p.ab(pathIndex_, p.many(pathItem_), (head, tail) => ({ key0: head, keys: tail })); const path_ = p.eitherOr(arrayPath_, objectPath_); const bareValueToken_ = p.map(bareValue_$1, (v) => ({ type: 'bareValue', value: v })); const keyToken_ = p.chain(p.eitherOr(p.map(p.discard(pc.char('-'), pc.char('-')), () => false), p.map(p.discard(pc.char(':')), () => true)), (isSubkey) => { const pPath = (isSubkey) ? path_ : objectPath_; return p.choice(p.abc(pc.char('!'), pPath, p.end, (bang, path) => ({ type: isSubkey ? 'subKey' : 'fullKey', path: path, data: { type: 'negation' } })), p.ab(pPath, p.choice(p.map(p.end, () => 'bareKey'), p.map(p.discard(pc.char('['), pc.char(']'), p.end), () => 'arrayKey'), p.map(p.discard(pc.char('{'), pc.char('}'), p.end), () => 'objectKey')), (path, type) => ({ type: isSubkey ? 'subKey' : 'fullKey', path: path, data: { type: type } })), p.abc(pPath, p.right(pc.char('='), bareValue_$1), p.end, (path, value) => ({ type: isSubkey ? 'subKey' : 'fullKey', path: path, data: { type: 'keyValue', value: value } })), p.error((data) => `Failed to parse the argument "${data.options.arg}".`)); }); const matchArgToken_ = p.otherwise(keyToken_, bareValueToken_); function argToToken(arg) { return pc.match(matchArgToken_, arg, { arg: arg }); } function keyDataToJsonValue(data) { switch (data.type) { case 'bareKey': return true; case 'negation': return false; case 'arrayKey': return []; case 'objectKey': return ({}); case 'keyValue': return data.value; } } const arraySubKey_ = p.token((t) => (t.type === 'subKey' && isArrayPath(t.path)) ? { path: t.path, value: keyDataToJsonValue(t.data) } : undefined); const objectSubKey_ = p.token((t) => (t.type === 'subKey' && isObjectPath(t.path)) ? { path: t.path, value: keyDataToJsonValue(t.data) } : undefined); const bareValue_ = p.decide(p.token((t) => { if (t.type !== 'bareValue') { return undefined; } if (isJsonObject(t.value)) { return p.map(p.many(objectSubKey_), (ss) => ({ value: t.value, subs: ss })); } if (isJsonArray(t.value)) { return p.map(p.many(arraySubKey_), (ss) => ({ value: t.value, subs: ss })); } return p.emit({ value: t.value }); })); function nestValueResult(vr, i) { const value0 = { path: { key0: i, keys: [] }, value: vr.value }; if (isJsonArray(vr.value)) { return [ value0, ...vr.subs.map(s => ({ path: { key0: i, keys: [s.path.key0, ...s.path.keys] }, value: s.value })) ]; } if (isJsonObject(vr.value)) { return [ value0, ...vr.subs.map(s => ({ path: { key0: i, keys: [s.path.key0, ...s.path.keys] }, value: s.value })) ]; } return [value0]; } const fullKey_ = p.decide(p.token((t, data, i) => { if (t.type !== 'fullKey') { return undefined; } if (t.data.type === 'arrayKey' && data.tokens[i + 1] && data.tokens[i + 1].type === 'bareValue') { return p.map(p.many(bareValue_), (vs) => ({ type: 'arrayKeyValue', path: t.path, value: [], subs: vs.flatMap(nestValueResult) })); } const value = keyDataToJsonValue(t.data); if (isJsonObject(value)) { return p.map(p.many(objectSubKey_), (ss) => ({ type: 'objectKeyValue', path: t.path, value: value, subs: ss })); } if (isJsonArray(value)) { return p.map(p.many(arraySubKey_), (ss) => ({ type: 'arrayKeyValue', path: t.path, value: value, subs: ss })); } return p.emit({ type: 'primitiveKeyValue', path: t.path, value: value }); })); const parseAllKeys = p.many(fullKey_); function stringifyKey(key, unkey) { if (unkey) { key = unkey(key); } return key .replace(/\./g, '\\.') .replace(/\\=/g, '\\='); } function stringifyPath(path, unkey) { return path.map((fr) => (typeof fr === 'number') ? `[${fr}]` : `.${stringifyKey(fr, unkey)}`).join('').replace(/^\./, ''); } function stringifyArg(unkey) { return arg => { const path = stringifyPath(arg.path, unkey); if (arg.value === 'true') { return `--${path}`; } if (arg.value === 'false') { return `--!${path}`; } return `--${path}=${arg.value}`; }; } function newArg(value) { return { type: 'one', path: [], value: value }; } function nest(u, key) { return { type: 'one', path: [key, ...u.path], value: u.value }; } function unparseArray(json) { const unparsed = json.map(unparse); const values = []; const args = []; function flushValues() { if (!values.length) { return; } args.push(newArg(`[${values.join(',')}]`)); values.length = 0; } for (let i = 0; i < unparsed.length; i++) { const u = unparsed[i]; if (u.type === 'one' && u.path.length === 0) { values.push(u.value); } else { flushValues(); if (u.type === 'one') { args.push(nest(u, i)); } else { args.push(...u.args.map((a) => nest(a, i))); } } } flushValues(); return (args.length === 0) ? newArg('[]') : (args.length === 1) ? args[0] : { type: 'many', args: args }; } function unparseObject(json) { const args = []; for (const key of Object.keys(json)) { const u = unparse(json[key]); if (u.type === 'one') { args.push(nest(u, key)); } else { args.push(...u.args.map((a) => nest(a, key))); } } return (args.length === 0) ? newArg('{}') : (args.length === 1) ? args[0] : { type: 'many', args: args }; } function unparseString(str) { return newArg((/[\s={}[,\]"'`]|^[-.\d]|^(?:true|false|null)$/i.test(str)) ? JSON.stringify(str) : str); } function unparse(json) { switch (typeof json) { case 'boolean': case 'number': return newArg(JSON.stringify(json)); case 'string': return unparseString(json); case 'object': if (json === null) { return newArg(JSON.stringify(json)); } else if (Array.isArray(json)) { return unparseArray(json); } else { return unparseObject(json); } } } /** * Convert a JSON object into an equivalent array of arguments. * * @param json - JSON object to break down into arguments. * @param unkey - A function to transform keys * (for example change camel case of JSON keys to kebab case of CLI arguments). * @returns An array of argument strings (unescaped, may require escaping specific to a shell). */ function unparseArgs(json, unkey) { let unparsed = unparseObject(json); if (unparsed.type === 'one') { if (unparsed.path.length === 0) { // empty root object return []; } unparsed = { type: 'many', args: [unparsed] }; } return unparsed.args.map(stringifyArg(unkey)); } /** * Parse arguments into JSON object, * run required actions as defined in options object. * * This function uses `process.argv` by itself. * * @param options - {@link Options} object. */ function handleArgv(options = {}) { return handleArgs(process.argv.slice(2), options); } /** * Parse arguments into JSON object, * run required actions as defined in options object. * * This function expects a stripped arguments array. * * Use {@link handleArgv} instead in case you don't do anything with it. * * @param args - arguments array (for example `process.argv.slice(2)`). * @param options - {@link Options} object. */ function handleArgs(args, options = {}) { const commandResult = parseAllCommands({ tokens: args, options: options }, 0); if (commandResult.value.command === 'version') { const version = options.handlers?.version?.(); if (version) { console.log(version); } return; } if (commandResult.value.command === 'help') { const handler = options.handlers?.help; if (handler) { const baseHelpText = getHelp(options); const help = (typeof handler === 'function') ? handler(baseHelpText) : baseHelpText; if (help) { console.log(help); } } return; } let json = parseJsonFromKeys(args.slice(commandResult.position), options); if (commandResult.value.type === 'commandWithData' && options.handlers?.merge) { // In fact, options.handlers.merge is definitely set here, // otherwise it would've errored in parseAllCommands call above. json = options.handlers.merge(json, commandResult.value.json); } if (commandResult.value.command === 'inspect' && options.handlers?.inspect) { const logString = (typeof options.handlers.inspect === 'function') ? options.handlers.inspect(json) : inspect(json, options.handlers.inspect); if (logString) { console.log(logString); } return; } if (commandResult.value.command === 'unparse' && options.handlers?.unparse) { const argStrings = unparseArgs(json, options?.handlers?.unkey); const handler = options.handlers.unparse; const logString = (typeof handler === 'function') ? handler(argStrings) : argStrings.join(' '); if (logString) { console.log(logString); } return; } if (options.handlers?.json) { const logString = options.handlers.json(json); if (logString) { console.log(logString); } } else { throw new Error(`What to do with parsed args JSON object? 'json' handler is not specified.`); } } function parseJsonFromKeys(args, options) { const tokens = args.map(argToToken); const tokensData = { tokens: tokens, options: options }; const allKeysResult = parseAllKeys(tokensData, 0); if (p.remainingTokensNumber(tokensData, allKeysResult.position) > 0) { const remainingArgs = args .slice(allKeysResult.position) .join(' '); throw new Error(`Some args can not be parsed: ${remainingArgs}`); } const json = allKeysResult.value.reduce((acc, kv) => { if (kv.type === 'objectKeyValue') { for (const subKey of kv.subs) { setJsonObjectItem(kv.value, subKey.value, options.handlers?.key, subKey.path.key0, ...subKey.path.keys); } } else if (kv.type === 'arrayKeyValue') { for (const subKey of kv.subs) { setJsonArrayItem(kv.value, subKey.value, options.handlers?.key, subKey.path.key0, ...subKey.path.keys); } } setJsonObjectItem(acc, kv.value, options.handlers?.key, kv.path.key0, ...kv.path.keys); return acc; }, {}); return json; } export { handleArgs, handleArgv, unparseArgs };