UNPKG

commandos

Version:

Command line parser, compatible with DOS style command

812 lines (712 loc) 26.9 kB
'use strict'; const { verify } = require('crypto'); const MODULE_REQUIRE = 1 /* built-in */ , util = require('util') /* NPM */ /* in-package */ , absorb = require('jinang/absorb') , papply = require('jinang/papply') , safeClone = require('jinang/safeClone') , split = require('jinang/split') /* in-file */ , ifUndefined = function () { let ret; for (let i = 0; i < arguments.length && util.isUndefined(ret); i++) { ret = arguments[i]; } return ret; } // 验证并解析字符串,从中提取选项名。 , splitIntoNames = (/*string*/ name, /*boolean*/ prefixedWithDash) => { let names = []; name.trim().split(/[\s,]+/).forEach(part => { if (part.startsWith('--')) { names.push(part.substr(2)); } else if (part.startsWith('-')) { absorb(names, part.substr(1).split('')); } else if (!prefixedWithDash) { names.push(part); } }); return names; }; /** * Parse definition string. * 解析参数定义字符串。 * @param {string} desc * @return {Object} See @tag COLUMN */ function parseColumn(desc) { // @tag COLUMN let column = { name : null, alias : [], assignable : undefined, caseSensitive : undefined, multiple : undefined, nullable : undefined, overwrite : undefined, nonOption : undefined, enum : undefined, }; if (typeof desc == 'string') { /** * Please google "non-option argument". * 请自行百度 "non-option argument" 获取相关资料。 */ desc = desc.replace(/(^|\s)\[(.+)\](\s|$)/, (content) => { let nonOption = RegExp.$2.trim(); if (!/^([^:]+)(:(.*))?$/.test(nonOption)) { throw new Error(`invalid nonOption definition: ${nonOption}`); } let positions = RegExp.$1.trim().split(/[,\s]+/); let valueDef = RegExp.$3.trim(); /** * Create if-match-position function. * 生成位置匹配函数。 */ positions = positions.map(position => { let fn; if (position == '*') { return fn = () => true; } if (/^\d+$/.test(position)) { position = parseInt(position); return fn = (index) => index == position; } if (/^(>|>=|<|<=|=)(\d+)$/.test(position)) { position = parseInt(RegExp.$2); switch (RegExp.$1) { case '>' : return fn = (index) => index > position; case '>=' : return fn = (index) => index >= position; case '<' : return fn = (index) => index < position; case '<=' : return fn = (index) => index <= position; case '=' : return fn = (index) => index == position; } } throw new Error(`invalid nonOption definition: ${nonOption}`); }); // 最终的位置匹配函数。 let indexValidator = (index) => { let valid = true; for (let i = 0; valid && i < positions.length; i++) { valid = valid && positions[i](index); } return valid; }; /** * Create if-match-value function. * 生成值匹配函数。 * * 描述值匹配的方法有以下几种: * =* foobar * # Equal to "foobar" (case insensitive). * * = foobar * # Equal to "foobar" (case sensitive). * * ~* <REGULAR_EXPRESSION> * # Match regular expression (case insensitive). * * ~ <REGULAR_EXPRESSION> * # Match regular expression (case sensitive). */ let valueValidator = null; if (valueDef == '') { valueValidator = () => true; } else if (/^=\*(.+)$/.test(valueDef)) { let v = RegExp.$1.trim().toLowerCase(); valueValidator = (value) => v == value.toLowerCase(); } else if (/^=(.+)$/.test(valueDef)) { let v = RegExp.$1.trim(); valueValidator = (value) => v == value; } else if (/^~\*(.+)$/.test(valueDef)) { let re = new RegExp(RegExp.$1.trim(), 'i'); valueValidator = (value) => re.test(value); } else if (/^~(.+)$/.test(valueDef)) { let re = new RegExp(RegExp.$1.trim()); valueValidator = (value) => re.test(value); } else { throw new Error(`invalid nonOption definition: ${nonOption}`); } // 生成完整的非选项参数匹配函数。 column.nonOption = (value, index) => indexValidator(index) && valueValidator(value); // 位置替补定义语句已完成其使命。 // 注意须用一个空格替换,以免将可能的前后片断粘连在一起。 return ' '; }); /** * Replace "( content )" with #0, #1, ... * And save content(s) in an array in turns. * 用占位符号 #0、#1、…… 取代括号中的内容(连同括号本身)。 * 括号中的内容将依次保存在数组 `inParenthesses` 中。 */ let inParentheses = []; desc = desc.replace(/\s*\([^)]+\)/g, (content) => { let index = inParentheses.length; content = content.trim(); inParentheses.push(content.substring(1, content.length - 1)); return `#${index}`; }); // --------------------------- // Start to parse `desc` word for word. // 开始逐字解析。 let parts = desc.split(/\s+/g); let decos = []; parts.forEach((part, index) => { // 首词无论是否前缀 - 或 --,均视为选项名。 let dashed = (index > 0); let names = splitIntoNames(part, dashed); if (index == 0) { column.name = names.shift(); column.alias = names; } else if (names.length) { column.alias = column.alias.concat(names); } else { decos.push(part.toLowerCase()); } }); let notdeco = false; decos.forEach((deco) => { // NOT is keyword to decorate the following decorator. if (deco == 'not') { notdeco = true; return; } let alias = splitIntoNames(deco, true); if (alias.length) { column.alias.push.apply(column.alias, alias); return; } let argsText = null; if (/^(.+)#(\d)+$/.test(deco)) { deco = RegExp.$1; argsText = inParentheses[parseInt(RegExp.$2)]; } switch (deco) { case 'required': case 'nullable': case 'assignable': case 'multiple': case 'overwrite': case 'objected': case 'caseSensitive': // actually, "caseSensitive" will not really occur because deco has been lowercased before. column[deco] = !notdeco; break; case 'null': column.nullable = !notdeco; break; case 'case-sensitive': case 'case_sensitive': case 'casesensitive': column.caseSensitive = !notdeco; break; case 'case-insensitive': case 'case_insensitive': case 'caseinsensitive': column.caseSensitive = notdeco; break; case 'alias': absorb(column.alias, argsText.split(',').map(name => name.trim())); break; case 'default': column.default = JSON.parse(argsText); break; case 'enum': column.enum = argsText.split(/\s*,\s*/); break; case 'comment': column.comment = argsText; break; default: break; } // Reset the NOT decorator. notdeco = false; }); } else { column = Object.assign(column, desc); if (typeof column.alias == 'string') { column.alias = splitIntoNames(column.alias); } else if (!column.alias) { column.alias = []; } let names = splitIntoNames(column.name); column.name = names.shift(); column.alias = names.concat(column.alias); } column.multiple = ifUndefined(column.multiple, false); if (column.multiple && (column.nullable === true || column.assignable === false || column.overwrite === true)) { throw new Error(`option MULTIPLE should also be ASSIGNABLE, NOT NULLABLE and NOT overwrite: ${column.name}`); } if (typeof column.nonOption != 'undefined') { if (typeof column.nonOption == 'number') { let pos = column.nonOption; column.nonOption = (index, value) => index === pos; } else if (column.nonOption == 'string') { let text = column.nonOption; column.nonOption = (index, value) => value == text; } else if (column.nonOption instanceof RegExp) { let re = column.nonOption; column.nonOption = (index, value) => re.text(value); } else if (typeof column.nonOption != 'function') { throw new Error(`invalid option's nonOption property: $column.nonOption`); } } column.assignable = ifUndefined(column.assignable, true); column.nullable = column.enum ? false : ifUndefined(column.nullable, true); if (column.name.endsWith('-*')) { column.name = column.name.slice(0, -2); column.objected = true; } return column; } /** * Parse raw arguments, which generated by `parseRaw()`, according to arguments definition (one group only). * 根据一组参数定义对原始参数进行校验。 * @param {Object} raw * @param {Object} def */ function parseOptions(raw, def) { // raw := { options, $, -- } /** * Distill argument object from `raw.options`. * 提取原始参数。 */ let consumeOption = (index, novalue) => { let option = raw.options.splice(index, 1)[0]; let value = option.value; /** * If `value` is a number, it is actually a placeholder. * 原始选项值通常是字符串,只有当代表占位符时,才是一个数字。 * E.g. #0 or #1 */ if (typeof value == 'number') { if (novalue) { value = true; } else { let index = value; value = raw.$[index]; raw.$[index] = null; } } return value; }; let parsedOptions = {}; let names_notation_cache = {}; for (let I = 0; I < def.options.length; I++) { const column = def.options[I]; let caseSensitive = ifUndefined(column.caseSensitive, def.caseSensitive); let overwrite = ifUndefined(column.overwrite, def.overwrite); /** * `names` includes formal name and alias. * `names` 包括参数的正式名称和别名。 */ const names = [column.name].concat(column.alias); const names_lc = caseSensitive ? null : names.map(name => name.toLowerCase()); const names_matching = (names, name) => { let matched = false; for (let i = 0; i < names.length && !matched; i++) { if (column.objected) { let prefix = names[i]; if (name.startsWith(prefix) && name[prefix.length] == '-') { matched = name.slice(prefix.length + 1); } } else { matched = (name == names[i]); } } return matched; }; // names_notation 变量仅用于在遭遇异常时,生成错误信息。 const names_notation = names.map(name => (name.length > 1 ? '--' : '-') + name + (column.objected ? '-*' : '')).join(', '); names_notation_cache[column.name] = names_notation; let found = false; let value = null; if (column.multiple) value = []; if (column.objected) value = {}; for (let i = 0; i < raw.options.length; i++) { let option = raw.options[i]; let matched = caseSensitive ? names_matching(names, option.name) : names_matching(names_lc, option.name.toLowerCase()); if (matched) { if (column.objected) { let v = consumeOption(i, !column.assignable); if (!column.nullable && typeof v == 'boolean') { throw new Error(`option need to be valued: ${names_notation}`); } value[matched] = v; } else if (column.multiple) { let v = consumeOption(i); if (typeof v == 'boolean') { throw new Error(`option need to be valued: ${names_notation}`); } value.push(v); } else { if (found && !overwrite) { throw new Error(`option not allowed to be duplicated: ${names_notation}`); } value = consumeOption(i, !column.assignable); } i--; found = true; } } if (found) { parsedOptions[column.name] = value; } } if (raw.options.length) { if (def.explicit) { let names_notation = raw.options.map(option => (option.name.length > 1 ? '--' : '-') + option.name); throw new Error(`unknown options: ${names_notation}`); } else { while (raw.options.length) { let option = raw.options[0]; parsedOptions[option.name] = consumeOption(0); } } } // 余项指没有归属于任何显式选项的命令行参数。 // 在依据选项定义的 nonOption 属性消费余项之前,需要先删除已被其他选项显式占用的余项。 raw.$ = raw.$.filter(v => v !== null); for (let I = 0; I < def.options.length; I++) { const column = def.options[I]; const names_notation = names_notation_cache[column.name]; let found = parsedOptions.hasOwnProperty(column.name); let value = parsedOptions[column.name]; // 消费余项。 if (!found && column.nonOption) { value = column.multiple ? [] : null; let matchedIndexes = []; for (let i = 0, $i; i < raw.$.length; i++) { $i = raw.$[i]; if ($i === null) continue; if (column.nonOption($i, i)) { // 将匹配项中余项数组中剥离。 raw.$[i] = null; found = true; // 如果选项支持重复项,则继续尝试匹配,否则终止。 if (column.multiple) { value.push($i); } else { value = column.assignable ? $i : true; break; } } } if (found) { parsedOptions[column.name] = value; } } if (found) { let reason = verifyArgument(column, value); if (reason) { throw new Error(`option invalid: ${names_notation}, ${reason}`); } } if (!found && column.required) { throw new Error(`option required: ${names_notation}`); } if (!found && column.hasOwnProperty('default')) { parsedOptions[column.name] = column.default; } if (found && column.parser) { parsedOptions[column.name] = column.parser(parsedOptions[column.name]); } } // 注意:因为选项可能依据 nonOption 属性又消费了一轮余项,因此这里有必要再筛选一次。 parsedOptions.$ = raw.$.filter(v => v !== null); /** * Keep arguments after double-dash without any changes. * 保留非参余项。 */ parsedOptions['--'] = raw['--']; return parsedOptions; } function verifyArgument(column, value) { let reason = null; // Multiple argument. if (Array.isArray(value)) { for (let i = 0; i < value.length && !reason; i++) { reason = verifyArgument(column, value[i]); } return reason; } // Objected argument. if (util.isObject(value)) { for (let name in value) { reason = verifyArgument(column, value[name]); if (reason) break; } return reason; } // Normal argument. if (!column.nullable && typeof value == 'boolean') { return `should be valued`; } if (column.enum && !column.enum.includes(value)) { return `should equal to ${column.enum.join('|')}`; } return reason; } /** * Parse command arguments one by one. * This method will not check if the argument(s) is valid or acceptable. * 逐一处理命令行参数。 * 这个环节不会检查参数的合法性。 * * @param {Array} args * @param {Object} def - definition about arguments * @return { options: [ { name, value }, ... ], $: [ ... ], __: null | [ ... ] } */ /* private */ function parseRaw(args, def) { let raw = { /** * 原始的参数对象数组,每个参数对象包括 name 和 value 属性。 * * 如果命令行局部形如 --gender=male,那么就会生成一个对象: * { name: 'gender', value: 'male' } * * 如果命令行局部形如 --gender male,那么就会生成一个对象: * { name: 'gender', value: 3 } * 这个数字代表一个索引,真正的参数值应到 $ 数组中检索。 */ options: [], /** * 以 - 或 -- 开头的参数片断以外的所有片断。 */ $: [], /** * 双横线(double dash)之后的原始参数字符串数组。 * 保留原始状态,不作解析。 * 如果其值是 null,代表命令行中不存在独立的双横线。 * 如果其值是 [](空数组),代表命令行以双横线结尾。 */ '--': null, }; let parseArg = arg => { if (raw['--']) { raw['--'].push(arg); return; } if (arg == '--') { raw['--'] = []; return; } /** * An command line argument may be in form of: * 命令行参数可以是以下形式之一: * -n * # argument in short style * # 短格式 * * --name * # argument in long style * # 长格式 * * --no-verbose * # means verbose == false * # 取反。这种形式同时暗示 verbose 是一个无值的开关参数。 * * --name=Ching * --name Ching * # manes name == "Ching" */ if (/^(-{1,2})(no-)?([^=]*)(=(.+))?$/i.test(arg)) { let dash = RegExp.$1; let no = RegExp.$2; let name = RegExp.$3; let value = RegExp.$4 ? RegExp.$5 : true; if (name.length == 0) { throw new Error(`incomprehensible option: ${arg}`); } else if (no) { if (dash.length == 1 || value !== true) { throw new Error(`incomprehensible option: ${arg}`); } value = false; raw.options.push({ name, value }); } else if (dash.length == 1) { let names = name.split(''); let lastname = names.pop(); names.forEach(shortname => raw.options.push({ name: shortname, value: true })); raw.options.push({ name: lastname, value }); } else { raw.options.push({ name, value }); } } else { raw.$.push(arg); // 如果上一个选项非 --no-* 形式选项,则标记其对应的值的序号。 let last = raw.options[raw.options.length - 1]; if (last && last.value === true) { last.value = raw.$.length - 1; } } }; args.forEach(arg => { try { parseArg(arg); } catch (ex) { if (!def.ignoreInvalidArgument) throw ex; } }); return raw; } /** * Parse command line or command arguments. * 解析命令行或命令行参数。 * @param {Object|string|string[]} cmd * @param {Object} def * @param {boolean} onlyArgs - PRIVATE */ function parseCommand(cmd, def, onlyArgs) { // --------------------------- // Argument Validation // Because `onlyArgs` is a private argument which may only be passed in by // `parseCommand.onlyArgs()`. It always occupies the third position in the // argument list. // `onlyArgs` 是私有参数,因此无须考虑其位置的变化。 let argv = null, readyMadeOptions = null; if (typeof arguments[0] == 'string') { argv = split(arguments[0], /* splitter */ /\s+/, /* string delimiters */ ['"', "'"], /* escaper in delimiters */ '\\' ); def = arguments[1]; } // If arguments look like (Array), the only array will be regarded as // command line args. else if (arguments[0] instanceof Array) { argv = arguments[0].slice(0); def = arguments[1]; } // If the first argument is an object, it will be regarded as made-ready // command line args ONLY IF the second argument exists and is *def*. else if (typeof arguments[0] == 'object' && (typeof arguments[1] == 'object' || arguments[1] instanceof Array)) { let cmd = arguments[0]; def = arguments[1]; readyMadeOptions = { options: [], $: [], '--': null, }; for (let name in cmd) { if (name == '$' || name == '--') { readyMadeOptions[name] = Array.isArray(cmd[name]) ? cmd[name] : [ cmd[name] ]; } else { readyMadeOptions.options.push({ name, value: cmd[name] }); } } } // If only one argument passed in, ... else { argv = process.argv.slice(1); def = arguments[0]; onlyArgs = false; } if (def instanceof Array) { def = { options: def }; } def = Object.assign({ overwrite: true, caseSensitive: true, catcher: null, explicit: false, groups: null, options: [], }, def); let args = null; if (argv) args = onlyArgs ? argv : argv.slice(1); // @replaced 兼容 parseCommand.onlyArgs() 方法中的调用。 // let [name, ...args] = argv; // --------------------------- // Main Process let parsedOptions = null; try { let raw = readyMadeOptions || parseRaw(args, def); // @replaced // let raw = parseRaw(args, def); // If there are more than one groups, find the most matching one. // 如果参数定义中支持多个群组,则选择匹配程度最高的群组。 if (def.groups && def.groups.length) { let reasons = []; let maxMatching = -1; for (let i = 0; i < def.groups.length; i++) { let rawcopy = safeClone(raw); let parsed = null; let matching = 0; def.options = def.groups[i].map(parseColumn); try { parsed = parseOptions(rawcopy, def); // 取匹配度最高的选项组。 def.options.forEach(option => { if (parsed.hasOwnProperty(option.name)) matching++; }); if (matching > maxMatching) { maxMatching = matching; parsedOptions = parsed; } } catch (ex) { reasons.push(ex); } } if (!parsedOptions) { let error = new Error('None of the option groups matched'); error.reasons = reasons; throw error; } } else { def.options = def.options.map(parseColumn); parsedOptions = parseOptions(raw, def); } } catch(ex) { if (def.catcher) def.catcher(ex); else throw ex; } return parsedOptions; } /** * Same as parseCommand() but the command name itself is absent in `args`. * 此方法与 parseCommand() 方法的区别是输入的命令行参数被认为仅包含命令参数,即不包含命令名本身, * E.g. * parseCommand('foo --name ching --age 18', def); * parseCommand.onlyArgs('--name ching --age 18', def); */ parseCommand.onlyArgs = function(args, def) { return parseCommand(args, def, true); }; module.exports = parseCommand;