Skip to content

Commit 3650a6a

Browse files
committed
feat(rules): migrate static operations
Fix #10 The rule does not handle `never`. The transformation performs the following algorithm: - Removes all side-effect imports to static `Observable` operators. - Finds all `Observable.[operator]` `CallExpression`s. - Finds the operator rename (i.e. `throw` becomes `throwError`). - Replaces `Observable.[operator]` with `Observable.[alias(rename[operator])]`. - The alias is `observable[OperatorName]`, used in order to reduce the possibility of naming collisions with user defined functions. - Adds import statement for the operator of the type `import { operator as OperatorAlias } from 'rxjs';`
1 parent dbb44ac commit 3650a6a

9 files changed

+261
-72
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ dist
55
npm-debug.log
66
.vscode
77
yarn.lock
8+
demo.ts
9+
tsconfig-demo.json
810

README.md

+7-6
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ TSLint rules for rxjs.
66

77
This repository provides the following rules:
88

9-
| Rule Name | Configuration | Description |
10-
| :-----------------------------: | :-----------: | :-----------------------------------------------------: |
11-
| `collapse-rxjs-imports` | none | Collapses multiple imports from `rxjs` to a single one. |
12-
| `migrate-to-pipeable-operators` | none | Migrates side-effect operators to pipeables. |
13-
| `update-rxjs-imports` | none | Updates RxJS 5.x.x imports to RxJS 6.0 |
9+
| Rule Name | Configuration | Description |
10+
| :---------------------------------: | :-----------: | :-----------------------------------------------------: |
11+
| `collapse-rxjs-imports` | none | Collapses multiple imports from `rxjs` to a single one. |
12+
| `migrate-to-pipeable-operators` | none | Migrates side-effect operators to pipeables. |
13+
| `migrate-static-observable-methods` | none | Migrates static `Observable` method calls |
14+
| `update-rxjs-imports` | none | Updates RxJS 5.x.x imports to RxJS 6.0 |
1415

1516
## Migration to RxJS 6
1617

@@ -30,6 +31,7 @@ npm i rxjs-tslint
3031
"rules": {
3132
"update-rxjs-imports": true,
3233
"migrate-to-pipeable-operators": true,
34+
"migrate-static-observable-methods": true,
3335
"collapse-rxjs-imports": true
3436
}
3537
}
@@ -51,4 +53,3 @@ npm i rxjs-tslint
5153
## License
5254

5355
MIT
54-

migrate-tslint.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"rules": {
44
"update-rxjs-imports": true,
55
"migrate-to-pipeable-operators": true,
6+
"migrate-static-observable-methods": true,
67
"collapse-rxjs-imports": true
78
}
8-
}
9+
}

package-lock.json

+13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+6-10
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,25 @@
77
"docs": "ts-node build/buildDocs.ts",
88
"lint": "tslint -c tslint.json \"src/**/*.ts\" \"test/**/*.ts\"",
99
"lint:fix": "npm run lint -- --fix",
10-
"release": "npm run build && rimraf dist && tsc -p tsconfig-release.json && npm run copy:common && npm run prepare:package && BUILD_TYPE=prod npm run set:vars",
10+
"release":
11+
"npm run build && rimraf dist && tsc -p tsconfig-release.json && npm run copy:common && npm run prepare:package && BUILD_TYPE=prod npm run set:vars",
1112
"build": "rimraf dist && tsc && npm run lint && npm t",
1213
"copy:common": "cp README.md dist",
1314
"prepare:package": "cat package.json | ts-node build/package.ts > dist/package.json",
1415
"test": "rimraf dist && tsc && mocha -R nyan dist/test --recursive",
15-
"test:watch": "rimraf dist && tsc && BUILD_TYPE=dev npm run set:vars && mocha -R nyan dist/test --watch --recursive",
16+
"test:watch":
17+
"rimraf dist && tsc && BUILD_TYPE=dev npm run set:vars && mocha -R nyan dist/test --watch --recursive",
1618
"set:vars": "ts-node build/vars.ts --src ./dist",
1719
"tscv": "tsc --version",
1820
"tsc": "tsc",
1921
"tsc:watch": "tsc --w"
2022
},
21-
"contributors": [
22-
"Minko Gechev <[email protected]>"
23-
],
23+
"contributors": ["Minko Gechev <[email protected]>"],
2424
"repository": {
2525
"type": "git",
2626
"url": "git+https://github.com/mgechev/tslint-rules.git"
2727
},
28-
"keywords": [
29-
"rxjs",
30-
"lint",
31-
"tslint"
32-
],
28+
"keywords": ["rxjs", "lint", "tslint"],
3329
"author": {
3430
"name": "Minko Gechev",
3531
"email": "[email protected]"

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { Rule as CollapseRxjsImports } from './collapseRxjsImportsRule';
22
export { Rule as UpdateRxjsImports } from './updateRxjsImportsRule';
33
export { Rule as MigrateToPipeableOperators } from './migrateToPipeableOperatorsRule';
4+
export { Rule as MigrateStaticObservableMethods } from './migrateStaticObservableMethodsRule';
+182
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Original author Bowen Ni
2+
// Modifications mgechev.
3+
4+
import * as Lint from 'tslint';
5+
import * as tsutils from 'tsutils';
6+
import * as ts from 'typescript';
7+
import { subtractSets, concatSets, isObservable, returnsObservable, computeInsertionIndexForImports } from './utils';
8+
/**
9+
* A typed TSLint rule that inspects observable chains using patched RxJs
10+
* operators and turns them into a pipeable operator chain.
11+
*/
12+
export class Rule extends Lint.Rules.TypedRule {
13+
static metadata: Lint.IRuleMetadata = {
14+
ruleName: 'migrate-static-observable-methods',
15+
description: 'Updates the static methods of the Observable class.',
16+
optionsDescription: '',
17+
options: null,
18+
typescriptOnly: true,
19+
type: 'functionality'
20+
};
21+
static IMPORT_FAILURE_STRING = 'prefer operator imports with no side-effects';
22+
static OBSERVABLE_FAILURE_STRING = 'prefer function calls';
23+
24+
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): Lint.RuleFailure[] {
25+
const failure = this.applyWithFunction(sourceFile, ctx => this.walk(ctx, program));
26+
return failure;
27+
}
28+
private walk(ctx: Lint.WalkContext<void>, program: ts.Program) {
29+
this.removePatchedOperatorImports(ctx);
30+
const sourceFile = ctx.sourceFile;
31+
const typeChecker = program.getTypeChecker();
32+
const insertionStart = computeInsertionIndexForImports(sourceFile);
33+
let rxjsOperatorImports = findImportedRxjsOperators(sourceFile);
34+
35+
function checkPatchableOperatorUsage(node: ts.Node) {
36+
if (!isRxjsStaticOperatorCallExpression(node, typeChecker)) {
37+
return ts.forEachChild(node, checkPatchableOperatorUsage);
38+
}
39+
40+
const callExpr = node as ts.CallExpression;
41+
if (!tsutils.isPropertyAccessExpression(callExpr.expression)) {
42+
return ts.forEachChild(node, checkPatchableOperatorUsage);
43+
}
44+
45+
const propAccess = callExpr.expression as ts.PropertyAccessExpression;
46+
const name = propAccess.name.getText(sourceFile);
47+
const operatorName = OPERATOR_RENAMES[name] || name;
48+
const start = propAccess.getStart(sourceFile);
49+
const end = propAccess.getEnd();
50+
const operatorsToImport = new Set<string>([operatorName]);
51+
const operatorsToAdd = subtractSets(operatorsToImport, rxjsOperatorImports);
52+
const imports = createImportReplacements(operatorsToAdd, insertionStart);
53+
rxjsOperatorImports = concatSets(rxjsOperatorImports, operatorsToAdd);
54+
ctx.addFailure(
55+
start,
56+
end,
57+
Rule.OBSERVABLE_FAILURE_STRING,
58+
[Lint.Replacement.replaceFromTo(start, end, operatorAlias(operatorName))].concat(imports)
59+
);
60+
return ts.forEachChild(node, checkPatchableOperatorUsage);
61+
}
62+
63+
return ts.forEachChild(ctx.sourceFile, checkPatchableOperatorUsage);
64+
}
65+
66+
private removePatchedOperatorImports(ctx: Lint.WalkContext<void>): void {
67+
const sourceFile = ctx.sourceFile;
68+
for (const importStatement of sourceFile.statements.filter(tsutils.isImportDeclaration)) {
69+
const moduleSpecifier = importStatement.moduleSpecifier.getText();
70+
if (!moduleSpecifier.startsWith(`'rxjs/add/observable/`)) {
71+
continue;
72+
}
73+
const importStatementStart = importStatement.getStart(sourceFile);
74+
const importStatementEnd = importStatement.getEnd();
75+
ctx.addFailure(
76+
importStatementStart,
77+
importStatementEnd,
78+
Rule.IMPORT_FAILURE_STRING,
79+
Lint.Replacement.deleteFromTo(importStatementStart, importStatementEnd)
80+
);
81+
}
82+
}
83+
}
84+
85+
function isRxjsStaticOperator(node: ts.PropertyAccessExpression) {
86+
return 'Observable' === node.expression.getText() && RXJS_OPERATORS.has(node.name.getText());
87+
}
88+
89+
function isRxjsStaticOperatorCallExpression(node: ts.Node, typeChecker: ts.TypeChecker) {
90+
// Expression is of the form fn()
91+
if (!tsutils.isCallExpression(node)) {
92+
return false;
93+
}
94+
// Expression is of the form foo.fn
95+
if (!tsutils.isPropertyAccessExpression(node.expression)) {
96+
return false;
97+
}
98+
// fn is one of RxJs instance operators
99+
if (!isRxjsStaticOperator(node.expression)) {
100+
return false;
101+
}
102+
// fn(): k. Checks if k is an observable. Required to distinguish between
103+
// array operators with same name as RxJs operators.
104+
if (!returnsObservable(node, typeChecker)) {
105+
return false;
106+
}
107+
return true;
108+
}
109+
110+
function findImportedRxjsOperators(sourceFile: ts.SourceFile): Set<string> {
111+
return new Set<string>(
112+
sourceFile.statements.filter(tsutils.isImportDeclaration).reduce((current, decl) => {
113+
if (!decl.importClause) {
114+
return current;
115+
}
116+
if (!decl.moduleSpecifier.getText().startsWith(`'rxjs'`)) {
117+
return current;
118+
}
119+
if (!decl.importClause.namedBindings) {
120+
return current;
121+
}
122+
const bindings = decl.importClause.namedBindings;
123+
if (ts.isNamedImports(bindings)) {
124+
return [
125+
...current,
126+
...(Array.from(bindings.elements) || []).map(element => {
127+
return element.name.getText();
128+
})
129+
];
130+
}
131+
return current;
132+
}, [])
133+
);
134+
}
135+
136+
function operatorAlias(operator: string) {
137+
return 'observable' + operator[0].toUpperCase() + operator.substring(1, operator.length);
138+
}
139+
140+
function createImportReplacements(operatorsToAdd: Set<string>, startIndex: number): Lint.Replacement[] {
141+
return [...Array.from(operatorsToAdd.values())].map(operator =>
142+
Lint.Replacement.appendText(startIndex, `\nimport {${operator} as ${operatorAlias(operator)}} from 'rxjs';\n`)
143+
);
144+
}
145+
146+
/*
147+
* https://github.com/ReactiveX/rxjs/tree/master/compat/add/observable
148+
*/
149+
const RXJS_OPERATORS = new Set([
150+
'bindCallback',
151+
'bindNodeCallback',
152+
'combineLatest',
153+
'concat',
154+
'defer',
155+
'empty',
156+
'forkJoin',
157+
'from',
158+
'fromEvent',
159+
'fromEventPattern',
160+
'fromPromise',
161+
'generate',
162+
'if',
163+
'interval',
164+
'merge',
165+
'never',
166+
'of',
167+
'onErrorResumeNext',
168+
'pairs',
169+
'rase',
170+
'range',
171+
'throw',
172+
'timer',
173+
'using',
174+
'zip'
175+
]);
176+
177+
// Not handling NEVER
178+
const OPERATOR_RENAMES: { [key: string]: string } = {
179+
throw: 'throwError',
180+
if: 'iif',
181+
fromPromise: 'from'
182+
};

src/migrateToPipeableOperatorsRule.ts

+4-55
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import * as Lint from 'tslint';
55
import * as tsutils from 'tsutils';
66
import * as ts from 'typescript';
7+
import { subtractSets, concatSets, isObservable, returnsObservable, computeInsertionIndexForImports } from './utils';
78
/**
89
* A typed TSLint rule that inspects observable chains using patched RxJs
910
* operators and turns them into a pipeable operator chain.
@@ -131,34 +132,7 @@ export class Rule extends Lint.Rules.TypedRule {
131132
}
132133
}
133134
}
134-
/**
135-
* Returns true if the {@link type} is an Observable or one of its sub-classes.
136-
*/
137-
function isObservable(type: ts.Type, tc: ts.TypeChecker): boolean {
138-
if (tsutils.isTypeReference(type)) {
139-
type = type.target;
140-
}
141-
if (type.symbol !== undefined && type.symbol.name === 'Observable') {
142-
return true;
143-
}
144-
if (tsutils.isUnionOrIntersectionType(type)) {
145-
return type.types.some(t => isObservable(t, tc));
146-
}
147-
const bases = type.getBaseTypes();
148-
return bases !== undefined && bases.some(t => isObservable(t, tc));
149-
}
150-
/**
151-
* Returns true if the return type of the expression represented by the {@link
152-
* node} is an Observable or one of its subclasses.
153-
*/
154-
function returnsObservable(node: ts.CallLikeExpression, tc: ts.TypeChecker) {
155-
const signature = tc.getResolvedSignature(node);
156-
if (signature === undefined) {
157-
return false;
158-
}
159-
const returnType = tc.getReturnTypeOfSignature(signature);
160-
return isObservable(returnType, tc);
161-
}
135+
162136
/**
163137
* Returns true if the identifier of the current expression is an RxJS instance
164138
* operator like map, switchMap etc.
@@ -222,20 +196,7 @@ function findImportedRxjsOperators(sourceFile: ts.SourceFile): Set<string> {
222196
}, [])
223197
);
224198
}
225-
/**
226-
* Returns the index to be used for inserting import statements potentially
227-
* after a leading file overview comment (separated from the file with \n\n).
228-
*/
229-
function computeInsertionIndexForImports(sourceFile: ts.SourceFile): number {
230-
const comments = ts.getLeadingCommentRanges(sourceFile.getFullText(), 0) || [];
231-
if (comments.length > 0) {
232-
const commentEnd = comments[0].end;
233-
if (sourceFile.text.substring(commentEnd, commentEnd + 2) === '\n\n') {
234-
return commentEnd + 2;
235-
}
236-
}
237-
return sourceFile.getFullStart();
238-
}
199+
239200
/**
240201
* Generates an array of {@link Lint.Replacement} representing import statements
241202
* for the {@link operatorsToAdd}.
@@ -248,19 +209,7 @@ function createImportReplacements(operatorsToAdd: Set<string>, startIndex: numbe
248209
Lint.Replacement.appendText(startIndex, `\nimport {${operator}} from 'rxjs/operators/${operator}';\n`)
249210
);
250211
}
251-
/**
252-
* Returns a new Set that contains elements present in the {@link source} but
253-
* not present in {@link target}
254-
*/
255-
function subtractSets<T>(source: Set<T>, target: Set<T>): Set<T> {
256-
return new Set([...Array.from(source.values())].filter(x => !target.has(x)));
257-
}
258-
/**
259-
* Returns a new Set that contains union of the two input sets.
260-
*/
261-
function concatSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {
262-
return new Set([...Array.from(set1.values()), ...Array.from(set2.values())]);
263-
}
212+
264213
/**
265214
* Returns the last chained RxJS call expression by walking up the AST.
266215
*

0 commit comments

Comments
 (0)