Skip to content

Commit cec2d60

Browse files
feat(no-topromise): suggest lastValueFrom or firstValueFrom (#38)
Adds 2 manual editor suggestions to `no-topromise`. Developers can choose between `lastValueFrom`, which behaves closest to the deprecated `toPromise`, or `firstValueFrom`. Resolves #37 .
1 parent 05862a3 commit cec2d60

File tree

5 files changed

+300
-12
lines changed

5 files changed

+300
-12
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ The package includes the following rules.
9898
| [no-subject-value](docs/rules/no-subject-value.md) | Disallow accessing the `value` property of a `BehaviorSubject` instance. | | | | 💭 | |
9999
| [no-subscribe-handlers](docs/rules/no-subscribe-handlers.md) | Disallow passing handlers to `subscribe`. | | | | 💭 | |
100100
| [no-tap](docs/rules/no-tap.md) | Disallow the `tap` operator. | | | | ||
101-
| [no-topromise](docs/rules/no-topromise.md) | Disallow use of the `toPromise` method. | | | | 💭 | |
101+
| [no-topromise](docs/rules/no-topromise.md) | Disallow use of the `toPromise` method. | | | 💡 | 💭 | |
102102
| [no-unbound-methods](docs/rules/no-unbound-methods.md) | Disallow passing unbound methods. || | | 💭 | |
103103
| [no-unsafe-catch](docs/rules/no-unsafe-catch.md) | Disallow unsafe `catchError` usage in effects and epics. | | | | 💭 | |
104104
| [no-unsafe-first](docs/rules/no-unsafe-first.md) | Disallow unsafe `first`/`take` usage in effects and epics. | | | | 💭 | |

docs/rules/no-topromise.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# Disallow use of the `toPromise` method (`rxjs-x/no-topromise`)
22

3+
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
4+
35
💭 This rule requires [type information](https://typescript-eslint.io/linting/typed-linting).
46

57
<!-- end auto-generated rule header -->
68

79
This rule effects failures if the `toPromise` method is used.
10+
11+
## Further reading
12+
13+
- [Conversion to Promises](https://rxjs.dev/deprecations/to-promise)

src/etc/is.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ export function isIdentifier(node: TSESTree.Node): node is TSESTree.Identifier {
6262
return node.type === AST_NODE_TYPES.Identifier;
6363
}
6464

65+
export function isImportDeclaration(node: TSESTree.Node): node is TSESTree.ImportDeclaration {
66+
return node.type === AST_NODE_TYPES.ImportDeclaration;
67+
}
68+
69+
export function isImportNamespaceSpecifier(node: TSESTree.Node): node is TSESTree.ImportNamespaceSpecifier {
70+
return node.type === AST_NODE_TYPES.ImportNamespaceSpecifier;
71+
}
72+
73+
export function isImportSpecifier(node: TSESTree.Node): node is TSESTree.ImportSpecifier {
74+
return node.type === AST_NODE_TYPES.ImportSpecifier;
75+
}
76+
6577
export function isLiteral(node: TSESTree.Node): node is TSESTree.Literal {
6678
return node.type === AST_NODE_TYPES.Literal;
6779
}

src/rules/no-topromise.ts

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { TSESTree as es } from '@typescript-eslint/utils';
2-
import { getTypeServices } from '../etc';
1+
import { TSESTree as es, TSESLint } from '@typescript-eslint/utils';
2+
import { getTypeServices, isIdentifier, isImportDeclaration, isImportNamespaceSpecifier, isImportSpecifier } from '../etc';
33
import { ruleCreator } from '../utils';
44

55
export const noTopromiseRule = ruleCreator({
@@ -9,25 +9,105 @@ export const noTopromiseRule = ruleCreator({
99
description: 'Disallow use of the `toPromise` method.',
1010
requiresTypeChecking: true,
1111
},
12+
hasSuggestions: true,
1213
messages: {
1314
forbidden: 'The toPromise method is forbidden.',
15+
suggestLastValueFrom: 'Use lastValueFrom instead.',
16+
suggestFirstValueFrom: 'Use firstValueFrom instead.',
1417
},
1518
schema: [],
1619
type: 'problem',
1720
},
1821
name: 'no-topromise',
1922
create: (context) => {
2023
const { couldBeObservable } = getTypeServices(context);
24+
25+
function getQuote(raw: string) {
26+
const match = /^\s*('|")/.exec(raw);
27+
if (!match) {
28+
return undefined;
29+
}
30+
const [, quote] = match;
31+
return quote;
32+
}
33+
34+
function createFix(
35+
conversion: 'lastValueFrom' | 'firstValueFrom',
36+
callExpression: es.CallExpression,
37+
observableNode: es.Node,
38+
importDeclarations: es.ImportDeclaration[],
39+
) {
40+
return function* fix(fixer: TSESLint.RuleFixer) {
41+
let namespace = '';
42+
let functionName: string = conversion;
43+
44+
const rxjsImportDeclaration = importDeclarations.find(node => node.source.value === 'rxjs');
45+
46+
if (rxjsImportDeclaration?.specifiers?.every(isImportNamespaceSpecifier)) {
47+
// Existing rxjs namespace import. Use alias.
48+
namespace = rxjsImportDeclaration.specifiers[0].local.name + '.';
49+
} else if (rxjsImportDeclaration?.specifiers?.every(isImportSpecifier)) {
50+
// Existing rxjs named import.
51+
const { specifiers } = rxjsImportDeclaration;
52+
const existingSpecifier = specifiers.find(node => (isIdentifier(node.imported) ? node.imported.name : node.imported.value) === functionName);
53+
if (existingSpecifier) {
54+
// Function already imported. Use its alias, if any.
55+
functionName = existingSpecifier.local.name;
56+
} else {
57+
// Function not already imported. Add it.
58+
const lastSpecifier = specifiers[specifiers.length - 1];
59+
yield fixer.insertTextAfter(lastSpecifier, `, ${functionName}`);
60+
}
61+
} else if (importDeclarations.length) {
62+
// No rxjs import. Add to end of imports, respecting quotes.
63+
const lastImport = importDeclarations[importDeclarations.length - 1];
64+
const quote = getQuote(lastImport.source.raw) ?? '"';
65+
yield fixer.insertTextAfter(
66+
importDeclarations[importDeclarations.length - 1],
67+
`\nimport { ${functionName} } from ${quote}rxjs${quote};`,
68+
);
69+
} else {
70+
console.warn('No import declarations found. Unable to suggest a fix.');
71+
return;
72+
}
73+
74+
yield fixer.replaceText(
75+
callExpression,
76+
`${namespace}${functionName}(${context.sourceCode.getText(observableNode)})`,
77+
);
78+
};
79+
}
80+
2181
return {
22-
[`MemberExpression[property.name="toPromise"]`]: (
23-
node: es.MemberExpression,
82+
[`CallExpression[callee.property.name="toPromise"]`]: (
83+
node: es.CallExpression,
2484
) => {
25-
if (couldBeObservable(node.object)) {
26-
context.report({
27-
messageId: 'forbidden',
28-
node: node.property,
29-
});
85+
const memberExpression = node.callee as es.MemberExpression;
86+
if (!couldBeObservable(memberExpression.object)) {
87+
return;
3088
}
89+
90+
const { body } = context.sourceCode.ast;
91+
const importDeclarations = body.filter(isImportDeclaration);
92+
if (!importDeclarations.length) {
93+
// couldBeObservable yet no imports? Skip.
94+
return;
95+
}
96+
97+
context.report({
98+
messageId: 'forbidden',
99+
node: memberExpression.property,
100+
suggest: [
101+
{
102+
messageId: 'suggestLastValueFrom',
103+
fix: createFix('lastValueFrom', node, memberExpression.object, importDeclarations),
104+
},
105+
{
106+
messageId: 'suggestFirstValueFrom',
107+
fix: createFix('firstValueFrom', node, memberExpression.object, importDeclarations),
108+
},
109+
],
110+
});
31111
},
32112
};
33113
},

tests/rules/no-topromise.test.ts

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ ruleTester({ types: true }).run('no-topromise', noTopromiseRule, {
2020
};
2121
a.toPromise().then(value => console.log(value));
2222
`,
23+
stripIndent`
24+
// no imports
25+
class Observable {
26+
toPromise() {
27+
return Promise.resolve("a");
28+
}
29+
}
30+
const a = new Observable();
31+
a.toPromise().then(value => console.log(value));
32+
`,
2333
],
2434
invalid: [
2535
fromFixture(
@@ -28,17 +38,197 @@ ruleTester({ types: true }).run('no-topromise', noTopromiseRule, {
2838
import { of } from "rxjs";
2939
const a = of("a");
3040
a.toPromise().then(value => console.log(value));
31-
~~~~~~~~~ [forbidden]
41+
~~~~~~~~~ [forbidden suggest 0 1]
3242
`,
43+
{
44+
suggestions: [
45+
{
46+
messageId: 'suggestLastValueFrom',
47+
output: stripIndent`
48+
// observable toPromise
49+
import { of, lastValueFrom } from "rxjs";
50+
const a = of("a");
51+
lastValueFrom(a).then(value => console.log(value));
52+
`,
53+
},
54+
{
55+
messageId: 'suggestFirstValueFrom',
56+
output: stripIndent`
57+
// observable toPromise
58+
import { of, firstValueFrom } from "rxjs";
59+
const a = of("a");
60+
firstValueFrom(a).then(value => console.log(value));
61+
`,
62+
},
63+
],
64+
},
3365
),
3466
fromFixture(
3567
stripIndent`
3668
// subject toPromise
3769
import { Subject } from "rxjs";
3870
const a = new Subject<string>();
3971
a.toPromise().then(value => console.log(value));
40-
~~~~~~~~~ [forbidden]
72+
~~~~~~~~~ [forbidden suggest 0 1]
73+
`,
74+
{
75+
suggestions: [
76+
{
77+
messageId: 'suggestLastValueFrom',
78+
output: stripIndent`
79+
// subject toPromise
80+
import { Subject, lastValueFrom } from "rxjs";
81+
const a = new Subject<string>();
82+
lastValueFrom(a).then(value => console.log(value));
83+
`,
84+
},
85+
{
86+
messageId: 'suggestFirstValueFrom',
87+
output: stripIndent`
88+
// subject toPromise
89+
import { Subject, firstValueFrom } from "rxjs";
90+
const a = new Subject<string>();
91+
firstValueFrom(a).then(value => console.log(value));
92+
`,
93+
},
94+
],
95+
},
96+
),
97+
fromFixture(
98+
stripIndent`
99+
// weird whitespace
100+
import { of } from "rxjs";
101+
const a = { foo$: of("a") };
102+
a
103+
.foo$
104+
.toPromise().then(value => console.log(value))
105+
~~~~~~~~~ [forbidden suggest 0 1]
106+
.catch(error => console.error(error));
107+
`,
108+
{
109+
suggestions: [
110+
{
111+
messageId: 'suggestLastValueFrom',
112+
output: stripIndent`
113+
// weird whitespace
114+
import { of, lastValueFrom } from "rxjs";
115+
const a = { foo$: of("a") };
116+
lastValueFrom(a
117+
.foo$).then(value => console.log(value))
118+
.catch(error => console.error(error));
119+
`,
120+
},
121+
{
122+
messageId: 'suggestFirstValueFrom',
123+
output: stripIndent`
124+
// weird whitespace
125+
import { of, firstValueFrom } from "rxjs";
126+
const a = { foo$: of("a") };
127+
firstValueFrom(a
128+
.foo$).then(value => console.log(value))
129+
.catch(error => console.error(error));
130+
`,
131+
},
132+
],
133+
},
134+
),
135+
fromFixture(
136+
stripIndent`
137+
// lastValueFrom already imported
138+
import { lastValueFrom as lvf, of } from "rxjs";
139+
const a = of("a");
140+
a.toPromise().then(value => console.log(value));
141+
~~~~~~~~~ [forbidden suggest 0 1]
142+
`,
143+
{
144+
suggestions: [
145+
{
146+
messageId: 'suggestLastValueFrom',
147+
output: stripIndent`
148+
// lastValueFrom already imported
149+
import { lastValueFrom as lvf, of } from "rxjs";
150+
const a = of("a");
151+
lvf(a).then(value => console.log(value));
152+
`,
153+
},
154+
{
155+
messageId: 'suggestFirstValueFrom',
156+
output: stripIndent`
157+
// lastValueFrom already imported
158+
import { lastValueFrom as lvf, of, firstValueFrom } from "rxjs";
159+
const a = of("a");
160+
firstValueFrom(a).then(value => console.log(value));
161+
`,
162+
},
163+
],
164+
},
165+
),
166+
fromFixture(
167+
stripIndent`
168+
// rxjs not already imported
169+
import { fromFetch } from "rxjs/fetch";
170+
171+
const a = fromFetch("https://api.some.com");
172+
a.toPromise().then(value => console.log(value));
173+
~~~~~~~~~ [forbidden suggest 0 1]
174+
`,
175+
{
176+
suggestions: [
177+
{
178+
messageId: 'suggestLastValueFrom',
179+
output: stripIndent`
180+
// rxjs not already imported
181+
import { fromFetch } from "rxjs/fetch";
182+
import { lastValueFrom } from "rxjs";
183+
184+
const a = fromFetch("https://api.some.com");
185+
lastValueFrom(a).then(value => console.log(value));
186+
`,
187+
},
188+
{
189+
messageId: 'suggestFirstValueFrom',
190+
output: stripIndent`
191+
// rxjs not already imported
192+
import { fromFetch } from "rxjs/fetch";
193+
import { firstValueFrom } from "rxjs";
194+
195+
const a = fromFetch("https://api.some.com");
196+
firstValueFrom(a).then(value => console.log(value));
197+
`,
198+
},
199+
],
200+
},
201+
),
202+
fromFixture(
203+
stripIndent`
204+
// namespace import
205+
import * as Rx from "rxjs";
206+
const a = Rx.of("a");
207+
a.toPromise().then(value => console.log(value));
208+
~~~~~~~~~ [forbidden suggest 0 1]
41209
`,
210+
{
211+
suggestions: [
212+
{
213+
messageId: 'suggestLastValueFrom',
214+
output: stripIndent`
215+
// namespace import
216+
import * as Rx from "rxjs";
217+
const a = Rx.of("a");
218+
Rx.lastValueFrom(a).then(value => console.log(value));
219+
`,
220+
},
221+
{
222+
messageId: 'suggestFirstValueFrom',
223+
output: stripIndent`
224+
// namespace import
225+
import * as Rx from "rxjs";
226+
const a = Rx.of("a");
227+
Rx.firstValueFrom(a).then(value => console.log(value));
228+
`,
229+
},
230+
],
231+
},
42232
),
43233
],
44234
});

0 commit comments

Comments
 (0)