Skip to content

Commit e3b8090

Browse files
feat(prefer-root-operators): new rule (#34)
Help developers migrate from `rxjs/operators` to `rxjs`. - Handles both imports and exports. - Will auto-rename any operators that have a different name in `rxjs` (concat -> concatWith as concat). - Will not attempt to auto-fix if there's a risk of breaking code. e.g. the `partition` operator was not moved at all, so we leave that import alone. Ditto for `*` imports/exports. - **This will likely be added to the `recommended` config when v1 releases.** That change will also include bumping the RxJS peer dependency version to v7.2 instead of v7.0. Resolves #9 .
1 parent cec2d60 commit e3b8090

File tree

5 files changed

+437
-0
lines changed

5 files changed

+437
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ The package includes the following rules.
106106
| [no-unsafe-switchmap](docs/rules/no-unsafe-switchmap.md) | Disallow unsafe `switchMap` usage in effects and epics. | | | | 💭 | |
107107
| [no-unsafe-takeuntil](docs/rules/no-unsafe-takeuntil.md) | Disallow applying operators after `takeUntil`. || | | 💭 | |
108108
| [prefer-observer](docs/rules/prefer-observer.md) | Disallow passing separate handlers to `subscribe` and `tap`. | | 🔧 | 💡 | 💭 | |
109+
| [prefer-root-operators](docs/rules/prefer-root-operators.md) | Disallow importing operators from `rxjs/operators`. | | 🔧 | 💡 | | |
109110
| [suffix-subjects](docs/rules/suffix-subjects.md) | Enforce the use of a suffix in subject identifiers. | | | | 💭 | |
110111
| [throw-error](docs/rules/throw-error.md) | Enforce passing only `Error` values to `throwError`. | | | | 💭 | |
111112

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Disallow importing operators from `rxjs/operators` (`rxjs-x/prefer-root-operators`)
2+
3+
🔧💡 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).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
This rule prevents importing from the `rxjs/operators` export site.
8+
Most operators were moved to the `rxjs` export site in RxJS v7.2.0
9+
(excepting a few old and deprecated operators).
10+
The `rxjs/operators` export site has since been deprecated and will be removed in a future major version.
11+
12+
Note that because a few operators were renamed or not migrated to the `rxjs` export site,
13+
this rule may not provide an automatic fixer if renaming the import path is not guaranteed to be safe.
14+
See the documentation linked below.
15+
16+
## Rule details
17+
18+
Examples of **incorrect** code for this rule:
19+
20+
```ts
21+
import { map } from 'rxjs/operators';
22+
```
23+
24+
Examples of **correct** code for this rule:
25+
26+
```ts
27+
import { map } from 'rxjs';
28+
```
29+
30+
## Further reading
31+
32+
- [Importing instructions](https://rxjs.dev/guide/importing)

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { noUnsafeSubjectNext } from './rules/no-unsafe-subject-next';
4242
import { noUnsafeSwitchmapRule } from './rules/no-unsafe-switchmap';
4343
import { noUnsafeTakeuntilRule } from './rules/no-unsafe-takeuntil';
4444
import { preferObserverRule } from './rules/prefer-observer';
45+
import { preferRootOperatorsRule } from './rules/prefer-root-operators';
4546
import { suffixSubjectsRule } from './rules/suffix-subjects';
4647
import { throwErrorRule } from './rules/throw-error';
4748

@@ -88,6 +89,7 @@ const plugin = {
8889
'no-unsafe-switchmap': noUnsafeSwitchmapRule,
8990
'no-unsafe-takeuntil': noUnsafeTakeuntilRule,
9091
'prefer-observer': preferObserverRule,
92+
'prefer-root-operators': preferRootOperatorsRule,
9193
'suffix-subjects': suffixSubjectsRule,
9294
'throw-error': throwErrorRule,
9395
},

src/rules/prefer-root-operators.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { TSESTree as es, TSESLint } from '@typescript-eslint/utils';
2+
import { isIdentifier, isImportSpecifier, isLiteral } from '../etc';
3+
import { ruleCreator } from '../utils';
4+
5+
// See https://rxjs.dev/guide/importing#how-to-migrate
6+
7+
const RENAMED_OPERATORS: Record<string, string> = {
8+
combineLatest: 'combineLatestWith',
9+
concat: 'concatWith',
10+
merge: 'mergeWith',
11+
onErrorResumeNext: 'onErrorResumeNextWith',
12+
race: 'raceWith',
13+
zip: 'zipWith',
14+
};
15+
16+
const DEPRECATED_OPERATORS = [
17+
'partition',
18+
];
19+
20+
export const preferRootOperatorsRule = ruleCreator({
21+
defaultOptions: [],
22+
meta: {
23+
docs: {
24+
description: 'Disallow importing operators from `rxjs/operators`.',
25+
},
26+
fixable: 'code',
27+
hasSuggestions: true,
28+
messages: {
29+
forbidden: 'RxJS imports from `rxjs/operators` are forbidden; import from `rxjs` instead.',
30+
forbiddenWithoutFix: 'RxJS imports from `rxjs/operators` are forbidden; import from `rxjs` instead. Note some operators may have been renamed or deprecated.',
31+
suggest: 'Replace with import from `rxjs`.',
32+
},
33+
schema: [],
34+
type: 'suggestion',
35+
},
36+
name: 'prefer-root-operators',
37+
create: (context) => {
38+
function getQuote(raw: string): string | undefined {
39+
const match = /^\s*('|")/.exec(raw);
40+
if (!match) {
41+
return undefined;
42+
}
43+
const [, quote] = match;
44+
return quote;
45+
}
46+
47+
function getSourceReplacement(rawLocation: string): string | undefined {
48+
const quote = getQuote(rawLocation);
49+
if (!quote) {
50+
return undefined;
51+
}
52+
if (/^['"]rxjs\/operators/.test(rawLocation)) {
53+
return `${quote}rxjs${quote}`;
54+
}
55+
return undefined;
56+
}
57+
58+
function hasDeprecatedOperators(specifiers?: es.ImportSpecifier[] | es.ExportSpecifier[]): boolean {
59+
return !!specifiers?.some(s => DEPRECATED_OPERATORS.includes(getName(getOperatorNode(s))));
60+
}
61+
62+
function getName(node: es.Identifier | es.StringLiteral): string {
63+
return isIdentifier(node) ? node.name : node.value;
64+
}
65+
66+
function getOperatorNode(node: es.ImportSpecifier | es.ExportSpecifier): es.Identifier | es.StringLiteral {
67+
return isImportSpecifier(node) ? node.imported : node.local;
68+
}
69+
70+
function getAliasNode(node: es.ImportSpecifier | es.ExportSpecifier): es.Identifier | es.StringLiteral {
71+
return isImportSpecifier(node) ? node.local : node.exported;
72+
}
73+
74+
function getOperatorReplacement(name: string): string | undefined {
75+
return RENAMED_OPERATORS[name];
76+
}
77+
78+
function isNodesEqual(a: es.Node, b: es.Node): boolean {
79+
return a.range[0] === b.range[0] && a.range[1] === b.range[1];
80+
}
81+
82+
function createFix(source: es.Node, replacement: string, specifiers: es.ImportSpecifier[] | es.ExportSpecifier[]) {
83+
return function* fix(fixer: TSESLint.RuleFixer) {
84+
// Rename the module name.
85+
yield fixer.replaceText(source, replacement);
86+
87+
// Rename the imported operators if necessary.
88+
for (const specifier of specifiers) {
89+
const operatorNode = getOperatorNode(specifier);
90+
const operatorName = getName(operatorNode);
91+
92+
const operatorReplacement = getOperatorReplacement(operatorName);
93+
if (!operatorReplacement) {
94+
// The operator has the same name.
95+
continue;
96+
}
97+
98+
const aliasNode = getAliasNode(specifier);
99+
if (isNodesEqual(aliasNode, operatorNode)) {
100+
// concat -> concatWith as concat
101+
yield fixer.insertTextBefore(operatorNode, operatorReplacement + ' as ');
102+
} else if (isIdentifier(operatorNode)) {
103+
// concat as c -> concatWith as c
104+
yield fixer.replaceText(operatorNode, operatorReplacement);
105+
} else {
106+
// 'concat' as c -> 'concatWith' as c
107+
const quote = getQuote(operatorNode.raw);
108+
if (!quote) {
109+
continue;
110+
}
111+
yield fixer.replaceText(operatorNode, quote + operatorReplacement + quote);
112+
}
113+
}
114+
};
115+
}
116+
117+
function reportNode(source: es.Literal, specifiers?: es.ImportSpecifier[] | es.ExportSpecifier[]): void {
118+
const replacement = getSourceReplacement(source.raw);
119+
if (!replacement || hasDeprecatedOperators(specifiers)) {
120+
context.report({
121+
messageId: 'forbiddenWithoutFix',
122+
node: source,
123+
});
124+
return;
125+
}
126+
127+
if (!specifiers) {
128+
context.report({
129+
messageId: 'forbiddenWithoutFix',
130+
node: source,
131+
suggest: [{ messageId: 'suggest', fix: (fixer) => fixer.replaceText(source, replacement) }],
132+
});
133+
return;
134+
}
135+
136+
const fix = createFix(source, replacement, specifiers);
137+
context.report({
138+
fix,
139+
messageId: 'forbidden',
140+
node: source,
141+
suggest: [{ messageId: 'suggest', fix }],
142+
});
143+
}
144+
145+
return {
146+
'ImportDeclaration[source.value="rxjs/operators"]': (node: es.ImportDeclaration) => {
147+
// Exclude side effect imports, default imports, and namespace imports.
148+
const specifiers = node.specifiers.length && node.specifiers.every(importClause => isImportSpecifier(importClause))
149+
? node.specifiers
150+
: undefined;
151+
152+
reportNode(node.source, specifiers);
153+
},
154+
'ImportExpression[source.value="rxjs/operators"]': (node: es.ImportExpression) => {
155+
if (isLiteral(node.source)) {
156+
reportNode(node.source);
157+
}
158+
},
159+
'ExportNamedDeclaration[source.value="rxjs/operators"]': (node: es.ExportNamedDeclarationWithSource) => {
160+
reportNode(node.source, node.specifiers);
161+
},
162+
'ExportAllDeclaration[source.value="rxjs/operators"]': (node: es.ExportAllDeclaration) => {
163+
reportNode(node.source);
164+
},
165+
};
166+
},
167+
});

0 commit comments

Comments
 (0)