Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add the ability to configure typescript to javascript file extension conversion #112

Merged
merged 4 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/rules/no-missing-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,30 @@ If a path is relative, it will be resolved from CWD.

Default is `[]`

#### typescriptExtensionMap

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid duplication, I'm just wondering whether we could add a section to the readme that describes the shared settings. In the rule docs we can add a link to that. (surely not blocking merging this PR. 😄 )


Adds the ability to change the extension mapping when converting between typescript and javascript

Default is:

```json
[
[ "", ".js" ],
[ ".ts", ".js" ],
[ ".cts", ".cjs" ],
[ ".mts", ".mjs" ],
[ ".tsx", ".jsx" ],
]
```

### 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.

- `allowModules`
- `resolvePaths`
- `typescriptExtensionMap`

```js
// .eslintrc.js
Expand All @@ -84,6 +101,13 @@ module.exports = {
"node": {
"allowModules": ["electron"],
"resolvePaths": [__dirname],
"typescriptExtensionMap": [
[ "", ".js" ],
[ ".ts", ".js" ],
[ ".cts", ".cjs" ],
[ ".mts", ".mjs" ],
[ ".tsx", ".js" ],
]
}
},
"rules": {
Expand Down
26 changes: 25 additions & 1 deletion docs/rules/no-missing-require.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ When an import path does not exist, this rule checks whether or not any of `path

Default is `[".js", ".json", ".node"]`.

#### typescriptExtensionMap

Adds the ability to change the extension mapping when converting between typescript and javascript

Default is:

```json
[
[ "", ".js" ],
[ ".ts", ".js" ],
[ ".cts", ".cjs" ],
[ ".mts", ".mjs" ],
[ ".tsx", ".jsx" ],
]
```

### Shared Settings

The following options can be set by [shared settings](http://eslint.org/docs/user-guide/configuring.html#adding-shared-settings).
Expand All @@ -90,6 +106,7 @@ Several rules have the same option, but we can set this option at once.
- `allowModules`
- `resolvePaths`
- `tryExtensions`
- `typescriptExtensionMap`

```js
// .eslintrc.js
Expand All @@ -98,7 +115,14 @@ module.exports = {
"node": {
"allowModules": ["electron"],
"resolvePaths": [__dirname],
"tryExtensions": [".js", ".json", ".node"]
"tryExtensions": [".js", ".json", ".node"],
"typescriptExtensionMap": [
[ "", ".js" ],
[ ".ts", ".js" ],
[ ".cts", ".cjs" ],
[ ".mts", ".mjs" ],
[ ".tsx", ".js" ],
]
}
},
"rules": {
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/no-missing-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 getTypescriptExtensionMap = require("../util/get-typescript-extension-map")
const visitImport = require("../util/visit-import")

module.exports = {
Expand All @@ -26,6 +27,7 @@ module.exports = {
properties: {
allowModules: getAllowModules.schema,
resolvePaths: getResolvePaths.schema,
typescriptExtensionMap: getTypescriptExtensionMap.schema,
},
additionalProperties: false,
},
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/no-missing-require.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { checkExistence, messages } = require("../util/check-existence")
const getAllowModules = require("../util/get-allow-modules")
const getResolvePaths = require("../util/get-resolve-paths")
const getTryExtensions = require("../util/get-try-extensions")
const getTypescriptExtensionMap = require("../util/get-typescript-extension-map")
const visitRequire = require("../util/visit-require")

module.exports = {
Expand All @@ -28,6 +29,7 @@ module.exports = {
allowModules: getAllowModules.schema,
tryExtensions: getTryExtensions.schema,
resolvePaths: getResolvePaths.schema,
typescriptExtensionMap: getTypescriptExtensionMap.schema,
},
additionalProperties: false,
},
Expand Down
14 changes: 9 additions & 5 deletions lib/util/check-existence.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,21 @@ exports.checkExistence = function checkExistence(context, targets) {
let missingFile = target.moduleName == null && !exists(target.filePath)
if (missingFile && isTypescript(context)) {
const parsed = path.parse(target.filePath)
const reversedExt = mapTypescriptExtension(
const reversedExts = mapTypescriptExtension(
context,
target.filePath,
parsed.ext,
true
)
const reversedPath =
path.resolve(parsed.dir, parsed.name) + reversedExt
missingFile = target.moduleName == null && !exists(reversedPath)
const reversedPaths = reversedExts.map(
reversedExt =>
path.resolve(parsed.dir, parsed.name) + reversedExt
)
missingFile = reversedPaths.every(
reversedPath =>
target.moduleName == null && !exists(reversedPath)
)
}

if (missingModule || missingFile) {
context.report({
node: target.node,
Expand Down
81 changes: 81 additions & 0 deletions lib/util/get-typescript-extension-map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use strict"

const DEFAULT_MAPPING = normalise([
["", ".js"],
[".ts", ".js"],
[".cts", ".cjs"],
[".mts", ".mjs"],
[".tsx", ".jsx"],
])

/**
* @typedef {Object} ExtensionMap
* @property {Record<string, string>} forward Convert from typescript to javascript
* @property {Record<string, string[]>} backward Convert from javascript to typescript
*/

function normalise(typescriptExtensionMap) {
const forward = {}
const backward = {}
for (const [typescript, javascript] of typescriptExtensionMap) {
forward[typescript] = javascript
if (!typescript) {
continue
}
backward[javascript] ??= []
backward[javascript].push(typescript)
}
return { forward, backward }
}

/**
* Gets `typescriptExtensionMap` property from a given option object.
*
* @param {object|undefined} option - An option object to get.
* @returns {ExtensionMap} The `typescriptExtensionMap` value, or `null`.
*/
function get(option) {
if (
option &&
option.typescriptExtensionMap &&
Array.isArray(option.typescriptExtensionMap)
Comment on lines +39 to +41

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits: can be simply rewritten to Array.isArray(option?.typescriptExtensionMap), as the package has dropped node.js < v16.

Copy link
Author

@scagood scagood Sep 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I am totally going to fix this in another PR!

I just followed what was there 👀

) {
return normalise(option.typescriptExtensionMap)
}

return null
}

/**
* 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`.
*
* @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)
) ||
// TODO: Detect tsconfig.json here
DEFAULT_MAPPING
)
}

module.exports.schema = {
type: "array",
items: {
type: "array",
prefixItems: [
{ type: "string", pattern: "^(?:|\\.\\w+)$" },
{ type: "string", pattern: "^\\.\\w+$" },
],
additionalItems: false,
},
uniqueItems: true,
}
30 changes: 9 additions & 21 deletions lib/util/map-typescript-extension.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,8 @@
"use strict"

const path = require("path")
const isTypescript = require("../util/is-typescript")

const mapping = {
"": ".js", // default empty extension will map to js
".ts": ".js",
".cts": ".cjs",
".mts": ".mjs",
".tsx": ".jsx",
}

const reverseMapping = {
".js": ".ts",
".cjs": ".cts",
".mjs": ".mts",
".jsx": ".tsx",
}
const isTypescript = require("./is-typescript")
const getTypescriptExtensionMap = require("./get-typescript-extension-map")

/**
* Maps the typescript file extension that should be added in an import statement,
Expand All @@ -25,7 +11,7 @@ const reverseMapping = {
* For example, in typescript, when referencing another typescript from a typescript file,
* a .js extension should be used instead of the original .ts extension of the referenced file.
*
* @param {RuleContext} context
* @param {import('eslint').Rule.RuleContext} context
* @param {string} filePath The filePath of the import
* @param {string} fallbackExtension The non-typescript fallback
* @param {boolean} reverse Execute a reverse path mapping
Expand All @@ -37,14 +23,16 @@ module.exports = function mapTypescriptExtension(
fallbackExtension,
reverse = false
) {
const { forward, backward } = getTypescriptExtensionMap(context)
const ext = path.extname(filePath)
if (reverse) {
if (isTypescript(context) && ext in reverseMapping) {
return reverseMapping[ext]
if (isTypescript(context) && ext in backward) {
return backward[ext]
}
return [fallbackExtension]
} else {
if (isTypescript(context) && ext in mapping) {
return mapping[ext]
if (isTypescript(context) && ext in forward) {
return forward[ext]
}
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"devDependencies": {
"@eslint/eslintrc": "^2.0.3",
"@eslint/js": "^8.43.0",
"@types/eslint": "^8.44.2",
"@typescript-eslint/parser": "^5.60.0",
"codecov": "^3.8.2",
"esbuild": "^0.18.7",
Expand Down
34 changes: 34 additions & 0 deletions tests/lib/rules/file-extension-in-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ if (!DynamicImportSupported) {
)
}

const tsReactExtensionMap = [
["", ".js"],
[".ts", ".js"],
[".cts", ".cjs"],
[".mts", ".mjs"],
[".tsx", ".js"],
]

function fixture(filename) {
return path.resolve(
__dirname,
Expand Down Expand Up @@ -146,6 +154,32 @@ new RuleTester({
code: "import './c'",
options: ["never", { ".json": "always" }],
},

// typescriptExtensionMap
{
filename: fixture("test.tsx"),
code: "require('./d.js');",
env: { node: true },
settings: { node: { typescriptExtensionMap: tsReactExtensionMap } },
},
{
filename: fixture("test.tsx"),
code: "require('./e.js');",
env: { node: true },
settings: { node: { typescriptExtensionMap: tsReactExtensionMap } },
},
{
filename: fixture("test.ts"),
code: "require('./d.js');",
env: { node: true },
settings: { node: { typescriptExtensionMap: tsReactExtensionMap } },
},
{
filename: fixture("test.ts"),
code: "require('./e.js');",
env: { node: true },
settings: { node: { typescriptExtensionMap: tsReactExtensionMap } },
},
],
invalid: [
{
Expand Down
Loading
Loading