diff --git a/README.md b/README.md index 4c0f50d9..b717b793 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ The package includes the following rules. | [no-unsafe-switchmap](docs/rules/no-unsafe-switchmap.md) | Disallow unsafe `switchMap` usage in effects and epics. | | | | 💭 | | | [no-unsafe-takeuntil](docs/rules/no-unsafe-takeuntil.md) | Disallow applying operators after `takeUntil`. | ✅ | | | 💭 | | | [prefer-observer](docs/rules/prefer-observer.md) | Disallow passing separate handlers to `subscribe` and `tap`. | | 🔧 | 💡 | 💭 | | +| [prefer-root-operators](docs/rules/prefer-root-operators.md) | Disallow importing operators from `rxjs/operators`. | | 🔧 | 💡 | | | | [suffix-subjects](docs/rules/suffix-subjects.md) | Enforce the use of a suffix in subject identifiers. | | | | 💭 | | | [throw-error](docs/rules/throw-error.md) | Enforce passing only `Error` values to `throwError`. | | | | 💭 | | diff --git a/docs/rules/prefer-root-operators.md b/docs/rules/prefer-root-operators.md new file mode 100644 index 00000000..bfc205c6 --- /dev/null +++ b/docs/rules/prefer-root-operators.md @@ -0,0 +1,32 @@ +# Disallow importing operators from `rxjs/operators` (`rxjs-x/prefer-root-operators`) + +🔧💡 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). + + + +This rule prevents importing from the `rxjs/operators` export site. +Most operators were moved to the `rxjs` export site in RxJS v7.2.0 +(excepting a few old and deprecated operators). +The `rxjs/operators` export site has since been deprecated and will be removed in a future major version. + +Note that because a few operators were renamed or not migrated to the `rxjs` export site, +this rule may not provide an automatic fixer if renaming the import path is not guaranteed to be safe. +See the documentation linked below. + +## Rule details + +Examples of **incorrect** code for this rule: + +```ts +import { map } from 'rxjs/operators'; +``` + +Examples of **correct** code for this rule: + +```ts +import { map } from 'rxjs'; +``` + +## Further reading + +- [Importing instructions](https://rxjs.dev/guide/importing) diff --git a/src/index.ts b/src/index.ts index fce1f0b6..ab3491b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ import { noUnsafeSubjectNext } from './rules/no-unsafe-subject-next'; import { noUnsafeSwitchmapRule } from './rules/no-unsafe-switchmap'; import { noUnsafeTakeuntilRule } from './rules/no-unsafe-takeuntil'; import { preferObserverRule } from './rules/prefer-observer'; +import { preferRootOperatorsRule } from './rules/prefer-root-operators'; import { suffixSubjectsRule } from './rules/suffix-subjects'; import { throwErrorRule } from './rules/throw-error'; @@ -88,6 +89,7 @@ const plugin = { 'no-unsafe-switchmap': noUnsafeSwitchmapRule, 'no-unsafe-takeuntil': noUnsafeTakeuntilRule, 'prefer-observer': preferObserverRule, + 'prefer-root-operators': preferRootOperatorsRule, 'suffix-subjects': suffixSubjectsRule, 'throw-error': throwErrorRule, }, diff --git a/src/rules/prefer-root-operators.ts b/src/rules/prefer-root-operators.ts new file mode 100644 index 00000000..1e7e793b --- /dev/null +++ b/src/rules/prefer-root-operators.ts @@ -0,0 +1,167 @@ +import { TSESTree as es, TSESLint } from '@typescript-eslint/utils'; +import { isIdentifier, isImportSpecifier, isLiteral } from '../etc'; +import { ruleCreator } from '../utils'; + +// See https://rxjs.dev/guide/importing#how-to-migrate + +const RENAMED_OPERATORS: Record = { + combineLatest: 'combineLatestWith', + concat: 'concatWith', + merge: 'mergeWith', + onErrorResumeNext: 'onErrorResumeNextWith', + race: 'raceWith', + zip: 'zipWith', +}; + +const DEPRECATED_OPERATORS = [ + 'partition', +]; + +export const preferRootOperatorsRule = ruleCreator({ + defaultOptions: [], + meta: { + docs: { + description: 'Disallow importing operators from `rxjs/operators`.', + }, + fixable: 'code', + hasSuggestions: true, + messages: { + forbidden: 'RxJS imports from `rxjs/operators` are forbidden; import from `rxjs` instead.', + forbiddenWithoutFix: 'RxJS imports from `rxjs/operators` are forbidden; import from `rxjs` instead. Note some operators may have been renamed or deprecated.', + suggest: 'Replace with import from `rxjs`.', + }, + schema: [], + type: 'suggestion', + }, + name: 'prefer-root-operators', + create: (context) => { + function getQuote(raw: string): string | undefined { + const match = /^\s*('|")/.exec(raw); + if (!match) { + return undefined; + } + const [, quote] = match; + return quote; + } + + function getSourceReplacement(rawLocation: string): string | undefined { + const quote = getQuote(rawLocation); + if (!quote) { + return undefined; + } + if (/^['"]rxjs\/operators/.test(rawLocation)) { + return `${quote}rxjs${quote}`; + } + return undefined; + } + + function hasDeprecatedOperators(specifiers?: es.ImportSpecifier[] | es.ExportSpecifier[]): boolean { + return !!specifiers?.some(s => DEPRECATED_OPERATORS.includes(getName(getOperatorNode(s)))); + } + + function getName(node: es.Identifier | es.StringLiteral): string { + return isIdentifier(node) ? node.name : node.value; + } + + function getOperatorNode(node: es.ImportSpecifier | es.ExportSpecifier): es.Identifier | es.StringLiteral { + return isImportSpecifier(node) ? node.imported : node.local; + } + + function getAliasNode(node: es.ImportSpecifier | es.ExportSpecifier): es.Identifier | es.StringLiteral { + return isImportSpecifier(node) ? node.local : node.exported; + } + + function getOperatorReplacement(name: string): string | undefined { + return RENAMED_OPERATORS[name]; + } + + function isNodesEqual(a: es.Node, b: es.Node): boolean { + return a.range[0] === b.range[0] && a.range[1] === b.range[1]; + } + + function createFix(source: es.Node, replacement: string, specifiers: es.ImportSpecifier[] | es.ExportSpecifier[]) { + return function* fix(fixer: TSESLint.RuleFixer) { + // Rename the module name. + yield fixer.replaceText(source, replacement); + + // Rename the imported operators if necessary. + for (const specifier of specifiers) { + const operatorNode = getOperatorNode(specifier); + const operatorName = getName(operatorNode); + + const operatorReplacement = getOperatorReplacement(operatorName); + if (!operatorReplacement) { + // The operator has the same name. + continue; + } + + const aliasNode = getAliasNode(specifier); + if (isNodesEqual(aliasNode, operatorNode)) { + // concat -> concatWith as concat + yield fixer.insertTextBefore(operatorNode, operatorReplacement + ' as '); + } else if (isIdentifier(operatorNode)) { + // concat as c -> concatWith as c + yield fixer.replaceText(operatorNode, operatorReplacement); + } else { + // 'concat' as c -> 'concatWith' as c + const quote = getQuote(operatorNode.raw); + if (!quote) { + continue; + } + yield fixer.replaceText(operatorNode, quote + operatorReplacement + quote); + } + } + }; + } + + function reportNode(source: es.Literal, specifiers?: es.ImportSpecifier[] | es.ExportSpecifier[]): void { + const replacement = getSourceReplacement(source.raw); + if (!replacement || hasDeprecatedOperators(specifiers)) { + context.report({ + messageId: 'forbiddenWithoutFix', + node: source, + }); + return; + } + + if (!specifiers) { + context.report({ + messageId: 'forbiddenWithoutFix', + node: source, + suggest: [{ messageId: 'suggest', fix: (fixer) => fixer.replaceText(source, replacement) }], + }); + return; + } + + const fix = createFix(source, replacement, specifiers); + context.report({ + fix, + messageId: 'forbidden', + node: source, + suggest: [{ messageId: 'suggest', fix }], + }); + } + + return { + 'ImportDeclaration[source.value="rxjs/operators"]': (node: es.ImportDeclaration) => { + // Exclude side effect imports, default imports, and namespace imports. + const specifiers = node.specifiers.length && node.specifiers.every(importClause => isImportSpecifier(importClause)) + ? node.specifiers + : undefined; + + reportNode(node.source, specifiers); + }, + 'ImportExpression[source.value="rxjs/operators"]': (node: es.ImportExpression) => { + if (isLiteral(node.source)) { + reportNode(node.source); + } + }, + 'ExportNamedDeclaration[source.value="rxjs/operators"]': (node: es.ExportNamedDeclarationWithSource) => { + reportNode(node.source, node.specifiers); + }, + 'ExportAllDeclaration[source.value="rxjs/operators"]': (node: es.ExportAllDeclaration) => { + reportNode(node.source); + }, + }; + }, +}); diff --git a/tests/rules/prefer-root-operators.test.ts b/tests/rules/prefer-root-operators.test.ts new file mode 100644 index 00000000..4627b494 --- /dev/null +++ b/tests/rules/prefer-root-operators.test.ts @@ -0,0 +1,235 @@ +import { stripIndent } from 'common-tags'; +import { preferRootOperatorsRule } from '../../src/rules/prefer-root-operators'; +import { fromFixture } from '../etc'; +import { ruleTester } from '../rule-tester'; + +ruleTester({ types: false }).run('prefer-root-operators', preferRootOperatorsRule, { + valid: [ + stripIndent` + // import declaration named + import { concatWith } from "rxjs"; + import { mergeWith } from 'rxjs'; + `, + stripIndent` + // import declaration namespace + import * as Rx from "rxjs"; + `, + stripIndent` + // import expression + const { concatWith } = await import("rxjs"); + `, + stripIndent` + // import expression without a string literal is not supported + const path = "rxjs/operators"; + const { concat } = await import(path); + `, + stripIndent` + // export named + export { concatWith, mergeWith as m } from "rxjs"; + `, + stripIndent` + // export all + export * from "rxjs"; + `, + stripIndent` + // unrelated import + import { ajax } from "rxjs/ajax"; + import { fromFetch } from "rxjs/fetch"; + import { TestScheduler } from "rxjs/testing"; + import { webSocket } from "rxjs/webSocket"; + import * as prefixedPackage from "rxjs-prefixed-package"; + `, + ], + invalid: [ + fromFixture( + stripIndent` + // import declaration named + import { map as m, filter, 'tap' as tap } from "rxjs/operators"; + ~~~~~~~~~~~~~~~~ [forbidden suggest] + `, + { + output: stripIndent` + // import declaration named + import { map as m, filter, 'tap' as tap } from "rxjs"; + `, + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // import declaration named + import { map as m, filter, 'tap' as tap } from "rxjs"; + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // import declaration named, renamed operators + import { 'merge' as m, race as race } from 'rxjs/operators'; + ~~~~~~~~~~~~~~~~ [forbidden suggest] + `, + { + output: stripIndent` + // import declaration named, renamed operators + import { 'mergeWith' as m, raceWith as race } from 'rxjs'; + `, + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // import declaration named, renamed operators + import { 'mergeWith' as m, raceWith as race } from 'rxjs'; + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // import declaration named, deprecated operator + import { partition } from "rxjs/operators"; + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix] + `, + ), + fromFixture( + stripIndent` + // import declaration namespace + import * as RxOperators from "rxjs/operators"; + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix suggest] + `, + { + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // import declaration namespace + import * as RxOperators from "rxjs"; + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // import declaration default + import RxOperators, { map } from "rxjs/operators"; + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix suggest] + `, + { + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // import declaration default + import RxOperators, { map } from "rxjs"; + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // import expression + const { concat, merge: m, map } = await import("rxjs/operators"); + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix suggest] + `, + { + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // import expression + const { concat, merge: m, map } = await import("rxjs"); + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // import expression, separated import + const opPromise = import("rxjs/operators"); + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix suggest] + const { concat } = await opPromise; + `, + { + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // import expression, separated import + const opPromise = import("rxjs"); + const { concat } = await opPromise; + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // import expression, deprecated operator + const { concat, partition } = await import("rxjs/operators"); + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix suggest] + `, + { + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // import expression, deprecated operator + const { concat, partition } = await import("rxjs"); + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // export named + export { concat, merge as m, map, 'race' as "r" } from "rxjs/operators"; + ~~~~~~~~~~~~~~~~ [forbidden suggest] + `, + { + output: stripIndent` + // export named + export { concatWith as concat, mergeWith as m, map, 'raceWith' as "r" } from "rxjs"; + `, + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // export named + export { concatWith as concat, mergeWith as m, map, 'raceWith' as "r" } from "rxjs"; + `, + }, + ], + }, + ), + fromFixture( + stripIndent` + // export named, deprecated operator + export { concat, partition } from "rxjs/operators"; + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix] + `, + ), + fromFixture( + stripIndent` + // export all + export * from "rxjs/operators"; + ~~~~~~~~~~~~~~~~ [forbiddenWithoutFix suggest] + `, + { + suggestions: [ + { + messageId: 'suggest', + output: stripIndent` + // export all + export * from "rxjs"; + `, + }, + ], + }, + ), + ], +});