Skip to content
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/light-llamas-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-import-x": minor
---

feat: port [`react-x/prefer-react-namespace-import`](https://eslint-react.xyz/docs/rules/prefer-react-namespace-import) into `prefer-namespace-import`
41 changes: 21 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,26 +298,27 @@ settings:

### Style guide

| Name                            | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ |
| :------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- | :-- | :---------- | :-- | :-- | :-- | :-- |
| [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. | | | | 🔧 | 💡 | |
| [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`. | | | | 🔧 | | ❌ |
| [max-dependencies](docs/rules/max-dependencies.md) | Enforce the maximum number of dependencies a module can have. | | | | | | |
| [newline-after-import](docs/rules/newline-after-import.md) | Enforce a newline after import statements. | | | | 🔧 | | |
| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md) | Forbid anonymous values as default exports. | | | | | | |
| [no-default-export](docs/rules/no-default-export.md) | Forbid default exports. | | | | | | |
| [no-duplicates](docs/rules/no-duplicates.md) | Forbid repeated import of the same module in multiple places. | | ☑️ 🚸 ☑️ 🚸 | | 🔧 | | |
| [no-named-default](docs/rules/no-named-default.md) | Forbid named default exports. | | | | | | |
| [no-named-export](docs/rules/no-named-export.md) | Forbid named exports. | | | | | | |
| [no-namespace](docs/rules/no-namespace.md) | Forbid namespace (a.k.a. "wildcard" `*`) imports. | | | | 🔧 | | |
| [no-unassigned-import](docs/rules/no-unassigned-import.md) | Forbid unassigned imports. | | | | | | |
| [order](docs/rules/order.md) | Enforce a convention in module import order. | | | | 🔧 | | |
| [prefer-default-export](docs/rules/prefer-default-export.md) | Prefer a default export if module exports a single name or multiple names. | | | | | | |
| Name                            | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ |
| :------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------- | :-- | :---------- | :-- | :-- | :-- | :-- |
| [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. | | | | 🔧 | 💡 | |
| [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`. | | | | 🔧 | | ❌ |
| [max-dependencies](docs/rules/max-dependencies.md) | Enforce the maximum number of dependencies a module can have. | | | | | | |
| [newline-after-import](docs/rules/newline-after-import.md) | Enforce a newline after import statements. | | | | 🔧 | | |
| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md) | Forbid anonymous values as default exports. | | | | | | |
| [no-default-export](docs/rules/no-default-export.md) | Forbid default exports. | | | | | | |
| [no-duplicates](docs/rules/no-duplicates.md) | Forbid repeated import of the same module in multiple places. | | ☑️ 🚸 ☑️ 🚸 | | 🔧 | | |
| [no-named-default](docs/rules/no-named-default.md) | Forbid named default exports. | | | | | | |
| [no-named-export](docs/rules/no-named-export.md) | Forbid named exports. | | | | | | |
| [no-namespace](docs/rules/no-namespace.md) | Forbid namespace (a.k.a. "wildcard" `*`) imports. | | | | 🔧 | | |
| [no-unassigned-import](docs/rules/no-unassigned-import.md) | Forbid unassigned imports. | | | | | | |
| [order](docs/rules/order.md) | Enforce a convention in module import order. | | | | 🔧 | | |
| [prefer-default-export](docs/rules/prefer-default-export.md) | Prefer a default export if module exports a single name or multiple names. | | | | | | |
| [prefer-namespace-import](docs/rules/prefer-namespace-import.md) | Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc. | | | | 🔧 | | |

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

Expand Down
46 changes: 46 additions & 0 deletions docs/rules/prefer-namespace-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# import-x/prefer-namespace-import

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

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

Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc.

## Rule Details

### rule schema

```jsonc
{
"import-x/prefer-namespace-import": [
"error", // or "off", "warn"
{
"patterns": [
// Exact match
"foo",
// RegExp
"/^prefix-/",
],
},
],
}
```

### Config Options

`patterns` is an array of strings or `RegExp` patterns that specify which modules should be imported using namespace imports.

#### Example

```js
/*eslint import-x/prefer-namespace-import: [2, { patterns: ['react'] }]*/

// bad
import React from 'react'

// good
import * as React from 'react'

// ignored
import ReactDOM from 'react-dom'
```
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import noUselessPathSegments from './rules/no-useless-path-segments.js'
import noWebpackLoaderSyntax from './rules/no-webpack-loader-syntax.js'
import order from './rules/order.js'
import preferDefaultExport from './rules/prefer-default-export.js'
import preferNamespaceImport from './rules/prefer-namespace-import.js'
import unambiguous from './rules/unambiguous.js'
// configs
import type {
Expand Down Expand Up @@ -111,6 +112,7 @@ const rules = {
order,
'newline-after-import': newlineAfterImport,
'prefer-default-export': preferDefaultExport,
'prefer-namespace-import': preferNamespaceImport,
'no-default-export': noDefaultExport,
'no-named-export': noNamedExport,
'no-dynamic-require': noDynamicRequire,
Expand Down
2 changes: 1 addition & 1 deletion src/rules/prefer-default-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface Options {
target?: 'single' | 'any'
}

type MessageId = 'single' | 'any'
export type MessageId = 'single' | 'any'

export default createRule<[Options?], MessageId>({
name: 'prefer-default-export',
Expand Down
110 changes: 110 additions & 0 deletions src/rules/prefer-namespace-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createRule } from '../utils/index.js'

export interface Options {
patterns?: readonly string[]
}

export type MessageId = 'preferNamespaceImport'

export default createRule<[Options?], MessageId>({
name: 'prefer-namespace-import',
meta: {
type: 'problem',
docs: {
category: 'Style guide',
description:
'Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc.',
},
fixable: 'code',
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
patterns: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
},
},
},
],
messages: {
preferNamespaceImport:
'Prefer importing {{specifier}} as \'import * as {{specifier}} from "{{source}}"\';',
},
},
defaultOptions: [],
create(context) {
const { patterns } = context.options[0] ?? {}
if (!patterns?.length) {
return {}
}
const regexps = patterns.map(toRegExp)
return {
ImportDefaultSpecifier(node) {
const importSource = node.parent.source.value
if (!regexps.some(exp => exp.test(importSource))) {
return
}
const defaultSpecifier = node.local.name
const hasOtherSpecifiers = node.parent.specifiers.length > 1
context.report({
messageId: 'preferNamespaceImport',
node: hasOtherSpecifiers ? node : node.parent,
data: {
source: importSource,
specifier: defaultSpecifier,
},
fix(fixer) {
const importDeclarationText = context.sourceCode.getText(
node.parent,
)
const localName = node.local.name
if (!hasOtherSpecifiers) {
return fixer.replaceText(node, `* as ${localName}`)
}
const isTypeImport = node.parent.importKind === 'type'
const importStringPrefix = `import${isTypeImport ? ' type' : ''}`
// remove the default specifier and prepend the namespace import specifier
const rightBraceIndex = importDeclarationText.indexOf('}') + 1
const specifiers = importDeclarationText.slice(
importDeclarationText.indexOf('{'),
rightBraceIndex,
)
const remainingText = importDeclarationText.slice(rightBraceIndex)
return fixer.replaceText(
node.parent,
[
`${importStringPrefix} * as ${localName} ${remainingText.trimStart()}`,
`${importStringPrefix} ${specifiers}${remainingText}`,
].join('\n'),
)
},
})
},
}
},
})

/** Regular expression for matching a RegExp string. */
const REGEXP_STR = /^\/(.+)\/([A-Za-z]*)$/u

/**
* Convert a string to the `RegExp`. Normal strings (e.g. `"foo"`) is converted
* to `/^foo$/` of `RegExp`. Strings like `"/^foo/i"` are converted to `/^foo/i`
* of `RegExp`.
*
* @param string The string to convert.
* @returns Returns the `RegExp`.
* @see https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/utils/regexp.ts
*/
function toRegExp(string: string): { test(s: string): boolean } {
const [, pattern, flags = 'u'] = REGEXP_STR.exec(string) ?? []
if (pattern != null) {
return new RegExp(pattern, flags)
}
return { test: s => s === string }
}
162 changes: 162 additions & 0 deletions test/rules/prefer-namespace-import.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { RuleTester as TSESLintRuleTester } from '@typescript-eslint/rule-tester'
import type { TSESLint } from '@typescript-eslint/utils'

import {
createRuleTestCaseFunctions,
getNonDefaultParsers,
parsers,
testFilePath,
} from '../utils.js'

import { cjsRequire as require } from 'eslint-plugin-import-x'
import rule from 'eslint-plugin-import-x/rules/prefer-namespace-import'

const ruleTester = new TSESLintRuleTester()

const { tValid, tInvalid } = createRuleTestCaseFunctions<typeof rule>()

const options = [{ patterns: ['/^@scope/', '/^prefix-/', 'specific'] }] as const

ruleTester.run('prefer-namespace-import', rule, {
valid: [
tValid({
code: `import * as Name from '@scope/name';`,
}),
tValid({
code: `import * as Name from 'prefix-name';`,
}),
tValid({
code: `import * as Name from 'specific';`,
}),
tValid({
code: `
import * as Name1 from '@scope/name';
import * as Name2 from 'prefix-name';
import * as Name2 from 'specific';
`,
}),
tValid({
code: `import Name from 'other-name';`,
options,
}),
],
invalid: [
tInvalid({
code: `
import Name1 from '@scope/name';
import Name2 from 'prefix-name';
import Name3 from 'prefix-name' with { type: 'json' };
import Name4, { name4 } from 'prefix-name' with { type: 'json' };
import Name5 from 'specific';
import Name6 from 'other-name';
`,
errors: [
{
messageId: 'preferNamespaceImport',
data: { source: '@scope/name', specifier: 'Name1' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'prefix-name', specifier: 'Name2' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'prefix-name', specifier: 'Name3' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'prefix-name', specifier: 'Name4' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'specific', specifier: 'Name5' },
},
],
options,
output: `
import * as Name1 from '@scope/name';
import * as Name2 from 'prefix-name';
import * as Name3 from 'prefix-name' with { type: 'json' };
import * as Name4 from 'prefix-name' with { type: 'json' };
import { name4 } from 'prefix-name' with { type: 'json' };
import * as Name5 from 'specific';
import Name6 from 'other-name';
`,
}),
],
})

describe('TypeScript', () => {
for (const parser of getNonDefaultParsers()) {
const parserConfig = {
languageOptions: {
...(parser === parsers.BABEL && {
parser: require<TSESLint.Parser.LooseParserModule>(parsers.BABEL),
}),
},
filename: testFilePath('foo.ts'),
}

ruleTester.run('prefer-namespace-import', rule, {
valid: [
tValid({
code: `
import type * as Name1 from '@scope/name';
import type * as Name2 from 'prefix-name';
`,
...parserConfig,
}),
tValid({
code: `import type Name from 'other-name';`,
options,
...parserConfig,
}),
],
invalid: [
tInvalid({
code: `
import type Name1 from '@scope/name';
import type Name2 from 'prefix-name';
import type Name3 from 'prefix-name' with { type: 'json' };
import Name4, { type name4 } from 'prefix-name' with { type: 'json' };
import type Name5 from 'specific';
import type Name6 from 'other-name';
`,
errors: [
{
messageId: 'preferNamespaceImport',
data: { source: '@scope/name', specifier: 'Name1' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'prefix-name', specifier: 'Name2' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'prefix-name', specifier: 'Name3' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'prefix-name', specifier: 'Name4' },
},
{
messageId: 'preferNamespaceImport',
data: { source: 'specific', specifier: 'Name5' },
},
],
options,
output: `
import type * as Name1 from '@scope/name';
import type * as Name2 from 'prefix-name';
import type * as Name3 from 'prefix-name' with { type: 'json' };
import * as Name4 from 'prefix-name' with { type: 'json' };
import { type name4 } from 'prefix-name' with { type: 'json' };
import type * as Name5 from 'specific';
import type Name6 from 'other-name';
`,
...parserConfig,
}),
],
})
}
})
Loading