cli.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Tobias Koppers @sokra
  4. */
  5. "use strict";
  6. const path = require("path");
  7. const webpackSchema = require("../schemas/WebpackOptions.json");
  8. /** @typedef {import("json-schema").JSONSchema4} JSONSchema4 */
  9. /** @typedef {import("json-schema").JSONSchema6} JSONSchema6 */
  10. /** @typedef {import("json-schema").JSONSchema7} JSONSchema7 */
  11. /** @typedef {JSONSchema4 | JSONSchema6 | JSONSchema7} JSONSchema */
  12. /** @typedef {JSONSchema & { absolutePath: boolean, instanceof: string, cli: { helper?: boolean, exclude?: boolean, description?: string, negatedDescription?: string, resetDescription?: string } }} Schema */
  13. // TODO add originPath to PathItem for better errors
  14. /**
  15. * @typedef {object} PathItem
  16. * @property {Schema} schema the part of the schema
  17. * @property {string} path the path in the config
  18. */
  19. /** @typedef {"unknown-argument" | "unexpected-non-array-in-path" | "unexpected-non-object-in-path" | "multiple-values-unexpected" | "invalid-value"} ProblemType */
  20. /** @typedef {string | number | boolean | RegExp} Value */
  21. /**
  22. * @typedef {object} Problem
  23. * @property {ProblemType} type
  24. * @property {string} path
  25. * @property {string} argument
  26. * @property {Value=} value
  27. * @property {number=} index
  28. * @property {string=} expected
  29. */
  30. /**
  31. * @typedef {object} LocalProblem
  32. * @property {ProblemType} type
  33. * @property {string} path
  34. * @property {string=} expected
  35. */
  36. /** @typedef {{ [key: string]: EnumValue }} EnumValueObject */
  37. /** @typedef {EnumValue[]} EnumValueArray */
  38. /** @typedef {string | number | boolean | EnumValueObject | EnumValueArray | null} EnumValue */
  39. /**
  40. * @typedef {object} ArgumentConfig
  41. * @property {string=} description
  42. * @property {string=} negatedDescription
  43. * @property {string} path
  44. * @property {boolean} multiple
  45. * @property {"enum" | "string" | "path" | "number" | "boolean" | "RegExp" | "reset"} type
  46. * @property {EnumValue[]=} values
  47. */
  48. /** @typedef {"string" | "number" | "boolean"} SimpleType */
  49. /**
  50. * @typedef {object} Argument
  51. * @property {string | undefined} description
  52. * @property {SimpleType} simpleType
  53. * @property {boolean} multiple
  54. * @property {ArgumentConfig[]} configs
  55. */
  56. /** @typedef {Record<string, Argument>} Flags */
  57. /** @typedef {Record<string, EXPECTED_ANY>} ObjectConfiguration */
  58. /**
  59. * @param {Schema=} schema a json schema to create arguments for (by default webpack schema is used)
  60. * @returns {Flags} object of arguments
  61. */
  62. const getArguments = (schema = webpackSchema) => {
  63. /** @type {Flags} */
  64. const flags = {};
  65. /**
  66. * @param {string} input input
  67. * @returns {string} result
  68. */
  69. const pathToArgumentName = input =>
  70. input
  71. .replace(/\./g, "-")
  72. .replace(/\[\]/g, "")
  73. .replace(
  74. /(\p{Uppercase_Letter}+|\p{Lowercase_Letter}|\d)(\p{Uppercase_Letter}+)/gu,
  75. "$1-$2"
  76. )
  77. .replace(/-?[^\p{Uppercase_Letter}\p{Lowercase_Letter}\d]+/gu, "-")
  78. .toLowerCase();
  79. /**
  80. * @param {string} path path
  81. * @returns {Schema} schema part
  82. */
  83. const getSchemaPart = path => {
  84. const newPath = path.split("/");
  85. let schemaPart = schema;
  86. for (let i = 1; i < newPath.length; i++) {
  87. const inner = schemaPart[/** @type {keyof Schema} */ (newPath[i])];
  88. if (!inner) {
  89. break;
  90. }
  91. schemaPart = inner;
  92. }
  93. return schemaPart;
  94. };
  95. /**
  96. * @param {PathItem[]} path path in the schema
  97. * @returns {string | undefined} description
  98. */
  99. const getDescription = path => {
  100. for (const { schema } of path) {
  101. if (schema.cli) {
  102. if (schema.cli.helper) continue;
  103. if (schema.cli.description) return schema.cli.description;
  104. }
  105. if (schema.description) return schema.description;
  106. }
  107. };
  108. /**
  109. * @param {PathItem[]} path path in the schema
  110. * @returns {string | undefined} negative description
  111. */
  112. const getNegatedDescription = path => {
  113. for (const { schema } of path) {
  114. if (schema.cli) {
  115. if (schema.cli.helper) continue;
  116. if (schema.cli.negatedDescription) return schema.cli.negatedDescription;
  117. }
  118. }
  119. };
  120. /**
  121. * @param {PathItem[]} path path in the schema
  122. * @returns {string | undefined} reset description
  123. */
  124. const getResetDescription = path => {
  125. for (const { schema } of path) {
  126. if (schema.cli) {
  127. if (schema.cli.helper) continue;
  128. if (schema.cli.resetDescription) return schema.cli.resetDescription;
  129. }
  130. }
  131. };
  132. /**
  133. * @param {Schema} schemaPart schema
  134. * @returns {Pick<ArgumentConfig, "type" | "values"> | undefined} partial argument config
  135. */
  136. const schemaToArgumentConfig = schemaPart => {
  137. if (schemaPart.enum) {
  138. return {
  139. type: "enum",
  140. values: schemaPart.enum
  141. };
  142. }
  143. switch (schemaPart.type) {
  144. case "number":
  145. return {
  146. type: "number"
  147. };
  148. case "string":
  149. return {
  150. type: schemaPart.absolutePath ? "path" : "string"
  151. };
  152. case "boolean":
  153. return {
  154. type: "boolean"
  155. };
  156. }
  157. if (schemaPart.instanceof === "RegExp") {
  158. return {
  159. type: "RegExp"
  160. };
  161. }
  162. return undefined;
  163. };
  164. /**
  165. * @param {PathItem[]} path path in the schema
  166. * @returns {void}
  167. */
  168. const addResetFlag = path => {
  169. const schemaPath = path[0].path;
  170. const name = pathToArgumentName(`${schemaPath}.reset`);
  171. const description =
  172. getResetDescription(path) ||
  173. `Clear all items provided in '${schemaPath}' configuration. ${getDescription(
  174. path
  175. )}`;
  176. flags[name] = {
  177. configs: [
  178. {
  179. type: "reset",
  180. multiple: false,
  181. description,
  182. path: schemaPath
  183. }
  184. ],
  185. description: undefined,
  186. simpleType:
  187. /** @type {SimpleType} */
  188. (/** @type {unknown} */ (undefined)),
  189. multiple: /** @type {boolean} */ (/** @type {unknown} */ (undefined))
  190. };
  191. };
  192. /**
  193. * @param {PathItem[]} path full path in schema
  194. * @param {boolean} multiple inside of an array
  195. * @returns {number} number of arguments added
  196. */
  197. const addFlag = (path, multiple) => {
  198. const argConfigBase = schemaToArgumentConfig(path[0].schema);
  199. if (!argConfigBase) return 0;
  200. const negatedDescription = getNegatedDescription(path);
  201. const name = pathToArgumentName(path[0].path);
  202. /** @type {ArgumentConfig} */
  203. const argConfig = {
  204. ...argConfigBase,
  205. multiple,
  206. description: getDescription(path),
  207. path: path[0].path
  208. };
  209. if (negatedDescription) {
  210. argConfig.negatedDescription = negatedDescription;
  211. }
  212. if (!flags[name]) {
  213. flags[name] = {
  214. configs: [],
  215. description: undefined,
  216. simpleType:
  217. /** @type {SimpleType} */
  218. (/** @type {unknown} */ (undefined)),
  219. multiple: /** @type {boolean} */ (/** @type {unknown} */ (undefined))
  220. };
  221. }
  222. if (
  223. flags[name].configs.some(
  224. item => JSON.stringify(item) === JSON.stringify(argConfig)
  225. )
  226. ) {
  227. return 0;
  228. }
  229. if (
  230. flags[name].configs.some(
  231. item => item.type === argConfig.type && item.multiple !== multiple
  232. )
  233. ) {
  234. if (multiple) {
  235. throw new Error(
  236. `Conflicting schema for ${path[0].path} with ${argConfig.type} type (array type must be before single item type)`
  237. );
  238. }
  239. return 0;
  240. }
  241. flags[name].configs.push(argConfig);
  242. return 1;
  243. };
  244. // TODO support `not` and `if/then/else`
  245. // TODO support `const`, but we don't use it on our schema
  246. /**
  247. * @param {Schema} schemaPart the current schema
  248. * @param {string} schemaPath the current path in the schema
  249. * @param {PathItem[]} path all previous visited schemaParts
  250. * @param {string | null} inArray if inside of an array, the path to the array
  251. * @returns {number} added arguments
  252. */
  253. const traverse = (schemaPart, schemaPath = "", path = [], inArray = null) => {
  254. while (schemaPart.$ref) {
  255. schemaPart = getSchemaPart(schemaPart.$ref);
  256. }
  257. const repetitions = path.filter(({ schema }) => schema === schemaPart);
  258. if (
  259. repetitions.length >= 2 ||
  260. repetitions.some(({ path }) => path === schemaPath)
  261. ) {
  262. return 0;
  263. }
  264. if (schemaPart.cli && schemaPart.cli.exclude) return 0;
  265. /** @type {PathItem[]} */
  266. const fullPath = [{ schema: schemaPart, path: schemaPath }, ...path];
  267. let addedArguments = 0;
  268. addedArguments += addFlag(fullPath, Boolean(inArray));
  269. if (schemaPart.type === "object") {
  270. if (schemaPart.properties) {
  271. for (const property of Object.keys(schemaPart.properties)) {
  272. addedArguments += traverse(
  273. /** @type {Schema} */
  274. (schemaPart.properties[property]),
  275. schemaPath ? `${schemaPath}.${property}` : property,
  276. fullPath,
  277. inArray
  278. );
  279. }
  280. }
  281. return addedArguments;
  282. }
  283. if (schemaPart.type === "array") {
  284. if (inArray) {
  285. return 0;
  286. }
  287. if (Array.isArray(schemaPart.items)) {
  288. const i = 0;
  289. for (const item of schemaPart.items) {
  290. addedArguments += traverse(
  291. /** @type {Schema} */
  292. (item),
  293. `${schemaPath}.${i}`,
  294. fullPath,
  295. schemaPath
  296. );
  297. }
  298. return addedArguments;
  299. }
  300. addedArguments += traverse(
  301. /** @type {Schema} */
  302. (schemaPart.items),
  303. `${schemaPath}[]`,
  304. fullPath,
  305. schemaPath
  306. );
  307. if (addedArguments > 0) {
  308. addResetFlag(fullPath);
  309. addedArguments++;
  310. }
  311. return addedArguments;
  312. }
  313. const maybeOf = schemaPart.oneOf || schemaPart.anyOf || schemaPart.allOf;
  314. if (maybeOf) {
  315. const items = maybeOf;
  316. for (let i = 0; i < items.length; i++) {
  317. addedArguments += traverse(
  318. /** @type {Schema} */
  319. (items[i]),
  320. schemaPath,
  321. fullPath,
  322. inArray
  323. );
  324. }
  325. return addedArguments;
  326. }
  327. return addedArguments;
  328. };
  329. traverse(schema);
  330. // Summarize flags
  331. for (const name of Object.keys(flags)) {
  332. /** @type {Argument} */
  333. const argument = flags[name];
  334. argument.description = argument.configs.reduce((desc, { description }) => {
  335. if (!desc) return description;
  336. if (!description) return desc;
  337. if (desc.includes(description)) return desc;
  338. return `${desc} ${description}`;
  339. }, /** @type {string | undefined} */ (undefined));
  340. argument.simpleType =
  341. /** @type {SimpleType} */
  342. (
  343. argument.configs.reduce((t, argConfig) => {
  344. /** @type {SimpleType} */
  345. let type = "string";
  346. switch (argConfig.type) {
  347. case "number":
  348. type = "number";
  349. break;
  350. case "reset":
  351. case "boolean":
  352. type = "boolean";
  353. break;
  354. case "enum": {
  355. const values =
  356. /** @type {NonNullable<ArgumentConfig["values"]>} */
  357. (argConfig.values);
  358. if (values.every(v => typeof v === "boolean")) type = "boolean";
  359. if (values.every(v => typeof v === "number")) type = "number";
  360. break;
  361. }
  362. }
  363. if (t === undefined) return type;
  364. return t === type ? t : "string";
  365. }, /** @type {SimpleType | undefined} */ (undefined))
  366. );
  367. argument.multiple = argument.configs.some(c => c.multiple);
  368. }
  369. return flags;
  370. };
  371. const cliAddedItems = new WeakMap();
  372. /** @typedef {string | number} Property */
  373. /**
  374. * @param {ObjectConfiguration} config configuration
  375. * @param {string} schemaPath path in the config
  376. * @param {number | undefined} index index of value when multiple values are provided, otherwise undefined
  377. * @returns {{ problem?: LocalProblem, object?: ObjectConfiguration, property?: Property, value?: EXPECTED_OBJECT | EXPECTED_ANY[] }} problem or object with property and value
  378. */
  379. const getObjectAndProperty = (config, schemaPath, index = 0) => {
  380. if (!schemaPath) return { value: config };
  381. const parts = schemaPath.split(".");
  382. const property = /** @type {string} */ (parts.pop());
  383. let current = config;
  384. let i = 0;
  385. for (const part of parts) {
  386. const isArray = part.endsWith("[]");
  387. const name = isArray ? part.slice(0, -2) : part;
  388. let value = current[name];
  389. if (isArray) {
  390. if (value === undefined) {
  391. value = {};
  392. current[name] = [...Array.from({ length: index }), value];
  393. cliAddedItems.set(current[name], index + 1);
  394. } else if (!Array.isArray(value)) {
  395. return {
  396. problem: {
  397. type: "unexpected-non-array-in-path",
  398. path: parts.slice(0, i).join(".")
  399. }
  400. };
  401. } else {
  402. let addedItems = cliAddedItems.get(value) || 0;
  403. while (addedItems <= index) {
  404. value.push(undefined);
  405. addedItems++;
  406. }
  407. cliAddedItems.set(value, addedItems);
  408. const x = value.length - addedItems + index;
  409. if (value[x] === undefined) {
  410. value[x] = {};
  411. } else if (value[x] === null || typeof value[x] !== "object") {
  412. return {
  413. problem: {
  414. type: "unexpected-non-object-in-path",
  415. path: parts.slice(0, i).join(".")
  416. }
  417. };
  418. }
  419. value = value[x];
  420. }
  421. } else if (value === undefined) {
  422. value = current[name] = {};
  423. } else if (value === null || typeof value !== "object") {
  424. return {
  425. problem: {
  426. type: "unexpected-non-object-in-path",
  427. path: parts.slice(0, i).join(".")
  428. }
  429. };
  430. }
  431. current = value;
  432. i++;
  433. }
  434. const value = current[property];
  435. if (property.endsWith("[]")) {
  436. const name = property.slice(0, -2);
  437. const value = current[name];
  438. if (value === undefined) {
  439. current[name] = [...Array.from({ length: index }), undefined];
  440. cliAddedItems.set(current[name], index + 1);
  441. return { object: current[name], property: index, value: undefined };
  442. } else if (!Array.isArray(value)) {
  443. current[name] = [value, ...Array.from({ length: index }), undefined];
  444. cliAddedItems.set(current[name], index + 1);
  445. return { object: current[name], property: index + 1, value: undefined };
  446. }
  447. let addedItems = cliAddedItems.get(value) || 0;
  448. while (addedItems <= index) {
  449. value.push(undefined);
  450. addedItems++;
  451. }
  452. cliAddedItems.set(value, addedItems);
  453. const x = value.length - addedItems + index;
  454. if (value[x] === undefined) {
  455. value[x] = {};
  456. } else if (value[x] === null || typeof value[x] !== "object") {
  457. return {
  458. problem: {
  459. type: "unexpected-non-object-in-path",
  460. path: schemaPath
  461. }
  462. };
  463. }
  464. return {
  465. object: value,
  466. property: x,
  467. value: value[x]
  468. };
  469. }
  470. return { object: current, property, value };
  471. };
  472. /**
  473. * @param {ObjectConfiguration} config configuration
  474. * @param {string} schemaPath path in the config
  475. * @param {ParsedValue} value parsed value
  476. * @param {number | undefined} index index of value when multiple values are provided, otherwise undefined
  477. * @returns {LocalProblem | null} problem or null for success
  478. */
  479. const setValue = (config, schemaPath, value, index) => {
  480. const { problem, object, property } = getObjectAndProperty(
  481. config,
  482. schemaPath,
  483. index
  484. );
  485. if (problem) return problem;
  486. /** @type {ObjectConfiguration} */
  487. (object)[/** @type {Property} */ (property)] = value;
  488. return null;
  489. };
  490. /**
  491. * @param {ArgumentConfig} argConfig processing instructions
  492. * @param {ObjectConfiguration} config configuration
  493. * @param {Value} value the value
  494. * @param {number | undefined} index the index if multiple values provided
  495. * @returns {LocalProblem | null} a problem if any
  496. */
  497. const processArgumentConfig = (argConfig, config, value, index) => {
  498. if (index !== undefined && !argConfig.multiple) {
  499. return {
  500. type: "multiple-values-unexpected",
  501. path: argConfig.path
  502. };
  503. }
  504. const parsed = parseValueForArgumentConfig(argConfig, value);
  505. if (parsed === undefined) {
  506. return {
  507. type: "invalid-value",
  508. path: argConfig.path,
  509. expected: getExpectedValue(argConfig)
  510. };
  511. }
  512. const problem = setValue(config, argConfig.path, parsed, index);
  513. if (problem) return problem;
  514. return null;
  515. };
  516. /**
  517. * @param {ArgumentConfig} argConfig processing instructions
  518. * @returns {string | undefined} expected message
  519. */
  520. const getExpectedValue = argConfig => {
  521. switch (argConfig.type) {
  522. case "boolean":
  523. return "true | false";
  524. case "RegExp":
  525. return "regular expression (example: /ab?c*/)";
  526. case "enum":
  527. return /** @type {NonNullable<ArgumentConfig["values"]>} */ (
  528. argConfig.values
  529. )
  530. .map(v => `${v}`)
  531. .join(" | ");
  532. case "reset":
  533. return "true (will reset the previous value to an empty array)";
  534. default:
  535. return argConfig.type;
  536. }
  537. };
  538. /** @typedef {null | string | number | boolean | RegExp | EnumValue | []} ParsedValue */
  539. /**
  540. * @param {ArgumentConfig} argConfig processing instructions
  541. * @param {Value} value the value
  542. * @returns {ParsedValue | undefined} parsed value
  543. */
  544. const parseValueForArgumentConfig = (argConfig, value) => {
  545. switch (argConfig.type) {
  546. case "string":
  547. if (typeof value === "string") {
  548. return value;
  549. }
  550. break;
  551. case "path":
  552. if (typeof value === "string") {
  553. return path.resolve(value);
  554. }
  555. break;
  556. case "number":
  557. if (typeof value === "number") return value;
  558. if (typeof value === "string" && /^[+-]?\d*(\.\d*)[eE]\d+$/) {
  559. const n = Number(value);
  560. if (!Number.isNaN(n)) return n;
  561. }
  562. break;
  563. case "boolean":
  564. if (typeof value === "boolean") return value;
  565. if (value === "true") return true;
  566. if (value === "false") return false;
  567. break;
  568. case "RegExp":
  569. if (value instanceof RegExp) return value;
  570. if (typeof value === "string") {
  571. // cspell:word yugi
  572. const match = /^\/(.*)\/([yugi]*)$/.exec(value);
  573. if (match && !/[^\\]\//.test(match[1])) {
  574. return new RegExp(match[1], match[2]);
  575. }
  576. }
  577. break;
  578. case "enum": {
  579. const values =
  580. /** @type {EnumValue[]} */
  581. (argConfig.values);
  582. if (values.includes(/** @type {Exclude<Value, RegExp>} */ (value))) {
  583. return value;
  584. }
  585. for (const item of values) {
  586. if (`${item}` === value) return item;
  587. }
  588. break;
  589. }
  590. case "reset":
  591. if (value === true) return [];
  592. break;
  593. }
  594. };
  595. /** @typedef {Record<string, Value[]>} Values */
  596. /**
  597. * @param {Flags} args object of arguments
  598. * @param {ObjectConfiguration} config configuration
  599. * @param {Values} values object with values
  600. * @returns {Problem[] | null} problems or null for success
  601. */
  602. const processArguments = (args, config, values) => {
  603. /** @type {Problem[]} */
  604. const problems = [];
  605. for (const key of Object.keys(values)) {
  606. const arg = args[key];
  607. if (!arg) {
  608. problems.push({
  609. type: "unknown-argument",
  610. path: "",
  611. argument: key
  612. });
  613. continue;
  614. }
  615. /**
  616. * @param {Value} value value
  617. * @param {number | undefined} i index
  618. */
  619. const processValue = (value, i) => {
  620. const currentProblems = [];
  621. for (const argConfig of arg.configs) {
  622. const problem = processArgumentConfig(argConfig, config, value, i);
  623. if (!problem) {
  624. return;
  625. }
  626. currentProblems.push({
  627. ...problem,
  628. argument: key,
  629. value,
  630. index: i
  631. });
  632. }
  633. problems.push(...currentProblems);
  634. };
  635. const value = values[key];
  636. if (Array.isArray(value)) {
  637. for (let i = 0; i < value.length; i++) {
  638. processValue(value[i], i);
  639. }
  640. } else {
  641. processValue(value, undefined);
  642. }
  643. }
  644. if (problems.length === 0) return null;
  645. return problems;
  646. };
  647. module.exports.getArguments = getArguments;
  648. module.exports.processArguments = processArguments;