eslint-plugin-restrict-replace-import 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,10 +33,7 @@ Then configure the rules you want to use under the rules section.
33
33
  ```json
34
34
  {
35
35
  "rules": {
36
- "restrict-replace-import/restrict-import": [
37
- "error",
38
- ["restricted-package1", "restricted-package2"]
39
- ]
36
+ "restrict-replace-import/restrict-import": ["error", ["restricted-package1", "restricted-package2"]]
40
37
  }
41
38
  }
42
39
  ```
@@ -95,7 +92,7 @@ Is it possible as well to perform multiple partial replacements by setting and O
95
92
  "replacement": {
96
93
  "par(regExp)?tial-": "successfully-",
97
94
  "repla(regExp)?cements": "replaced",
98
- "with-": "",
95
+ "with-": ""
99
96
  }
100
97
  }
101
98
  ]
@@ -103,18 +100,68 @@ Is it possible as well to perform multiple partial replacements by setting and O
103
100
  }
104
101
  }
105
102
  ```
103
+
106
104
  Given that rule configuration it will perform the following replacement:
107
105
 
108
106
  Input:
107
+
109
108
  ```js
110
109
  import { useState } from 'with-partial-replacements'
111
110
  ```
112
111
 
113
112
  Output:
113
+
114
114
  ```js
115
115
  import { useState } from 'successfully-replaced'
116
116
  ```
117
117
 
118
+ You can also restrict specific named imports from a package while allowing others:
119
+
120
+ ```json
121
+ {
122
+ "rules": {
123
+ "restrict-replace-import/restrict-import": [
124
+ "error",
125
+ [
126
+ {
127
+ "target": "restricted-module",
128
+ "namedImports": ["restrictedImport", "alsoRestricted"],
129
+ "replacement": "replacement-module" // Object is not supported yet
130
+ }
131
+ ]
132
+ ]
133
+ }
134
+ }
135
+ ```
136
+
137
+ This configuration will:
138
+ - Allow: `import { allowedImport } from 'restricted-module'`
139
+ - Replace: `import { restrictedImport } from 'restricted-module'` → `import { restrictedImport } from 'replacement-module'`
140
+
141
+ The rule handles various import scenarios:
142
+ - Named imports with aliases: `import { restrictedImport as aliasName }`
143
+ - Mixed allowed/restricted imports: Will split into separate import statements
144
+ - Default exports with named imports
145
+ - Multiple restricted named imports
146
+ - Merging with existing imports from replacement module
147
+
148
+ Examples of complex scenarios:
149
+
150
+ ```js
151
+ // Before
152
+ import { existingImport } from 'replacement-module';
153
+ import { restrictedImport } from 'restricted-module';
154
+
155
+ // After
156
+ import { existingImport, restrictedImport } from 'replacement-module';
157
+
158
+ // Before
159
+ import defaultExport, { restrictedImport, allowed } from 'restricted-module';
160
+
161
+ // After
162
+ import { restrictedImport } from 'replacement-module'
163
+ import defaultExport, { allowed } from 'restricted-module'
164
+ ```
118
165
 
119
166
  ## Rules
120
167
 
@@ -122,8 +169,8 @@ import { useState } from 'successfully-replaced'
122
169
 
123
170
  🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).
124
171
 
125
- | Name | Description | 🔧 |
126
- | :----------------------------------------------- | :--------------------------------------- | :-- |
127
- | [restrict-import](docs/rules/restrict-import.md) | Prevent the Import of a Specific Package | 🔧 |
172
+ | Name | Description | 🔧 |
173
+ | :----------------------------------------------- | :--------------------------------------- | :- |
174
+ | [restrict-import](docs/rules/restrict-import.md) | Prevent the Import of a Specific Package | 🔧 |
128
175
 
129
176
  <!-- end auto-generated rules list -->
@@ -4,74 +4,163 @@
4
4
 
5
5
  <!-- end auto-generated rule header -->
6
6
 
7
- This rule aims to prevent the import of a specific package.
7
+ This rule aims to prevent the import of a specific package and optionally replace it with an alternative package.
8
8
 
9
- With additional configuration, this rule can also suggest an alternative package to import instead.
9
+ ## Features
10
10
 
11
- If the alternative package is specified, auto-fixing will replace the import statement with the alternative package.
11
+ - Restrict imports from specific packages
12
+ - Replace restricted imports with alternative packages
13
+ - Support for RegExp patterns in package names
14
+ - Restrict specific named imports while allowing others
15
+ - Partial string replacements using RegExp
16
+ - Automatic merging with existing imports from replacement modules
12
17
 
13
- You can use RegExp to match multiple packages.
18
+ ## Options
14
19
 
15
- ## Rule Details
20
+ <!-- begin auto-generated rule options list -->
16
21
 
17
- Example configuration:
22
+ | Name | Description | Type | Required |
23
+ | :------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------- | :------- |
24
+ | `namedImports` | The named imports to be restricted. If not provided, all named imports will be restricted. | String[] | |
25
+ | `replacement` | The replacement for the import. If a string is provided, it will be used as the replacement for all imports. If an object is provided, the keys will be used as the pattern and the values will be used as the replacement. | | |
26
+ | `target` | The target of the import to be restricted | String | Yes |
27
+
28
+ <!-- end auto-generated rule options list -->
29
+
30
+ ## Usage Examples
31
+
32
+ ### Basic Usage - Restricting Packages
33
+
34
+ ```json
35
+ {
36
+ "rules": {
37
+ "restrict-replace-import/restrict-import": ["error", ["lodash"]]
38
+ }
39
+ }
40
+ ```
41
+
42
+ This will prevent imports from `lodash`:
43
+ ```js
44
+ // ❌ Error: `lodash` is restricted from being used
45
+ import _ from 'lodash'
46
+ ```
47
+
48
+ ### Replacing with Alternative Package
18
49
 
19
50
  ```json
20
51
  {
21
52
  "rules": {
22
53
  "restrict-replace-import/restrict-import": [
23
- "error",
24
- [
25
- {
26
- "target": "test-package",
27
- "replacement": "replacement-package"
28
- },
29
- "another-package"
30
- "with(?:-regex)?-support"
31
- ]
54
+ "error",
55
+ [{
56
+ "target": "react",
57
+ "replacement": "preact"
58
+ }]
32
59
  ]
33
60
  }
34
61
  }
35
62
  ```
36
63
 
37
- Examples of **incorrect** code for this rule with options above:
38
-
39
64
  ```js
40
- import testPackage from "test-package";
65
+ // Before
66
+ import { useState } from 'react'
41
67
 
42
- import anotherPackage from "another-package";
43
-
44
- import withRegexSupport from "with-regex-support";
45
- import withSupport from "with-support";
68
+ // After
69
+ import { useState } from 'preact'
46
70
  ```
47
71
 
48
- Examples of **correct** code for this rule with options above:
72
+ ### Using RegExp for Package Names
49
73
 
50
- ```js
51
- import testPackage from "replacement-package";
74
+ ```json
75
+ {
76
+ "rules": {
77
+ "restrict-replace-import/restrict-import": [
78
+ "error",
79
+ [{
80
+ "target": "with(?:-regex)?-support",
81
+ "replacement": "with-support-replacement"
82
+ }]
83
+ ]
84
+ }
85
+ }
86
+ ```
52
87
 
53
- import theOtherPackage from "the-other-package";
88
+ This will match and replace both:
89
+ ```js
90
+ import { something } from 'with-regex-support' // will be replaced
91
+ import { something } from 'with-support' // will be replaced
54
92
  ```
55
93
 
56
- ### Options
94
+ ### Partial String Replacements
57
95
 
58
- This rule takes a single argument, an array of strings or objects.
96
+ ```json
97
+ {
98
+ "rules": {
99
+ "restrict-replace-import/restrict-import": [
100
+ "error",
101
+ [{
102
+ "target": "with-partial-replacements",
103
+ "replacement": {
104
+ "par(regExp)?tial-": "successfully-",
105
+ "repla(regExp)?cements": "replaced",
106
+ "with-": ""
107
+ }
108
+ }]
109
+ ]
110
+ }
111
+ }
112
+ ```
59
113
 
60
- Each string or object represents a package that should be restricted.
114
+ ```js
115
+ // Before
116
+ import { useState } from 'with-partial-replacements'
61
117
 
62
- If the array element is a string, it represents the name of the package that should be restricted.
118
+ // After
119
+ import { useState } from 'successfully-replaced'
120
+ ```
63
121
 
64
- If the array element is an object, it represents the name of the package that should be restricted and the alternative package that should be suggested instead.
122
+ ### Restricting Specific Named Imports
65
123
 
66
- The alternative package is optional.
124
+ ```json
125
+ {
126
+ "rules": {
127
+ "restrict-replace-import/restrict-import": [
128
+ "error",
129
+ [{
130
+ "target": "restricted-module",
131
+ "namedImports": ["restrictedImport", "alsoRestricted"],
132
+ "replacement": "replacement-module" // Object is not supported yet
133
+ }]
134
+ ]
135
+ }
136
+ }
137
+ ```
67
138
 
68
- Scheme:
139
+ This configuration handles various scenarios:
69
140
 
70
- ```ts
71
- type Restriction =
72
- | string
73
- | {
74
- target: string;
75
- replacement?: string;
76
- };
141
+ ```js
142
+ // Allowed - import not in restricted list
143
+ import { allowedImport } from 'restricted-module'
144
+
145
+ // ❌ Will be replaced
146
+ import { restrictedImport } from 'restricted-module'
147
+ // ↓ Becomes
148
+ import { restrictedImport } from 'replacement-module'
149
+
150
+ // Mixed imports are split
151
+ import { restrictedImport, allowed } from 'restricted-module'
152
+ // ↓ Becomes
153
+ import { restrictedImport } from 'replacement-module'
154
+ import { allowed } from 'restricted-module'
155
+
156
+ // Handles aliases
157
+ import { restrictedImport as aliasName } from 'restricted-module'
158
+ // ↓ Becomes
159
+ import { restrictedImport as aliasName } from 'replacement-module'
160
+
161
+ // Merges with existing imports
162
+ import { existingImport } from 'replacement-module';
163
+ import { restrictedImport } from 'restricted-module';
164
+ // ↓ Becomes
165
+ import { existingImport, restrictedImport } from 'replacement-module';
77
166
  ```
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ import eslint from '@eslint/js'
4
+ import eslintPluginEslintPlugin from 'eslint-plugin-eslint-plugin'
5
+ import eslintPluginNode from 'eslint-plugin-node'
6
+ import { fixupPluginRules } from '@eslint/compat'
7
+ import globals from 'globals'
8
+
9
+ /**
10
+ * @type {import('eslint').Linter.Config[]}
11
+ */
12
+ export default [
13
+ eslint.configs.recommended,
14
+ {
15
+ languageOptions: {
16
+ ecmaVersion: 2023,
17
+ sourceType: 'commonjs',
18
+ globals: {
19
+ ...globals.node,
20
+ },
21
+ },
22
+ files: ['**/*.js'],
23
+ rules: {
24
+ ...eslintPluginEslintPlugin.configs.recommended.rules,
25
+ ...eslintPluginNode.configs.recommended.rules,
26
+ },
27
+ plugins: {
28
+ 'eslint-plugin': eslintPluginEslintPlugin,
29
+ node: fixupPluginRules(eslintPluginNode),
30
+ },
31
+ linterOptions: {
32
+ reportUnusedDisableDirectives: true,
33
+ },
34
+ },
35
+ {
36
+ files: ['tests/**/*.js'],
37
+ languageOptions: {
38
+ globals: {
39
+ mocha: 'readonly',
40
+ },
41
+ },
42
+ },
43
+ ]
package/lib/index.js CHANGED
@@ -2,21 +2,17 @@
2
2
  * @fileoverview ESLint Plugin for Restricting Import
3
3
  * @author shiwoo.park
4
4
  */
5
- "use strict";
5
+ 'use strict'
6
6
 
7
7
  //------------------------------------------------------------------------------
8
8
  // Requirements
9
9
  //------------------------------------------------------------------------------
10
10
 
11
- const requireIndex = require("requireindex");
11
+ const requireIndex = require('requireindex')
12
12
 
13
13
  //------------------------------------------------------------------------------
14
14
  // Plugin Definition
15
15
  //------------------------------------------------------------------------------
16
16
 
17
-
18
17
  // import all rules in lib/rules
19
- module.exports.rules = requireIndex(__dirname + "/rules");
20
-
21
-
22
-
18
+ module.exports.rules = requireIndex(__dirname + '/rules')
@@ -2,57 +2,459 @@
2
2
  * @fileoverview Prevent the Import of a Specific Package
3
3
  * @author shiwoo.park
4
4
  */
5
- "use strict";
5
+ 'use strict'
6
+
7
+ /**
8
+ * @typedef {string | {[key: string]: string;}} Replacement
9
+ * @typedef {{replacement: Replacement | null; namedImports: string[] | null;}} ImportRestrictionOptions
10
+ */
11
+
12
+ const createRestrictedPackagesMap = (options) => {
13
+ /**
14
+ * @type {Map<RegExp, ImportRestrictionOptions>}
15
+ */
16
+ const map = new Map()
17
+ options.forEach((config) => {
18
+ if (typeof config === 'string') {
19
+ map.set(new RegExp(`^${config}$`), {
20
+ replacement: null,
21
+ namedImports: null,
22
+ })
23
+ } else {
24
+ map.set(new RegExp(`^${config.target}$`), {
25
+ replacement: config.replacement || null,
26
+ namedImports: config.namedImports || null,
27
+ })
28
+ }
29
+ })
30
+ return map
31
+ }
32
+
33
+ /**
34
+ * @param {string} importSource
35
+ * @param {string[]} namedImports
36
+ * @param {Map<RegExp, ImportRestrictionOptions>} restrictedPackages
37
+ */
38
+ const checkIsRestrictedImport = (importSource, namedImports, restrictedPackages) => {
39
+ for (const [pattern, restrictedPackageOptions] of restrictedPackages) {
40
+ if (pattern.test(importSource)) {
41
+ if (!restrictedPackageOptions.namedImports?.length) {
42
+ return {
43
+ type: 'module',
44
+ pattern,
45
+ }
46
+ }
47
+
48
+ const restrictedImportedName = restrictedPackageOptions.namedImports.find((namedImport) =>
49
+ namedImports.includes(namedImport),
50
+ )
51
+ if (restrictedImportedName) {
52
+ return {
53
+ type: 'importedName',
54
+ pattern,
55
+ restrictedImportedName,
56
+ }
57
+ }
58
+ }
59
+ }
60
+ return null
61
+ }
62
+
63
+ /**
64
+ * Strip the beginning and ending of RegExp pattern (e.g. ^pattern$ -> pattern)
65
+ * @param {string} regExpPatternSource
66
+ */
67
+ const getPatternDisplayName = (regExpPatternSource) => regExpPatternSource.slice(1, -1)
68
+
69
+ const getQuoteStyle = (target) => (target?.includes("'") ? "'" : '"')
70
+
71
+ /**
72
+ * Format a list of import specifiers as a string
73
+ * @param {Array<{imported: string, local: string}>} specifiers
74
+ * @returns {string}
75
+ */
76
+ const formatSpecifiers = (specifiers) => {
77
+ return specifiers.map((s) => (s.imported === s.local ? s.imported : `${s.imported} as ${s.local}`)).join(', ')
78
+ }
79
+
80
+ /**
81
+ * Creates the text for a new import statement
82
+ * @param {Object} options
83
+ * @param {Array} options.specifiers - The import specifiers
84
+ * @param {string} options.source - The import source
85
+ * @param {string} options.quote - The quote style
86
+ * @param {string} options.semicolon - The semicolon (if any)
87
+ * @returns {string}
88
+ */
89
+ const createImportText = ({ specifiers, source, quote, semicolon = '' }) => {
90
+ const defaultSpecifier = specifiers.find((s) => s.type === 'ImportDefaultSpecifier')
91
+ const namespaceSpecifier = specifiers.find((s) => s.type === 'ImportNamespaceSpecifier')
92
+ const namedSpecifiers = specifiers.filter((s) => s.type === 'ImportSpecifier')
93
+
94
+ if (namespaceSpecifier) {
95
+ return `import * as ${namespaceSpecifier.local.name} from ${quote}${source}${quote}${semicolon}`
96
+ }
97
+
98
+ if (defaultSpecifier) {
99
+ if (namedSpecifiers.length === 0) {
100
+ return `import ${defaultSpecifier.local.name} from ${quote}${source}${quote}${semicolon}`
101
+ }
102
+
103
+ const namedText = namedSpecifiers
104
+ .map((s) => (s.imported.name === s.local.name ? s.imported.name : `${s.imported.name} as ${s.local.name}`))
105
+ .join(', ')
106
+
107
+ return `import ${defaultSpecifier.local.name}, { ${namedText} } from ${quote}${source}${quote}${semicolon}`
108
+ }
109
+
110
+ if (namedSpecifiers.length > 0) {
111
+ const namedText = namedSpecifiers
112
+ .map((s) => (s.imported.name === s.local.name ? s.imported.name : `${s.imported.name} as ${s.local.name}`))
113
+ .join(', ')
114
+
115
+ return `import { ${namedText} } from ${quote}${source}${quote}${semicolon}`
116
+ }
117
+
118
+ return `import ${quote}${source}${quote}${semicolon}`
119
+ }
120
+
121
+ /**
122
+ * @param {import('eslint').Rule.RuleContext} context
123
+ * @param {import('estree').ImportDeclaration} node
124
+ * @param {string[]} restrictedNames
125
+ * @param {string} replacement
126
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => void}
127
+ * @deprecated Function no longer used as each restriction is now handled individually
128
+ */
129
+ // eslint-disable-next-line no-unused-vars
130
+ const createNamedImportReplacer = (context, node, restrictedNames, replacement) => {
131
+ return (fixer) => {
132
+ if (!replacement) return null
133
+
134
+ const quote = getQuoteStyle(node.source.raw)
135
+ const semicolon = node.source.raw.endsWith(';') || node.source.value.endsWith(';') ? ';' : ''
136
+ const fixes = []
137
+
138
+ // Find restricted specifiers to move
139
+ const restrictedSpecifiers = node.specifiers.filter(
140
+ (specifier) => specifier.type === 'ImportSpecifier' && restrictedNames.includes(specifier.imported.name),
141
+ )
142
+
143
+ if (restrictedSpecifiers.length === 0) {
144
+ return null
145
+ }
146
+
147
+ // Format the restricted specifiers for moving
148
+ const specifiersToMove = restrictedSpecifiers.map((specifier) => ({
149
+ imported: specifier.imported.name,
150
+ local: specifier.local.name,
151
+ }))
152
+
153
+ // Handle the original import
154
+ const remainingSpecifiers = node.specifiers.filter(
155
+ (specifier) => specifier.type !== 'ImportSpecifier' || !restrictedNames.includes(specifier.imported.name),
156
+ )
157
+
158
+ // Remove or update the original import
159
+ if (remainingSpecifiers.length === 0) {
160
+ fixes.push(fixer.remove(node))
161
+ } else if (remainingSpecifiers.length < node.specifiers.length) {
162
+ const newImportText = createImportText({
163
+ specifiers: remainingSpecifiers,
164
+ source: node.source.value,
165
+ quote,
166
+ semicolon,
167
+ })
168
+ fixes.push(fixer.replaceText(node, newImportText))
169
+ }
170
+
171
+ // Add imports to the replacement module
172
+ const { sourceCode } = context
173
+ const allImports = sourceCode.ast.body.filter(
174
+ (node) => node.type === 'ImportDeclaration' && node.source.type === 'Literal',
175
+ )
176
+
177
+ const existingReplacementImport = allImports.find((importNode) => importNode.source.value === replacement)
178
+
179
+ if (existingReplacementImport) {
180
+ fixes.push(
181
+ ...updateExistingImport(
182
+ fixer,
183
+ sourceCode,
184
+ existingReplacementImport,
185
+ specifiersToMove,
186
+ quote,
187
+ semicolon,
188
+ replacement,
189
+ ),
190
+ )
191
+ } else {
192
+ // Create a new import for the replacement
193
+ const newSpecifiersText = formatSpecifiers(specifiersToMove)
194
+ const newImport = `import { ${newSpecifiersText} } from ${quote}${replacement}${quote}${semicolon}`
195
+ fixes.push(fixer.insertTextBefore(node, newImport + '\n'))
196
+ }
197
+
198
+ return fixes
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Updates an existing import with new specifiers
204
+ * @param {import('eslint').Rule.RuleFixer} fixer
205
+ * @param {import('eslint').SourceCode} sourceCode
206
+ * @param {import('estree').ImportDeclaration} existingImport
207
+ * @param {Array<{imported: string, local: string}>} specifiersToAdd
208
+ * @param {string} quote
209
+ * @param {string} semicolon
210
+ * @param {string} replacement
211
+ * @returns {Array<import('eslint').Rule.Fix>}
212
+ */
213
+ const updateExistingImport = (fixer, sourceCode, existingImport, specifiersToAdd, quote, semicolon, replacement) => {
214
+ const fixes = []
215
+ const existingNamedSpecifiers = existingImport.specifiers
216
+ .filter((s) => s.type === 'ImportSpecifier')
217
+ .map((s) => s.imported.name)
218
+
219
+ const newSpecifiersToAdd = specifiersToAdd.filter((s) => !existingNamedSpecifiers.includes(s.imported))
220
+
221
+ if (newSpecifiersToAdd.length === 0) {
222
+ return fixes
223
+ }
224
+
225
+ const existingText = sourceCode.getText(existingImport)
226
+ const namedSpecifiers = existingImport.specifiers.filter((s) => s.type === 'ImportSpecifier')
227
+
228
+ if (namedSpecifiers.length > 0) {
229
+ // Add new specifiers to existing named imports
230
+ const existingSpecifiersMatch = existingText.match(/import\s*(?:[^{]*,\s*)?{([^}]*)}/)
231
+ if (existingSpecifiersMatch) {
232
+ const existingSpecifiersText = existingSpecifiersMatch[1].trim()
233
+ const newSpecifierText = formatSpecifiers(newSpecifiersToAdd)
234
+ const combinedSpecifiers = `${existingSpecifiersText}, ${newSpecifierText}`
235
+ const newImportText = existingText.replace(/\{[^}]*\}/, `{ ${combinedSpecifiers} }`)
236
+ fixes.push(fixer.replaceText(existingImport, newImportText))
237
+ }
238
+ } else {
239
+ // Handle imports with default but no named imports
240
+ const defaultSpecifier = existingImport.specifiers.find((s) => s.type === 'ImportDefaultSpecifier')
241
+ if (defaultSpecifier) {
242
+ const defaultName = defaultSpecifier.local.name
243
+ const newSpecifiersText = formatSpecifiers(newSpecifiersToAdd)
244
+ const newText = `import ${defaultName}, { ${newSpecifiersText} } from ${quote}${replacement}${quote}${semicolon}`
245
+ fixes.push(fixer.replaceText(existingImport, newText))
246
+ }
247
+ }
248
+
249
+ return fixes
250
+ }
251
+
252
+ /**
253
+ * @param {import('estree').ImportDeclaration['source']} sourceNode
254
+ * @param {string} replacement
255
+ * @param {'"' | "'"} quote
256
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => void}
257
+ */
258
+ const createStringReplacer = (sourceNode, replacement, quote) => {
259
+ return (fixer) => fixer.replaceText(sourceNode, `${quote}${replacement}${quote}`)
260
+ }
261
+
262
+ /**
263
+ * @param {import('estree').ImportDeclaration['source']} sourceNode
264
+ * @param {Replacement} replacementPatterns
265
+ * @param {'"' | "'"} quote
266
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => void}
267
+ */
268
+ const createPatternReplacer = (sourceNode, replacementPatterns, quote) => {
269
+ return (fixer) => {
270
+ let result = sourceNode.value
271
+
272
+ if (typeof replacementPatterns === 'string') {
273
+ return createStringReplacer(sourceNode, replacementPatterns, quote)
274
+ }
275
+
276
+ for (const [pattern, replacement] of Object.entries(replacementPatterns)) {
277
+ const regex = new RegExp(pattern, 'g')
278
+ result = result.replace(regex, replacement)
279
+ }
280
+ return fixer.replaceText(sourceNode, `${quote}${result}${quote}`)
281
+ }
282
+ }
283
+
284
+ /**
285
+ * @param {import('estree').ImportDeclaration} node
286
+ * @param {Replacement} replacement
287
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => void}
288
+ */
289
+ const createModuleReplacer = (node, replacement) => {
290
+ if (!replacement) return null
291
+
292
+ const quote = getQuoteStyle(node.source.raw)
293
+
294
+ if (typeof replacement === 'string') {
295
+ return createStringReplacer(node.source, replacement, quote)
296
+ }
297
+
298
+ return createPatternReplacer(node.source, replacement, quote)
299
+ }
300
+
301
+ /**
302
+ * @param {import('eslint').Rule.RuleContext} context
303
+ * @param {import('estree').ImportDeclaration} node
304
+ * @param {Array<{importName: string, replacement: string}>} importRestrictions
305
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => any}
306
+ */
307
+ const createMultiNamedImportReplacer = (context, node, importRestrictions) => {
308
+ return (fixer) => {
309
+ if (!importRestrictions.length) return null
310
+
311
+ const quote = getQuoteStyle(node.source.raw)
312
+ const semicolon = node.source.raw.endsWith(';') || node.source.value.endsWith(';') ? ';' : ''
313
+ const fixes = []
314
+
315
+ const allRestrictedNames = importRestrictions.map((r) => r.importName)
316
+
317
+ // Group imports by replacement
318
+ const groupedByReplacement = importRestrictions.reduce((acc, restriction) => {
319
+ if (!restriction.replacement) return acc
320
+
321
+ if (!acc[restriction.replacement]) {
322
+ acc[restriction.replacement] = []
323
+ }
324
+
325
+ acc[restriction.replacement].push(restriction.importName)
326
+
327
+ return acc
328
+ }, {})
329
+
330
+ // Find non-restricted specifiers from the original import
331
+ const remainingSpecifiers = node.specifiers.filter(
332
+ (specifier) => specifier.type !== 'ImportSpecifier' || !allRestrictedNames.includes(specifier.imported.name),
333
+ )
334
+
335
+ // Update or remove the original import
336
+ if (remainingSpecifiers.length === 0) {
337
+ fixes.push(fixer.remove(node))
338
+ } else {
339
+ const newImportText = createImportText({
340
+ specifiers: remainingSpecifiers,
341
+ source: node.source.value,
342
+ quote,
343
+ semicolon,
344
+ })
345
+ fixes.push(fixer.replaceText(node, newImportText))
346
+ }
347
+
348
+ // Create new imports for each replacement module
349
+ const { sourceCode } = context
350
+ const allImports = sourceCode.ast.body.filter(
351
+ (node) => node.type === 'ImportDeclaration' && node.source.type === 'Literal',
352
+ )
353
+
354
+ // Process each replacement module
355
+ Object.entries(groupedByReplacement).forEach(([replacement, restrictedNames]) => {
356
+ // Find specifiers to move
357
+ const specifiersToMove = restrictedNames
358
+ .map((name) => {
359
+ const specifier = node.specifiers.find((s) => s.type === 'ImportSpecifier' && s.imported.name === name)
360
+ return specifier
361
+ ? {
362
+ imported: specifier.imported.name,
363
+ local: specifier.local.name,
364
+ }
365
+ : null
366
+ })
367
+ .filter(Boolean)
368
+
369
+ if (specifiersToMove.length === 0) return
370
+
371
+ // Find existing import for the same replacement module
372
+ const existingReplacementImport = allImports.find((importNode) => importNode.source.value === replacement)
373
+
374
+ if (existingReplacementImport) {
375
+ // Add to existing import
376
+ fixes.push(
377
+ ...updateExistingImport(
378
+ fixer,
379
+ sourceCode,
380
+ existingReplacementImport,
381
+ specifiersToMove,
382
+ quote,
383
+ semicolon,
384
+ replacement,
385
+ ),
386
+ )
387
+ } else {
388
+ // Create new import
389
+ const newSpecifiersText = formatSpecifiers(specifiersToMove)
390
+ const newImport = `import { ${newSpecifiersText} } from ${quote}${replacement}${quote}${semicolon}`
391
+ fixes.push(fixer.insertTextBefore(node, newImport + '\n'))
392
+ }
393
+ })
394
+
395
+ return fixes
396
+ }
397
+ }
6
398
 
7
399
  /** @type {import('eslint').Rule.RuleModule} */
8
400
  module.exports = {
9
401
  meta: {
10
- type: "problem",
402
+ type: 'problem',
11
403
  docs: {
12
- description:
13
- "Prevent the Import of a Specific Package",
404
+ description: 'Prevent the Import of a Specific Package',
14
405
  recommended: false,
15
- url: "https://github.com/custardcream98/eslint-plugin-restrict-replace-import/blob/main/docs/rules/restrict-import.md",
406
+ url: 'https://github.com/custardcream98/eslint-plugin-restrict-replace-import/blob/main/docs/rules/restrict-import.md',
16
407
  },
17
- fixable: "code",
408
+ fixable: 'code',
18
409
 
19
410
  messages: {
20
- ImportRestriction:
21
- "`{{ name }}` is restricted from being used.",
411
+ ImportRestriction: '`{{ name }}` is restricted from being used.',
22
412
  ImportRestrictionWithReplacement:
23
- "`{{ name }}` is restricted from being used. Replace it with `{{ replacement }}`.",
413
+ '`{{ name }}` is restricted from being used. Replace it with `{{ replacement }}`.',
414
+ ImportedNameRestriction: "Import of '{{importedName}}' from '{{name}}' is restricted",
415
+ ImportedNameRestrictionWithReplacement:
416
+ "Import of '{{importedName}}' from '{{name}}' is restricted. Replace it with '{{replacement}}'.",
24
417
  },
25
418
 
26
419
  schema: {
27
- type: "array",
420
+ type: 'array',
28
421
  maxLength: 1,
29
422
  minLength: 1,
30
423
  items: {
31
- type: "array",
424
+ type: 'array',
32
425
  items: {
33
426
  oneOf: [
34
427
  {
35
- type: "string",
428
+ type: 'string',
36
429
  },
37
430
  {
38
- type: "object",
431
+ type: 'object',
39
432
  properties: {
40
433
  target: {
41
- type: "string",
434
+ type: 'string',
435
+ description: 'The target of the import to be restricted',
436
+ },
437
+ namedImports: {
438
+ type: 'array',
439
+ items: { type: 'string' },
440
+ description:
441
+ 'The named imports to be restricted. If not provided, all named imports will be restricted.',
42
442
  },
43
443
  replacement: {
44
444
  oneOf: [
45
- { type: "string" },
46
- {
47
- type: "object",
445
+ { type: 'string' },
446
+ {
447
+ type: 'object',
48
448
  patternProperties: {
49
- ".*": { type: "string" }
50
- }
51
- }
52
- ]
449
+ '.*': { type: 'string' },
450
+ },
451
+ },
452
+ ],
453
+ description:
454
+ 'The replacement for the import. If a string is provided, it will be used as the replacement for all imports. If an object is provided, the keys will be used as the pattern and the values will be used as the replacement.',
53
455
  },
54
456
  },
55
- required: ["target"],
457
+ required: ['target'],
56
458
  additionalProperties: false,
57
459
  },
58
460
  ],
@@ -62,106 +464,85 @@ module.exports = {
62
464
  },
63
465
 
64
466
  create(context) {
65
- const restrictedPackages = new Map();
66
- const restrictedPackagesOption = context.options[0];
67
-
68
- restrictedPackagesOption.forEach((packageName) => {
69
- if (typeof packageName === "string") {
70
- restrictedPackages.set(
71
- new RegExp(`^${packageName}$`),
72
- null
73
- );
74
- return;
75
- }
76
-
77
- restrictedPackages.set(
78
- new RegExp(`^${packageName.target}$`),
79
- packageName.replacement
80
- ? packageName.replacement
81
- : null
82
- );
83
- });
84
-
85
- const checkRestricted = (importSource) => {
86
- const restrictedRegExp = Array.from(
87
- restrictedPackages.keys()
88
- ).find((packageName) => {
89
- return packageName.test(importSource);
90
- });
91
-
92
- return {
93
- isRestricted: !!restrictedRegExp,
94
- restrictedRegExp,
95
- };
96
- };
97
-
98
- const getReplacement = (restrictedRegExp) => {
99
- const replacement = restrictedPackages.get(
100
- restrictedRegExp
101
- );
102
-
103
- return replacement;
104
- };
467
+ const restrictedPackages = createRestrictedPackagesMap(context.options[0])
105
468
 
106
469
  return {
107
470
  ImportDeclaration(node) {
108
- const importSource = node.source.value;
109
- const importSourceType = node.source.type;
471
+ if (node.source.type !== 'Literal') return
472
+
473
+ const importSource = node.source.value
474
+ const namedImports = node.specifiers
475
+ .filter((specifier) => specifier.type === 'ImportSpecifier')
476
+ .map((specifier) => specifier.imported.name)
477
+ const checkerResult = checkIsRestrictedImport(importSource, namedImports, restrictedPackages)
478
+
479
+ if (!checkerResult) return
110
480
 
111
- const { isRestricted, restrictedRegExp } =
112
- checkRestricted(importSource);
481
+ const restrictedPackageOptions = restrictedPackages.get(checkerResult.pattern)
482
+ const patternName = getPatternDisplayName(checkerResult.pattern.source)
113
483
 
114
- if (
115
- !isRestricted ||
116
- importSourceType !== "Literal"
117
- ) {
118
- return;
484
+ if (checkerResult.type === 'module') {
485
+ context.report({
486
+ node,
487
+ messageId:
488
+ typeof restrictedPackageOptions.replacement === 'string'
489
+ ? 'ImportRestrictionWithReplacement'
490
+ : 'ImportRestriction',
491
+ data: {
492
+ name: patternName,
493
+ replacement: restrictedPackageOptions.replacement,
494
+ },
495
+ fix: createModuleReplacer(node, restrictedPackageOptions.replacement),
496
+ })
497
+ return
119
498
  }
120
499
 
121
- const replacement = getReplacement(
122
- restrictedRegExp
123
- );
124
- const quote = node.source.raw.includes("'")
125
- ? "'"
126
- : '"';
127
-
128
- context.report({
129
- node,
130
- messageId: typeof replacement === "string"
131
- ? "ImportRestrictionWithReplacement"
132
- : "ImportRestriction",
133
- data: {
134
- name: restrictedRegExp.toString().slice(2, -2),
135
- replacement,
136
- },
137
- fix: (fixer) => {
138
- if (!replacement) {
139
- return;
140
- }
500
+ // Find potential rules and replacement mappings for multiple restricted named imports
141
501
 
142
- if (typeof replacement === "string") {
143
- return fixer.replaceText(
144
- node.source,
145
- `${quote}${replacement}${quote}`
146
- );
147
- }
502
+ /**
503
+ * @type {{importName: string, replacement: string | null, pattern: RegExp}[]}
504
+ */
505
+ const importRestrictions = []
148
506
 
149
- if (typeof replacement === "object") {
150
- let partiallyReplaced = node.source.value;
151
- for (const [key, value] of Object.entries(replacement)) {
152
- const regex = new RegExp(key, 'g');
153
- partiallyReplaced = partiallyReplaced.replace(regex, value);
154
- }
155
- return fixer.replaceText(
156
- node.source,
157
- `${quote}${partiallyReplaced}${quote}`
158
- );
507
+ // Check each named import for restrictions
508
+ namedImports.forEach((importName) => {
509
+ for (const [pattern, options] of restrictedPackages.entries()) {
510
+ if (
511
+ pattern.test(importSource) &&
512
+ options.namedImports &&
513
+ options.namedImports.includes(importName) &&
514
+ // TODO: handle options.replacement as an object
515
+ (typeof options.replacement === 'string' || options.replacement === null)
516
+ ) {
517
+ importRestrictions.push({
518
+ importName,
519
+ replacement: options.replacement,
520
+ pattern,
521
+ })
522
+
523
+ break // Only use the first matching restriction for an import
159
524
  }
525
+ }
526
+ })
160
527
 
161
- return null;
162
- },
163
- });
528
+ if (importRestrictions.length === 0) {
529
+ return
530
+ }
531
+
532
+ // Report separate errors for each restricted import
533
+ importRestrictions.forEach((restriction) => {
534
+ context.report({
535
+ node,
536
+ messageId: restriction.replacement ? 'ImportedNameRestrictionWithReplacement' : 'ImportedNameRestriction',
537
+ data: {
538
+ importedName: restriction.importName,
539
+ name: importSource,
540
+ replacement: restriction.replacement,
541
+ },
542
+ fix: restriction.replacement ? createMultiNamedImportReplacer(context, node, importRestrictions) : null,
543
+ })
544
+ })
164
545
  },
165
- };
546
+ }
166
547
  },
167
- };
548
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-restrict-replace-import",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "ESLint Plugin for Restricting and Replacing Import",
5
5
  "keywords": [
6
6
  "eslint",
@@ -20,22 +20,27 @@
20
20
  "main": "./lib/index.js",
21
21
  "exports": "./lib/index.js",
22
22
  "scripts": {
23
+ "format": "prettier --write .",
23
24
  "lint": "npm-run-all \"lint:*\"",
24
25
  "lint:eslint-docs": "npm-run-all \"update:eslint-docs -- --check\"",
25
26
  "lint:js": "eslint .",
26
27
  "test": "mocha tests --recursive",
27
- "update:eslint-docs": "eslint-doc-generator"
28
+ "update:eslint-docs": "eslint-doc-generator",
29
+ "release": "npm run lint && npm run test && npm publish"
28
30
  },
29
31
  "dependencies": {
30
32
  "requireindex": "^1.2.0"
31
33
  },
32
34
  "devDependencies": {
33
- "eslint": "^8.19.0",
34
- "eslint-doc-generator": "^1.0.0",
35
- "eslint-plugin-eslint-plugin": "^5.0.0",
35
+ "@eslint/compat": "^1.2.7",
36
+ "@types/estree": "^1.0.6",
37
+ "eslint": "^9.22.0",
38
+ "eslint-doc-generator": "^2.1.0",
39
+ "eslint-plugin-eslint-plugin": "^6.4.0",
36
40
  "eslint-plugin-node": "^11.1.0",
37
41
  "mocha": "^10.0.0",
38
- "npm-run-all": "^4.1.5"
42
+ "npm-run-all": "^4.1.5",
43
+ "prettier": "^3.5.3"
39
44
  },
40
45
  "engines": {
41
46
  "node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
package/.eslintrc.js DELETED
@@ -1,19 +0,0 @@
1
- "use strict";
2
-
3
- module.exports = {
4
- root: true,
5
- extends: [
6
- "eslint:recommended",
7
- "plugin:eslint-plugin/recommended",
8
- "plugin:node/recommended",
9
- ],
10
- env: {
11
- node: true,
12
- },
13
- overrides: [
14
- {
15
- files: ["tests/**/*.js"],
16
- env: { mocha: true },
17
- },
18
- ],
19
- };