Skip to content

feat: support extends configuration from oxlint config content #419

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions src/build-from-oxlint-config/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
} from './types.js';
import { isObject } from './utilities.js';

// default categories, see <https://github.com/oxc-project/oxc/blob/0acca58/crates/oxc_linter/src/builder.rs#L82>
export const defaultCategories: OxlintConfigCategories = {
correctness: 'warn',
};

/**
* appends all rules which are enabled by a plugin and falls into a specific category
*/
Expand Down
113 changes: 113 additions & 0 deletions src/build-from-oxlint-config/extends.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { handleExtendsScope } from './extends.js';
import { OxlintConfig } from './types.js';

describe('handleExtendsScope', () => {
it('should handle empty extends configs', () => {
const extendsConfigs: OxlintConfig[] = [];
const config: OxlintConfig = {
plugins: ['react'],
categories: { correctness: 'warn' },
rules: { eqeqeq: 'error' },
};
handleExtendsScope(extendsConfigs, config);
expect(config).toEqual(config);
});

it('should merge extends configs', () => {
const extendsConfigs: OxlintConfig[] = [
{
plugins: ['react', 'unicorn'],
categories: { correctness: 'error' },
rules: { rule1: 'error' },
},
];
const config: OxlintConfig = {
categories: { correctness: 'warn' },
rules: { rule3: 'warn' },
};
handleExtendsScope(extendsConfigs, config);
expect(config).toEqual({
plugins: ['react', 'unicorn'],
categories: config.categories,
rules: { rule1: 'error', rule3: 'warn' },
});
});

it('should merge extends and de duplicate plugins and rules', () => {
const extendsConfigs: OxlintConfig[] = [
{
plugins: ['react', 'typescript'],
categories: { correctness: 'error' },
rules: { rule1: 'error', rule2: 'error' },
},
];
const config: OxlintConfig = {
plugins: ['react', 'unicorn'],
categories: { correctness: 'warn' },
rules: { rule1: 'warn' },
};
handleExtendsScope(extendsConfigs, config);
expect(config).toEqual({
plugins: ['react', 'typescript', 'unicorn'],
categories: config.categories,
rules: { rule1: 'warn', rule2: 'error' },
});
});

it('should merge multiple extends configs', () => {
const extendsConfigs: OxlintConfig[] = [
{
plugins: ['react', 'unicorn'],
categories: { correctness: 'error' },
rules: { rule1: 'error', rule2: 'error' },
},
{
plugins: ['typescript'],
overrides: [{ files: ['*.ts'], rules: { rule3: 'error' } }],
},
];
const config: OxlintConfig = {
plugins: ['react', 'vitest'],
categories: { correctness: 'warn' },
rules: { rule1: 'warn' },
};
handleExtendsScope(extendsConfigs, config);
expect(config).toEqual({
plugins: ['typescript', 'react', 'unicorn', 'vitest'],
categories: config.categories,
rules: { rule1: 'warn', rule2: 'error' },
overrides: [{ files: ['*.ts'], rules: { rule3: 'error' } }],
});
});

it('should merge multiple extends configs with multiple overrides', () => {
const extendsConfigs: OxlintConfig[] = [
{
plugins: ['react', 'unicorn'],
categories: { correctness: 'error' },
rules: { rule1: 'error', rule2: 'error' },
},
{
plugins: ['typescript'],
overrides: [{ files: ['*.ts'], rules: { rule3: 'error' } }],
},
];
const config: OxlintConfig = {
plugins: ['react', 'vitest'],
categories: { correctness: 'warn' },
rules: { rule1: 'warn' },
overrides: [{ files: ['*.spec.ts'], rules: { rule4: 'error' } }],
};
handleExtendsScope(extendsConfigs, config);
expect(config).toEqual({
plugins: ['typescript', 'react', 'unicorn', 'vitest'],
categories: config.categories,
rules: { rule1: 'warn', rule2: 'error' },
overrides: [
{ files: ['*.ts'], rules: { rule3: 'error' } },
{ files: ['*.spec.ts'], rules: { rule4: 'error' } },
],
});
});
});
101 changes: 101 additions & 0 deletions src/build-from-oxlint-config/extends.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import fs from 'node:fs';
import path from 'node:path';
import type {
OxlintConfig,
OxlintConfigOverride,
OxlintConfigPlugins,
OxlintConfigRules,
OxlintExtendsConfigs,
} from './types.js';
import { getConfigContent } from './utilities.js';
import { defaultPlugins, readPluginsFromConfig } from './plugins.js';
import { readRulesFromConfig } from './rules.js';
import { readOverridesFromConfig } from './overrides.js';

/**
* Checks if the given path is a file.
*
* @param path - The path to check.
* @returns `true` if the path is a file, `false` otherwise.
*/
const isFile = (path: string) => {
try {
return fs.statSync(path).isFile();
} catch {
return false;
}
};

/**
* tries to return the "extends" section from the config.
* it returns `undefined` when not found or invalid.
*/
const readExtendsFromConfig = (
config: OxlintConfig
): OxlintExtendsConfigs | undefined => {
return 'extends' in config && Array.isArray(config.extends)
? (config.extends as OxlintExtendsConfigs)
: undefined;
};

/**
* Resolves the paths of the "extends" section relative to the given config file.
*/
export const resolveRelativeExtendsPaths = (
config: OxlintConfig,
configFile: string
) => {
const extendsFiles = readExtendsFromConfig(config);
if (!extendsFiles || extendsFiles.length === 0) return;
const configFileDirectory = path.dirname(path.resolve(configFile));
config.extends = extendsFiles.map((extendFile) => {
if (isFile(extendFile)) {
return path.resolve(configFileDirectory, extendFile);
}
return extendFile;
});
};

/**
* Appends plugins, rules and overrides from the extends configs files to the given config.
*/
export const handleExtendsScope = (
extendsConfigs: OxlintConfig[],
config: OxlintConfig
) => {
let rules: OxlintConfigRules = readRulesFromConfig(config) ?? {};
const plugins: OxlintConfigPlugins = readPluginsFromConfig(config) ?? [];
const overrides: OxlintConfigOverride[] =
readOverridesFromConfig(config) ?? [];
for (const extendConfig of extendsConfigs) {
plugins.unshift(...(readPluginsFromConfig(extendConfig) ?? defaultPlugins));
rules = { ...readRulesFromConfig(extendConfig), ...rules };
overrides.unshift(...(readOverridesFromConfig(extendConfig) ?? []));
}
if (plugins.length > 0) config.plugins = [...new Set(plugins)];
if (Object.keys(rules).length > 0) config.rules = rules;
if (overrides.length > 0) config.overrides = overrides;
};

/**
* tries to return the content of the chain "extends" section from the config.
* it returns `undefined` when not found or invalid and ignores non file paths.
*/
export const readExtendsConfigsFromConfig = (
config: OxlintConfig
): OxlintConfig[] | undefined => {
const extendsFiles = readExtendsFromConfig(config);
if (!extendsFiles || extendsFiles.length === 0) return undefined;
const extendsConfigs: OxlintConfig[] = [];
for (const file of extendsFiles) {
if (!isFile(file)) continue; // ignore non file paths
const extendConfig = getConfigContent(file);
if (!extendConfig) continue;
resolveRelativeExtendsPaths(extendConfig, file);
extendsConfigs.push(
extendConfig,
...(readExtendsConfigsFromConfig(extendConfig) ?? [])
);
}
return extendsConfigs;
};
65 changes: 16 additions & 49 deletions src/build-from-oxlint-config/index.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,23 @@
import fs from 'node:fs';
import JSONCParser from 'jsonc-parser';
import {
EslintPluginOxlintConfig,
OxlintConfig,
OxlintConfigCategories,
OxlintConfigPlugins,
} from './types.js';
import { isObject } from './utilities.js';
import { EslintPluginOxlintConfig, OxlintConfig } from './types.js';
import { handleRulesScope, readRulesFromConfig } from './rules.js';
import {
defaultCategories,
handleCategoriesScope,
readCategoriesFromConfig,
} from './categories.js';
import { readPluginsFromConfig } from './plugins.js';
import { defaultPlugins, readPluginsFromConfig } from './plugins.js';
import {
handleIgnorePatternsScope,
readIgnorePatternsFromConfig,
} from './ignore-patterns.js';
import { handleOverridesScope, readOverridesFromConfig } from './overrides.js';
import { splitDisabledRulesForVueAndSvelteFiles } from '../config-helper.js';

// default plugins, see <https://oxc.rs/docs/guide/usage/linter/config#plugins>
const defaultPlugins: OxlintConfigPlugins = ['react', 'unicorn', 'typescript'];

// default categories, see <https://github.com/oxc-project/oxc/blob/0acca58/crates/oxc_linter/src/builder.rs#L82>
const defaultCategories: OxlintConfigCategories = { correctness: 'warn' };

/**
* tries to read the oxlint config file and returning its JSON content.
* if the file is not found or could not be parsed, undefined is returned.
* And an error message will be emitted to `console.error`
*/
const getConfigContent = (
oxlintConfigFile: string
): OxlintConfig | undefined => {
try {
const content = fs.readFileSync(oxlintConfigFile, 'utf8');

try {
const configContent = JSONCParser.parse(content);

if (!isObject(configContent)) {
throw new TypeError('not an valid config file');
}

return configContent;
} catch {
console.error(
`eslint-plugin-oxlint: could not parse oxlint config file: ${oxlintConfigFile}`
);
return undefined;
}
} catch {
console.error(
`eslint-plugin-oxlint: could not find oxlint config file: ${oxlintConfigFile}`
);
return undefined;
}
};
import {
handleExtendsScope,
readExtendsConfigsFromConfig,
resolveRelativeExtendsPaths,
} from './extends.js';
import { getConfigContent } from './utilities.js';

/**
* builds turned off rules, which are supported by oxlint.
Expand All @@ -66,6 +26,11 @@ const getConfigContent = (
export const buildFromOxlintConfig = (
config: OxlintConfig
): EslintPluginOxlintConfig[] => {
const extendConfigs = readExtendsConfigsFromConfig(config);
if (extendConfigs !== undefined && extendConfigs.length > 0) {
handleExtendsScope(extendConfigs, config);
}

const rules: Record<string, 'off'> = {};
const plugins = readPluginsFromConfig(config) ?? defaultPlugins;
const categories = readCategoriesFromConfig(config) ?? defaultCategories;
Expand Down Expand Up @@ -128,5 +93,7 @@ export const buildFromOxlintConfigFile = (
return [];
}

resolveRelativeExtendsPaths(config, oxlintConfigFile);

return buildFromOxlintConfig(config);
};
7 changes: 7 additions & 0 deletions src/build-from-oxlint-config/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import {
OxlintConfigPlugins,
} from './types.js';

// default plugins, see <https://oxc.rs/docs/guide/usage/linter/config#plugins>
export const defaultPlugins: OxlintConfigPlugins = [
'react',
'unicorn',
'typescript',
];

/**
* tries to return the "plugins" section from the config.
* it returns `undefined` when not found or invalid.
Expand Down
3 changes: 3 additions & 0 deletions src/build-from-oxlint-config/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Linter } from 'eslint';

export type OxlintExtendsConfigs = string[];

export type OxlintConfigPlugins = string[];

export type OxlintConfigCategories = Record<string, unknown>;
Expand All @@ -18,6 +20,7 @@ export type OxlintConfigOverride = {

export type OxlintConfig = {
[key: string]: unknown;
extends?: OxlintExtendsConfigs;
plugins?: OxlintConfigPlugins;
categories?: OxlintConfigCategories;
rules?: OxlintConfigRules;
Expand Down
37 changes: 37 additions & 0 deletions src/build-from-oxlint-config/utilities.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
import fs from 'node:fs';
import JSONCParser from 'jsonc-parser';
import { OxlintConfig } from './types.js';

/**
* Detects it the value is an object
*/
export const isObject = (value: unknown): boolean =>
typeof value === 'object' && value !== null && !Array.isArray(value);

/**
* tries to read the oxlint config file and returning its JSON content.
* if the file is not found or could not be parsed, undefined is returned.
* And an error message will be emitted to `console.error`
*/
export const getConfigContent = (
oxlintConfigFile: string
): OxlintConfig | undefined => {
try {
const content = fs.readFileSync(oxlintConfigFile, 'utf8');

try {
const configContent = JSONCParser.parse(content);

if (!isObject(configContent)) {
throw new TypeError('not an valid config file');
}

return configContent;
} catch {
console.error(
`eslint-plugin-oxlint: could not parse oxlint config file: ${oxlintConfigFile}`
);
return undefined;
}
} catch {
console.error(
`eslint-plugin-oxlint: could not find oxlint config file: ${oxlintConfigFile}`
);
return undefined;
}
};