Skip to content

Commit 06f5feb

Browse files
feat(no-topromise): suggest lastValueFrom or firstValueFrom
1 parent 05862a3 commit 06f5feb

File tree

5 files changed

+287
-12
lines changed

5 files changed

+287
-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: 86 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,102 @@ 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+
) {
39+
return function* fix(fixer: TSESLint.RuleFixer) {
40+
let namespace = '';
41+
let functionName: string = conversion;
42+
43+
const { body } = context.sourceCode.ast;
44+
const importDeclarations = body.filter(isImportDeclaration);
45+
46+
const rxjsImportDeclaration = importDeclarations.find(node => node.source.value === 'rxjs');
47+
48+
if (rxjsImportDeclaration?.specifiers?.every(isImportNamespaceSpecifier)) {
49+
// Existing rxjs namespace import. Use alias.
50+
namespace = rxjsImportDeclaration.specifiers[0].local.name + '.';
51+
} else if (rxjsImportDeclaration?.specifiers?.every(isImportSpecifier)) {
52+
// Existing rxjs named import.
53+
const { specifiers } = rxjsImportDeclaration;
54+
const existingSpecifier = specifiers.find(node => (isIdentifier(node.imported) ? node.imported.name : node.imported.value) === functionName);
55+
if (existingSpecifier) {
56+
// Function already imported. Use its alias, if any.
57+
functionName = existingSpecifier.local.name;
58+
} else {
59+
// Function not already imported. Add it.
60+
const lastSpecifier = specifiers[specifiers.length - 1];
61+
yield fixer.insertTextAfter(lastSpecifier, `, ${functionName}`);
62+
}
63+
} else if (importDeclarations.length) {
64+
// No rxjs import. Add to end of imports, respecting quotes.
65+
const lastImport = importDeclarations[importDeclarations.length - 1];
66+
const quote = getQuote(lastImport.source.raw) ?? '"';
67+
yield fixer.insertTextAfter(
68+
importDeclarations[importDeclarations.length - 1],
69+
`\nimport { ${functionName} } from ${quote}rxjs${quote};`,
70+
);
71+
} else {
72+
// No imports. Add to top of file.
73+
yield fixer.insertTextBefore(
74+
body[0],
75+
`import { ${functionName} } from "rxjs";\n`,
76+
);
77+
}
78+
79+
yield fixer.replaceText(
80+
callExpression,
81+
`${namespace}${functionName}(${context.sourceCode.getText(observableNode)})`,
82+
);
83+
};
84+
}
85+
2186
return {
22-
[`MemberExpression[property.name="toPromise"]`]: (
23-
node: es.MemberExpression,
87+
[`CallExpression[callee.property.name="toPromise"]`]: (
88+
node: es.CallExpression,
2489
) => {
25-
if (couldBeObservable(node.object)) {
26-
context.report({
27-
messageId: 'forbidden',
28-
node: node.property,
29-
});
90+
const memberExpression = node.callee as es.MemberExpression;
91+
if (!couldBeObservable(memberExpression.object)) {
92+
return;
3093
}
94+
context.report({
95+
messageId: 'forbidden',
96+
node: memberExpression.property,
97+
suggest: [
98+
{
99+
messageId: 'suggestLastValueFrom',
100+
fix: createFix('lastValueFrom', node, memberExpression.object),
101+
},
102+
{
103+
messageId: 'suggestFirstValueFrom',
104+
fix: createFix('firstValueFrom', node, memberExpression.object),
105+
},
106+
],
107+
});
31108
},
32109
};
33110
},

tests/rules/no-topromise.test.ts

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,197 @@ ruleTester({ types: true }).run('no-topromise', noTopromiseRule, {
2828
import { of } from "rxjs";
2929
const a = of("a");
3030
a.toPromise().then(value => console.log(value));
31-
~~~~~~~~~ [forbidden]
31+
~~~~~~~~~ [forbidden suggest 0 1]
3232
`,
33+
{
34+
suggestions: [
35+
{
36+
messageId: 'suggestLastValueFrom',
37+
output: stripIndent`
38+
// observable toPromise
39+
import { of, lastValueFrom } from "rxjs";
40+
const a = of("a");
41+
lastValueFrom(a).then(value => console.log(value));
42+
`,
43+
},
44+
{
45+
messageId: 'suggestFirstValueFrom',
46+
output: stripIndent`
47+
// observable toPromise
48+
import { of, firstValueFrom } from "rxjs";
49+
const a = of("a");
50+
firstValueFrom(a).then(value => console.log(value));
51+
`,
52+
},
53+
],
54+
},
3355
),
3456
fromFixture(
3557
stripIndent`
3658
// subject toPromise
3759
import { Subject } from "rxjs";
3860
const a = new Subject<string>();
3961
a.toPromise().then(value => console.log(value));
40-
~~~~~~~~~ [forbidden]
62+
~~~~~~~~~ [forbidden suggest 0 1]
4163
`,
64+
{
65+
suggestions: [
66+
{
67+
messageId: 'suggestLastValueFrom',
68+
output: stripIndent`
69+
// subject toPromise
70+
import { Subject, lastValueFrom } from "rxjs";
71+
const a = new Subject<string>();
72+
lastValueFrom(a).then(value => console.log(value));
73+
`,
74+
},
75+
{
76+
messageId: 'suggestFirstValueFrom',
77+
output: stripIndent`
78+
// subject toPromise
79+
import { Subject, firstValueFrom } from "rxjs";
80+
const a = new Subject<string>();
81+
firstValueFrom(a).then(value => console.log(value));
82+
`,
83+
},
84+
],
85+
},
86+
),
87+
fromFixture(
88+
stripIndent`
89+
// weird whitespace
90+
import { of } from "rxjs";
91+
const a = { foo$: of("a") };
92+
a
93+
.foo$
94+
.toPromise().then(value => console.log(value))
95+
~~~~~~~~~ [forbidden suggest 0 1]
96+
.catch(error => console.error(error));
97+
`,
98+
{
99+
suggestions: [
100+
{
101+
messageId: 'suggestLastValueFrom',
102+
output: stripIndent`
103+
// weird whitespace
104+
import { of, lastValueFrom } from "rxjs";
105+
const a = { foo$: of("a") };
106+
lastValueFrom(a
107+
.foo$).then(value => console.log(value))
108+
.catch(error => console.error(error));
109+
`,
110+
},
111+
{
112+
messageId: 'suggestFirstValueFrom',
113+
output: stripIndent`
114+
// weird whitespace
115+
import { of, firstValueFrom } from "rxjs";
116+
const a = { foo$: of("a") };
117+
firstValueFrom(a
118+
.foo$).then(value => console.log(value))
119+
.catch(error => console.error(error));
120+
`,
121+
},
122+
],
123+
},
124+
),
125+
fromFixture(
126+
stripIndent`
127+
// lastValueFrom already imported
128+
import { lastValueFrom as lvf, of } from "rxjs";
129+
const a = of("a");
130+
a.toPromise().then(value => console.log(value));
131+
~~~~~~~~~ [forbidden suggest 0 1]
132+
`,
133+
{
134+
suggestions: [
135+
{
136+
messageId: 'suggestLastValueFrom',
137+
output: stripIndent`
138+
// lastValueFrom already imported
139+
import { lastValueFrom as lvf, of } from "rxjs";
140+
const a = of("a");
141+
lvf(a).then(value => console.log(value));
142+
`,
143+
},
144+
{
145+
messageId: 'suggestFirstValueFrom',
146+
output: stripIndent`
147+
// lastValueFrom already imported
148+
import { lastValueFrom as lvf, of, firstValueFrom } from "rxjs";
149+
const a = of("a");
150+
firstValueFrom(a).then(value => console.log(value));
151+
`,
152+
},
153+
],
154+
},
155+
),
156+
fromFixture(
157+
stripIndent`
158+
// rxjs not already imported
159+
import { fromFetch } from "rxjs/fetch";
160+
161+
const a = fromFetch("https://api.some.com");
162+
a.toPromise().then(value => console.log(value));
163+
~~~~~~~~~ [forbidden suggest 0 1]
164+
`,
165+
{
166+
suggestions: [
167+
{
168+
messageId: 'suggestLastValueFrom',
169+
output: stripIndent`
170+
// rxjs not already imported
171+
import { fromFetch } from "rxjs/fetch";
172+
import { lastValueFrom } from "rxjs";
173+
174+
const a = fromFetch("https://api.some.com");
175+
lastValueFrom(a).then(value => console.log(value));
176+
`,
177+
},
178+
{
179+
messageId: 'suggestFirstValueFrom',
180+
output: stripIndent`
181+
// rxjs not already imported
182+
import { fromFetch } from "rxjs/fetch";
183+
import { firstValueFrom } from "rxjs";
184+
185+
const a = fromFetch("https://api.some.com");
186+
firstValueFrom(a).then(value => console.log(value));
187+
`,
188+
},
189+
],
190+
},
191+
),
192+
fromFixture(
193+
stripIndent`
194+
// namespace import
195+
import * as Rx from "rxjs";
196+
const a = Rx.of("a");
197+
a.toPromise().then(value => console.log(value));
198+
~~~~~~~~~ [forbidden suggest 0 1]
199+
`,
200+
{
201+
suggestions: [
202+
{
203+
messageId: 'suggestLastValueFrom',
204+
output: stripIndent`
205+
// namespace import
206+
import * as Rx from "rxjs";
207+
const a = Rx.of("a");
208+
Rx.lastValueFrom(a).then(value => console.log(value));
209+
`,
210+
},
211+
{
212+
messageId: 'suggestFirstValueFrom',
213+
output: stripIndent`
214+
// namespace import
215+
import * as Rx from "rxjs";
216+
const a = Rx.of("a");
217+
Rx.firstValueFrom(a).then(value => console.log(value));
218+
`,
219+
},
220+
],
221+
},
42222
),
43223
],
44224
});

0 commit comments

Comments
 (0)