cli.js 18 KB

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