blob: 60d44cf96d3beda6c663dba338d0d3f91a46885d [file] [log] [blame]
Tim van der Lippe2c891972021-07-29 16:22:50 +01001'use strict';
2
3Object.defineProperty(exports, '__esModule', { value: true });
4
5function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
6
7var path = _interopDefault(require('path'));
8var minimatch = _interopDefault(require('minimatch'));
9var createDebug = _interopDefault(require('debug'));
10var objectSchema = require('@humanwhocodes/object-schema');
11
12/**
13 * @fileoverview ConfigSchema
14 * @author Nicholas C. Zakas
15 */
16
17//------------------------------------------------------------------------------
18// Helpers
19//------------------------------------------------------------------------------
20
21/**
22 * Assets that a given value is an array.
23 * @param {*} value The value to check.
24 * @returns {void}
25 * @throws {TypeError} When the value is not an array.
26 */
27function assertIsArray(value) {
28 if (!Array.isArray(value)) {
29 throw new TypeError('Expected value to be an array.');
30 }
31}
32
33/**
34 * Assets that a given value is an array containing only strings and functions.
35 * @param {*} value The value to check.
36 * @returns {void}
37 * @throws {TypeError} When the value is not an array of strings and functions.
38 */
39function assertIsArrayOfStringsAndFunctions(value, name) {
40 assertIsArray(value);
41
42 if (value.some(item => typeof item !== 'string' && typeof item !== 'function')) {
43 throw new TypeError('Expected array to only contain strings.');
44 }
45}
46
47//------------------------------------------------------------------------------
48// Exports
49//------------------------------------------------------------------------------
50
51/**
52 * The base schema that every ConfigArray uses.
53 * @type Object
54 */
55const baseSchema = Object.freeze({
56 name: {
57 required: false,
58 merge() {
59 return undefined;
60 },
61 validate(value) {
62 if (typeof value !== 'string') {
63 throw new TypeError('Property must be a string.');
64 }
65 }
66 },
67 files: {
68 required: false,
69 merge() {
70 return undefined;
71 },
72 validate(value) {
73
74 // first check if it's an array
75 assertIsArray(value);
76
77 // then check each member
78 value.forEach(item => {
79 if (Array.isArray(item)) {
80 assertIsArrayOfStringsAndFunctions(item);
81 } else if (typeof item !== 'string' && typeof item !== 'function') {
82 throw new TypeError('Items must be a string, a function, or an array of strings and functions.');
83 }
84 });
85
86 }
87 },
88 ignores: {
89 required: false,
90 merge() {
91 return undefined;
92 },
93 validate: assertIsArrayOfStringsAndFunctions
94 }
95});
96
97/**
98 * @fileoverview ConfigArray
99 * @author Nicholas C. Zakas
100 */
101
102//------------------------------------------------------------------------------
103// Helpers
104//------------------------------------------------------------------------------
105
106const debug = createDebug('@hwc/config-array');
107
108const MINIMATCH_OPTIONS = {
109 matchBase: true
110};
111
112/**
113 * Shorthand for checking if a value is a string.
114 * @param {any} value The value to check.
115 * @returns {boolean} True if a string, false if not.
116 */
117function isString(value) {
118 return typeof value === 'string';
119}
120
121/**
122 * Normalizes a `ConfigArray` by flattening it and executing any functions
123 * that are found inside.
124 * @param {Array} items The items in a `ConfigArray`.
125 * @param {Object} context The context object to pass into any function
126 * found.
Tim van der Lippe0fb47802021-11-08 16:23:10 +0000127 * @returns {Promise<Array>} A flattened array containing only config objects.
Tim van der Lippe2c891972021-07-29 16:22:50 +0100128 * @throws {TypeError} When a config function returns a function.
129 */
130async function normalize(items, context) {
131
Tim van der Lippe0fb47802021-11-08 16:23:10 +0000132 async function *flatTraverse(array) {
133 for (let item of array) {
134 if (typeof item === 'function') {
135 item = item(context);
136 if (item.then) {
137 item = await item;
138 }
139 }
140
141 if (Array.isArray(item)) {
142 yield * flatTraverse(item);
143 } else if (typeof item === 'function') {
144 throw new TypeError('A config function can only return an object or array.');
145 } else {
146 yield item;
147 }
148 }
149 }
150
151 /*
152 * Async iterables cannot be used with the spread operator, so we need to manually
153 * create the array to return.
154 */
155 const asyncIterable = await flatTraverse(items);
156 const configs = [];
157
158 for await (const config of asyncIterable) {
159 configs.push(config);
160 }
161
162 return configs;
163}
164
165/**
166 * Normalizes a `ConfigArray` by flattening it and executing any functions
167 * that are found inside.
168 * @param {Array} items The items in a `ConfigArray`.
169 * @param {Object} context The context object to pass into any function
170 * found.
171 * @returns {Array} A flattened array containing only config objects.
172 * @throws {TypeError} When a config function returns a function.
173 */
174function normalizeSync(items, context) {
Tim van der Lippe2c891972021-07-29 16:22:50 +0100175
176 function *flatTraverse(array) {
177 for (let item of array) {
178 if (typeof item === 'function') {
179 item = item(context);
Tim van der Lippe0fb47802021-11-08 16:23:10 +0000180 if (item.then) {
181 throw new TypeError('Async config functions are not supported.');
182 }
Tim van der Lippe2c891972021-07-29 16:22:50 +0100183 }
184
185 if (Array.isArray(item)) {
186 yield * flatTraverse(item);
187 } else if (typeof item === 'function') {
188 throw new TypeError('A config function can only return an object or array.');
189 } else {
190 yield item;
191 }
192 }
193 }
194
195 return [...flatTraverse(items)];
196}
197
198/**
199 * Determines if a given file path is matched by a config. If the config
200 * has no `files` field, then it matches; otherwise, if a `files` field
201 * is present then we match the globs in `files` and exclude any globs in
202 * `ignores`.
203 * @param {string} filePath The absolute file path to check.
204 * @param {Object} config The config object to check.
205 * @returns {boolean} True if the file path is matched by the config,
206 * false if not.
207 */
208function pathMatches(filePath, basePath, config) {
209
210 // a config without a `files` field always matches
211 if (!config.files) {
212 return true;
213 }
214
215 // if files isn't an array, throw an error
216 if (!Array.isArray(config.files) || config.files.length === 0) {
217 throw new TypeError('The files key must be a non-empty array.');
218 }
219
220 const relativeFilePath = path.relative(basePath, filePath);
221
222 // match both strings and functions
223 const match = pattern => {
224 if (isString(pattern)) {
225 return minimatch(relativeFilePath, pattern, MINIMATCH_OPTIONS);
226 }
227
228 if (typeof pattern === 'function') {
229 return pattern(filePath);
230 }
231 };
232
233 // check for all matches to config.files
234 let matches = config.files.some(pattern => {
235 if (Array.isArray(pattern)) {
236 return pattern.every(match);
237 }
238
239 return match(pattern);
240 });
241
242 /*
243 * If the file path matches the config.files patterns, then check to see
244 * if there are any files to ignore.
245 */
246 if (matches && config.ignores) {
247 matches = !config.ignores.some(pattern => {
248 return minimatch(filePath, pattern, MINIMATCH_OPTIONS);
249 });
250 }
251
252 return matches;
253}
254
255/**
256 * Ensures that a ConfigArray has been normalized.
257 * @param {ConfigArray} configArray The ConfigArray to check.
258 * @returns {void}
259 * @throws {Error} When the `ConfigArray` is not normalized.
260 */
261function assertNormalized(configArray) {
262 // TODO: Throw more verbose error
263 if (!configArray.isNormalized()) {
264 throw new Error('ConfigArray must be normalized to perform this operation.');
265 }
266}
267
268//------------------------------------------------------------------------------
269// Public Interface
270//------------------------------------------------------------------------------
271
272const ConfigArraySymbol = {
273 isNormalized: Symbol('isNormalized'),
274 configCache: Symbol('configCache'),
275 schema: Symbol('schema'),
276 finalizeConfig: Symbol('finalizeConfig'),
277 preprocessConfig: Symbol('preprocessConfig')
278};
279
280/**
281 * Represents an array of config objects and provides method for working with
282 * those config objects.
283 */
284class ConfigArray extends Array {
285
286 /**
287 * Creates a new instance of ConfigArray.
288 * @param {Iterable|Function|Object} configs An iterable yielding config
289 * objects, or a config function, or a config object.
290 * @param {string} [options.basePath=""] The path of the config file
291 * @param {boolean} [options.normalized=false] Flag indicating if the
292 * configs have already been normalized.
293 * @param {Object} [options.schema] The additional schema
294 * definitions to use for the ConfigArray schema.
295 */
296 constructor(configs, { basePath = '', normalized = false, schema: customSchema } = {}) {
297 super();
298
299 /**
300 * Tracks if the array has been normalized.
301 * @property isNormalized
302 * @type boolean
303 * @private
304 */
305 this[ConfigArraySymbol.isNormalized] = normalized;
306
307 /**
308 * The schema used for validating and merging configs.
309 * @property schema
310 * @type ObjectSchema
311 * @private
312 */
313 this[ConfigArraySymbol.schema] = new objectSchema.ObjectSchema({
314 ...customSchema,
315 ...baseSchema
316 });
317
318 /**
319 * The path of the config file that this array was loaded from.
320 * This is used to calculate filename matches.
321 * @property basePath
322 * @type string
323 */
324 this.basePath = basePath;
325
326 /**
327 * A cache to store calculated configs for faster repeat lookup.
328 * @property configCache
329 * @type Map
330 * @private
331 */
332 this[ConfigArraySymbol.configCache] = new Map();
333
334 // load the configs into this array
335 if (Array.isArray(configs)) {
336 this.push(...configs);
337 } else {
338 this.push(configs);
339 }
340
341 }
342
343 /**
344 * Prevent normal array methods from creating a new `ConfigArray` instance.
345 * This is to ensure that methods such as `slice()` won't try to create a
346 * new instance of `ConfigArray` behind the scenes as doing so may throw
347 * an error due to the different constructor signature.
348 * @returns {Function} The `Array` constructor.
349 */
350 static get [Symbol.species]() {
351 return Array;
352 }
353
354 /**
355 * Returns the `files` globs from every config object in the array.
356 * Negated patterns (those beginning with `!`) are not returned.
357 * This can be used to determine which files will be matched by a
358 * config array or to use as a glob pattern when no patterns are provided
359 * for a command line interface.
360 * @returns {string[]} An array of string patterns.
361 */
362 get files() {
363
364 assertNormalized(this);
365
366 const result = [];
367
368 for (const config of this) {
369 if (config.files) {
370 config.files.forEach(filePattern => {
371 if (Array.isArray(filePattern)) {
372 result.push(...filePattern.filter(pattern => {
373 return isString(pattern) && !pattern.startsWith('!');
374 }));
375 } else if (isString(filePattern) && !filePattern.startsWith('!')) {
376 result.push(filePattern);
377 }
378 });
379 }
380 }
381
382 return result;
383 }
384
385 /**
386 * Returns the file globs that should always be ignored regardless of
387 * the matching `files` fields in any configs. This is necessary to mimic
388 * the behavior of things like .gitignore and .eslintignore, allowing a
389 * globbing operation to be faster.
390 * @returns {string[]} An array of string patterns to be ignored.
391 */
392 get ignores() {
393
394 assertNormalized(this);
395
396 const result = [];
397
398 for (const config of this) {
399 if (config.ignores && !config.files) {
400 result.push(...config.ignores.filter(isString));
401 }
402 }
403
404 return result;
405 }
406
407 /**
408 * Indicates if the config array has been normalized.
409 * @returns {boolean} True if the config array is normalized, false if not.
410 */
411 isNormalized() {
412 return this[ConfigArraySymbol.isNormalized];
413 }
414
415 /**
416 * Normalizes a config array by flattening embedded arrays and executing
417 * config functions.
418 * @param {ConfigContext} context The context object for config functions.
Tim van der Lippe0fb47802021-11-08 16:23:10 +0000419 * @returns {Promise<ConfigArray>} The current ConfigArray instance.
Tim van der Lippe2c891972021-07-29 16:22:50 +0100420 */
421 async normalize(context = {}) {
422
423 if (!this.isNormalized()) {
424 const normalizedConfigs = await normalize(this, context);
425 this.length = 0;
426 this.push(...normalizedConfigs.map(this[ConfigArraySymbol.preprocessConfig]));
427 this[ConfigArraySymbol.isNormalized] = true;
428
429 // prevent further changes
430 Object.freeze(this);
431 }
432
433 return this;
434 }
435
436 /**
Tim van der Lippe0fb47802021-11-08 16:23:10 +0000437 * Normalizes a config array by flattening embedded arrays and executing
438 * config functions.
439 * @param {ConfigContext} context The context object for config functions.
440 * @returns {ConfigArray} The current ConfigArray instance.
441 */
442 normalizeSync(context = {}) {
443
444 if (!this.isNormalized()) {
445 const normalizedConfigs = normalizeSync(this, context);
446 this.length = 0;
447 this.push(...normalizedConfigs.map(this[ConfigArraySymbol.preprocessConfig]));
448 this[ConfigArraySymbol.isNormalized] = true;
449
450 // prevent further changes
451 Object.freeze(this);
452 }
453
454 return this;
455 }
456
457 /**
Tim van der Lippe2c891972021-07-29 16:22:50 +0100458 * Finalizes the state of a config before being cached and returned by
459 * `getConfig()`. Does nothing by default but is provided to be
460 * overridden by subclasses as necessary.
461 * @param {Object} config The config to finalize.
462 * @returns {Object} The finalized config.
463 */
464 [ConfigArraySymbol.finalizeConfig](config) {
465 return config;
466 }
467
468 /**
469 * Preprocesses a config during the normalization process. This is the
470 * method to override if you want to convert an array item before it is
471 * validated for the first time. For example, if you want to replace a
472 * string with an object, this is the method to override.
473 * @param {Object} config The config to preprocess.
474 * @returns {Object} The config to use in place of the argument.
475 */
476 [ConfigArraySymbol.preprocessConfig](config) {
477 return config;
478 }
479
480 /**
481 * Returns the config object for a given file path.
482 * @param {string} filePath The complete path of a file to get a config for.
483 * @returns {Object} The config object for this file.
484 */
485 getConfig(filePath) {
486
487 assertNormalized(this);
488
489 // first check the cache to avoid duplicate work
490 let finalConfig = this[ConfigArraySymbol.configCache].get(filePath);
491
492 if (finalConfig) {
493 return finalConfig;
494 }
495
496 // No config found in cache, so calculate a new one
497
498 const matchingConfigs = [];
499
500 for (const config of this) {
501 if (pathMatches(filePath, this.basePath, config)) {
502 debug(`Matching config found for ${filePath}`);
503 matchingConfigs.push(config);
504 } else {
505 debug(`No matching config found for ${filePath}`);
506 }
507 }
508
509 finalConfig = matchingConfigs.reduce((result, config) => {
510 return this[ConfigArraySymbol.schema].merge(result, config);
511 }, {}, this);
512
513 finalConfig = this[ConfigArraySymbol.finalizeConfig](finalConfig);
514
515 this[ConfigArraySymbol.configCache].set(filePath, finalConfig);
516
517 return finalConfig;
518 }
519
520}
521
522exports.ConfigArray = ConfigArray;
523exports.ConfigArraySymbol = ConfigArraySymbol;