Skip to content

Commit

Permalink
Add function to load config with child configs
Browse files Browse the repository at this point in the history
  • Loading branch information
Mrtenz committed Sep 25, 2024
1 parent 0e669be commit 9518969
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 2 deletions.
51 changes: 51 additions & 0 deletions packages/base/src/file-system.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { readdir } from 'fs/promises';
import { join, sep } from 'path';

/**
* @typedef {import('eslint').Linter.Config} Config
* @typedef {{ path: string; config: Config }} ConfigFile
*/

/**
* Config file names for ESLint, that are supported by the config loading
* functionality in this package.
*
* @type {string[]}
*/
const ESLINT_CONFIG_NAMES = [
'eslint.config.js',
'eslint.config.cjs',
'eslint.config.mjs',
];

/**
* Get all ESLint config files in the workspace.
*
* @param {string} workspaceRoot - The absolute path to the root directory of
* the workspace.
* @returns {Promise<ConfigFile[]>} A promise that resolves to an array of
* ESLint configs with their paths.
*/
export async function getConfigFiles(workspaceRoot) {
const files = await readdir(workspaceRoot, {
recursive: true,
withFileTypes: true,
});

return files.reduce(async (promise, file) => {
const accumulator = await promise;
if (!file.isFile() || !ESLINT_CONFIG_NAMES.includes(file.name)) {
return accumulator;
}

const segments = file.parentPath.split(sep);
if (segments.includes('node_modules')) {
return accumulator;
}

const path = join(file.parentPath, file.name);
const config = await import(path);

return [...accumulator, { path, config: config.default }];
}, Promise.resolve([]));
}
88 changes: 88 additions & 0 deletions packages/base/src/file-system.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';

import { getConfigFiles } from './file-system.mjs';

vi.mock('fs/promises', (importOriginal) => ({
...importOriginal,
readdir: async () => [
{ isFile: () => true, parentPath: '/foo/bar', name: 'eslint.config.js' },
{
isFile: () => true,
parentPath: '/foo/bar/baz',
name: 'eslint.config.cjs',
},
{
isFile: () => true,
parentPath: '/foo/bar/baz/qux',
name: 'eslint.config.mjs',
},
{
isFile: () => true,
parentPath: '/foo/bar/node_modules/package',
name: 'eslint.config.js',
},
{
isFile: () => false,
parentPath: '/not/a/file',
name: 'eslint.config.js',
},
],
}));

vi.mock('/foo/bar/eslint.config.js', () => ({
default: {
rules: {
'no-console': 'error',
},
},
}));

vi.mock('/foo/bar/baz/eslint.config.cjs', () => ({
default: {
rules: {
'no-console': 'warn',
},
},
}));

vi.mock('/foo/bar/baz/qux/eslint.config.mjs', () => ({
default: {
rules: {
'no-console': 'off',
},
},
}));

describe('getConfigFiles', () => {
it('returns an array of ESLint config files with their paths', async () => {
const workspaceRoot = '/foo/bar';
const configFiles = await getConfigFiles(workspaceRoot);

expect(configFiles).toStrictEqual([
{
path: '/foo/bar/eslint.config.js',
config: {
rules: {
'no-console': 'error',
},
},
},
{
path: '/foo/bar/baz/eslint.config.cjs',
config: {
rules: {
'no-console': 'warn',
},
},
},
{
path: '/foo/bar/baz/qux/eslint.config.mjs',
config: {
rules: {
'no-console': 'off',
},
},
},
]);
});
});
111 changes: 111 additions & 0 deletions packages/base/src/utils.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { dirname, join, relative } from 'path';

import { getConfigFiles } from './file-system.mjs';

/**
* @typedef {import('eslint').Linter.Config} Config
* @typedef {Config & { extends?: Config | Config[] | Config[][] }} ConfigWithExtends
* @typedef {{ path: string; config: Config }} ConfigFile
*/

/**
Expand Down Expand Up @@ -103,3 +108,109 @@ export function createConfig(configs) {
return [getExtendedConfig(extendsValue, extension), originalConfig];
});
}

/**
* Get the files array, including the path for the workspace.
*
* @param {string[]} files - The files array.
* @param {string} workspacePath - The path to the workspace.
* @returns {string[]} The files array with the workspace path.
*/
function getWorkspaceFiles(files, workspacePath) {
return files.map((file) => join(workspacePath, file));
}

/**
* Get the config object for the child workspace with the correct paths. This
* updates the `files` and `ignores` properties with the correct paths relative
* to the workspace root.
*
* @param {ConfigFile} configFile - The config file object.
* @param {Config} configFile.config - The config object.
* @param {string} configFile.path - The path to the config file.
* @param {string} workspaceRoot - The absolute path to the root directory of
* the workspace.
* @returns {Config} The config object with the correct paths.
*/
function getWorkspaceConfig({ config, path }, workspaceRoot) {
const relativePath = relative(workspaceRoot, path);
const baseWorkspaceDirectory = dirname(relativePath);

if (!config.files && !config.ignores) {
return {
...config,
files: [join(baseWorkspaceDirectory, '**')],
};
}

const extension = {
...(config.files && {
files: getWorkspaceFiles(config.files, baseWorkspaceDirectory),
}),
...(config.ignores && {
ignores: getWorkspaceFiles(config.ignores, baseWorkspaceDirectory),
}),
};

return {
...config,
...extension,
};
}

/**
* Create a config object that is extendable through other config files on the
* file system inside the same workspace.
*
* This function is a wrapper around `createConfig`, but fetches the config
* objects from the file system, and merges them with the provided config
* objects.
*
* @param {ConfigWithExtends | ConfigWithExtends[]} configs - An array of config
* objects.
* @param {string} workspaceRoot - The absolute path to the root directory of
* the workspace, i.e., `import.meta.dirname`.
* @returns {Promise<Config[]>} A promise that resolves to an array of config
* objects with all `extends` properties resolved.
* @example Basic usage.
* import { createConfig } from '@metamask/eslint-config';
* import typescript from '@metamask/eslint-config-typescript';
*
* // Loads all child workspace configs and merges them with the provided
* // config objects.
* const configs = createWorkspaceConfig([
* {
* files: ['**\/*.ts'],
* extends: typescript,
* },
* ]);
*
* export default configs;
*
* @example Multiple extends are supported as well.
* import { createConfig } from '@metamask/eslint-config';
* import typescript from '@metamask/eslint-config-typescript';
* import nodejs from '@metamask/eslint-config-nodejs';
*
* // Loads all child workspace configs and merges them with the provided
* // config objects.
* const configs = createConfig([
* {
* files: ['**\/*.ts'],
* extends: [typescript, nodejs],
* },
* ]);
*
* export default configs;
*/
export async function createWorkspaceConfig(configs, workspaceRoot) {
const baseConfig = createConfig(configs);
const workspaceConfigs = await getConfigFiles(workspaceRoot);

return [
...baseConfig,
...workspaceConfigs.map((config) =>
getWorkspaceConfig(config, workspaceRoot),
),
];
}
62 changes: 60 additions & 2 deletions packages/base/src/utils.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';

import { createConfig } from './utils.mjs';
import { getConfigFiles } from './file-system.mjs';
import { createConfig, createWorkspaceConfig } from './utils.mjs';

vi.mock('./file-system.mjs', {
getConfigFiles: vi.fn(),
});

describe('createConfig', () => {
it('returns a config array for a single config', () => {
Expand Down Expand Up @@ -66,3 +71,56 @@ describe('createConfig', () => {
]);
});
});

describe('createExtendableConfig', () => {
it('returns a config array', async () => {
vi.mocked(getConfigFiles).mockResolvedValue([]);
const configs = { files: ['**/*.js'] };

const result = await createWorkspaceConfig(configs);
expect(result).toStrictEqual([configs]);
});

it('returns a config array with extended configs', async () => {
vi.mocked(getConfigFiles).mockResolvedValue([
{
path: '/workspace/child-a/config.js',
config: {
files: ['**/*.js'],
rules: {
'some-rule': 'error',
},
},
},
{
path: '/workspace/child-b/config.js',
config: {
files: ['**/*.ts', '**/*.tsx'],
rules: {
'some-other-rule': 'error',
},
},
},
]);

const configs = { extends: [{ files: ['**/*.js'] }], languageOptions: {} };

const result = await createWorkspaceConfig(configs, '/workspace');
expect(result).toStrictEqual([
{ files: ['**/*.js'] },
{ languageOptions: {} },
{
files: ['child-a/**/*.js'],
rules: {
'some-rule': 'error',
},
},
{
files: ['child-b/**/*.ts', 'child-b/**/*.tsx'],
rules: {
'some-other-rule': 'error',
},
},
]);
});
});

0 comments on commit 9518969

Please sign in to comment.