UNPKG

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