diff --git a/.changeset/flat-bikes-pay.md b/.changeset/flat-bikes-pay.md new file mode 100644 index 00000000..686d3f02 --- /dev/null +++ b/.changeset/flat-bikes-pay.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-jsonc": minor +--- + +feat: `jsonc/auto` rule works even in flat config diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 20482296..f95d8f1a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,9 +17,9 @@ // Configure tool-specific properties. "customizations": { "vscode": { - "extensions": ["dbaeumer.vscode-eslint"] - } - } + "extensions": ["dbaeumer.vscode-eslint"], + }, + }, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" diff --git a/.eslintignore b/.eslintignore index 59d3be91..47592836 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,3 +22,4 @@ /docs/.vitepress/cache /docs/.vitepress/build-system/shim /docs/.vitepress/dist +!/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/eslint.config.js diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 27f0695b..530d5447 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -53,6 +53,7 @@ export default async (): Promise> => { }, define: { "process.env.NODE_DEBUG": "false", + "process.env.ESLINT_USE_FLAT_CONFIG": "false", }, optimizeDeps: { exclude: ["eslint-compat-utils"], diff --git a/lib/rules/auto.ts b/lib/rules/auto.ts index 66a327f5..a18c1a60 100644 --- a/lib/rules/auto.ts +++ b/lib/rules/auto.ts @@ -1,4 +1,4 @@ -import { getFilename, getSourceCode } from "eslint-compat-utils"; +import { getCwd, getFilename, getSourceCode } from "eslint-compat-utils"; import type { RuleListener, RuleModule } from "../types"; import { createRule } from "../utils"; import { getAutoConfig } from "../utils/get-auto-jsonc-rules-config"; @@ -22,7 +22,7 @@ export default createRule("auto", { if (!sourceCode.parserServices.isJSON) { return {}; } - const autoConfig = getAutoConfig(getFilename(context)); + const autoConfig = getAutoConfig(getCwd(context), getFilename(context)); const visitor: RuleListener = {}; for (const ruleId of Object.keys(autoConfig)) { diff --git a/lib/utils/get-auto-jsonc-rules-config/calculate-config-for-file.ts b/lib/utils/get-auto-jsonc-rules-config/calculate-config-for-file.ts new file mode 100644 index 00000000..c0669e9a --- /dev/null +++ b/lib/utils/get-auto-jsonc-rules-config/calculate-config-for-file.ts @@ -0,0 +1,15 @@ +import type { Linter } from "eslint"; +// @ts-expect-error -- ignore +import { createSyncFn } from "synckit"; + +const getSync = createSyncFn(require.resolve("./worker")); + +/** + * Synchronously calculateConfigForFile + */ +export function calculateConfigForFile( + cwd: string, + fileName: string, +): Pick { + return getSync(cwd, fileName); +} diff --git a/lib/utils/get-auto-jsonc-rules-config.ts b/lib/utils/get-auto-jsonc-rules-config/index.ts similarity index 68% rename from lib/utils/get-auto-jsonc-rules-config.ts rename to lib/utils/get-auto-jsonc-rules-config/index.ts index 50a2c994..caf8a5f3 100644 --- a/lib/utils/get-auto-jsonc-rules-config.ts +++ b/lib/utils/get-auto-jsonc-rules-config/index.ts @@ -1,20 +1,34 @@ import type { Linter } from "eslint"; import { existsSync, statSync } from "fs"; import { dirname, extname, resolve } from "path"; -import type { RuleModule } from "../types"; +import type { RuleModule } from "../../types"; +import { shouldUseFlatConfig } from "./should-use-flat-config"; +import { calculateConfigForFile } from "./calculate-config-for-file"; -let configResolver: (filePath: string) => Linter.Config, ruleNames: Set; +const configResolvers: Record< + string, + undefined | ((filePath: string) => Pick) +> = {}; +let ruleNames: Set; /** * Get config resolver */ -function getConfigResolver(): (filePath: string) => Linter.Config { +function getConfigResolver( + cwd: string, +): (filePath: string) => Pick { + const configResolver = configResolvers[cwd]; if (configResolver) { return configResolver; } + if (shouldUseFlatConfig(cwd)) { + return (configResolvers[cwd] = (filePath: string) => + calculateConfigForFile(cwd, filePath)); + } + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- special - const plugin = require(".."); + const plugin = require("../.."); try { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- special const eslintrc = require("@eslint/eslintrc"); @@ -22,36 +36,37 @@ function getConfigResolver(): (filePath: string) => Linter.Config { additionalPluginPool: new Map([["eslint-plugin-jsonc", plugin]]), getEslintRecommendedConfig() { // eslint-disable-next-line @typescript-eslint/no-require-imports -- ignore - return require("../../conf/eslint-recommended.js"); + return require("../../../conf/eslint-recommended.js"); }, getEslintAllConfig() { // eslint-disable-next-line @typescript-eslint/no-require-imports -- ignore - return require("../../conf/eslint-all.js"); + return require("../../../conf/eslint-all.js"); }, // for v1.1.0 eslintRecommendedPath: require.resolve( - "../../conf/eslint-recommended.js", + "../../../conf/eslint-recommended.js", ), - eslintAllPath: require.resolve("../../conf/eslint-all.js"), + eslintAllPath: require.resolve("../../../conf/eslint-all.js"), }); - return (configResolver = (filePath: string) => { - const absolutePath = resolve(process.cwd(), filePath); + return (configResolvers[cwd] = (filePath: string) => { + const absolutePath = resolve(cwd, filePath); return configArrayFactory .getConfigArrayForFile(absolutePath) .extractConfig(absolutePath) .toCompatibleObjectAsConfigFileContent(); }); - } catch { + } catch (_e) { // ignore + // console.log(_e); } try { // For ESLint v6 // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- special const eslint = require("eslint"); - const engine = new eslint.CLIEngine({}); + const engine = new eslint.CLIEngine({ cwd }); engine.addPlugin("eslint-plugin-jsonc", plugin); - return (configResolver = (filePath) => { + return (configResolvers[cwd] = (filePath) => { // Adjust the file name to avoid a crash. // https://github.com/ota-meshi/eslint-plugin-jsonc/issues/28 let targetFilePath = filePath; @@ -95,8 +110,11 @@ function isValidFilename(filename: string) { * Get config for the given filename * @param filename */ -function getConfig(filename: string): Linter.Config { - return getConfigResolver()(filename); +function getConfig( + cwd: string, + filename: string, +): Pick { + return getConfigResolver(cwd)(filename); } /** @@ -108,24 +126,31 @@ function getJsoncRule(rule: string) { ruleNames || new Set( // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- special - (require("./rules").rules as RuleModule[]).map( + (require("../rules").rules as RuleModule[]).map( (r) => r.meta.docs.ruleName, ), ); - return ruleNames.has(rule) ? `jsonc/${rule}` : null; + const ruleName = rule.startsWith("@stylistic/") + ? rule.split("/").pop() ?? rule + : rule; + + return ruleNames.has(ruleName) ? `jsonc/${ruleName}` : null; } /** * Get additional jsonc rules config from fileName * @param filename */ -export function getAutoConfig(filename: string): { +export function getAutoConfig( + cwd: string, + filename: string, +): { [name: string]: Linter.RuleEntry; } { const autoConfig: { [name: string]: Linter.RuleEntry } = {}; - const config = getConfig(filename); + const config = getConfig(cwd, filename); if (config.rules) { for (const ruleName of Object.keys(config.rules)) { const jsoncName = getJsoncRule(ruleName); diff --git a/lib/utils/get-auto-jsonc-rules-config/should-use-flat-config.ts b/lib/utils/get-auto-jsonc-rules-config/should-use-flat-config.ts new file mode 100644 index 00000000..a598572c --- /dev/null +++ b/lib/utils/get-auto-jsonc-rules-config/should-use-flat-config.ts @@ -0,0 +1,61 @@ +/** copied from https://github.com/eslint/eslint/blob/v8.56.0/lib/eslint/flat-eslint.js#L1119 */ + +import path from "path"; +import fs from "fs"; + +const FLAT_CONFIG_FILENAME = "eslint.config.js"; +/** + * Returns whether flat config should be used. + * @returns {Promise} Whether flat config should be used. + */ +export function shouldUseFlatConfig(cwd: string): boolean { + // eslint-disable-next-line no-process-env -- ignore + switch (process.env.ESLINT_USE_FLAT_CONFIG) { + case "true": + return true; + case "false": + return false; + default: + // If neither explicitly enabled nor disabled, then use the presence + // of a flat config file to determine enablement. + return Boolean(findFlatConfigFile(cwd)); + } +} + +/** + * Searches from the current working directory up until finding the + * given flat config filename. + * @param {string} cwd The current working directory to search from. + * @returns {string|undefined} The filename if found or `undefined` if not. + */ +function findFlatConfigFile(cwd: string) { + return findUp(FLAT_CONFIG_FILENAME, { cwd }); +} + +/** We used https://github.com/sindresorhus/find-up/blob/b733bb70d3aa21b22fa011be8089110d467c317f/index.js#L94 as a reference */ +function findUp(name: string, options: { cwd: string }) { + let directory = path.resolve(options.cwd); + const { root } = path.parse(directory); + const stopAt = path.resolve(directory, root); + + // eslint-disable-next-line no-constant-condition -- ignore + while (true) { + const target = path.resolve(directory, name); + const stat = fs.existsSync(target) + ? fs.statSync(target, { + throwIfNoEntry: false, + }) + : null; + if (stat?.isFile()) { + return target; + } + + if (directory === stopAt) { + break; + } + + directory = path.dirname(directory); + } + + return null; +} diff --git a/lib/utils/get-auto-jsonc-rules-config/worker.ts b/lib/utils/get-auto-jsonc-rules-config/worker.ts new file mode 100644 index 00000000..c9305b6e --- /dev/null +++ b/lib/utils/get-auto-jsonc-rules-config/worker.ts @@ -0,0 +1,10 @@ +// @ts-expect-error -- ignore +import { runAsWorker } from "synckit"; +import { getESLint } from "eslint-compat-utils/eslint"; +const ESLint = getESLint(); + +runAsWorker(async (cwd: string, fileName: string) => { + const eslint = new ESLint({ cwd }); + const config = await eslint.calculateConfigForFile(fileName); + return { rules: config.rules }; +}); diff --git a/package.json b/package.json index 25dddf2b..855e89e1 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ "espree": "^9.6.1", "graphemer": "^1.4.0", "jsonc-eslint-parser": "^2.0.4", - "natural-compare": "^1.4.0" + "natural-compare": "^1.4.0", + "synckit": "^0.6.0" }, "peerDependencies": { "eslint": ">=6.0.0" diff --git a/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/eslint.config.js b/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/eslint.config.js new file mode 100644 index 00000000..67dc71db --- /dev/null +++ b/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/eslint.config.js @@ -0,0 +1,30 @@ +let plugin; +try { + plugin = require("../../../../../lib/index"); +} catch (e) { + // @ts-ignore -- ignore + plugin = require("../../../../../dist/index"); +} +const parser = require("jsonc-eslint-parser"); + +module.exports = [ + { + plugins: { + jsonc: plugin, + }, + rules: { + indent: "error", + "no-unused-vars": "off", + "no-multi-spaces": "error", + "no-multiple-empty-lines": "error", + "jsonc/auto": "error", + "jsonc/no-comments": "error", + }, + }, + { + files: ["**/*.json"], + languageOptions: { + parser, + }, + }, +]; diff --git a/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/src/test.js b/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/src/test.js new file mode 100644 index 00000000..bb22bb64 --- /dev/null +++ b/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/src/test.js @@ -0,0 +1,3 @@ +var a = { + foo: 'bar' +} \ No newline at end of file diff --git a/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/src/test.json b/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/src/test.json new file mode 100644 index 00000000..8a796876 --- /dev/null +++ b/tests/fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01/src/test.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} \ No newline at end of file diff --git a/tests/lib/auto-rule.ts b/tests/lib/auto-rule.ts index c61d3463..142a11a8 100644 --- a/tests/lib/auto-rule.ts +++ b/tests/lib/auto-rule.ts @@ -2,17 +2,19 @@ import path from "path"; import fs from "fs"; import assert from "assert"; import plugin from "../../lib/index"; -import { getLegacyESLint } from "eslint-compat-utils/eslint"; -const ESLint = getLegacyESLint(); - +import { getLegacyESLint, getESLint } from "eslint-compat-utils/eslint"; +import semver from "semver"; // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- -const TEST_CWD = path.join(__dirname, "../fixtures/integrations/eslint-plugin"); -const FIXTURE_ROOT = path.join(TEST_CWD, "./test-auto-rule01/src"); - describe("auto rule", () => { + const ESLint = getLegacyESLint(); + const TEST_CWD = path.join( + __dirname, + "../fixtures/integrations/eslint-plugin/test-auto-rule01", + ); + const FIXTURE_ROOT = path.join(TEST_CWD, "./src"); it("should auto rule enable", async () => { const fixtures: { [name: string]: string } = {}; for (const filename of fs.readdirSync(FIXTURE_ROOT)) { @@ -40,13 +42,60 @@ describe("auto rule", () => { }, fix: true, }); - const resultFixBefore = await engine.lintFiles(["test-auto-rule01/src"]); + const resultFixBefore = await engine.lintFiles(["src"]); + assert.strictEqual( + resultFixBefore.reduce((s, m) => s + m.errorCount, 0), + 2, + ); + + const resultFixAfter = await fixEngine.lintFiles(["src"]); + assert.strictEqual( + resultFixAfter.reduce((s, m) => s + m.errorCount, 0), + 0, + ); + await ESLint.outputFixes(resultFixAfter); + + for (const filename of Object.keys(fixtures)) { + const code = fs.readFileSync(path.join(FIXTURE_ROOT, filename), "utf8"); + assert.strictEqual(code, fixtures[filename]); + } + }); +}); + +describe("auto rule with flat config", () => { + const ESLint = getESLint(); + if (semver.satisfies(ESLint.version, "<8.0.0")) return; + const TEST_CWD = path.join( + __dirname, + "../fixtures/integrations/eslint-plugin/test-auto-rule-with-flat-config01", + ); + const FIXTURE_ROOT = path.join(TEST_CWD, "./src"); + it("should auto rule enable", async () => { + const fixtures: { [name: string]: string } = {}; + for (const filename of fs.readdirSync(FIXTURE_ROOT)) { + const code = fs.readFileSync(path.join(FIXTURE_ROOT, filename), "utf8"); + fixtures[filename] = code; + const invalidCode = code + .split("\n") + .map((line) => line.replace(/^[\t ]+/u, "")) + .join("\n"); + fs.writeFileSync(path.join(FIXTURE_ROOT, filename), invalidCode, "utf8"); + } + + const engine = new ESLint({ + cwd: TEST_CWD, + }); + const fixEngine = new ESLint({ + cwd: TEST_CWD, + fix: true, + }); + const resultFixBefore = await engine.lintFiles(["src"]); assert.strictEqual( resultFixBefore.reduce((s, m) => s + m.errorCount, 0), 2, ); - const resultFixAfter = await fixEngine.lintFiles(["test-auto-rule01/src"]); + const resultFixAfter = await fixEngine.lintFiles(["src"]); assert.strictEqual( resultFixAfter.reduce((s, m) => s + m.errorCount, 0), 0, diff --git a/tsconfig.json b/tsconfig.json index 0815140d..45001cbf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,12 +15,12 @@ "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { - "*": ["typings/*"] + "*": ["typings/*"], }, "esModuleInterop": true, "resolveJsonModule": true, - "skipLibCheck": true + "skipLibCheck": true, }, "include": [ "lib/**/*", @@ -28,7 +28,7 @@ "tests-integrations/lib/**/*", "tools/**/*", "typings/**/*", - "docs/.vitepress/**/*" + "docs/.vitepress/**/*", ], - "exclude": ["dist/**/*"] + "exclude": ["dist/**/*"], }