UNPKG

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