eslint-plugin-restrict-replace-import 1.5.0 → 2.0.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.
@@ -1,548 +0,0 @@
1
- /**
2
- * @fileoverview Prevent the Import of a Specific Package
3
- * @author shiwoo.park
4
- */
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
- }
398
-
399
- /** @type {import('eslint').Rule.RuleModule} */
400
- module.exports = {
401
- meta: {
402
- type: 'problem',
403
- docs: {
404
- description: 'Prevent the Import of a Specific Package',
405
- recommended: false,
406
- url: 'https://github.com/custardcream98/eslint-plugin-restrict-replace-import/blob/main/docs/rules/restrict-import.md',
407
- },
408
- fixable: 'code',
409
-
410
- messages: {
411
- ImportRestriction: '`{{ name }}` is restricted from being used.',
412
- ImportRestrictionWithReplacement:
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}}'.",
417
- },
418
-
419
- schema: {
420
- type: 'array',
421
- maxLength: 1,
422
- minLength: 1,
423
- items: {
424
- type: 'array',
425
- items: {
426
- oneOf: [
427
- {
428
- type: 'string',
429
- },
430
- {
431
- type: 'object',
432
- properties: {
433
- target: {
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.',
442
- },
443
- replacement: {
444
- oneOf: [
445
- { type: 'string' },
446
- {
447
- type: 'object',
448
- patternProperties: {
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.',
455
- },
456
- },
457
- required: ['target'],
458
- additionalProperties: false,
459
- },
460
- ],
461
- },
462
- },
463
- },
464
- },
465
-
466
- create(context) {
467
- const restrictedPackages = createRestrictedPackagesMap(context.options[0])
468
-
469
- return {
470
- ImportDeclaration(node) {
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
480
-
481
- const restrictedPackageOptions = restrictedPackages.get(checkerResult.pattern)
482
- const patternName = getPatternDisplayName(checkerResult.pattern.source)
483
-
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
498
- }
499
-
500
- // Find potential rules and replacement mappings for multiple restricted named imports
501
-
502
- /**
503
- * @type {{importName: string, replacement: string | null, pattern: RegExp}[]}
504
- */
505
- const importRestrictions = []
506
-
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
524
- }
525
- }
526
- })
527
-
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
- })
545
- },
546
- }
547
- },
548
- }