diff --git a/docs/rules/file-extension-in-import.md b/docs/rules/file-extension-in-import.md index 031c2777..3339fa55 100644 --- a/docs/rules/file-extension-in-import.md +++ b/docs/rules/file-extension-in-import.md @@ -90,6 +90,42 @@ import styles from "./styles.css" import logo from "./logo.png" ``` +### Shared Settings + +The following options can be set by [shared settings](http://eslint.org/docs/user-guide/configuring.html#adding-shared-settings). +Several rules have the same option, but we can set this option at once. + +#### typescriptExtensionMap + +Adds the ability to change the extension mapping when converting between typescript and javascript + +You can also use the [typescript compiler jsx options](https://www.typescriptlang.org/tsconfig#jsx) to automatically use the correct mapping. + +If this option is left undefined we: + +1. Check your `tsconfig.json` `compilerOptions.jsx` +2. Return the default mapping (jsx = `preserve`) + +```js +// .eslintrc.js +module.exports = { + "settings": { + "node": { + "typescriptExtensionMap": [ + [ "", ".js" ], + [ ".ts", ".js" ], + [ ".cts", ".cjs" ], + [ ".mts", ".mjs" ], + [ ".tsx", ".jsx" ], + ] + } + }, + "rules": { + "n/file-extension-in-import": "error" + } +} +``` + ## 🔎 Implementation - [Rule source](../../lib/rules/file-extension-in-import.js) diff --git a/docs/rules/no-missing-import.md b/docs/rules/no-missing-import.md index 0dff884a..3138b22d 100644 --- a/docs/rules/no-missing-import.md +++ b/docs/rules/no-missing-import.md @@ -73,6 +73,14 @@ Default is `[]` Adds the ability to change the extension mapping when converting between typescript and javascript +You can also use the [typescript compiler jsx options](https://www.typescriptlang.org/tsconfig#jsx) to automatically use the correct mapping. + +If this option is left undefined we: + +1. Check the Shared Settings +2. Check your `tsconfig.json` `compilerOptions.jsx` +3. Return the default mapping (jsx = `preserve`) + Default is: ```json @@ -85,6 +93,10 @@ Default is: ] ``` +#### tsconfigPath + +Adds the ability to specify the tsconfig used by the typescriptExtensionMap tool. + ### Shared Settings The following options can be set by [shared settings](http://eslint.org/docs/user-guide/configuring.html#adding-shared-settings). diff --git a/docs/rules/no-missing-require.md b/docs/rules/no-missing-require.md index 0a2df2be..b0fe2d90 100644 --- a/docs/rules/no-missing-require.md +++ b/docs/rules/no-missing-require.md @@ -86,6 +86,14 @@ Default is `[".js", ".json", ".node"]`. Adds the ability to change the extension mapping when converting between typescript and javascript +You can also use the [typescript compiler jsx options](https://www.typescriptlang.org/tsconfig#jsx) to automatically use the correct mapping. + +If this option is left undefined we: + +1. Check the Shared Settings +2. Check your `tsconfig.json` `compilerOptions.jsx` +3. Return the default mapping (jsx = `preserve`) + Default is: ```json @@ -98,6 +106,10 @@ Default is: ] ``` +#### tsconfigPath + +Adds the ability to specify the tsconfig used by the typescriptExtensionMap tool. + ### Shared Settings The following options can be set by [shared settings](http://eslint.org/docs/user-guide/configuring.html#adding-shared-settings). diff --git a/lib/rules/no-missing-import.js b/lib/rules/no-missing-import.js index 3b780475..2c73fc3f 100644 --- a/lib/rules/no-missing-import.js +++ b/lib/rules/no-missing-import.js @@ -7,6 +7,7 @@ const { checkExistence, messages } = require("../util/check-existence") const getAllowModules = require("../util/get-allow-modules") const getResolvePaths = require("../util/get-resolve-paths") +const getTSConfig = require("../util/get-tsconfig") const getTypescriptExtensionMap = require("../util/get-typescript-extension-map") const visitImport = require("../util/visit-import") @@ -28,6 +29,7 @@ module.exports = { allowModules: getAllowModules.schema, resolvePaths: getResolvePaths.schema, typescriptExtensionMap: getTypescriptExtensionMap.schema, + tsconfigPath: getTSConfig.schema, }, additionalProperties: false, }, diff --git a/lib/rules/no-missing-require.js b/lib/rules/no-missing-require.js index 7566fa49..11a36636 100644 --- a/lib/rules/no-missing-require.js +++ b/lib/rules/no-missing-require.js @@ -7,6 +7,7 @@ const { checkExistence, messages } = require("../util/check-existence") const getAllowModules = require("../util/get-allow-modules") const getResolvePaths = require("../util/get-resolve-paths") +const getTSConfig = require("../util/get-tsconfig") const getTryExtensions = require("../util/get-try-extensions") const getTypescriptExtensionMap = require("../util/get-typescript-extension-map") const visitRequire = require("../util/visit-require") @@ -30,6 +31,7 @@ module.exports = { tryExtensions: getTryExtensions.schema, resolvePaths: getResolvePaths.schema, typescriptExtensionMap: getTypescriptExtensionMap.schema, + tsconfigPath: getTSConfig.schema, }, additionalProperties: false, }, diff --git a/lib/util/get-tsconfig.js b/lib/util/get-tsconfig.js new file mode 100644 index 00000000..cbc202f0 --- /dev/null +++ b/lib/util/get-tsconfig.js @@ -0,0 +1,31 @@ +"use strict" + +const { getTsconfig, parseTsconfig } = require("get-tsconfig") +const fsCache = new Map() + +/** + * Attempts to get the ExtensionMap from the tsconfig given the path to the tsconfig file. + * + * @param {string} filename - The path to the tsconfig.json file + * @returns {import("get-tsconfig").TsConfigJsonResolved} + */ +function getTSConfig(filename) { + return parseTsconfig(filename, fsCache) +} + +/** + * Attempts to get the ExtensionMap from the tsconfig of a given file. + * + * @param {string} filename - The path to the file we need to find the tsconfig.json of + * @returns {import("get-tsconfig").TsConfigResult} + */ +function getTSConfigForFile(filename) { + return getTsconfig(filename, "tsconfig.json", fsCache) +} + +module.exports = { + getTSConfig, + getTSConfigForFile, +} + +module.exports.schema = { type: "string" } diff --git a/lib/util/get-typescript-extension-map.js b/lib/util/get-typescript-extension-map.js index da9fb93f..ccdb2feb 100644 --- a/lib/util/get-typescript-extension-map.js +++ b/lib/util/get-typescript-extension-map.js @@ -1,6 +1,16 @@ "use strict" +const { getTSConfig, getTSConfigForFile } = require("./get-tsconfig") + const DEFAULT_MAPPING = normalise([ + ["", ".js"], + [".ts", ".js"], + [".cts", ".cjs"], + [".mts", ".mjs"], + [".tsx", ".js"], +]) + +const PRESERVE_MAPPING = normalise([ ["", ".js"], [".ts", ".js"], [".cts", ".cjs"], @@ -8,6 +18,14 @@ const DEFAULT_MAPPING = normalise([ [".tsx", ".jsx"], ]) +const tsConfigMapping = { + react: DEFAULT_MAPPING, // Emit .js files with JSX changed to the equivalent React.createElement calls + "react-jsx": DEFAULT_MAPPING, // Emit .js files with the JSX changed to _jsx calls + "react-jsxdev": DEFAULT_MAPPING, // Emit .js files with the JSX changed to _jsx calls + "react-native": DEFAULT_MAPPING, // Emit .js files with the JSX unchanged + preserve: PRESERVE_MAPPING, // Emit .jsx files with the JSX unchanged +} + /** * @typedef {Object} ExtensionMap * @property {Record} forward Convert from typescript to javascript @@ -28,6 +46,22 @@ function normalise(typescriptExtensionMap) { return { forward, backward } } +/** + * Attempts to get the ExtensionMap from the resolved tsconfig. + * + * @param {import("get-tsconfig").TsConfigJsonResolved} [tsconfig] - The resolved tsconfig + * @returns {ExtensionMap} The `typescriptExtensionMap` value, or `null`. + */ +function getMappingFromTSConfig(tsconfig) { + const jsx = tsconfig?.compilerOptions?.jsx + + if ({}.hasOwnProperty.call(tsConfigMapping, jsx)) { + return tsConfigMapping[jsx] + } + + return null +} + /** * Gets `typescriptExtensionMap` property from a given option object. * @@ -36,46 +70,82 @@ function normalise(typescriptExtensionMap) { */ function get(option) { if ( - option && - option.typescriptExtensionMap && - Array.isArray(option.typescriptExtensionMap) + {}.hasOwnProperty.call(tsConfigMapping, option?.typescriptExtensionMap) ) { + return tsConfigMapping[option.typescriptExtensionMap] + } + + if (Array.isArray(option?.typescriptExtensionMap)) { return normalise(option.typescriptExtensionMap) } + if (option?.tsconfigPath) { + return getMappingFromTSConfig(getTSConfig(option?.tsconfigPath)) + } + return null } +/** + * Attempts to get the ExtensionMap from the tsconfig of a given file. + * + * @param {string} filename - The filename we're getting from + * @returns {ExtensionMap} The `typescriptExtensionMap` value, or `null`. + */ +function getFromTSConfigFromFile(filename) { + return getMappingFromTSConfig(getTSConfigForFile(filename)?.config) +} + /** * Gets "typescriptExtensionMap" setting. * - * 1. This checks `options` property, then returns it if exists. - * 2. This checks `settings.n` | `settings.node` property, then returns it if exists. - * 3. This returns `DEFAULT_MAPPING`. + * 1. This checks `options.typescriptExtensionMap`, if its an array then it gets returned. + * 2. This checks `options.typescriptExtensionMap`, if its a string, convert to the correct mapping. + * 3. This checks `settings.n.typescriptExtensionMap`, if its an array then it gets returned. + * 4. This checks `settings.node.typescriptExtensionMap`, if its an array then it gets returned. + * 5. This checks `settings.n.typescriptExtensionMap`, if its a string, convert to the correct mapping. + * 6. This checks `settings.node.typescriptExtensionMap`, if its a string, convert to the correct mapping. + * 7. This checks for a `tsconfig.json` `config.compilerOptions.jsx` property, if its a string, convert to the correct mapping. + * 8. This returns `PRESERVE_MAPPING`. * - * @param {import('eslint').Rule.RuleContext} context - The rule context. + * @param {import("eslint").Rule.RuleContext} context - The rule context. * @returns {string[]} A list of extensions. */ module.exports = function getTypescriptExtensionMap(context) { return ( - get(context.options && context.options[0]) || - get( - context.settings && (context.settings.n || context.settings.node) + get(context.options?.[0]) || + get(context.settings?.n ?? context.settings?.node) || + getFromTSConfigFromFile( + // eslint ^8 + context.physicalFilename ?? + // eslint ^7.28 (deprecated ^8) + context.getPhysicalFilename?.() ?? + // eslint ^8 (if physicalFilename undefined) + context.filename ?? + // eslint ^7 (deprecated ^8) + context.getFilename?.() ) || - // TODO: Detect tsconfig.json here - DEFAULT_MAPPING + PRESERVE_MAPPING ) } module.exports.schema = { - type: "array", - items: { - type: "array", - prefixItems: [ - { type: "string", pattern: "^(?:|\\.\\w+)$" }, - { type: "string", pattern: "^\\.\\w+$" }, - ], - additionalItems: false, - }, - uniqueItems: true, + oneOf: [ + { + type: "array", + items: { + type: "array", + prefixItems: [ + { type: "string", pattern: "^(?:|\\.\\w+)$" }, + { type: "string", pattern: "^\\.\\w+$" }, + ], + additionalItems: false, + }, + uniqueItems: true, + }, + { + type: "string", + enum: Object.keys(tsConfigMapping), + }, + ], } diff --git a/package.json b/package.json index cb2521a0..61d0002c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", "eslint-plugin-es-x": "^7.1.0", + "get-tsconfig": "^4.7.0", "ignore": "^5.2.4", "is-core-module": "^2.12.1", "minimatch": "^3.1.2", diff --git a/tests/fixtures/no-missing/ts-extends/base.tsconfig.json b/tests/fixtures/no-missing/ts-extends/base.tsconfig.json new file mode 100644 index 00000000..b5fbd521 --- /dev/null +++ b/tests/fixtures/no-missing/ts-extends/base.tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsx": "react" + } +} diff --git a/tests/fixtures/no-missing/ts-extends/d.ts b/tests/fixtures/no-missing/ts-extends/d.ts new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/no-missing/ts-extends/e.tsx b/tests/fixtures/no-missing/ts-extends/e.tsx new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/no-missing/ts-extends/tsconfig.json b/tests/fixtures/no-missing/ts-extends/tsconfig.json new file mode 100644 index 00000000..383418e6 --- /dev/null +++ b/tests/fixtures/no-missing/ts-extends/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": ["./base.tsconfig.json"] +} diff --git a/tests/fixtures/no-missing/ts-preserve/d.ts b/tests/fixtures/no-missing/ts-preserve/d.ts new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/no-missing/ts-preserve/e.tsx b/tests/fixtures/no-missing/ts-preserve/e.tsx new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/no-missing/ts-preserve/tsconfig.json b/tests/fixtures/no-missing/ts-preserve/tsconfig.json new file mode 100644 index 00000000..94e40481 --- /dev/null +++ b/tests/fixtures/no-missing/ts-preserve/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsx": "preserve" + } +} diff --git a/tests/fixtures/no-missing/ts-react/d.ts b/tests/fixtures/no-missing/ts-react/d.ts new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/no-missing/ts-react/e.tsx b/tests/fixtures/no-missing/ts-react/e.tsx new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/no-missing/ts-react/tsconfig.json b/tests/fixtures/no-missing/ts-react/tsconfig.json new file mode 100644 index 00000000..b5fbd521 --- /dev/null +++ b/tests/fixtures/no-missing/ts-react/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsx": "react" + } +} diff --git a/tests/lib/rules/no-missing-import.js b/tests/lib/rules/no-missing-import.js index 767b4766..af2dc125 100644 --- a/tests/lib/rules/no-missing-import.js +++ b/tests/lib/rules/no-missing-import.js @@ -156,59 +156,133 @@ ruleTester.run("no-missing-import", rule, { // typescriptExtensionMap { + // name: "settings.node - [] as react - d.ts as d.js", filename: fixture("test.tsx"), - code: "import a from './d.js';", + code: "import d from './d.js';", env: { node: true }, - settings: { - node: { typescriptExtensionMap: tsReactExtensionMap }, - }, + settings: { node: { typescriptExtensionMap: tsReactExtensionMap } }, }, { - filename: fixture("test.ts"), - code: "import a from './d.js';", + // name: "settings.node - [] as react - e.tsx as e.js", + filename: fixture("test.tsx"), + code: "import e from './e.js';", + env: { node: true }, + settings: { node: { typescriptExtensionMap: tsReactExtensionMap } }, + }, + { + // name: "options[0] - [] as react - d.ts as d.js", + filename: fixture("test.tsx"), + code: "import d from './d.js';", + options: [{ typescriptExtensionMap: tsReactExtensionMap }], + env: { node: true }, + }, + { + // name: "options[0] - [] as react - e.tsx as e.js", + filename: fixture("test.tsx"), + code: "import e from './e.js';", + options: [{ typescriptExtensionMap: tsReactExtensionMap }], + env: { node: true }, + }, + + // tsx mapping by name + { + // name: "options[0] - preserve - e.tsx as e.jsx", + filename: fixture("test.tsx"), + code: "import e from './e.jsx';", + options: [{ typescriptExtensionMap: "preserve" }], + env: { node: true }, + }, + { + // name: "options[0] - react - e.tsx as e.js", + filename: fixture("test.tsx"), + code: "import e from './e.js';", + options: [{ typescriptExtensionMap: "react" }], + env: { node: true }, + }, + { + // name: "settings.node - preserve - e.tsx as e.jsx", + filename: fixture("test.tsx"), + code: "import e from './e.jsx';", + settings: { node: { typescriptExtensionMap: "preserve" } }, env: { node: true }, - settings: { - node: { typescriptExtensionMap: tsReactExtensionMap }, - }, }, { + // name: "settings.node - react - e.tsx as e.js", filename: fixture("test.tsx"), - code: "import a from './e.js';", + code: "import e from './e.js';", + settings: { node: { typescriptExtensionMap: "react" } }, env: { node: true }, + }, + + // explicit tsx from config + { + // name: "options[0] - preserve - e.tsx as e.jsx", + filename: fixture("ts-react/test.tsx"), + code: "import e from './e.jsx';", + options: [{ tsconfigPath: fixture("ts-preserve/tsconfig.json") }], + env: { node: true }, + }, + { + // name: "options[0] - react - e.tsx as e.js", + filename: fixture("ts-preserve/test.tsx"), + code: "import e from './e.js';", + options: [{ tsconfigPath: fixture("ts-react/tsconfig.json") }], + env: { node: true }, + }, + { + // name: "settings.node - preserve - e.tsx as e.jsx", + filename: fixture("ts-react/test.tsx"), + code: "import e from './e.jsx';", settings: { - node: { typescriptExtensionMap: tsReactExtensionMap }, + node: { tsconfigPath: fixture("ts-preserve/tsconfig.json") }, }, + env: { node: true }, }, { - filename: fixture("test.ts"), - code: "import a from './e.js';", - env: { node: true }, + // name: "settings.node - react - e.tsx as e.js", + filename: fixture("ts-preserve/test.tsx"), + code: "import e from './e.js';", settings: { - node: { typescriptExtensionMap: tsReactExtensionMap }, + node: { tsconfigPath: fixture("ts-react/tsconfig.json") }, }, + env: { node: true }, }, + + // implicit tsx from config { - filename: fixture("test.tsx"), - code: "import a from './d.js';", - options: [{ typescriptExtensionMap: tsReactExtensionMap }], + // name: "tsconfig - jsx: react - e.tsx as e.js", + filename: fixture("ts-react/test.tsx"), + code: "import e from './e.js';", env: { node: true }, }, { - filename: fixture("test.ts"), - code: "import a from './d.js';", - options: [{ typescriptExtensionMap: tsReactExtensionMap }], + // name: "tsconfig - jsx: react - d.ts as d.js", + filename: fixture("ts-react/test.ts"), + code: "import d from './d.js';", env: { node: true }, }, { - filename: fixture("test.tsx"), - code: "import a from './e.js';", - options: [{ typescriptExtensionMap: tsReactExtensionMap }], + // name: "tsconfig - jsx: preserve - e.tsx as e.jsx", + filename: fixture("ts-preserve/test.tsx"), + code: "import e from './e.jsx';", env: { node: true }, }, { - filename: fixture("test.ts"), - code: "import a from './e.js';", - options: [{ typescriptExtensionMap: tsReactExtensionMap }], + // name: "tsconfig - jsx: preserve - d.ts as d.js", + filename: fixture("ts-preserve/test.ts"), + code: "import d from './d.js';", + env: { node: true }, + }, + { + // name: "tsconfig - extends: base (jsx: react) - e.tsx as e.js", + filename: fixture("ts-extends/test.tsx"), + code: "import e from './e.js';", + env: { node: true }, + }, + { + // name: "tsconfig - extends: base (jsx: react) - d.ts as d.js", + filename: fixture("ts-extends/test.ts"), + code: "import d from './d.js';", env: { node: true }, }, diff --git a/tests/lib/rules/no-missing-require.js b/tests/lib/rules/no-missing-require.js index f2b4aea7..74381147 100644 --- a/tests/lib/rules/no-missing-require.js +++ b/tests/lib/rules/no-missing-require.js @@ -281,6 +281,98 @@ ruleTester.run("no-missing-require", rule, { env: { node: true }, }, + // tsx mapping by name + { + filename: fixture("test.tsx"), + code: "require('./e.jsx');", + options: [{ typescriptExtensionMap: "preserve" }], + env: { node: true }, + }, + { + filename: fixture("test.tsx"), + code: "require('./e.js');", + options: [{ typescriptExtensionMap: "react" }], + env: { node: true }, + }, + { + filename: fixture("test.tsx"), + code: "require('./e.jsx');", + settings: { node: { typescriptExtensionMap: "preserve" } }, + env: { node: true }, + }, + { + filename: fixture("test.tsx"), + code: "require('./e.js');", + settings: { node: { typescriptExtensionMap: "react" } }, + env: { node: true }, + }, + + // explicit tsx from config + { + // name: "options[0] - preserve - e.tsx as e.jsx", + filename: fixture("ts-react/test.tsx"), + code: "require('./e.jsx');", + options: [{ tsconfigPath: fixture("ts-preserve/tsconfig.json") }], + env: { node: true }, + }, + { + // name: "options[0] - react - e.tsx as e.js", + filename: fixture("ts-preserve/test.tsx"), + code: "require('./e.js');", + options: [{ tsconfigPath: fixture("ts-react/tsconfig.json") }], + env: { node: true }, + }, + { + // name: "settings.node - preserve - e.tsx as e.jsx", + filename: fixture("ts-react/test.tsx"), + code: "require('./e.jsx');", + settings: { + node: { tsconfigPath: fixture("ts-preserve/tsconfig.json") }, + }, + env: { node: true }, + }, + { + // name: "settings.node - react - e.tsx as e.js", + filename: fixture("ts-preserve/test.tsx"), + code: "require('./e.js');", + settings: { + node: { tsconfigPath: fixture("ts-react/tsconfig.json") }, + }, + env: { node: true }, + }, + + // implicit tsx from config + { + filename: fixture("ts-react/test.tsx"), + code: "require('./e.js');", + env: { node: true }, + }, + { + filename: fixture("ts-react/test.ts"), + code: "require('./d.js');", + env: { node: true }, + }, + { + filename: fixture("ts-preserve/test.tsx"), + code: "require('./e.jsx');", + env: { node: true }, + }, + { + filename: fixture("ts-preserve/test.ts"), + code: "require('./d.js');", + env: { node: true }, + }, + { + filename: fixture("ts-extends/test.tsx"), + code: "require('./e.js');", + env: { node: true }, + }, + { + filename: fixture("ts-extends/test.ts"), + code: "require('./d.js');", + env: { node: true }, + }, + // require.resolve { filename: fixture("test.js"),