Skip to content

Commit

Permalink
feat: Add options merge algorithm for use by asconfig (#1343)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcodeIO authored Jun 17, 2020
1 parent f81250a commit 3163abb
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 19 deletions.
6 changes: 4 additions & 2 deletions cli/asc.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@
" tail-calls Tail call operations.",
" multi-value Multi value types."
],
"type": "S"
"type": "S",
"mutuallyExclusive": "disable"
},
"disable": {
"category": "Features",
Expand All @@ -198,7 +199,8 @@
" mutable-globals Mutable global imports and exports.",
""
],
"type": "S"
"type": "S",
"mutuallyExclusive": "enable"
},
"use": {
"category": "Features",
Expand Down
21 changes: 15 additions & 6 deletions cli/util/options.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
* @license Apache-2.0
*/

/** A set of options. */
export interface OptionSet {
[key: string]: number | string
}

/** Command line option description. */
export interface OptionDescription {
/** Textual description. */
description?: string | string[],
/** 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. */
Expand All @@ -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<string>
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 {
Expand All @@ -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;
114 changes: 103 additions & 11 deletions cli/util/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
49 changes: 49 additions & 0 deletions tests/cli/options.js
Original file line number Diff line number Diff line change
@@ -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"]);

0 comments on commit 3163abb

Please sign in to comment.