aspargvs
Version:
Parse argv as json object
626 lines (615 loc) • 24.2 kB
JavaScript
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 };