You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
655 lines
16 KiB
655 lines
16 KiB
/* |
|
MIT License http://www.opensource.org/licenses/mit-license.php |
|
Author Tobias Koppers @sokra |
|
*/ |
|
|
|
"use strict"; |
|
|
|
const path = require("path"); |
|
const webpackSchema = require("../schemas/WebpackOptions.json"); |
|
|
|
// TODO add originPath to PathItem for better errors |
|
/** |
|
* @typedef {Object} PathItem |
|
* @property {any} schema the part of the schema |
|
* @property {string} path the path in the config |
|
*/ |
|
|
|
/** @typedef {"unknown-argument" | "unexpected-non-array-in-path" | "unexpected-non-object-in-path" | "multiple-values-unexpected" | "invalid-value"} ProblemType */ |
|
|
|
/** |
|
* @typedef {Object} Problem |
|
* @property {ProblemType} type |
|
* @property {string} path |
|
* @property {string} argument |
|
* @property {any=} value |
|
* @property {number=} index |
|
* @property {string=} expected |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} LocalProblem |
|
* @property {ProblemType} type |
|
* @property {string} path |
|
* @property {string=} expected |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} ArgumentConfig |
|
* @property {string} description |
|
* @property {string} [negatedDescription] |
|
* @property {string} path |
|
* @property {boolean} multiple |
|
* @property {"enum"|"string"|"path"|"number"|"boolean"|"RegExp"|"reset"} type |
|
* @property {any[]=} values |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} Argument |
|
* @property {string} description |
|
* @property {"string"|"number"|"boolean"} simpleType |
|
* @property {boolean} multiple |
|
* @property {ArgumentConfig[]} configs |
|
*/ |
|
|
|
/** |
|
* @param {any=} schema a json schema to create arguments for (by default webpack schema is used) |
|
* @returns {Record<string, Argument>} object of arguments |
|
*/ |
|
const getArguments = (schema = webpackSchema) => { |
|
/** @type {Record<string, Argument>} */ |
|
const flags = {}; |
|
|
|
const pathToArgumentName = input => { |
|
return input |
|
.replace(/\./g, "-") |
|
.replace(/\[\]/g, "") |
|
.replace( |
|
/(\p{Uppercase_Letter}+|\p{Lowercase_Letter}|\d)(\p{Uppercase_Letter}+)/gu, |
|
"$1-$2" |
|
) |
|
.replace(/-?[^\p{Uppercase_Letter}\p{Lowercase_Letter}\d]+/gu, "-") |
|
.toLowerCase(); |
|
}; |
|
|
|
const getSchemaPart = path => { |
|
const newPath = path.split("/"); |
|
|
|
let schemaPart = schema; |
|
|
|
for (let i = 1; i < newPath.length; i++) { |
|
const inner = schemaPart[newPath[i]]; |
|
|
|
if (!inner) { |
|
break; |
|
} |
|
|
|
schemaPart = inner; |
|
} |
|
|
|
return schemaPart; |
|
}; |
|
|
|
/** |
|
* |
|
* @param {PathItem[]} path path in the schema |
|
* @returns {string | undefined} description |
|
*/ |
|
const getDescription = path => { |
|
for (const { schema } of path) { |
|
if (schema.cli) { |
|
if (schema.cli.helper) continue; |
|
if (schema.cli.description) return schema.cli.description; |
|
} |
|
if (schema.description) return schema.description; |
|
} |
|
}; |
|
|
|
/** |
|
* |
|
* @param {PathItem[]} path path in the schema |
|
* @returns {string | undefined} negative description |
|
*/ |
|
const getNegatedDescription = path => { |
|
for (const { schema } of path) { |
|
if (schema.cli) { |
|
if (schema.cli.helper) continue; |
|
if (schema.cli.negatedDescription) return schema.cli.negatedDescription; |
|
} |
|
} |
|
}; |
|
|
|
/** |
|
* |
|
* @param {PathItem[]} path path in the schema |
|
* @returns {string | undefined} reset description |
|
*/ |
|
const getResetDescription = path => { |
|
for (const { schema } of path) { |
|
if (schema.cli) { |
|
if (schema.cli.helper) continue; |
|
if (schema.cli.resetDescription) return schema.cli.resetDescription; |
|
} |
|
} |
|
}; |
|
|
|
/** |
|
* |
|
* @param {any} schemaPart schema |
|
* @returns {Pick<ArgumentConfig, "type"|"values">} partial argument config |
|
*/ |
|
const schemaToArgumentConfig = schemaPart => { |
|
if (schemaPart.enum) { |
|
return { |
|
type: "enum", |
|
values: schemaPart.enum |
|
}; |
|
} |
|
switch (schemaPart.type) { |
|
case "number": |
|
return { |
|
type: "number" |
|
}; |
|
case "string": |
|
return { |
|
type: schemaPart.absolutePath ? "path" : "string" |
|
}; |
|
case "boolean": |
|
return { |
|
type: "boolean" |
|
}; |
|
} |
|
if (schemaPart.instanceof === "RegExp") { |
|
return { |
|
type: "RegExp" |
|
}; |
|
} |
|
return undefined; |
|
}; |
|
|
|
/** |
|
* @param {PathItem[]} path path in the schema |
|
* @returns {void} |
|
*/ |
|
const addResetFlag = path => { |
|
const schemaPath = path[0].path; |
|
const name = pathToArgumentName(`${schemaPath}.reset`); |
|
const description = |
|
getResetDescription(path) || |
|
`Clear all items provided in '${schemaPath}' configuration. ${getDescription( |
|
path |
|
)}`; |
|
flags[name] = { |
|
configs: [ |
|
{ |
|
type: "reset", |
|
multiple: false, |
|
description, |
|
path: schemaPath |
|
} |
|
], |
|
description: undefined, |
|
simpleType: undefined, |
|
multiple: undefined |
|
}; |
|
}; |
|
|
|
/** |
|
* @param {PathItem[]} path full path in schema |
|
* @param {boolean} multiple inside of an array |
|
* @returns {number} number of arguments added |
|
*/ |
|
const addFlag = (path, multiple) => { |
|
const argConfigBase = schemaToArgumentConfig(path[0].schema); |
|
if (!argConfigBase) return 0; |
|
|
|
const negatedDescription = getNegatedDescription(path); |
|
const name = pathToArgumentName(path[0].path); |
|
/** @type {ArgumentConfig} */ |
|
const argConfig = { |
|
...argConfigBase, |
|
multiple, |
|
description: getDescription(path), |
|
path: path[0].path |
|
}; |
|
|
|
if (negatedDescription) { |
|
argConfig.negatedDescription = negatedDescription; |
|
} |
|
|
|
if (!flags[name]) { |
|
flags[name] = { |
|
configs: [], |
|
description: undefined, |
|
simpleType: undefined, |
|
multiple: undefined |
|
}; |
|
} |
|
|
|
if ( |
|
flags[name].configs.some( |
|
item => JSON.stringify(item) === JSON.stringify(argConfig) |
|
) |
|
) { |
|
return 0; |
|
} |
|
|
|
if ( |
|
flags[name].configs.some( |
|
item => item.type === argConfig.type && item.multiple !== multiple |
|
) |
|
) { |
|
if (multiple) { |
|
throw new Error( |
|
`Conflicting schema for ${path[0].path} with ${argConfig.type} type (array type must be before single item type)` |
|
); |
|
} |
|
return 0; |
|
} |
|
|
|
flags[name].configs.push(argConfig); |
|
|
|
return 1; |
|
}; |
|
|
|
// TODO support `not` and `if/then/else` |
|
// TODO support `const`, but we don't use it on our schema |
|
/** |
|
* |
|
* @param {object} schemaPart the current schema |
|
* @param {string} schemaPath the current path in the schema |
|
* @param {{schema: object, path: string}[]} path all previous visited schemaParts |
|
* @param {string | null} inArray if inside of an array, the path to the array |
|
* @returns {number} added arguments |
|
*/ |
|
const traverse = (schemaPart, schemaPath = "", path = [], inArray = null) => { |
|
while (schemaPart.$ref) { |
|
schemaPart = getSchemaPart(schemaPart.$ref); |
|
} |
|
|
|
const repetitions = path.filter(({ schema }) => schema === schemaPart); |
|
if ( |
|
repetitions.length >= 2 || |
|
repetitions.some(({ path }) => path === schemaPath) |
|
) { |
|
return 0; |
|
} |
|
|
|
if (schemaPart.cli && schemaPart.cli.exclude) return 0; |
|
|
|
const fullPath = [{ schema: schemaPart, path: schemaPath }, ...path]; |
|
|
|
let addedArguments = 0; |
|
|
|
addedArguments += addFlag(fullPath, !!inArray); |
|
|
|
if (schemaPart.type === "object") { |
|
if (schemaPart.properties) { |
|
for (const property of Object.keys(schemaPart.properties)) { |
|
addedArguments += traverse( |
|
schemaPart.properties[property], |
|
schemaPath ? `${schemaPath}.${property}` : property, |
|
fullPath, |
|
inArray |
|
); |
|
} |
|
} |
|
|
|
return addedArguments; |
|
} |
|
|
|
if (schemaPart.type === "array") { |
|
if (inArray) { |
|
return 0; |
|
} |
|
if (Array.isArray(schemaPart.items)) { |
|
let i = 0; |
|
for (const item of schemaPart.items) { |
|
addedArguments += traverse( |
|
item, |
|
`${schemaPath}.${i}`, |
|
fullPath, |
|
schemaPath |
|
); |
|
} |
|
|
|
return addedArguments; |
|
} |
|
|
|
addedArguments += traverse( |
|
schemaPart.items, |
|
`${schemaPath}[]`, |
|
fullPath, |
|
schemaPath |
|
); |
|
|
|
if (addedArguments > 0) { |
|
addResetFlag(fullPath); |
|
addedArguments++; |
|
} |
|
|
|
return addedArguments; |
|
} |
|
|
|
const maybeOf = schemaPart.oneOf || schemaPart.anyOf || schemaPart.allOf; |
|
|
|
if (maybeOf) { |
|
const items = maybeOf; |
|
|
|
for (let i = 0; i < items.length; i++) { |
|
addedArguments += traverse(items[i], schemaPath, fullPath, inArray); |
|
} |
|
|
|
return addedArguments; |
|
} |
|
|
|
return addedArguments; |
|
}; |
|
|
|
traverse(schema); |
|
|
|
// Summarize flags |
|
for (const name of Object.keys(flags)) { |
|
const argument = flags[name]; |
|
argument.description = argument.configs.reduce((desc, { description }) => { |
|
if (!desc) return description; |
|
if (!description) return desc; |
|
if (desc.includes(description)) return desc; |
|
return `${desc} ${description}`; |
|
}, /** @type {string | undefined} */ (undefined)); |
|
argument.simpleType = argument.configs.reduce((t, argConfig) => { |
|
/** @type {"string" | "number" | "boolean"} */ |
|
let type = "string"; |
|
switch (argConfig.type) { |
|
case "number": |
|
type = "number"; |
|
break; |
|
case "reset": |
|
case "boolean": |
|
type = "boolean"; |
|
break; |
|
case "enum": |
|
if (argConfig.values.every(v => typeof v === "boolean")) |
|
type = "boolean"; |
|
if (argConfig.values.every(v => typeof v === "number")) |
|
type = "number"; |
|
break; |
|
} |
|
if (t === undefined) return type; |
|
return t === type ? t : "string"; |
|
}, /** @type {"string" | "number" | "boolean" | undefined} */ (undefined)); |
|
argument.multiple = argument.configs.some(c => c.multiple); |
|
} |
|
|
|
return flags; |
|
}; |
|
|
|
const cliAddedItems = new WeakMap(); |
|
|
|
/** |
|
* @param {any} config configuration |
|
* @param {string} schemaPath path in the config |
|
* @param {number | undefined} index index of value when multiple values are provided, otherwise undefined |
|
* @returns {{ problem?: LocalProblem, object?: any, property?: string | number, value?: any }} problem or object with property and value |
|
*/ |
|
const getObjectAndProperty = (config, schemaPath, index = 0) => { |
|
if (!schemaPath) return { value: config }; |
|
const parts = schemaPath.split("."); |
|
let property = parts.pop(); |
|
let current = config; |
|
let i = 0; |
|
for (const part of parts) { |
|
const isArray = part.endsWith("[]"); |
|
const name = isArray ? part.slice(0, -2) : part; |
|
let value = current[name]; |
|
if (isArray) { |
|
if (value === undefined) { |
|
value = {}; |
|
current[name] = [...Array.from({ length: index }), value]; |
|
cliAddedItems.set(current[name], index + 1); |
|
} else if (!Array.isArray(value)) { |
|
return { |
|
problem: { |
|
type: "unexpected-non-array-in-path", |
|
path: parts.slice(0, i).join(".") |
|
} |
|
}; |
|
} else { |
|
let addedItems = cliAddedItems.get(value) || 0; |
|
while (addedItems <= index) { |
|
value.push(undefined); |
|
addedItems++; |
|
} |
|
cliAddedItems.set(value, addedItems); |
|
const x = value.length - addedItems + index; |
|
if (value[x] === undefined) { |
|
value[x] = {}; |
|
} else if (value[x] === null || typeof value[x] !== "object") { |
|
return { |
|
problem: { |
|
type: "unexpected-non-object-in-path", |
|
path: parts.slice(0, i).join(".") |
|
} |
|
}; |
|
} |
|
value = value[x]; |
|
} |
|
} else { |
|
if (value === undefined) { |
|
value = current[name] = {}; |
|
} else if (value === null || typeof value !== "object") { |
|
return { |
|
problem: { |
|
type: "unexpected-non-object-in-path", |
|
path: parts.slice(0, i).join(".") |
|
} |
|
}; |
|
} |
|
} |
|
current = value; |
|
i++; |
|
} |
|
let value = current[property]; |
|
if (property.endsWith("[]")) { |
|
const name = property.slice(0, -2); |
|
const value = current[name]; |
|
if (value === undefined) { |
|
current[name] = [...Array.from({ length: index }), undefined]; |
|
cliAddedItems.set(current[name], index + 1); |
|
return { object: current[name], property: index, value: undefined }; |
|
} else if (!Array.isArray(value)) { |
|
current[name] = [value, ...Array.from({ length: index }), undefined]; |
|
cliAddedItems.set(current[name], index + 1); |
|
return { object: current[name], property: index + 1, value: undefined }; |
|
} else { |
|
let addedItems = cliAddedItems.get(value) || 0; |
|
while (addedItems <= index) { |
|
value.push(undefined); |
|
addedItems++; |
|
} |
|
cliAddedItems.set(value, addedItems); |
|
const x = value.length - addedItems + index; |
|
if (value[x] === undefined) { |
|
value[x] = {}; |
|
} else if (value[x] === null || typeof value[x] !== "object") { |
|
return { |
|
problem: { |
|
type: "unexpected-non-object-in-path", |
|
path: schemaPath |
|
} |
|
}; |
|
} |
|
return { |
|
object: value, |
|
property: x, |
|
value: value[x] |
|
}; |
|
} |
|
} |
|
return { object: current, property, value }; |
|
}; |
|
|
|
/** |
|
* @param {any} config configuration |
|
* @param {string} schemaPath path in the config |
|
* @param {any} value parsed value |
|
* @param {number | undefined} index index of value when multiple values are provided, otherwise undefined |
|
* @returns {LocalProblem | null} problem or null for success |
|
*/ |
|
const setValue = (config, schemaPath, value, index) => { |
|
const { problem, object, property } = getObjectAndProperty( |
|
config, |
|
schemaPath, |
|
index |
|
); |
|
if (problem) return problem; |
|
object[property] = value; |
|
return null; |
|
}; |
|
|
|
/** |
|
* @param {ArgumentConfig} argConfig processing instructions |
|
* @param {any} config configuration |
|
* @param {any} value the value |
|
* @param {number | undefined} index the index if multiple values provided |
|
* @returns {LocalProblem | null} a problem if any |
|
*/ |
|
const processArgumentConfig = (argConfig, config, value, index) => { |
|
if (index !== undefined && !argConfig.multiple) { |
|
return { |
|
type: "multiple-values-unexpected", |
|
path: argConfig.path |
|
}; |
|
} |
|
const parsed = parseValueForArgumentConfig(argConfig, value); |
|
if (parsed === undefined) { |
|
return { |
|
type: "invalid-value", |
|
path: argConfig.path, |
|
expected: getExpectedValue(argConfig) |
|
}; |
|
} |
|
const problem = setValue(config, argConfig.path, parsed, index); |
|
if (problem) return problem; |
|
return null; |
|
}; |
|
|
|
/** |
|
* @param {ArgumentConfig} argConfig processing instructions |
|
* @returns {string | undefined} expected message |
|
*/ |
|
const getExpectedValue = argConfig => { |
|
switch (argConfig.type) { |
|
default: |
|
return argConfig.type; |
|
case "boolean": |
|
return "true | false"; |
|
case "RegExp": |
|
return "regular expression (example: /ab?c*/)"; |
|
case "enum": |
|
return argConfig.values.map(v => `${v}`).join(" | "); |
|
case "reset": |
|
return "true (will reset the previous value to an empty array)"; |
|
} |
|
}; |
|
|
|
/** |
|
* @param {ArgumentConfig} argConfig processing instructions |
|
* @param {any} value the value |
|
* @returns {any | undefined} parsed value |
|
*/ |
|
const parseValueForArgumentConfig = (argConfig, value) => { |
|
switch (argConfig.type) { |
|
case "string": |
|
if (typeof value === "string") { |
|
return value; |
|
} |
|
break; |
|
case "path": |
|
if (typeof value === "string") { |
|
return path.resolve(value); |
|
} |
|
break; |
|
case "number": |
|
if (typeof value === "number") return value; |
|
if (typeof value === "string" && /^[+-]?\d*(\.\d*)[eE]\d+$/) { |
|
const n = +value; |
|
if (!isNaN(n)) return n; |
|
} |
|
break; |
|
case "boolean": |
|
if (typeof value === "boolean") return value; |
|
if (value === "true") return true; |
|
if (value === "false") return false; |
|
break; |
|
case "RegExp": |
|
if (value instanceof RegExp) return value; |
|
if (typeof value === "string") { |
|
// cspell:word yugi |
|
const match = /^\/(.*)\/([yugi]*)$/.exec(value); |
|
if (match && !/[^\\]\//.test(match[1])) |
|
return new RegExp(match[1], match[2]); |
|
} |
|
break; |
|
case "enum": |
|
if (argConfig.values.includes(value)) return value; |
|
for (const item of argConfig.values) { |
|
if (`${item}` === value) return item; |
|
} |
|
break; |
|
case "reset": |
|
if (value === true) return []; |
|
break; |
|
} |
|
}; |
|
|
|
/** |
|
* @param {Record<string, Argument>} args object of arguments |
|
* @param {any} config configuration |
|
* @param {Record<string, string | number | boolean | RegExp | (string | number | boolean | RegExp)[]>} values object with values |
|
* @returns {Problem[] | null} problems or null for success |
|
*/ |
|
const processArguments = (args, config, values) => { |
|
/** @type {Problem[]} */ |
|
const problems = []; |
|
for (const key of Object.keys(values)) { |
|
const arg = args[key]; |
|
if (!arg) { |
|
problems.push({ |
|
type: "unknown-argument", |
|
path: "", |
|
argument: key |
|
}); |
|
continue; |
|
} |
|
const processValue = (value, i) => { |
|
const currentProblems = []; |
|
for (const argConfig of arg.configs) { |
|
const problem = processArgumentConfig(argConfig, config, value, i); |
|
if (!problem) { |
|
return; |
|
} |
|
currentProblems.push({ |
|
...problem, |
|
argument: key, |
|
value: value, |
|
index: i |
|
}); |
|
} |
|
problems.push(...currentProblems); |
|
}; |
|
let value = values[key]; |
|
if (Array.isArray(value)) { |
|
for (let i = 0; i < value.length; i++) { |
|
processValue(value[i], i); |
|
} |
|
} else { |
|
processValue(value, undefined); |
|
} |
|
} |
|
if (problems.length === 0) return null; |
|
return problems; |
|
}; |
|
|
|
exports.getArguments = getArguments; |
|
exports.processArguments = processArguments;
|
|
|