Skip to content

feat(extensions): support pathGroupOverrides and fix options #327

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

Merged
merged 6 commits into from
May 17, 2025
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
5 changes: 5 additions & 0 deletions .changeset/fast-bees-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-import-x": minor
---

feat(extensions): support `pathGroupOverrides` and `fix` options
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): ParsedPath => {
Copy link
Member Author

Choose a reason for hiding this comment

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

@stephenjason89 You'd better to use this pattern to support ?/# better in
original importPath 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.

Got it.

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