10
0

cli.js 17 KB

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