cli.js 16 KB

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