diff --git a/docs/src/use/configure/configuration-files.md b/docs/src/use/configure/configuration-files.md index 0247cd355c2..1597d85b260 100644 --- a/docs/src/use/configure/configuration-files.md +++ b/docs/src/use/configure/configuration-files.md @@ -21,6 +21,9 @@ The ESLint configuration file may be named any of the following: * `eslint.config.js` * `eslint.config.mjs` * `eslint.config.cjs` +* `eslint.config.ts` (requires [TypeScript setup](#typescript-support)) +* `eslint.config.mts` (requires [TypeScript setup](#typescript-support)) +* `eslint.config.cts` (requires [TypeScript setup](#typescript-support)) It should be placed in the root directory of your project and export an array of [configuration objects](#configuration-objects). Here's an example: @@ -495,3 +498,23 @@ npx eslint --config some-other-file.js **/*.js ``` In this case, ESLint does not search for `eslint.config.js` and instead uses `some-other-file.js`. + +## TypeScript Support + +::: warning +Loading TypeScript configuration files is an experimental feature and may change in future releases. +::: + +ESLint supports loading configuration files written in TypeScript. When ESLint is executed in a runtime that supports TypeScript natively (Deno and Bun), native imports will be used. For other runtimes, the TypeScript support is powered by [`importx`](https://github.com/antfu-collective/importx), which is not automatically installed with ESLint. You need to install it as a dev dependency manually: + +```shell +npm install --save-dev importx +``` + +Note that `importx` only strips off type annotations and does not perform type checking. + +When both `eslint.config.js` and `eslint.config.ts` are present in the same directory, JavaScript versions always take precedence unless provided specifically via `--config` option. + +::: tip +Due to the current limitation of Node.js loaders, top-level await in `eslint.config.ts` is only supported in Node.js v20.0 and later. +::: diff --git a/knip.jsonc b/knip.jsonc index 667cb5fb028..40cb7ec1392 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -27,6 +27,9 @@ "@wdio/cli", "rollup-plugin-node-polyfills", + // Optional dependencies for loading TypeScript configs + "importx", + // FIXME: not sure why is eslint-config-eslint reported as unused "eslint-config-eslint" ] diff --git a/lib/eslint/eslint.js b/lib/eslint/eslint.js index f20b2119a41..c88473436bb 100644 --- a/lib/eslint/eslint.js +++ b/lib/eslint/eslint.js @@ -98,7 +98,10 @@ const { Retrier } = require("@humanwhocodes/retry"); const FLAT_CONFIG_FILENAMES = [ "eslint.config.js", "eslint.config.mjs", - "eslint.config.cjs" + "eslint.config.cjs", + "eslint.config.ts", + "eslint.config.mts", + "eslint.config.cts" ]; const debug = require("debug")("eslint:eslint"); const privateMembers = new WeakMap(); @@ -271,6 +274,33 @@ function findFlatConfigFile(cwd) { ); } +/** + * Check if the file is a TypeScript file. + * @param {string} filePath The file path to check. + * @returns {boolean} `true` if the file is a TypeScript file, `false` if it's not. + */ +function isFileTS(filePath) { + const fileExtension = path.extname(filePath); + + return fileExtension.endsWith("ts"); +} + +/** + * Check if the current runtime supports native TypeScript. + * In those cases, we can use `import()` to load TypeScript files. + * @returns {boolean} `true` if the runtime supports TypeScript, `false` if it doesn't. + */ +function doesRuntimeSupportsTS() { + + /** + * We only do cheap early checks here to avoid requiring users to install `importx` in those cases, + * full checks are done by `importx` when it's used. + * + * It's known the Bun and Deno support TypeScript natively. + */ + return Boolean(globalThis.Bun || globalThis.Deno); +} + /** * Load the config array from the given filename. * @param {string} filePath The filename to load from. @@ -314,7 +344,18 @@ async function loadFlatConfigFile(filePath) { delete require.cache[filePath]; } - const config = (await import(fileURL)).default; + let config; + + if (isFileTS(filePath) && !doesRuntimeSupportsTS()) { + config = await import("importx") + .then(r => r.import(fileURL, __filename)); + } else { + config = await import(fileURL); + } + + if (config.default) { + config = await config.default; + } importedConfigFileModificationTime.set(filePath, mtime); diff --git a/package.json b/package.json index 6fc673d2911..dfb09553d73 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,14 @@ "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, + "peerDependencies": { + "importx": "*" + }, + "peerDependenciesMeta": { + "importx": { + "optional": true + } + }, "devDependencies": { "@babel/core": "^7.4.3", "@babel/preset-env": "^7.4.3", @@ -157,6 +165,7 @@ "semver": "^7.5.3", "shelljs": "^0.8.5", "sinon": "^11.0.0", + "importx": "^0.3.10", "typescript": "^5.3.3", "vite-plugin-commonjs": "^0.10.0", "webpack": "^5.23.0", diff --git a/tests/fixtures/config-ts/cts/eslint.config.cts b/tests/fixtures/config-ts/cts/eslint.config.cts new file mode 100644 index 00000000000..23ce3989537 --- /dev/null +++ b/tests/fixtures/config-ts/cts/eslint.config.cts @@ -0,0 +1,15 @@ +interface FakeFlatConfigItem { + plugins?: Record; + name?: string; + rules?: Record; +} + +const config: FakeFlatConfigItem[] = [ + { + rules: { + "no-undef": "error" as string + } + }, +] + +module.exports = config; diff --git a/tests/fixtures/config-ts/custom/eslint.custom.config.mts b/tests/fixtures/config-ts/custom/eslint.custom.config.mts new file mode 100644 index 00000000000..5c62c89e5d4 --- /dev/null +++ b/tests/fixtures/config-ts/custom/eslint.custom.config.mts @@ -0,0 +1,15 @@ +interface FakeFlatConfigItem { + plugins?: Record; + name?: string; + rules?: Record; +} + +const config: FakeFlatConfigItem[] = [ + { + rules: { + "no-undef": "error" as string + } + }, +] + +export default config; diff --git a/tests/fixtures/config-ts/js-ts-mixed/eslint.config.js b/tests/fixtures/config-ts/js-ts-mixed/eslint.config.js new file mode 100644 index 00000000000..1ae391e66fd --- /dev/null +++ b/tests/fixtures/config-ts/js-ts-mixed/eslint.config.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + "no-undef": "error" + } +}; diff --git a/tests/fixtures/config-ts/js-ts-mixed/eslint.config.ts b/tests/fixtures/config-ts/js-ts-mixed/eslint.config.ts new file mode 100644 index 00000000000..e584502331a --- /dev/null +++ b/tests/fixtures/config-ts/js-ts-mixed/eslint.config.ts @@ -0,0 +1,5 @@ +module.exports = { + rules: { + "no-undef": "warn" as string + } +}; diff --git a/tests/fixtures/config-ts/js-ts-mixed/foo.js b/tests/fixtures/config-ts/js-ts-mixed/foo.js new file mode 100644 index 00000000000..e901f01b487 --- /dev/null +++ b/tests/fixtures/config-ts/js-ts-mixed/foo.js @@ -0,0 +1 @@ +foo; diff --git a/tests/fixtures/config-ts/mts/eslint.config.mts b/tests/fixtures/config-ts/mts/eslint.config.mts new file mode 100644 index 00000000000..5c62c89e5d4 --- /dev/null +++ b/tests/fixtures/config-ts/mts/eslint.config.mts @@ -0,0 +1,15 @@ +interface FakeFlatConfigItem { + plugins?: Record; + name?: string; + rules?: Record; +} + +const config: FakeFlatConfigItem[] = [ + { + rules: { + "no-undef": "error" as string + } + }, +] + +export default config; diff --git a/tests/fixtures/config-ts/top-level-await/eslint.config.mts b/tests/fixtures/config-ts/top-level-await/eslint.config.mts new file mode 100644 index 00000000000..3772813fd74 --- /dev/null +++ b/tests/fixtures/config-ts/top-level-await/eslint.config.mts @@ -0,0 +1,17 @@ + +interface FakeFlatConfigItem { + plugins?: Record; + name?: string; + rules?: Record; +} + +const config: FakeFlatConfigItem[] = [ + // Top-level await + await Promise.resolve({ + rules: { + "no-undef": "error" as string + } + }), +] + +export default config; diff --git a/tests/fixtures/config-ts/ts-const-enum/enum.mts b/tests/fixtures/config-ts/ts-const-enum/enum.mts new file mode 100644 index 00000000000..0ff5ba02a84 --- /dev/null +++ b/tests/fixtures/config-ts/ts-const-enum/enum.mts @@ -0,0 +1,5 @@ +export const enum Level { + Error = 2, + Warn = 1, + Off = 0, +} diff --git a/tests/fixtures/config-ts/ts-const-enum/eslint.config.mts b/tests/fixtures/config-ts/ts-const-enum/eslint.config.mts new file mode 100644 index 00000000000..220511c2d4a --- /dev/null +++ b/tests/fixtures/config-ts/ts-const-enum/eslint.config.mts @@ -0,0 +1,9 @@ +import { Level } from "./enum.mts"; + +export default [ + { + rules: { + "no-undef": Level.Error + } + }, +] as const; diff --git a/tests/fixtures/config-ts/ts-namespace/eslint.config.mts b/tests/fixtures/config-ts/ts-namespace/eslint.config.mts new file mode 100644 index 00000000000..778e6ba44a6 --- /dev/null +++ b/tests/fixtures/config-ts/ts-namespace/eslint.config.mts @@ -0,0 +1,9 @@ +import { LocalNamespace } from "./namespace.mts"; + +export default [ + { + rules: { + "no-undef": LocalNamespace.Level.Error + } + }, +] as const; diff --git a/tests/fixtures/config-ts/ts-namespace/namespace.mts b/tests/fixtures/config-ts/ts-namespace/namespace.mts new file mode 100644 index 00000000000..e32db39f97d --- /dev/null +++ b/tests/fixtures/config-ts/ts-namespace/namespace.mts @@ -0,0 +1,7 @@ +export namespace LocalNamespace { + export const enum Level { + Error = 2, + Warn = 1, + Off = 0, + } +} diff --git a/tests/fixtures/config-ts/ts/eslint.config.ts b/tests/fixtures/config-ts/ts/eslint.config.ts new file mode 100644 index 00000000000..23ce3989537 --- /dev/null +++ b/tests/fixtures/config-ts/ts/eslint.config.ts @@ -0,0 +1,15 @@ +interface FakeFlatConfigItem { + plugins?: Record; + name?: string; + rules?: Record; +} + +const config: FakeFlatConfigItem[] = [ + { + rules: { + "no-undef": "error" as string + } + }, +] + +module.exports = config; diff --git a/tests/lib/cli-engine/lint-result-cache.js b/tests/lib/cli-engine/lint-result-cache.js index 04dd49ca84d..d5d23e5e970 100644 --- a/tests/lib/cli-engine/lint-result-cache.js +++ b/tests/lib/cli-engine/lint-result-cache.js @@ -163,16 +163,21 @@ describe("LintResultCache", () => { it("contains node version during hashing", () => { const version = "node-=-version"; - sandbox.stub(process, "version").value(version); - const NewLintResultCache = proxyquire("../../../lib/cli-engine/lint-result-cache.js", { - "./hash": hashStub - }); - const newLintResultCache = new NewLintResultCache(cacheFileLocation, "metadata"); + const versionStub = sandbox.stub(process, "version").value(version); - newLintResultCache.getCachedLintResults(filePath, fakeConfig); + try { + const NewLintResultCache = proxyquire("../../../lib/cli-engine/lint-result-cache.js", { + "./hash": hashStub + }); + const newLintResultCache = new NewLintResultCache(cacheFileLocation, "metadata"); - assert.ok(hashStub.calledOnce); - assert.ok(hashStub.calledWithMatch(version)); + newLintResultCache.getCachedLintResults(filePath, fakeConfig); + + assert.ok(hashStub.calledOnce); + assert.ok(hashStub.calledWithMatch(version)); + } finally { + versionStub.restore(); + } }); }); diff --git a/tests/lib/eslint/eslint.js b/tests/lib/eslint/eslint.js index ec364a6f9b7..5ad84663d20 100644 --- a/tests/lib/eslint/eslint.js +++ b/tests/lib/eslint/eslint.js @@ -920,7 +920,7 @@ describe("ESLint", () => { eslint = new ESLint({ cwd: getFixturePath("promise-config") }); - const results = await eslint.lintText('var foo = "bar";'); + const results = await eslint.lintText("var foo = \"bar\";"); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].messages.length, 1); @@ -994,6 +994,142 @@ describe("ESLint", () => { assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); }); }); + + describe("TypeScript config files", () => { + + it("should load eslint.config.ts", async () => { + + const cwd = getFixturePath("config-ts/ts"); + + eslint = new ESLint({ + cwd + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + + + it("should load eslint.config.mts", async () => { + + const cwd = getFixturePath("config-ts/mts"); + + eslint = new ESLint({ + cwd + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + + it("should load eslint.config.cts", async () => { + + const cwd = getFixturePath("config-ts/cts"); + + eslint = new ESLint({ + cwd + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + + it("should load ts config with custom path", async () => { + + const cwd = getFixturePath("config-ts/custom"); + + eslint = new ESLint({ + cwd, + overrideConfigFile: getFixturePath("config-ts/custom/eslint.custom.config.mts") + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + + it("should load eslint.config.mts with top-level-await", async () => { + + // in Node.js v18, importx loader fallback to jiti, which does not support top-level-await + if (process.version.startsWith("v18.")) { + return; + } + + const cwd = getFixturePath("config-ts/top-level-await"); + + eslint = new ESLint({ + cwd + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + + + it("should js config should win over when both present", async () => { + const cwd = getFixturePath("config-ts/js-ts-mixed"); + + eslint = new ESLint({ + cwd + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + + it("should work with const enum", async () => { + const cwd = getFixturePath("config-ts/ts-const-enum"); + + eslint = new ESLint({ + cwd + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + + it("should work with ts namespace", async () => { + const cwd = getFixturePath("config-ts/ts-namespace"); + + eslint = new ESLint({ + cwd + }); + + const results = await eslint.lintText("foo"); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].severity, 2); + assert.strictEqual(results[0].messages[0].ruleId, "no-undef"); + }); + }); }); describe("lintFiles()", () => { @@ -7123,7 +7259,7 @@ describe("ESLint", () => { } }); - const [{ messages }] = await eslint.lintText('const foo = "bar"'); + const [{ messages }] = await eslint.lintText("const foo = \"bar\""); /* * baseConfig: { quotes: ["error", "double"], semi: "error" } @@ -7193,7 +7329,7 @@ describe("ESLint", () => { const teardown = createCustomTeardown({ cwd, files: { - "package.json": '{ "type": "module" }', + "package.json": "{ \"type\": \"module\" }", "eslint.config.js": configFileContent, "a.js": "foo\nbar;" }