cli.js 18 KB

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