Skip to content

Commit 1c8b9ae

Browse files
JounQinRel1cxautofix-ci[bot]
authored
feat: port react-x/prefer-react-namespace-import into prefer-namespace-import (#386)
Co-authored-by: Rel1cx <rel1cx@proton.me> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 10cf017 commit 1c8b9ae

File tree

8 files changed

+347
-29
lines changed

8 files changed

+347
-29
lines changed

.changeset/light-llamas-return.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-import-x": minor
3+
---
4+
5+
feat: port [`react-x/prefer-react-namespace-import`](https://eslint-react.xyz/docs/rules/prefer-react-namespace-import) into `prefer-namespace-import`

README.md

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -298,26 +298,27 @@ settings:
298298

299299
### Style guide
300300

301-
| Name                            | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ |
302-
| :------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- | :-- | :---------- | :-- | :-- | :-- | :-- |
303-
| [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | |
304-
| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | |
305-
| [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | |
306-
| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | 🔧 | 💡 | |
307-
| [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | |
308-
| [group-exports](docs/rules/group-exports.md) | Prefer named exports to be grouped together in a single export declaration. | | | | | | |
309-
| [imports-first](docs/rules/imports-first.md) | Replaced by `import-x/first`. | | | | 🔧 | | ❌ |
310-
| [max-dependencies](docs/rules/max-dependencies.md) | Enforce the maximum number of dependencies a module can have. | | | | | | |
311-
| [newline-after-import](docs/rules/newline-after-import.md) | Enforce a newline after import statements. | | | | 🔧 | | |
312-
| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md) | Forbid anonymous values as default exports. | | | | | | |
313-
| [no-default-export](docs/rules/no-default-export.md) | Forbid default exports. | | | | | | |
314-
| [no-duplicates](docs/rules/no-duplicates.md) | Forbid repeated import of the same module in multiple places. | | ☑️ 🚸 ☑️ 🚸 | | 🔧 | | |
315-
| [no-named-default](docs/rules/no-named-default.md) | Forbid named default exports. | | | | | | |
316-
| [no-named-export](docs/rules/no-named-export.md) | Forbid named exports. | | | | | | |
317-
| [no-namespace](docs/rules/no-namespace.md) | Forbid namespace (a.k.a. "wildcard" `*`) imports. | | | | 🔧 | | |
318-
| [no-unassigned-import](docs/rules/no-unassigned-import.md) | Forbid unassigned imports. | | | | | | |
319-
| [order](docs/rules/order.md) | Enforce a convention in module import order. | | | | 🔧 | | |
320-
| [prefer-default-export](docs/rules/prefer-default-export.md) | Prefer a default export if module exports a single name or multiple names. | | | | | | |
301+
| Name                            | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ |
302+
| :------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------- | :-- | :---------- | :-- | :-- | :-- | :-- |
303+
| [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | |
304+
| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | |
305+
| [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | |
306+
| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | 🔧 | 💡 | |
307+
| [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | |
308+
| [group-exports](docs/rules/group-exports.md) | Prefer named exports to be grouped together in a single export declaration. | | | | | | |
309+
| [imports-first](docs/rules/imports-first.md) | Replaced by `import-x/first`. | | | | 🔧 | | ❌ |
310+
| [max-dependencies](docs/rules/max-dependencies.md) | Enforce the maximum number of dependencies a module can have. | | | | | | |
311+
| [newline-after-import](docs/rules/newline-after-import.md) | Enforce a newline after import statements. | | | | 🔧 | | |
312+
| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md) | Forbid anonymous values as default exports. | | | | | | |
313+
| [no-default-export](docs/rules/no-default-export.md) | Forbid default exports. | | | | | | |
314+
| [no-duplicates](docs/rules/no-duplicates.md) | Forbid repeated import of the same module in multiple places. | | ☑️ 🚸 ☑️ 🚸 | | 🔧 | | |
315+
| [no-named-default](docs/rules/no-named-default.md) | Forbid named default exports. | | | | | | |
316+
| [no-named-export](docs/rules/no-named-export.md) | Forbid named exports. | | | | | | |
317+
| [no-namespace](docs/rules/no-namespace.md) | Forbid namespace (a.k.a. "wildcard" `*`) imports. | | | | 🔧 | | |
318+
| [no-unassigned-import](docs/rules/no-unassigned-import.md) | Forbid unassigned imports. | | | | | | |
319+
| [order](docs/rules/order.md) | Enforce a convention in module import order. | | | | 🔧 | | |
320+
| [prefer-default-export](docs/rules/prefer-default-export.md) | Prefer a default export if module exports a single name or multiple names. | | | | | | |
321+
| [prefer-namespace-import](docs/rules/prefer-namespace-import.md) | Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc. | | | | 🔧 | | |
321322

322323
<!-- end auto-generated rules list -->
323324

docs/rules/prefer-namespace-import.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# import-x/prefer-namespace-import
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc.
8+
9+
## Rule Details
10+
11+
### rule schema
12+
13+
```jsonc
14+
{
15+
"import-x/prefer-namespace-import": [
16+
"error", // or "off", "warn"
17+
{
18+
"patterns": [
19+
// Exact match
20+
"foo",
21+
// RegExp
22+
"/^prefix-/",
23+
],
24+
},
25+
],
26+
}
27+
```
28+
29+
### Config Options
30+
31+
`patterns` is an array of strings or `RegExp` patterns that specify which modules should be imported using namespace imports.
32+
33+
#### Example
34+
35+
```js
36+
/*eslint import-x/prefer-namespace-import: [2, { patterns: ['react'] }]*/
37+
38+
// bad
39+
import React from 'react'
40+
41+
// good
42+
import * as React from 'react'
43+
44+
// ignored
45+
import ReactDOM from 'react-dom'
46+
```

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import noUselessPathSegments from './rules/no-useless-path-segments.js'
6565
import noWebpackLoaderSyntax from './rules/no-webpack-loader-syntax.js'
6666
import order from './rules/order.js'
6767
import preferDefaultExport from './rules/prefer-default-export.js'
68+
import preferNamespaceImport from './rules/prefer-namespace-import.js'
6869
import unambiguous from './rules/unambiguous.js'
6970
// configs
7071
import type {
@@ -111,6 +112,7 @@ const rules = {
111112
order,
112113
'newline-after-import': newlineAfterImport,
113114
'prefer-default-export': preferDefaultExport,
115+
'prefer-namespace-import': preferNamespaceImport,
114116
'no-default-export': noDefaultExport,
115117
'no-named-export': noNamedExport,
116118
'no-dynamic-require': noDynamicRequire,

src/rules/prefer-default-export.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export interface Options {
66
target?: 'single' | 'any'
77
}
88

9-
type MessageId = 'single' | 'any'
9+
export type MessageId = 'single' | 'any'
1010

1111
export default createRule<[Options?], MessageId>({
1212
name: 'prefer-default-export',

src/rules/prefer-namespace-import.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { createRule } from '../utils/index.js'
2+
3+
export interface Options {
4+
patterns?: readonly string[]
5+
}
6+
7+
export type MessageId = 'preferNamespaceImport'
8+
9+
export default createRule<[Options?], MessageId>({
10+
name: 'prefer-namespace-import',
11+
meta: {
12+
type: 'problem',
13+
docs: {
14+
category: 'Style guide',
15+
description:
16+
'Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc.',
17+
},
18+
fixable: 'code',
19+
schema: [
20+
{
21+
type: 'object',
22+
additionalProperties: false,
23+
properties: {
24+
patterns: {
25+
type: 'array',
26+
items: {
27+
type: 'string',
28+
},
29+
uniqueItems: true,
30+
},
31+
},
32+
},
33+
],
34+
messages: {
35+
preferNamespaceImport:
36+
'Prefer importing {{specifier}} as \'import * as {{specifier}} from "{{source}}"\';',
37+
},
38+
},
39+
defaultOptions: [],
40+
create(context) {
41+
const { patterns } = context.options[0] ?? {}
42+
if (!patterns?.length) {
43+
return {}
44+
}
45+
const regexps = patterns.map(toRegExp)
46+
return {
47+
ImportDefaultSpecifier(node) {
48+
const importSource = node.parent.source.value
49+
if (!regexps.some(exp => exp.test(importSource))) {
50+
return
51+
}
52+
const defaultSpecifier = node.local.name
53+
const hasOtherSpecifiers = node.parent.specifiers.length > 1
54+
context.report({
55+
messageId: 'preferNamespaceImport',
56+
node: hasOtherSpecifiers ? node : node.parent,
57+
data: {
58+
source: importSource,
59+
specifier: defaultSpecifier,
60+
},
61+
fix(fixer) {
62+
const importDeclarationText = context.sourceCode.getText(
63+
node.parent,
64+
)
65+
const localName = node.local.name
66+
if (!hasOtherSpecifiers) {
67+
return fixer.replaceText(node, `* as ${localName}`)
68+
}
69+
const isTypeImport = node.parent.importKind === 'type'
70+
const importStringPrefix = `import${isTypeImport ? ' type' : ''}`
71+
// remove the default specifier and prepend the namespace import specifier
72+
const rightBraceIndex = importDeclarationText.indexOf('}') + 1
73+
const specifiers = importDeclarationText.slice(
74+
importDeclarationText.indexOf('{'),
75+
rightBraceIndex,
76+
)
77+
const remainingText = importDeclarationText.slice(rightBraceIndex)
78+
return fixer.replaceText(
79+
node.parent,
80+
[
81+
`${importStringPrefix} * as ${localName} ${remainingText.trimStart()}`,
82+
`${importStringPrefix} ${specifiers}${remainingText}`,
83+
].join('\n'),
84+
)
85+
},
86+
})
87+
},
88+
}
89+
},
90+
})
91+
92+
/** Regular expression for matching a RegExp string. */
93+
const REGEXP_STR = /^\/(.+)\/([A-Za-z]*)$/u
94+
95+
/**
96+
* Convert a string to the `RegExp`. Normal strings (e.g. `"foo"`) is converted
97+
* to `/^foo$/` of `RegExp`. Strings like `"/^foo/i"` are converted to `/^foo/i`
98+
* of `RegExp`.
99+
*
100+
* @param string The string to convert.
101+
* @returns Returns the `RegExp`.
102+
* @see https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/utils/regexp.ts
103+
*/
104+
function toRegExp(string: string): { test(s: string): boolean } {
105+
const [, pattern, flags = 'u'] = REGEXP_STR.exec(string) ?? []
106+
if (pattern != null) {
107+
return new RegExp(pattern, flags)
108+
}
109+
return { test: s => s === string }
110+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { RuleTester as TSESLintRuleTester } from '@typescript-eslint/rule-tester'
2+
import type { TSESLint } from '@typescript-eslint/utils'
3+
4+
import {
5+
createRuleTestCaseFunctions,
6+
getNonDefaultParsers,
7+
parsers,
8+
testFilePath,
9+
} from '../utils.js'
10+
11+
import { cjsRequire as require } from 'eslint-plugin-import-x'
12+
import rule from 'eslint-plugin-import-x/rules/prefer-namespace-import'
13+
14+
const ruleTester = new TSESLintRuleTester()
15+
16+
const { tValid, tInvalid } = createRuleTestCaseFunctions<typeof rule>()
17+
18+
const options = [{ patterns: ['/^@scope/', '/^prefix-/', 'specific'] }] as const
19+
20+
ruleTester.run('prefer-namespace-import', rule, {
21+
valid: [
22+
tValid({
23+
code: `import * as Name from '@scope/name';`,
24+
}),
25+
tValid({
26+
code: `import * as Name from 'prefix-name';`,
27+
}),
28+
tValid({
29+
code: `import * as Name from 'specific';`,
30+
}),
31+
tValid({
32+
code: `
33+
import * as Name1 from '@scope/name';
34+
import * as Name2 from 'prefix-name';
35+
import * as Name2 from 'specific';
36+
`,
37+
}),
38+
tValid({
39+
code: `import Name from 'other-name';`,
40+
options,
41+
}),
42+
],
43+
invalid: [
44+
tInvalid({
45+
code: `
46+
import Name1 from '@scope/name';
47+
import Name2 from 'prefix-name';
48+
import Name3 from 'prefix-name' with { type: 'json' };
49+
import Name4, { name4 } from 'prefix-name' with { type: 'json' };
50+
import Name5 from 'specific';
51+
import Name6 from 'other-name';
52+
`,
53+
errors: [
54+
{
55+
messageId: 'preferNamespaceImport',
56+
data: { source: '@scope/name', specifier: 'Name1' },
57+
},
58+
{
59+
messageId: 'preferNamespaceImport',
60+
data: { source: 'prefix-name', specifier: 'Name2' },
61+
},
62+
{
63+
messageId: 'preferNamespaceImport',
64+
data: { source: 'prefix-name', specifier: 'Name3' },
65+
},
66+
{
67+
messageId: 'preferNamespaceImport',
68+
data: { source: 'prefix-name', specifier: 'Name4' },
69+
},
70+
{
71+
messageId: 'preferNamespaceImport',
72+
data: { source: 'specific', specifier: 'Name5' },
73+
},
74+
],
75+
options,
76+
output: `
77+
import * as Name1 from '@scope/name';
78+
import * as Name2 from 'prefix-name';
79+
import * as Name3 from 'prefix-name' with { type: 'json' };
80+
import * as Name4 from 'prefix-name' with { type: 'json' };
81+
import { name4 } from 'prefix-name' with { type: 'json' };
82+
import * as Name5 from 'specific';
83+
import Name6 from 'other-name';
84+
`,
85+
}),
86+
],
87+
})
88+
89+
describe('TypeScript', () => {
90+
for (const parser of getNonDefaultParsers()) {
91+
const parserConfig = {
92+
languageOptions: {
93+
...(parser === parsers.BABEL && {
94+
parser: require<TSESLint.Parser.LooseParserModule>(parsers.BABEL),
95+
}),
96+
},
97+
filename: testFilePath('foo.ts'),
98+
}
99+
100+
ruleTester.run('prefer-namespace-import', rule, {
101+
valid: [
102+
tValid({
103+
code: `
104+
import type * as Name1 from '@scope/name';
105+
import type * as Name2 from 'prefix-name';
106+
`,
107+
...parserConfig,
108+
}),
109+
tValid({
110+
code: `import type Name from 'other-name';`,
111+
options,
112+
...parserConfig,
113+
}),
114+
],
115+
invalid: [
116+
tInvalid({
117+
code: `
118+
import type Name1 from '@scope/name';
119+
import type Name2 from 'prefix-name';
120+
import type Name3 from 'prefix-name' with { type: 'json' };
121+
import Name4, { type name4 } from 'prefix-name' with { type: 'json' };
122+
import type Name5 from 'specific';
123+
import type Name6 from 'other-name';
124+
`,
125+
errors: [
126+
{
127+
messageId: 'preferNamespaceImport',
128+
data: { source: '@scope/name', specifier: 'Name1' },
129+
},
130+
{
131+
messageId: 'preferNamespaceImport',
132+
data: { source: 'prefix-name', specifier: 'Name2' },
133+
},
134+
{
135+
messageId: 'preferNamespaceImport',
136+
data: { source: 'prefix-name', specifier: 'Name3' },
137+
},
138+
{
139+
messageId: 'preferNamespaceImport',
140+
data: { source: 'prefix-name', specifier: 'Name4' },
141+
},
142+
{
143+
messageId: 'preferNamespaceImport',
144+
data: { source: 'specific', specifier: 'Name5' },
145+
},
146+
],
147+
options,
148+
output: `
149+
import type * as Name1 from '@scope/name';
150+
import type * as Name2 from 'prefix-name';
151+
import type * as Name3 from 'prefix-name' with { type: 'json' };
152+
import * as Name4 from 'prefix-name' with { type: 'json' };
153+
import { type name4 } from 'prefix-name' with { type: 'json' };
154+
import type * as Name5 from 'specific';
155+
import type Name6 from 'other-name';
156+
`,
157+
...parserConfig,
158+
}),
159+
],
160+
})
161+
}
162+
})

0 commit comments

Comments
 (0)