Skip to content

Commit b2f3767

Browse files
yunarchSysix
andauthored
feat: support extends configuration from oxlint config content (#419)
## Description This PR introduces an initial implementation to support the extends property in configuration files and follow the full chain of extended files. Currently, it handles merging of only the following fields: - `rules` - `plugins` - `overrides` These are the only fields currently supported by oxlint, and it doesn’t appear necessary to support others at this stage. Fixes #386 --------- Co-authored-by: Sysix <[email protected]>
1 parent 281dc1b commit b2f3767

File tree

7 files changed

+303
-49
lines changed

7 files changed

+303
-49
lines changed

src/build-from-oxlint-config/categories.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {
77
} from './types.js';
88
import { isObject } from './utilities.js';
99

10+
// default categories, see <https://github.com/oxc-project/oxc/blob/0acca58/crates/oxc_linter/src/builder.rs#L82>
11+
export const defaultCategories: OxlintConfigCategories = {
12+
correctness: 'warn',
13+
};
14+
1015
/**
1116
* appends all rules which are enabled by a plugin and falls into a specific category
1217
*/
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { handleExtendsScope, resolveRelativeExtendsPaths } from './extends.js';
3+
import { OxlintConfig } from './types.js';
4+
5+
describe('handleExtendsScope', () => {
6+
it('should handle empty extends configs', () => {
7+
const extendsConfigs: OxlintConfig[] = [];
8+
const config: OxlintConfig = {
9+
plugins: ['react'],
10+
categories: { correctness: 'warn' },
11+
rules: { eqeqeq: 'error' },
12+
};
13+
handleExtendsScope(extendsConfigs, config);
14+
expect(config).toEqual(config);
15+
});
16+
17+
it('should merge extends configs', () => {
18+
const extendsConfigs: OxlintConfig[] = [
19+
{
20+
plugins: ['react', 'unicorn'],
21+
categories: { correctness: 'error' },
22+
rules: { rule1: 'error' },
23+
},
24+
];
25+
const config: OxlintConfig = {
26+
categories: { correctness: 'warn' },
27+
rules: { rule3: 'warn' },
28+
};
29+
handleExtendsScope(extendsConfigs, config);
30+
expect(config).toEqual({
31+
plugins: ['react', 'unicorn'],
32+
categories: config.categories,
33+
rules: { rule1: 'error', rule3: 'warn' },
34+
});
35+
});
36+
37+
it('should merge extends and de duplicate plugins and rules', () => {
38+
const extendsConfigs: OxlintConfig[] = [
39+
{
40+
plugins: ['react', 'typescript'],
41+
categories: { correctness: 'error' },
42+
rules: { rule1: 'error', rule2: 'error' },
43+
},
44+
];
45+
const config: OxlintConfig = {
46+
plugins: ['react', 'unicorn'],
47+
categories: { correctness: 'warn' },
48+
rules: { rule1: 'warn' },
49+
};
50+
handleExtendsScope(extendsConfigs, config);
51+
expect(config).toEqual({
52+
plugins: ['react', 'typescript', 'unicorn'],
53+
categories: config.categories,
54+
rules: { rule1: 'warn', rule2: 'error' },
55+
});
56+
});
57+
58+
it('should merge multiple extends configs', () => {
59+
const extendsConfigs: OxlintConfig[] = [
60+
{
61+
plugins: ['react', 'unicorn'],
62+
categories: { correctness: 'error' },
63+
rules: { rule1: 'error', rule2: 'error' },
64+
},
65+
{
66+
plugins: ['typescript'],
67+
overrides: [{ files: ['*.ts'], rules: { rule3: 'error' } }],
68+
},
69+
];
70+
const config: OxlintConfig = {
71+
plugins: ['react', 'vitest'],
72+
categories: { correctness: 'warn' },
73+
rules: { rule1: 'warn' },
74+
};
75+
handleExtendsScope(extendsConfigs, config);
76+
expect(config).toEqual({
77+
plugins: ['typescript', 'react', 'unicorn', 'vitest'],
78+
categories: config.categories,
79+
rules: { rule1: 'warn', rule2: 'error' },
80+
overrides: [{ files: ['*.ts'], rules: { rule3: 'error' } }],
81+
});
82+
});
83+
84+
it('should merge multiple extends configs with multiple overrides', () => {
85+
const extendsConfigs: OxlintConfig[] = [
86+
{
87+
plugins: ['react', 'unicorn'],
88+
categories: { correctness: 'error' },
89+
rules: { rule1: 'error', rule2: 'error' },
90+
},
91+
{
92+
plugins: ['typescript'],
93+
overrides: [{ files: ['*.ts'], rules: { rule3: 'error' } }],
94+
},
95+
];
96+
const config: OxlintConfig = {
97+
plugins: ['react', 'vitest'],
98+
categories: { correctness: 'warn' },
99+
rules: { rule1: 'warn' },
100+
overrides: [{ files: ['*.spec.ts'], rules: { rule4: 'error' } }],
101+
};
102+
handleExtendsScope(extendsConfigs, config);
103+
expect(config).toEqual({
104+
plugins: ['typescript', 'react', 'unicorn', 'vitest'],
105+
categories: config.categories,
106+
rules: { rule1: 'warn', rule2: 'error' },
107+
overrides: [
108+
{ files: ['*.ts'], rules: { rule3: 'error' } },
109+
{ files: ['*.spec.ts'], rules: { rule4: 'error' } },
110+
],
111+
});
112+
});
113+
});
114+
115+
describe('resolveRelativeExtendsPaths', () => {
116+
it('should resolve relative paths', () => {
117+
const config: OxlintConfig = {
118+
extends: [
119+
'./extends1.json',
120+
'./folder/extends2.json',
121+
'../parent/extends3.json',
122+
],
123+
__misc: {
124+
filePath: '/root/of/the/file/test-config.json',
125+
},
126+
};
127+
resolveRelativeExtendsPaths(config);
128+
129+
expect(config.extends).toEqual([
130+
'/root/of/the/file/extends1.json',
131+
'/root/of/the/file/folder/extends2.json',
132+
'/root/of/the/parent/extends3.json',
133+
]);
134+
});
135+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import path from 'node:path';
2+
import type {
3+
OxlintConfig,
4+
OxlintConfigOverride,
5+
OxlintConfigPlugins,
6+
OxlintConfigRules,
7+
OxlintConfigExtends,
8+
} from './types.js';
9+
import { getConfigContent } from './utilities.js';
10+
import { defaultPlugins, readPluginsFromConfig } from './plugins.js';
11+
import { readRulesFromConfig } from './rules.js';
12+
import { readOverridesFromConfig } from './overrides.js';
13+
14+
/**
15+
* tries to return the "extends" section from the config.
16+
* it returns `undefined` when not found or invalid.
17+
*/
18+
const readExtendsFromConfig = (
19+
config: OxlintConfig
20+
): OxlintConfigExtends | undefined => {
21+
return 'extends' in config && Array.isArray(config.extends)
22+
? (config.extends as OxlintConfigExtends)
23+
: undefined;
24+
};
25+
26+
/**
27+
* Resolves the paths of the "extends" section relative to the given config file.
28+
*/
29+
export const resolveRelativeExtendsPaths = (config: OxlintConfig) => {
30+
if (!config.__misc?.filePath) {
31+
return;
32+
}
33+
34+
const extendsFiles = readExtendsFromConfig(config);
35+
if (!extendsFiles?.length) return;
36+
const configFileDirectory = path.dirname(config.__misc.filePath);
37+
config.extends = extendsFiles.map((extendFile) =>
38+
path.resolve(configFileDirectory, extendFile)
39+
);
40+
};
41+
42+
/**
43+
* Appends plugins, rules and overrides from the extends configs files to the given config.
44+
*/
45+
export const handleExtendsScope = (
46+
extendsConfigs: OxlintConfig[],
47+
config: OxlintConfig
48+
) => {
49+
let rules: OxlintConfigRules = readRulesFromConfig(config) ?? {};
50+
const plugins: OxlintConfigPlugins = readPluginsFromConfig(config) ?? [];
51+
const overrides: OxlintConfigOverride[] =
52+
readOverridesFromConfig(config) ?? [];
53+
for (const extendConfig of extendsConfigs) {
54+
plugins.unshift(...(readPluginsFromConfig(extendConfig) ?? defaultPlugins));
55+
rules = { ...readRulesFromConfig(extendConfig), ...rules };
56+
overrides.unshift(...(readOverridesFromConfig(extendConfig) ?? []));
57+
}
58+
if (plugins.length > 0) config.plugins = [...new Set(plugins)];
59+
if (Object.keys(rules).length > 0) config.rules = rules;
60+
if (overrides.length > 0) config.overrides = overrides;
61+
};
62+
63+
/**
64+
* tries to return the content of the chain "extends" section from the config.
65+
*/
66+
export const readExtendsConfigsFromConfig = (
67+
config: OxlintConfig
68+
): OxlintConfig[] => {
69+
const extendsFiles = readExtendsFromConfig(config);
70+
if (!extendsFiles?.length) return [];
71+
72+
const extendsConfigs: OxlintConfig[] = [];
73+
for (const file of extendsFiles) {
74+
const extendConfig = getConfigContent(file);
75+
if (!extendConfig) continue;
76+
77+
extendConfig.__misc = {
78+
filePath: file,
79+
};
80+
81+
resolveRelativeExtendsPaths(extendConfig);
82+
83+
extendsConfigs.push(
84+
extendConfig,
85+
...readExtendsConfigsFromConfig(extendConfig)
86+
);
87+
}
88+
return extendsConfigs;
89+
};

src/build-from-oxlint-config/index.ts

Lines changed: 21 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,24 @@
1-
import fs from 'node:fs';
2-
import JSONCParser from 'jsonc-parser';
3-
import {
4-
EslintPluginOxlintConfig,
5-
OxlintConfig,
6-
OxlintConfigCategories,
7-
OxlintConfigPlugins,
8-
} from './types.js';
9-
import { isObject } from './utilities.js';
1+
import { EslintPluginOxlintConfig, OxlintConfig } from './types.js';
102
import { handleRulesScope, readRulesFromConfig } from './rules.js';
113
import {
4+
defaultCategories,
125
handleCategoriesScope,
136
readCategoriesFromConfig,
147
} from './categories.js';
15-
import { readPluginsFromConfig } from './plugins.js';
8+
import { defaultPlugins, readPluginsFromConfig } from './plugins.js';
169
import {
1710
handleIgnorePatternsScope,
1811
readIgnorePatternsFromConfig,
1912
} from './ignore-patterns.js';
2013
import { handleOverridesScope, readOverridesFromConfig } from './overrides.js';
2114
import { splitDisabledRulesForVueAndSvelteFiles } from '../config-helper.js';
22-
23-
// default plugins, see <https://oxc.rs/docs/guide/usage/linter/config#plugins>
24-
const defaultPlugins: OxlintConfigPlugins = ['react', 'unicorn', 'typescript'];
25-
26-
// default categories, see <https://github.com/oxc-project/oxc/blob/0acca58/crates/oxc_linter/src/builder.rs#L82>
27-
const defaultCategories: OxlintConfigCategories = { correctness: 'warn' };
28-
29-
/**
30-
* tries to read the oxlint config file and returning its JSON content.
31-
* if the file is not found or could not be parsed, undefined is returned.
32-
* And an error message will be emitted to `console.error`
33-
*/
34-
const getConfigContent = (
35-
oxlintConfigFile: string
36-
): OxlintConfig | undefined => {
37-
try {
38-
const content = fs.readFileSync(oxlintConfigFile, 'utf8');
39-
40-
try {
41-
const configContent = JSONCParser.parse(content);
42-
43-
if (!isObject(configContent)) {
44-
throw new TypeError('not an valid config file');
45-
}
46-
47-
return configContent;
48-
} catch {
49-
console.error(
50-
`eslint-plugin-oxlint: could not parse oxlint config file: ${oxlintConfigFile}`
51-
);
52-
return undefined;
53-
}
54-
} catch {
55-
console.error(
56-
`eslint-plugin-oxlint: could not find oxlint config file: ${oxlintConfigFile}`
57-
);
58-
return undefined;
59-
}
60-
};
15+
import {
16+
handleExtendsScope,
17+
readExtendsConfigsFromConfig,
18+
resolveRelativeExtendsPaths,
19+
} from './extends.js';
20+
import { getConfigContent } from './utilities.js';
21+
import path from 'node:path';
6122

6223
/**
6324
* builds turned off rules, which are supported by oxlint.
@@ -66,6 +27,13 @@ const getConfigContent = (
6627
export const buildFromOxlintConfig = (
6728
config: OxlintConfig
6829
): EslintPluginOxlintConfig[] => {
30+
resolveRelativeExtendsPaths(config);
31+
32+
const extendConfigs = readExtendsConfigsFromConfig(config);
33+
if (extendConfigs.length > 0) {
34+
handleExtendsScope(extendConfigs, config);
35+
}
36+
6937
const rules: Record<string, 'off'> = {};
7038
const plugins = readPluginsFromConfig(config) ?? defaultPlugins;
7139
const categories = readCategoriesFromConfig(config) ?? defaultCategories;
@@ -128,5 +96,9 @@ export const buildFromOxlintConfigFile = (
12896
return [];
12997
}
13098

99+
config.__misc = {
100+
filePath: path.resolve(oxlintConfigFile),
101+
};
102+
131103
return buildFromOxlintConfig(config);
132104
};

src/build-from-oxlint-config/plugins.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import {
44
OxlintConfigPlugins,
55
} from './types.js';
66

7+
// default plugins, see <https://oxc.rs/docs/guide/usage/linter/config#plugins>
8+
export const defaultPlugins: OxlintConfigPlugins = [
9+
'react',
10+
'unicorn',
11+
'typescript',
12+
];
13+
714
/**
815
* tries to return the "plugins" section from the config.
916
* it returns `undefined` when not found or invalid.

src/build-from-oxlint-config/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Linter } from 'eslint';
22

3+
export type OxlintConfigExtends = string[];
4+
35
export type OxlintConfigPlugins = string[];
46

57
export type OxlintConfigCategories = Record<string, unknown>;
@@ -18,8 +20,15 @@ export type OxlintConfigOverride = {
1820

1921
export type OxlintConfig = {
2022
[key: string]: unknown;
23+
extends?: OxlintConfigExtends;
2124
plugins?: OxlintConfigPlugins;
2225
categories?: OxlintConfigCategories;
2326
rules?: OxlintConfigRules;
2427
ignorePatterns?: OxlintConfigIgnorePatterns;
28+
29+
// extra properties only used by `eslint-plugin-oxlint`
30+
__misc?: {
31+
// absolute path to the config file
32+
filePath: string;
33+
};
2534
};

0 commit comments

Comments
 (0)