diff --git a/cli/asc.json b/cli/asc.json index c675b64b58..fbbe218b70 100644 --- a/cli/asc.json +++ b/cli/asc.json @@ -188,7 +188,8 @@ " tail-calls Tail call operations.", " multi-value Multi value types." ], - "type": "S" + "type": "S", + "mutuallyExclusive": "disable" }, "disable": { "category": "Features", @@ -198,7 +199,8 @@ " mutable-globals Mutable global imports and exports.", "" ], - "type": "S" + "type": "S", + "mutuallyExclusive": "enable" }, "use": { "category": "Features", diff --git a/cli/util/options.d.ts b/cli/util/options.d.ts index 630c496030..e12013fef3 100644 --- a/cli/util/options.d.ts +++ b/cli/util/options.d.ts @@ -3,6 +3,11 @@ * @license Apache-2.0 */ +/** A set of options. */ +export interface OptionSet { + [key: string]: number | string +} + /** Command line option description. */ export interface OptionDescription { /** Textual description. */ @@ -10,7 +15,7 @@ export interface OptionDescription { /** Data type. One of (b)oolean [default], (i)nteger, (f)loat or (s)tring. Uppercase means multiple values. */ type?: "b" | "i" | "f" | "s" | "I" | "F" | "S", /** Substituted options, if any. */ - value?: { [key: string]: number | string }, + value?: OptionSet, /** Short alias, if any. */ alias?: string /** The default value, if any. */ @@ -27,19 +32,17 @@ interface Config { /** Parsing result. */ interface Result { /** Parsed options. */ - options: { [key: string]: number | string }, + options: OptionSet, /** Unknown options. */ unknown: string[], /** Normal arguments. */ arguments: string[], /** Trailing arguments. */ - trailing: string[], - /** Provided arguments from the cli. */ - provided: Set + trailing: string[] } /** Parses the specified command line arguments according to the given configuration. */ -export function parse(argv: string[], config: Config): Result; +export function parse(argv: string[], config: Config, propagateDefaults?: boolean): Result; /** Help formatting options. */ interface HelpOptions { @@ -53,3 +56,9 @@ interface HelpOptions { /** Generates the help text for the specified configuration. */ export function help(config: Config, options?: HelpOptions): string; + +/** Merges two sets of options into one, preferring the current over the parent set. */ +export function merge(config: Config, currentOptions: OptionSet, parentOptions: OptionSet): OptionSet; + +/** Populates default values on a parsed options result. */ +export function addDefaults(config: Config, options: OptionSet): OptionSet; diff --git a/cli/util/options.js b/cli/util/options.js index ac20ba68e7..8d3b39ed61 100644 --- a/cli/util/options.js +++ b/cli/util/options.js @@ -16,12 +16,11 @@ const colorsUtil = require("./colors"); // S | string array /** Parses the specified command line arguments according to the given configuration. */ -function parse(argv, config) { +function parse(argv, config, propagateDefaults = true) { var options = {}; var unknown = []; var args = []; var trailing = []; - var provided = new Set(); // make an alias map and initialize defaults var aliases = {}; @@ -54,13 +53,15 @@ function parse(argv, config) { else { args.push(arg); continue; } // argument } if (option) { - if (option.type == null || option.type === "b") { - options[key] = true; // flag - provided.add(key); + if (option.value) { + // alias setting fixed values + Object.keys(option.value).forEach(k => options[k] = option.value[k]); + } else if (option.type == null || option.type === "b") { + // boolean flag not taking a value + options[key] = true; } else { - // the argument was provided - if (i + 1 < argv.length && argv[i + 1].charCodeAt(0) != 45) { // present - provided.add(key); + if (i + 1 < argv.length && argv[i + 1].charCodeAt(0) != 45) { + // non-boolean with given value switch (option.type) { case "i": options[key] = parseInt(argv[++i], 10); break; case "I": options[key] = (options[key] || []).concat(parseInt(argv[++i], 10)); break; @@ -70,7 +71,8 @@ function parse(argv, config) { case "S": options[key] = (options[key] || []).concat(argv[++i].split(",")); break; default: unknown.push(arg); --i; } - } else { // omitted + } else { + // non-boolean with omitted value switch (option.type) { case "i": case "f": options[key] = option.default || 0; break; @@ -82,12 +84,12 @@ function parse(argv, config) { } } } - if (option.value) Object.keys(option.value).forEach(k => options[k] = option.value[k]); } else unknown.push(arg); } while (i < k) trailing.push(argv[i++]); // trailing + if (propagateDefaults) addDefaults(config, options); - return { options, unknown, arguments: args, trailing, provided }; + return { options, unknown, arguments: args, trailing }; } exports.parse = parse; @@ -138,3 +140,93 @@ function help(config, options) { } exports.help = help; + +/** Sanitizes an option value to be a valid value of the option's type. */ +function sanitizeValue(value, type) { + if (value != null) { + switch (type) { + case undefined: + case "b": return Boolean(value); + case "i": return Math.trunc(value) || 0; + case "f": return Number(value) || 0; + case "s": return String(value); + case "I": { + if (!Array.isArray(value)) value = [ value ]; + return value.map(v => Math.trunc(v) || 0); + } + case "F": { + if (!Array.isArray(value)) value = [ value ]; + return value.map(v => Number(v) || 0); + } + case "S": { + if (!Array.isArray(value)) value = [ value ]; + return value.map(String); + } + } + } + return undefined; +} + +/** Merges two sets of options into one, preferring the current over the parent set. */ +function merge(config, currentOptions, parentOptions) { + const mergedOptions = {}; + for (const [key, { type, mutuallyExclusive }] of Object.entries(config)) { + let currentValue = sanitizeValue(currentOptions[key], type); + let parentValue = sanitizeValue(parentOptions[key], type); + if (currentValue == null) { + if (parentValue != null) { + // only parent value present + if (Array.isArray(parentValue)) { + let exclude; + if (mutuallyExclusive != null && (exclude = currentOptions[mutuallyExclusive])) { + mergedOptions[key] = parentValue.filter(value => !exclude.includes(value)); + } else { + mergedOptions[key] = parentValue.slice(); + } + } else { + mergedOptions[key] = parentValue; + } + } + } else if (parentValue == null) { + // only current value present + if (Array.isArray(currentValue)) { + mergedOptions[key] = currentValue.slice(); + } else { + mergedOptions[key] = currentValue; + } + } else { + // both current and parent values present + if (Array.isArray(currentValue)) { + let exclude; + if (mutuallyExclusive != null && (exclude = currentOptions[mutuallyExclusive])) { + mergedOptions[key] = [ + ...currentValue, + ...parentValue.filter(value => !currentValue.includes(value) && !exclude.includes(value)) + ]; + } else { + mergedOptions[key] = [ + ...currentValue, + ...parentValue.filter(value => !currentValue.includes(value)) // dedup + ]; + } + } else { + mergedOptions[key] = currentValue; + } + } + } + return mergedOptions; +} + +exports.merge = merge; + +/** Populates default values on a parsed options result. */ +function addDefaults(config, options) { + for (const [key, { default: defaultValue }] of Object.entries(config)) { + if (options[key] == null && defaultValue != null) { + options[key] = defaultValue; + } + } + return options; +} + +exports.addDefaults = addDefaults; diff --git a/tests/cli/options.js b/tests/cli/options.js new file mode 100644 index 0000000000..e24575b876 --- /dev/null +++ b/tests/cli/options.js @@ -0,0 +1,49 @@ +const assert = require("assert"); +const optionsUtil = require("../../cli/util/options"); + +const config = { + "enable": { + "type": "S", + "mutuallyExclusive": "disable" + }, + "disable": { + "type": "S", + "mutuallyExclusive": "enable" + }, + "other": { + "type": "S", + "default": ["x"] + } +}; + +// Present in both should concat +var merged = optionsUtil.merge(config, { enable: ["a"] }, { enable: ["b"] }); +assert.deepEqual(merged.enable, ["a", "b"]); + +merged = optionsUtil.merge(config, { enable: ["a"] }, { enable: ["a", "b"] }); +assert.deepEqual(merged.enable, ["a", "b"]); + +// Mutually exclusive should exclude +merged = optionsUtil.merge(config, { enable: ["a", "b"] }, { disable: ["a", "c"] }); +assert.deepEqual(merged.enable, ["a", "b"]); +assert.deepEqual(merged.disable, ["c"]); + +merged = optionsUtil.merge(config, { disable: ["a", "b"] }, { enable: ["a", "c"] }); +assert.deepEqual(merged.enable, ["c"]); +assert.deepEqual(merged.disable, ["a", "b"]); + +// Populating defaults should work after the fact +merged = optionsUtil.addDefaults(config, {}); +assert.deepEqual(merged.other, ["x"]); + +merged = optionsUtil.addDefaults(config, { other: ["y"] }); +assert.deepEqual(merged.other, ["y"]); + +// Complete usage test +var result = optionsUtil.parse(["--enable", "a", "--disable", "b"], config, false); +merged = optionsUtil.merge(config, result.options, { enable: ["b", "c"] }); +merged = optionsUtil.merge(config, merged, { disable: ["a", "d"] }); +optionsUtil.addDefaults(config, merged); +assert.deepEqual(merged.enable, ["a", "c"]); +assert.deepEqual(merged.disable, ["b", "d"]); +assert.deepEqual(merged.other, ["x"]);