odin-js-fundamentals-part-5/node_modules/@humanwhocodes/config-array/api.js

1012 lines
27 KiB
JavaScript
Raw Permalink Normal View History

2024-01-11 08:52:05 +00:00
'use strict';
var path = require('path');
var minimatch = require('minimatch');
var createDebug = require('debug');
var objectSchema = require('@humanwhocodes/object-schema');
/**
* @fileoverview ConfigSchema
* @author Nicholas C. Zakas
*/
//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------
/**
* Assets that a given value is an array.
* @param {*} value The value to check.
* @returns {void}
* @throws {TypeError} When the value is not an array.
*/
function assertIsArray(value) {
if (!Array.isArray(value)) {
throw new TypeError('Expected value to be an array.');
}
}
/**
* Assets that a given value is an array containing only strings and functions.
* @param {*} value The value to check.
* @returns {void}
* @throws {TypeError} When the value is not an array of strings and functions.
*/
function assertIsArrayOfStringsAndFunctions(value, name) {
assertIsArray(value);
if (value.some(item => typeof item !== 'string' && typeof item !== 'function')) {
throw new TypeError('Expected array to only contain strings.');
}
}
//------------------------------------------------------------------------------
// Exports
//------------------------------------------------------------------------------
/**
* The base schema that every ConfigArray uses.
* @type Object
*/
const baseSchema = Object.freeze({
name: {
required: false,
merge() {
return undefined;
},
validate(value) {
if (typeof value !== 'string') {
throw new TypeError('Property must be a string.');
}
}
},
files: {
required: false,
merge() {
return undefined;
},
validate(value) {
// first check if it's an array
assertIsArray(value);
// then check each member
value.forEach(item => {
if (Array.isArray(item)) {
assertIsArrayOfStringsAndFunctions(item);
} else if (typeof item !== 'string' && typeof item !== 'function') {
throw new TypeError('Items must be a string, a function, or an array of strings and functions.');
}
});
}
},
ignores: {
required: false,
merge() {
return undefined;
},
validate: assertIsArrayOfStringsAndFunctions
}
});
/**
* @fileoverview ConfigArray
* @author Nicholas C. Zakas
*/
//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------
const Minimatch = minimatch.Minimatch;
const minimatchCache = new Map();
const negatedMinimatchCache = new Map();
const debug = createDebug('@hwc/config-array');
const MINIMATCH_OPTIONS = {
// matchBase: true,
dot: true
};
const CONFIG_TYPES = new Set(['array', 'function']);
/**
* Shorthand for checking if a value is a string.
* @param {any} value The value to check.
* @returns {boolean} True if a string, false if not.
*/
function isString(value) {
return typeof value === 'string';
}
/**
* Asserts that the files key of a config object is a nonempty array.
* @param {object} config The config object to check.
* @returns {void}
* @throws {TypeError} If the files key isn't a nonempty array.
*/
function assertNonEmptyFilesArray(config) {
if (!Array.isArray(config.files) || config.files.length === 0) {
throw new TypeError('The files key must be a non-empty array.');
}
}
/**
* Wrapper around minimatch that caches minimatch patterns for
* faster matching speed over multiple file path evaluations.
* @param {string} filepath The file path to match.
* @param {string} pattern The glob pattern to match against.
* @param {object} options The minimatch options to use.
* @returns
*/
function doMatch(filepath, pattern, options = {}) {
let cache = minimatchCache;
if (options.flipNegate) {
cache = negatedMinimatchCache;
}
let matcher = cache.get(pattern);
if (!matcher) {
matcher = new Minimatch(pattern, Object.assign({}, MINIMATCH_OPTIONS, options));
cache.set(pattern, matcher);
}
return matcher.match(filepath);
}
/**
* Normalizes a `ConfigArray` by flattening it and executing any functions
* that are found inside.
* @param {Array} items The items in a `ConfigArray`.
* @param {Object} context The context object to pass into any function
* found.
* @param {Array<string>} extraConfigTypes The config types to check.
* @returns {Promise<Array>} A flattened array containing only config objects.
* @throws {TypeError} When a config function returns a function.
*/
async function normalize(items, context, extraConfigTypes) {
const allowFunctions = extraConfigTypes.includes('function');
const allowArrays = extraConfigTypes.includes('array');
async function* flatTraverse(array) {
for (let item of array) {
if (typeof item === 'function') {
if (!allowFunctions) {
throw new TypeError('Unexpected function.');
}
item = item(context);
if (item.then) {
item = await item;
}
}
if (Array.isArray(item)) {
if (!allowArrays) {
throw new TypeError('Unexpected array.');
}
yield* flatTraverse(item);
} else if (typeof item === 'function') {
throw new TypeError('A config function can only return an object or array.');
} else {
yield item;
}
}
}
/*
* Async iterables cannot be used with the spread operator, so we need to manually
* create the array to return.
*/
const asyncIterable = await flatTraverse(items);
const configs = [];
for await (const config of asyncIterable) {
configs.push(config);
}
return configs;
}
/**
* Normalizes a `ConfigArray` by flattening it and executing any functions
* that are found inside.
* @param {Array} items The items in a `ConfigArray`.
* @param {Object} context The context object to pass into any function
* found.
* @param {Array<string>} extraConfigTypes The config types to check.
* @returns {Array} A flattened array containing only config objects.
* @throws {TypeError} When a config function returns a function.
*/
function normalizeSync(items, context, extraConfigTypes) {
const allowFunctions = extraConfigTypes.includes('function');
const allowArrays = extraConfigTypes.includes('array');
function* flatTraverse(array) {
for (let item of array) {
if (typeof item === 'function') {
if (!allowFunctions) {
throw new TypeError('Unexpected function.');
}
item = item(context);
if (item.then) {
throw new TypeError('Async config functions are not supported.');
}
}
if (Array.isArray(item)) {
if (!allowArrays) {
throw new TypeError('Unexpected array.');
}
yield* flatTraverse(item);
} else if (typeof item === 'function') {
throw new TypeError('A config function can only return an object or array.');
} else {
yield item;
}
}
}
return [...flatTraverse(items)];
}
/**
* Determines if a given file path should be ignored based on the given
* matcher.
* @param {Array<string|() => boolean>} ignores The ignore patterns to check.
* @param {string} filePath The absolute path of the file to check.
* @param {string} relativeFilePath The relative path of the file to check.
* @returns {boolean} True if the path should be ignored and false if not.
*/
function shouldIgnorePath(ignores, filePath, relativeFilePath) {
// all files outside of the basePath are ignored
if (relativeFilePath.startsWith('..')) {
return true;
}
return ignores.reduce((ignored, matcher) => {
if (!ignored) {
if (typeof matcher === 'function') {
return matcher(filePath);
}
// don't check negated patterns because we're not ignored yet
if (!matcher.startsWith('!')) {
return doMatch(relativeFilePath, matcher);
}
// otherwise we're still not ignored
return false;
}
// only need to check negated patterns because we're ignored
if (typeof matcher === 'string' && matcher.startsWith('!')) {
return !doMatch(relativeFilePath, matcher, {
flipNegate: true
});
}
return ignored;
}, false);
}
/**
* Determines if a given file path is matched by a config based on
* `ignores` only.
* @param {string} filePath The absolute file path to check.
* @param {string} basePath The base path for the config.
* @param {Object} config The config object to check.
* @returns {boolean} True if the file path is matched by the config,
* false if not.
*/
function pathMatchesIgnores(filePath, basePath, config) {
/*
* For both files and ignores, functions are passed the absolute
* file path while strings are compared against the relative
* file path.
*/
const relativeFilePath = path.relative(basePath, filePath);
return Object.keys(config).length > 1 &&
!shouldIgnorePath(config.ignores, filePath, relativeFilePath);
}
/**
* Determines if a given file path is matched by a config. If the config
* has no `files` field, then it matches; otherwise, if a `files` field
* is present then we match the globs in `files` and exclude any globs in
* `ignores`.
* @param {string} filePath The absolute file path to check.
* @param {string} basePath The base path for the config.
* @param {Object} config The config object to check.
* @returns {boolean} True if the file path is matched by the config,
* false if not.
*/
function pathMatches(filePath, basePath, config) {
/*
* For both files and ignores, functions are passed the absolute
* file path while strings are compared against the relative
* file path.
*/
const relativeFilePath = path.relative(basePath, filePath);
// if files isn't an array, throw an error
assertNonEmptyFilesArray(config);
// match both strings and functions
const match = pattern => {
if (isString(pattern)) {
return doMatch(relativeFilePath, pattern);
}
if (typeof pattern === 'function') {
return pattern(filePath);
}
throw new TypeError(`Unexpected matcher type ${pattern}.`);
};
// check for all matches to config.files
let filePathMatchesPattern = config.files.some(pattern => {
if (Array.isArray(pattern)) {
return pattern.every(match);
}
return match(pattern);
});
/*
* If the file path matches the config.files patterns, then check to see
* if there are any files to ignore.
*/
if (filePathMatchesPattern && config.ignores) {
filePathMatchesPattern = !shouldIgnorePath(config.ignores, filePath, relativeFilePath);
}
return filePathMatchesPattern;
}
/**
* Ensures that a ConfigArray has been normalized.
* @param {ConfigArray} configArray The ConfigArray to check.
* @returns {void}
* @throws {Error} When the `ConfigArray` is not normalized.
*/
function assertNormalized(configArray) {
// TODO: Throw more verbose error
if (!configArray.isNormalized()) {
throw new Error('ConfigArray must be normalized to perform this operation.');
}
}
/**
* Ensures that config types are valid.
* @param {Array<string>} extraConfigTypes The config types to check.
* @returns {void}
* @throws {Error} When the config types array is invalid.
*/
function assertExtraConfigTypes(extraConfigTypes) {
if (extraConfigTypes.length > 2) {
throw new TypeError('configTypes must be an array with at most two items.');
}
for (const configType of extraConfigTypes) {
if (!CONFIG_TYPES.has(configType)) {
throw new TypeError(`Unexpected config type "${configType}" found. Expected one of: "object", "array", "function".`);
}
}
}
//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
const ConfigArraySymbol = {
isNormalized: Symbol('isNormalized'),
configCache: Symbol('configCache'),
schema: Symbol('schema'),
finalizeConfig: Symbol('finalizeConfig'),
preprocessConfig: Symbol('preprocessConfig')
};
// used to store calculate data for faster lookup
const dataCache = new WeakMap();
/**
* Represents an array of config objects and provides method for working with
* those config objects.
*/
class ConfigArray extends Array {
/**
* Creates a new instance of ConfigArray.
* @param {Iterable|Function|Object} configs An iterable yielding config
* objects, or a config function, or a config object.
* @param {string} [options.basePath=""] The path of the config file
* @param {boolean} [options.normalized=false] Flag indicating if the
* configs have already been normalized.
* @param {Object} [options.schema] The additional schema
* definitions to use for the ConfigArray schema.
* @param {Array<string>} [options.configTypes] List of config types supported.
*/
constructor(configs, {
basePath = '',
normalized = false,
schema: customSchema,
extraConfigTypes = []
} = {}
) {
super();
/**
* Tracks if the array has been normalized.
* @property isNormalized
* @type boolean
* @private
*/
this[ConfigArraySymbol.isNormalized] = normalized;
/**
* The schema used for validating and merging configs.
* @property schema
* @type ObjectSchema
* @private
*/
this[ConfigArraySymbol.schema] = new objectSchema.ObjectSchema(
Object.assign({}, customSchema, baseSchema)
);
/**
* The path of the config file that this array was loaded from.
* This is used to calculate filename matches.
* @property basePath
* @type string
*/
this.basePath = basePath;
assertExtraConfigTypes(extraConfigTypes);
/**
* The supported config types.
* @property configTypes
* @type Array<string>
*/
this.extraConfigTypes = Object.freeze([...extraConfigTypes]);
/**
* A cache to store calculated configs for faster repeat lookup.
* @property configCache
* @type Map
* @private
*/
this[ConfigArraySymbol.configCache] = new Map();
// init cache
dataCache.set(this, {
explicitMatches: new Map(),
directoryMatches: new Map(),
files: undefined,
ignores: undefined
});
// load the configs into this array
if (Array.isArray(configs)) {
this.push(...configs);
} else {
this.push(configs);
}
}
/**
* Prevent normal array methods from creating a new `ConfigArray` instance.
* This is to ensure that methods such as `slice()` won't try to create a
* new instance of `ConfigArray` behind the scenes as doing so may throw
* an error due to the different constructor signature.
* @returns {Function} The `Array` constructor.
*/
static get [Symbol.species]() {
return Array;
}
/**
* Returns the `files` globs from every config object in the array.
* This can be used to determine which files will be matched by a
* config array or to use as a glob pattern when no patterns are provided
* for a command line interface.
* @returns {Array<string|Function>} An array of matchers.
*/
get files() {
assertNormalized(this);
// if this data has been cached, retrieve it
const cache = dataCache.get(this);
if (cache.files) {
return cache.files;
}
// otherwise calculate it
const result = [];
for (const config of this) {
if (config.files) {
config.files.forEach(filePattern => {
result.push(filePattern);
});
}
}
// store result
cache.files = result;
dataCache.set(this, cache);
return result;
}
/**
* Returns ignore matchers that should always be ignored regardless of
* the matching `files` fields in any configs. This is necessary to mimic
* the behavior of things like .gitignore and .eslintignore, allowing a
* globbing operation to be faster.
* @returns {string[]} An array of string patterns and functions to be ignored.
*/
get ignores() {
assertNormalized(this);
// if this data has been cached, retrieve it
const cache = dataCache.get(this);
if (cache.ignores) {
return cache.ignores;
}
// otherwise calculate it
const result = [];
for (const config of this) {
/*
* We only count ignores if there are no other keys in the object.
* In this case, it acts list a globally ignored pattern. If there
* are additional keys, then ignores act like exclusions.
*/
if (config.ignores && Object.keys(config).length === 1) {
/*
* If there are directory ignores, then we need to double up
* the patterns to be ignored. For instance, `foo` will also
* need `foo/**` in order to account for subdirectories.
*/
config.ignores.forEach(ignore => {
result.push(ignore);
if (typeof ignore === 'string') {
// unignoring files won't work unless we unignore directories too
if (ignore.startsWith('!')) {
if (ignore.endsWith('/**')) {
result.push(ignore.slice(0, ignore.length - 3));
} else if (ignore.endsWith('/*')) {
result.push(ignore.slice(0, ignore.length - 2));
}
}
// directories should work with or without a trailing slash
if (ignore.endsWith('/')) {
result.push(ignore.slice(0, ignore.length - 1));
result.push(ignore + '**');
} else if (!ignore.endsWith('*')) {
result.push(ignore + '/**');
}
}
});
}
}
// store result
cache.ignores = result;
dataCache.set(this, cache);
return result;
}
/**
* Indicates if the config array has been normalized.
* @returns {boolean} True if the config array is normalized, false if not.
*/
isNormalized() {
return this[ConfigArraySymbol.isNormalized];
}
/**
* Normalizes a config array by flattening embedded arrays and executing
* config functions.
* @param {ConfigContext} context The context object for config functions.
* @returns {Promise<ConfigArray>} The current ConfigArray instance.
*/
async normalize(context = {}) {
if (!this.isNormalized()) {
const normalizedConfigs = await normalize(this, context, this.extraConfigTypes);
this.length = 0;
this.push(...normalizedConfigs.map(this[ConfigArraySymbol.preprocessConfig].bind(this)));
this[ConfigArraySymbol.isNormalized] = true;
// prevent further changes
Object.freeze(this);
}
return this;
}
/**
* Normalizes a config array by flattening embedded arrays and executing
* config functions.
* @param {ConfigContext} context The context object for config functions.
* @returns {ConfigArray} The current ConfigArray instance.
*/
normalizeSync(context = {}) {
if (!this.isNormalized()) {
const normalizedConfigs = normalizeSync(this, context, this.extraConfigTypes);
this.length = 0;
this.push(...normalizedConfigs.map(this[ConfigArraySymbol.preprocessConfig].bind(this)));
this[ConfigArraySymbol.isNormalized] = true;
// prevent further changes
Object.freeze(this);
}
return this;
}
/**
* Finalizes the state of a config before being cached and returned by
* `getConfig()`. Does nothing by default but is provided to be
* overridden by subclasses as necessary.
* @param {Object} config The config to finalize.
* @returns {Object} The finalized config.
*/
[ConfigArraySymbol.finalizeConfig](config) {
return config;
}
/**
* Preprocesses a config during the normalization process. This is the
* method to override if you want to convert an array item before it is
* validated for the first time. For example, if you want to replace a
* string with an object, this is the method to override.
* @param {Object} config The config to preprocess.
* @returns {Object} The config to use in place of the argument.
*/
[ConfigArraySymbol.preprocessConfig](config) {
return config;
}
/**
* Determines if a given file path explicitly matches a `files` entry
* and also doesn't match an `ignores` entry. Configs that don't have
* a `files` property are not considered an explicit match.
* @param {string} filePath The complete path of a file to check.
* @returns {boolean} True if the file path matches a `files` entry
* or false if not.
*/
isExplicitMatch(filePath) {
assertNormalized(this);
const cache = dataCache.get(this);
// first check the cache to avoid duplicate work
let result = cache.explicitMatches.get(filePath);
if (typeof result == 'boolean') {
return result;
}
// TODO: Maybe move elsewhere? Maybe combine with getConfig() logic?
const relativeFilePath = path.relative(this.basePath, filePath);
if (shouldIgnorePath(this.ignores, filePath, relativeFilePath)) {
debug(`Ignoring ${filePath}`);
// cache and return result
cache.explicitMatches.set(filePath, false);
return false;
}
// filePath isn't automatically ignored, so try to find a match
for (const config of this) {
if (!config.files) {
continue;
}
if (pathMatches(filePath, this.basePath, config)) {
debug(`Matching config found for ${filePath}`);
cache.explicitMatches.set(filePath, true);
return true;
}
}
return false;
}
/**
* Returns the config object for a given file path.
* @param {string} filePath The complete path of a file to get a config for.
* @returns {Object} The config object for this file.
*/
getConfig(filePath) {
assertNormalized(this);
const cache = this[ConfigArraySymbol.configCache];
// first check the cache for a filename match to avoid duplicate work
let finalConfig = cache.get(filePath);
if (finalConfig) {
return finalConfig;
}
// next check to see if the file should be ignored
// check if this should be ignored due to its directory
if (this.isDirectoryIgnored(path.dirname(filePath))) {
debug(`Ignoring ${filePath} based on directory pattern`);
// cache and return result - finalConfig is undefined at this point
cache.set(filePath, finalConfig);
return finalConfig;
}
// TODO: Maybe move elsewhere?
const relativeFilePath = path.relative(this.basePath, filePath);
if (shouldIgnorePath(this.ignores, filePath, relativeFilePath)) {
debug(`Ignoring ${filePath} based on file pattern`);
// cache and return result - finalConfig is undefined at this point
cache.set(filePath, finalConfig);
return finalConfig;
}
// filePath isn't automatically ignored, so try to construct config
const matchingConfigIndices = [];
let matchFound = false;
const universalPattern = /\/\*{1,2}$/;
this.forEach((config, index) => {
if (!config.files) {
if (!config.ignores) {
debug(`Anonymous universal config found for ${filePath}`);
matchingConfigIndices.push(index);
return;
}
if (pathMatchesIgnores(filePath, this.basePath, config)) {
debug(`Matching config found for ${filePath} (based on ignores: ${config.ignores})`);
matchingConfigIndices.push(index);
return;
}
debug(`Skipped config found for ${filePath} (based on ignores: ${config.ignores})`);
return;
}
assertNonEmptyFilesArray(config);
/*
* If a config has a files pattern ending in /** or /*, and the
* filePath only matches those patterns, then the config is only
* applied if there is another config where the filePath matches
* a file with a specific extensions such as *.js.
*/
const universalFiles = config.files.filter(
pattern => universalPattern.test(pattern)
);
// universal patterns were found so we need to check the config twice
if (universalFiles.length) {
debug('Universal files patterns found. Checking carefully.');
const nonUniversalFiles = config.files.filter(
pattern => !universalPattern.test(pattern)
);
// check that the config matches without the non-universal files first
if (
nonUniversalFiles.length &&
pathMatches(
filePath, this.basePath,
{ files: nonUniversalFiles, ignores: config.ignores }
)
) {
debug(`Matching config found for ${filePath}`);
matchingConfigIndices.push(index);
matchFound = true;
return;
}
// if there wasn't a match then check if it matches with universal files
if (
universalFiles.length &&
pathMatches(
filePath, this.basePath,
{ files: universalFiles, ignores: config.ignores }
)
) {
debug(`Matching config found for ${filePath}`);
matchingConfigIndices.push(index);
return;
}
// if we make here, then there was no match
return;
}
// the normal case
if (pathMatches(filePath, this.basePath, config)) {
debug(`Matching config found for ${filePath}`);
matchingConfigIndices.push(index);
matchFound = true;
return;
}
});
// if matching both files and ignores, there will be no config to create
if (!matchFound) {
debug(`No matching configs found for ${filePath}`);
// cache and return result - finalConfig is undefined at this point
cache.set(filePath, finalConfig);
return finalConfig;
}
// check to see if there is a config cached by indices
finalConfig = cache.get(matchingConfigIndices.toString());
if (finalConfig) {
// also store for filename for faster lookup next time
cache.set(filePath, finalConfig);
return finalConfig;
}
// otherwise construct the config
finalConfig = matchingConfigIndices.reduce((result, index) => {
return this[ConfigArraySymbol.schema].merge(result, this[index]);
}, {}, this);
finalConfig = this[ConfigArraySymbol.finalizeConfig](finalConfig);
cache.set(filePath, finalConfig);
cache.set(matchingConfigIndices.toString(), finalConfig);
return finalConfig;
}
/**
* Determines if the given filepath is ignored based on the configs.
* @param {string} filePath The complete path of a file to check.
* @returns {boolean} True if the path is ignored, false if not.
* @deprecated Use `isFileIgnored` instead.
*/
isIgnored(filePath) {
return this.isFileIgnored(filePath);
}
/**
* Determines if the given filepath is ignored based on the configs.
* @param {string} filePath The complete path of a file to check.
* @returns {boolean} True if the path is ignored, false if not.
*/
isFileIgnored(filePath) {
return this.getConfig(filePath) === undefined;
}
/**
* Determines if the given directory is ignored based on the configs.
* This checks only default `ignores` that don't have `files` in the
* same config. A pattern such as `/foo` be considered to ignore the directory
* while a pattern such as `/foo/**` is not considered to ignore the
* directory because it is matching files.
* @param {string} directoryPath The complete path of a directory to check.
* @returns {boolean} True if the directory is ignored, false if not. Will
* return true for any directory that is not inside of `basePath`.
* @throws {Error} When the `ConfigArray` is not normalized.
*/
isDirectoryIgnored(directoryPath) {
assertNormalized(this);
const relativeDirectoryPath = path.relative(this.basePath, directoryPath)
.replace(/\\/g, '/');
if (relativeDirectoryPath.startsWith('..')) {
return true;
}
// first check the cache
const cache = dataCache.get(this).directoryMatches;
if (cache.has(relativeDirectoryPath)) {
return cache.get(relativeDirectoryPath);
}
const directoryParts = relativeDirectoryPath.split('/');
let relativeDirectoryToCheck = '';
let result = false;
/*
* In order to get the correct gitignore-style ignores, where an
* ignored parent directory cannot have any descendants unignored,
* we need to check every directory starting at the parent all
* the way down to the actual requested directory.
*
* We aggressively cache all of this info to make sure we don't
* have to recalculate everything for every call.
*/
do {
relativeDirectoryToCheck += directoryParts.shift() + '/';
result = shouldIgnorePath(
this.ignores,
path.join(this.basePath, relativeDirectoryToCheck),
relativeDirectoryToCheck
);
cache.set(relativeDirectoryToCheck, result);
} while (!result && directoryParts.length);
// also cache the result for the requested path
cache.set(relativeDirectoryPath, result);
return result;
}
}
exports.ConfigArray = ConfigArray;
exports.ConfigArraySymbol = ConfigArraySymbol;