diff --git a/src/build-from-oxlint-config/categories.ts b/src/build-from-oxlint-config/categories.ts index bbd809a..c32db56 100644 --- a/src/build-from-oxlint-config/categories.ts +++ b/src/build-from-oxlint-config/categories.ts @@ -7,6 +7,11 @@ import { } from './types.js'; import { isObject } from './utilities.js'; +// default categories, see +export const defaultCategories: OxlintConfigCategories = { + correctness: 'warn', +}; + /** * appends all rules which are enabled by a plugin and falls into a specific category */ diff --git a/src/build-from-oxlint-config/extends.spec.ts b/src/build-from-oxlint-config/extends.spec.ts new file mode 100644 index 0000000..4548c10 --- /dev/null +++ b/src/build-from-oxlint-config/extends.spec.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { handleExtendsScope, resolveRelativeExtendsPaths } 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' } }, + ], + }); + }); +}); + +describe('resolveRelativeExtendsPaths', () => { + it('should resolve relative paths', () => { + const config: OxlintConfig = { + extends: [ + './extends1.json', + './folder/extends2.json', + '../parent/extends3.json', + ], + __misc: { + filePath: '/root/of/the/file/test-config.json', + }, + }; + resolveRelativeExtendsPaths(config); + + expect(config.extends).toEqual([ + '/root/of/the/file/extends1.json', + '/root/of/the/file/folder/extends2.json', + '/root/of/the/parent/extends3.json', + ]); + }); +}); diff --git a/src/build-from-oxlint-config/extends.ts b/src/build-from-oxlint-config/extends.ts new file mode 100644 index 0000000..82c249e --- /dev/null +++ b/src/build-from-oxlint-config/extends.ts @@ -0,0 +1,89 @@ +import path from 'node:path'; +import type { + OxlintConfig, + OxlintConfigOverride, + OxlintConfigPlugins, + OxlintConfigRules, + OxlintConfigExtends, +} from './types.js'; +import { getConfigContent } from './utilities.js'; +import { defaultPlugins, readPluginsFromConfig } from './plugins.js'; +import { readRulesFromConfig } from './rules.js'; +import { readOverridesFromConfig } from './overrides.js'; + +/** + * tries to return the "extends" section from the config. + * it returns `undefined` when not found or invalid. + */ +const readExtendsFromConfig = ( + config: OxlintConfig +): OxlintConfigExtends | undefined => { + return 'extends' in config && Array.isArray(config.extends) + ? (config.extends as OxlintConfigExtends) + : undefined; +}; + +/** + * Resolves the paths of the "extends" section relative to the given config file. + */ +export const resolveRelativeExtendsPaths = (config: OxlintConfig) => { + if (!config.__misc?.filePath) { + return; + } + + const extendsFiles = readExtendsFromConfig(config); + if (!extendsFiles?.length) return; + const configFileDirectory = path.dirname(config.__misc.filePath); + config.extends = extendsFiles.map((extendFile) => + path.resolve(configFileDirectory, 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. + */ +export const readExtendsConfigsFromConfig = ( + config: OxlintConfig +): OxlintConfig[] => { + const extendsFiles = readExtendsFromConfig(config); + if (!extendsFiles?.length) return []; + + const extendsConfigs: OxlintConfig[] = []; + for (const file of extendsFiles) { + const extendConfig = getConfigContent(file); + if (!extendConfig) continue; + + extendConfig.__misc = { + filePath: file, + }; + + resolveRelativeExtendsPaths(extendConfig); + + extendsConfigs.push( + extendConfig, + ...readExtendsConfigsFromConfig(extendConfig) + ); + } + return extendsConfigs; +}; diff --git a/src/build-from-oxlint-config/index.ts b/src/build-from-oxlint-config/index.ts index 8fc7bfc..e66d19d 100644 --- a/src/build-from-oxlint-config/index.ts +++ b/src/build-from-oxlint-config/index.ts @@ -1,63 +1,24 @@ -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 -const defaultPlugins: OxlintConfigPlugins = ['react', 'unicorn', 'typescript']; - -// default categories, see -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'; +import path from 'node:path'; /** * builds turned off rules, which are supported by oxlint. @@ -66,6 +27,13 @@ const getConfigContent = ( export const buildFromOxlintConfig = ( config: OxlintConfig ): EslintPluginOxlintConfig[] => { + resolveRelativeExtendsPaths(config); + + const extendConfigs = readExtendsConfigsFromConfig(config); + if (extendConfigs.length > 0) { + handleExtendsScope(extendConfigs, config); + } + const rules: Record = {}; const plugins = readPluginsFromConfig(config) ?? defaultPlugins; const categories = readCategoriesFromConfig(config) ?? defaultCategories; @@ -128,5 +96,9 @@ export const buildFromOxlintConfigFile = ( return []; } + config.__misc = { + filePath: path.resolve(oxlintConfigFile), + }; + return buildFromOxlintConfig(config); }; diff --git a/src/build-from-oxlint-config/plugins.ts b/src/build-from-oxlint-config/plugins.ts index 1181a1d..01643e0 100644 --- a/src/build-from-oxlint-config/plugins.ts +++ b/src/build-from-oxlint-config/plugins.ts @@ -4,6 +4,13 @@ import { OxlintConfigPlugins, } from './types.js'; +// default plugins, see +export const defaultPlugins: OxlintConfigPlugins = [ + 'react', + 'unicorn', + 'typescript', +]; + /** * tries to return the "plugins" section from the config. * it returns `undefined` when not found or invalid. diff --git a/src/build-from-oxlint-config/types.ts b/src/build-from-oxlint-config/types.ts index f903199..c6d8e2d 100644 --- a/src/build-from-oxlint-config/types.ts +++ b/src/build-from-oxlint-config/types.ts @@ -1,5 +1,7 @@ import type { Linter } from 'eslint'; +export type OxlintConfigExtends = string[]; + export type OxlintConfigPlugins = string[]; export type OxlintConfigCategories = Record; @@ -18,8 +20,15 @@ export type OxlintConfigOverride = { export type OxlintConfig = { [key: string]: unknown; + extends?: OxlintConfigExtends; plugins?: OxlintConfigPlugins; categories?: OxlintConfigCategories; rules?: OxlintConfigRules; ignorePatterns?: OxlintConfigIgnorePatterns; + + // extra properties only used by `eslint-plugin-oxlint` + __misc?: { + // absolute path to the config file + filePath: string; + }; }; diff --git a/src/build-from-oxlint-config/utilities.ts b/src/build-from-oxlint-config/utilities.ts index 1cf66ed..2dc4354 100644 --- a/src/build-from-oxlint-config/utilities.ts +++ b/src/build-from-oxlint-config/utilities.ts @@ -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; + } +};