blob: a9aacf46b5cd4be136ecabe66180b7fc6bbb99a0 [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.
127 * @returns {Array} A flattened array containing only config objects.
128 * @throws {TypeError} When a config function returns a function.
129 */
130async function normalize(items, context) {
131
132 // TODO: Allow async config functions
133
134 function *flatTraverse(array) {
135 for (let item of array) {
136 if (typeof item === 'function') {
137 item = item(context);
138 }
139
140 if (Array.isArray(item)) {
141 yield * flatTraverse(item);
142 } else if (typeof item === 'function') {
143 throw new TypeError('A config function can only return an object or array.');
144 } else {
145 yield item;
146 }
147 }
148 }
149
150 return [...flatTraverse(items)];
151}
152
153/**
154 * Determines if a given file path is matched by a config. If the config
155 * has no `files` field, then it matches; otherwise, if a `files` field
156 * is present then we match the globs in `files` and exclude any globs in
157 * `ignores`.
158 * @param {string} filePath The absolute file path to check.
159 * @param {Object} config The config object to check.
160 * @returns {boolean} True if the file path is matched by the config,
161 * false if not.
162 */
163function pathMatches(filePath, basePath, config) {
164
165 // a config without a `files` field always matches
166 if (!config.files) {
167 return true;
168 }
169
170 // if files isn't an array, throw an error
171 if (!Array.isArray(config.files) || config.files.length === 0) {
172 throw new TypeError('The files key must be a non-empty array.');
173 }
174
175 const relativeFilePath = path.relative(basePath, filePath);
176
177 // match both strings and functions
178 const match = pattern => {
179 if (isString(pattern)) {
180 return minimatch(relativeFilePath, pattern, MINIMATCH_OPTIONS);
181 }
182
183 if (typeof pattern === 'function') {
184 return pattern(filePath);
185 }
186 };
187
188 // check for all matches to config.files
189 let matches = config.files.some(pattern => {
190 if (Array.isArray(pattern)) {
191 return pattern.every(match);
192 }
193
194 return match(pattern);
195 });
196
197 /*
198 * If the file path matches the config.files patterns, then check to see
199 * if there are any files to ignore.
200 */
201 if (matches && config.ignores) {
202 matches = !config.ignores.some(pattern => {
203 return minimatch(filePath, pattern, MINIMATCH_OPTIONS);
204 });
205 }
206
207 return matches;
208}
209
210/**
211 * Ensures that a ConfigArray has been normalized.
212 * @param {ConfigArray} configArray The ConfigArray to check.
213 * @returns {void}
214 * @throws {Error} When the `ConfigArray` is not normalized.
215 */
216function assertNormalized(configArray) {
217 // TODO: Throw more verbose error
218 if (!configArray.isNormalized()) {
219 throw new Error('ConfigArray must be normalized to perform this operation.');
220 }
221}
222
223//------------------------------------------------------------------------------
224// Public Interface
225//------------------------------------------------------------------------------
226
227const ConfigArraySymbol = {
228 isNormalized: Symbol('isNormalized'),
229 configCache: Symbol('configCache'),
230 schema: Symbol('schema'),
231 finalizeConfig: Symbol('finalizeConfig'),
232 preprocessConfig: Symbol('preprocessConfig')
233};
234
235/**
236 * Represents an array of config objects and provides method for working with
237 * those config objects.
238 */
239class ConfigArray extends Array {
240
241 /**
242 * Creates a new instance of ConfigArray.
243 * @param {Iterable|Function|Object} configs An iterable yielding config
244 * objects, or a config function, or a config object.
245 * @param {string} [options.basePath=""] The path of the config file
246 * @param {boolean} [options.normalized=false] Flag indicating if the
247 * configs have already been normalized.
248 * @param {Object} [options.schema] The additional schema
249 * definitions to use for the ConfigArray schema.
250 */
251 constructor(configs, { basePath = '', normalized = false, schema: customSchema } = {}) {
252 super();
253
254 /**
255 * Tracks if the array has been normalized.
256 * @property isNormalized
257 * @type boolean
258 * @private
259 */
260 this[ConfigArraySymbol.isNormalized] = normalized;
261
262 /**
263 * The schema used for validating and merging configs.
264 * @property schema
265 * @type ObjectSchema
266 * @private
267 */
268 this[ConfigArraySymbol.schema] = new objectSchema.ObjectSchema({
269 ...customSchema,
270 ...baseSchema
271 });
272
273 /**
274 * The path of the config file that this array was loaded from.
275 * This is used to calculate filename matches.
276 * @property basePath
277 * @type string
278 */
279 this.basePath = basePath;
280
281 /**
282 * A cache to store calculated configs for faster repeat lookup.
283 * @property configCache
284 * @type Map
285 * @private
286 */
287 this[ConfigArraySymbol.configCache] = new Map();
288
289 // load the configs into this array
290 if (Array.isArray(configs)) {
291 this.push(...configs);
292 } else {
293 this.push(configs);
294 }
295
296 }
297
298 /**
299 * Prevent normal array methods from creating a new `ConfigArray` instance.
300 * This is to ensure that methods such as `slice()` won't try to create a
301 * new instance of `ConfigArray` behind the scenes as doing so may throw
302 * an error due to the different constructor signature.
303 * @returns {Function} The `Array` constructor.
304 */
305 static get [Symbol.species]() {
306 return Array;
307 }
308
309 /**
310 * Returns the `files` globs from every config object in the array.
311 * Negated patterns (those beginning with `!`) are not returned.
312 * This can be used to determine which files will be matched by a
313 * config array or to use as a glob pattern when no patterns are provided
314 * for a command line interface.
315 * @returns {string[]} An array of string patterns.
316 */
317 get files() {
318
319 assertNormalized(this);
320
321 const result = [];
322
323 for (const config of this) {
324 if (config.files) {
325 config.files.forEach(filePattern => {
326 if (Array.isArray(filePattern)) {
327 result.push(...filePattern.filter(pattern => {
328 return isString(pattern) && !pattern.startsWith('!');
329 }));
330 } else if (isString(filePattern) && !filePattern.startsWith('!')) {
331 result.push(filePattern);
332 }
333 });
334 }
335 }
336
337 return result;
338 }
339
340 /**
341 * Returns the file globs that should always be ignored regardless of
342 * the matching `files` fields in any configs. This is necessary to mimic
343 * the behavior of things like .gitignore and .eslintignore, allowing a
344 * globbing operation to be faster.
345 * @returns {string[]} An array of string patterns to be ignored.
346 */
347 get ignores() {
348
349 assertNormalized(this);
350
351 const result = [];
352
353 for (const config of this) {
354 if (config.ignores && !config.files) {
355 result.push(...config.ignores.filter(isString));
356 }
357 }
358
359 return result;
360 }
361
362 /**
363 * Indicates if the config array has been normalized.
364 * @returns {boolean} True if the config array is normalized, false if not.
365 */
366 isNormalized() {
367 return this[ConfigArraySymbol.isNormalized];
368 }
369
370 /**
371 * Normalizes a config array by flattening embedded arrays and executing
372 * config functions.
373 * @param {ConfigContext} context The context object for config functions.
374 * @returns {ConfigArray} A new ConfigArray instance that is normalized.
375 */
376 async normalize(context = {}) {
377
378 if (!this.isNormalized()) {
379 const normalizedConfigs = await normalize(this, context);
380 this.length = 0;
381 this.push(...normalizedConfigs.map(this[ConfigArraySymbol.preprocessConfig]));
382 this[ConfigArraySymbol.isNormalized] = true;
383
384 // prevent further changes
385 Object.freeze(this);
386 }
387
388 return this;
389 }
390
391 /**
392 * Finalizes the state of a config before being cached and returned by
393 * `getConfig()`. Does nothing by default but is provided to be
394 * overridden by subclasses as necessary.
395 * @param {Object} config The config to finalize.
396 * @returns {Object} The finalized config.
397 */
398 [ConfigArraySymbol.finalizeConfig](config) {
399 return config;
400 }
401
402 /**
403 * Preprocesses a config during the normalization process. This is the
404 * method to override if you want to convert an array item before it is
405 * validated for the first time. For example, if you want to replace a
406 * string with an object, this is the method to override.
407 * @param {Object} config The config to preprocess.
408 * @returns {Object} The config to use in place of the argument.
409 */
410 [ConfigArraySymbol.preprocessConfig](config) {
411 return config;
412 }
413
414 /**
415 * Returns the config object for a given file path.
416 * @param {string} filePath The complete path of a file to get a config for.
417 * @returns {Object} The config object for this file.
418 */
419 getConfig(filePath) {
420
421 assertNormalized(this);
422
423 // first check the cache to avoid duplicate work
424 let finalConfig = this[ConfigArraySymbol.configCache].get(filePath);
425
426 if (finalConfig) {
427 return finalConfig;
428 }
429
430 // No config found in cache, so calculate a new one
431
432 const matchingConfigs = [];
433
434 for (const config of this) {
435 if (pathMatches(filePath, this.basePath, config)) {
436 debug(`Matching config found for ${filePath}`);
437 matchingConfigs.push(config);
438 } else {
439 debug(`No matching config found for ${filePath}`);
440 }
441 }
442
443 finalConfig = matchingConfigs.reduce((result, config) => {
444 return this[ConfigArraySymbol.schema].merge(result, config);
445 }, {}, this);
446
447 finalConfig = this[ConfigArraySymbol.finalizeConfig](finalConfig);
448
449 this[ConfigArraySymbol.configCache].set(filePath, finalConfig);
450
451 return finalConfig;
452 }
453
454}
455
456exports.ConfigArray = ConfigArray;
457exports.ConfigArraySymbol = ConfigArraySymbol;