Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ jobs:
- 18
- 20
- 22
- 24
eslint:
- 8.56
- 8
- 9

include:
- executeLint: true
node: 20
node: 22
eslint: 9
os: ubuntu-latest
fail-fast: false

Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export default [
| [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | |
| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | |
| [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | |
| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | | | |
| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | 🔧 | 💡 | |
| [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | |
| [group-exports](docs/rules/group-exports.md) | Prefer named exports to be grouped together in a single export declaration. | | | | | | |
| [imports-first](docs/rules/imports-first.md) | Replaced by `import-x/first`. | | | | 🔧 | | ❌ |
Expand Down Expand Up @@ -700,7 +700,6 @@ Detailed changes for each release are documented in [CHANGELOG.md](./CHANGELOG.m
[`eslint_d`]: https://www.npmjs.com/package/eslint_d
[`eslint-loader`]: https://www.npmjs.com/package/eslint-loader
[`get-tsconfig`]: https://github.com/privatenumber/get-tsconfig
[`napi-rs`]: https://github.com/napi-rs/napi-rs
[`tsconfig-paths`]: https://github.com/dividab/tsconfig-paths
[`typescript`]: https://github.com/microsoft/TypeScript
[`unrs-resolver`]: https://github.com/unrs/unrs-resolver
Expand Down
2 changes: 2 additions & 0 deletions docs/rules/extensions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# import-x/extensions

🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).

<!-- end auto-generated rule header -->

Some file resolve algorithms allow you to omit the file extension within the import source path. For example the `node` resolver (which does not yet support ESM/`import`) can resolve `./foo/bar` to the absolute path `/User/someone/foo/bar.js` because the `.js` extension is resolved automatically by default in CJS. Depending on the resolver you can configure more extensions to get resolved automatically.
Expand Down
161 changes: 139 additions & 22 deletions src/rules/extensions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import path from 'node:path'

import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'
import type { RuleFixer } from '@typescript-eslint/utils/ts-eslint'
import { minimatch } from 'minimatch'
import type { MinimatchOptions } from 'minimatch'

import type { FileExtension, RuleContext } from '../types.js'
import {
isBuiltIn,
Expand All @@ -8,32 +13,53 @@
createRule,
moduleVisitor,
resolve,
parsePath,
stringifyPath,
} from '../utils/index.js'

const modifierValues = ['always', 'ignorePackages', 'never'] as const

const modifierSchema = {
type: 'string' as const,
type: 'string',
enum: [...modifierValues],
}
} satisfies JSONSchema4

const modifierByFileExtensionSchema = {
type: 'object' as const,
type: 'object',
patternProperties: { '.*': modifierSchema },
}
} satisfies JSONSchema4

const properties = {
type: 'object' as const,
type: 'object',
properties: {
pattern: modifierByFileExtensionSchema,
ignorePackages: {
type: 'boolean' as const,
type: 'boolean',
},
checkTypeImports: {
type: 'boolean' as const,
type: 'boolean',
},
pathGroupOverrides: {
type: 'array',
items: {
type: 'object',
properties: {
pattern: { type: 'string' },
patternOptions: { type: 'object' },
action: {
type: 'string',
enum: ['enforce', 'ignore'],
},
},
additionalProperties: false,
required: ['pattern', 'action'],
},
},
fix: {
type: 'boolean',
},
},
}
} satisfies JSONSchema4

export type Modifier = (typeof modifierValues)[number]

Expand All @@ -43,15 +69,27 @@
ignorePackages?: boolean
checkTypeImports?: boolean
pattern: ModifierByFileExtension
pathGroupOverrides?: PathGroupOverride[]
fix?: boolean
}

export interface PathGroupOverride {
pattern: string
patternOptions?: Record<string, MinimatchOptions>
action: 'enforce' | 'ignore'
}

export interface OptionsItemWithoutPatternProperty {
ignorePackages?: boolean
checkTypeImports?: boolean
pathGroupOverrides?: PathGroupOverride[]
fix?: boolean
}

export type Options =
| []
| [OptionsItemWithoutPatternProperty]
| [OptionsItemWithPatternProperty]
| [Modifier]
| [Modifier, OptionsItemWithoutPatternProperty]
| [Modifier, OptionsItemWithPatternProperty]
Expand All @@ -63,16 +101,20 @@
pattern?: Record<string, Modifier>
ignorePackages?: boolean
checkTypeImports?: boolean
pathGroupOverrides?: PathGroupOverride[]
fix?: boolean
}

export type MessageId = 'missing' | 'missingKnown' | 'unexpected'
export type MessageId = 'missing' | 'missingKnown' | 'unexpected' | 'addMissing'

function buildProperties(context: RuleContext<MessageId, Options>) {
const result: Required<NormalizedOptions> = {
defaultConfig: 'never',
pattern: {},
ignorePackages: false,
checkTypeImports: false,
pathGroupOverrides: [],
fix: false,
}

for (const obj of context.options) {
Expand All @@ -88,16 +130,16 @@

// If this is not the new structure, transfer all props to result.pattern
if (
(!('pattern' in obj) || obj.pattern === undefined) &&
obj.ignorePackages === undefined &&
obj.checkTypeImports === undefined
(!('pattern' in obj) || obj.pattern == null) &&
obj.ignorePackages == null &&
obj.checkTypeImports == null
) {
Object.assign(result.pattern, obj)
continue
}

// If pattern is provided, transfer all props
if ('pattern' in obj && obj.pattern !== undefined) {
if ('pattern' in obj && obj.pattern != null) {
Object.assign(result.pattern, obj.pattern)
}

Expand All @@ -109,6 +151,14 @@
if (typeof obj.checkTypeImports === 'boolean') {
result.checkTypeImports = obj.checkTypeImports
}

if (obj.fix != null) {
result.fix = Boolean(obj.fix)
}

if (Array.isArray(obj.pathGroupOverrides)) {
result.pathGroupOverrides = obj.pathGroupOverrides
}
}

if (result.defaultConfig === 'ignorePackages') {
Expand All @@ -124,14 +174,18 @@
return false
}
const slashCount = file.split('/').length - 1
return slashCount === 0 || (isScoped(file) && slashCount <= 1)
}

if (slashCount === 0) {
return true
}
if (isScoped(file) && slashCount <= 1) {
return true
function computeOverrideAction(
pathGroupOverrides: PathGroupOverride[],
path: string,
) {
for (const { pattern, patternOptions, action } of pathGroupOverrides) {
if (minimatch(path, pattern, patternOptions || { nocomment: true })) {
return action
}
}
return false
}

export default createRule<Options, MessageId>({
Expand All @@ -143,6 +197,8 @@
description:
'Ensure consistent use of file extension within the import path.',
},
fixable: 'code',
hasSuggestions: true,
schema: {
anyOf: [
{
Expand Down Expand Up @@ -178,6 +234,8 @@
'Missing file extension "{{extension}}" for "{{importPath}}"',
unexpected:
'Unexpected use of file extension "{{extension}}" for "{{importPath}}"',
addMissing:
'Add "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"',
},
},
defaultOptions: [],
Expand Down Expand Up @@ -221,16 +279,29 @@

const importPathWithQueryString = source.value

// If not undefined, the user decided if rules are enforced on this import
const overrideAction = computeOverrideAction(
props.pathGroupOverrides || [],
importPathWithQueryString,
)

if (overrideAction === 'ignore') {
return
}

// don't enforce anything on builtins
if (isBuiltIn(importPathWithQueryString, context.settings)) {
if (
!overrideAction &&
isBuiltIn(importPathWithQueryString, context.settings)
) {
return
}

const importPath = importPathWithQueryString.replace(/\?(.*)$/, '')

// don't enforce in root external packages as they may have names with `.js`.
// Like `import Decimal from decimal.js`)
if (isExternalRootModule(importPath)) {
if (!overrideAction && isExternalRootModule(importPath)) {
return
}

Expand Down Expand Up @@ -261,17 +332,55 @@
}
const extensionRequired = isUseOfExtensionRequired(
extension,
isPackage,
!overrideAction && isPackage,
)
const extensionForbidden = isUseOfExtensionForbidden(extension)
if (extensionRequired && !extensionForbidden) {
const { pathname, query, hash } = parsePath(
importPathWithQueryString,
)
const fixedImportPath = stringifyPath({
pathname: `${
/([\\/]|[\\/]?\.?\.)$/.test(pathname)
Copy link

Choose a reason for hiding this comment

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

Consider adding an inline comment explaining the regex /([\/]|[\/]?\.?\\.)$/ used here to decide whether to append /index.<extension> (for directory-like paths) versus just .<extension>. This would improve code clarity.

Copy link
Member Author

@JounQin JounQin May 18, 2025

Choose a reason for hiding this comment

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

@stephenjason89 You also need to change your fix implementation for import-js/eslint-plugin-import#3177.

Copy link
Contributor

Choose a reason for hiding this comment

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

Will do. Thanks!

? `${
pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
}/index.${extension}`
: `${pathname}.${extension}`
}`,
query,
hash,
})
const fixOrSuggest = {
fix(fixer: RuleFixer) {
return fixer.replaceText(
source,
JSON.stringify(fixedImportPath),
)
},
}
context.report({
node: source,
messageId: extension ? 'missingKnown' : 'missing',
data: {
extension,
importPath: importPathWithQueryString,
},
...(extension &&
(props.fix
? fixOrSuggest

Check warning on line 370 in src/rules/extensions.ts

View check run for this annotation

Codecov / codecov/patch

src/rules/extensions.ts#L370

Added line #L370 was not covered by tests
: {
suggest: [
{
...fixOrSuggest,
messageId: 'addMissing',
data: {
extension,
importPath: importPathWithQueryString,
fixedImportPath: fixedImportPath,
},
},
],
})),
})
}
} else if (
Expand All @@ -286,6 +395,14 @@
extension,
importPath: importPathWithQueryString,
},
...(props.fix && {
Copy link
Member Author

@JounQin JounQin May 17, 2025

Choose a reason for hiding this comment

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

Let's also add suggestions here in another PR.

fix(fixer) {
return fixer.replaceText(
source,
JSON.stringify(importPath.slice(0, -(extension.length + 1))),
)
},
}),
})
}
},
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './lazy-value.js'
export * from './legacy-resolver-settings.js'
export * from './package-path.js'
export * from './parse.js'
export * from './parse-path.js'
export * from './pkg-dir.js'
export * from './pkg-up.js'
export * from './read-pkg-up.js'
Expand Down
25 changes: 25 additions & 0 deletions src/utils/parse-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface ParsedPath {
pathname: string
query: string
hash: string
}

export const parsePath = (path: string) => {
const hashIndex = path.indexOf('#')
const queryIndex = path.indexOf('?')
const hasHash = hashIndex !== -1
const hash = hasHash ? path.slice(hashIndex) : ''
const hasQuery = queryIndex !== -1 && (!hasHash || queryIndex < hashIndex)
const query = hasQuery
? path.slice(queryIndex, hasHash ? hashIndex : undefined)
: ''
const pathname = hasQuery
? path.slice(0, queryIndex)
: hasHash
? path.slice(0, hashIndex)
: path
return { pathname, query, hash }
}

export const stringifyPath = ({ pathname, query, hash }: ParsedPath) =>
pathname + query + hash
Loading
Loading