Skip to content

Commit c14c3e6

Browse files
feat(no-misused-observables): check void func args
1 parent 95c53fd commit c14c3e6

File tree

3 files changed

+141
-5
lines changed

3 files changed

+141
-5
lines changed

src/etc/get-type-services.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function getTypeServices<
3838
|| ts.isMethodDeclaration(tsNode)
3939
|| ts.isFunctionExpression(tsNode)
4040
) {
41-
tsTypeNode = tsNode.type ?? tsNode.body;
41+
tsTypeNode = tsNode.type ?? tsNode.body; // TODO(#57): this doesn't work for Block bodies.
4242
} else if (
4343
ts.isCallSignatureDeclaration(tsNode)
4444
|| ts.isMethodSignature(tsNode)

src/rules/no-misused-observables.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { TSESLint } from '@typescript-eslint/utils';
1+
import { TSESTree as es, ESLintUtils, TSESLint } from '@typescript-eslint/utils';
2+
import * as tsutils from 'ts-api-utils';
3+
import ts from 'typescript';
24
import { getTypeServices } from '../etc';
35
import { ruleCreator } from '../utils';
46

@@ -39,12 +41,23 @@ export const noMisusedObservablesRule = ruleCreator({
3941
},
4042
name: 'no-misused-observables',
4143
create: (context) => {
42-
const { couldBeObservable } = getTypeServices(context);
44+
const { program, esTreeNodeToTSNodeMap } = ESLintUtils.getParserServices(context);
45+
const checker = program.getTypeChecker();
46+
const { couldBeObservable, couldReturnObservable } = getTypeServices(context);
4347
const [config = {}] = context.options;
4448
const { checksVoidReturn = true, checksSpreads = true } = config;
4549

4650
const voidReturnChecks: TSESLint.RuleListener = {
47-
51+
CallExpression: checkArguments,
52+
// NewExpression: checkArguments,
53+
// JSXAttribute: checkJSXAttribute,
54+
// ClassDeclaration: checkClassLikeOrInterfaceNode,
55+
// ClassExpression: checkClassLikeOrInterfaceNode,
56+
// TSInterfaceDeclaration: checkClassLikeOrInterfaceNode,
57+
// Property: checkProperty,
58+
// ReturnStatement: checkReturnStatement,
59+
// AssignmentExpression: checkAssignment,
60+
// VariableDeclarator: checkVariableDeclarator,
4861
};
4962

5063
const spreadChecks: TSESLint.RuleListener = {
@@ -58,9 +71,75 @@ export const noMisusedObservablesRule = ruleCreator({
5871
},
5972
};
6073

74+
function checkArguments(node: es.CallExpression | es.NewExpression): void {
75+
const tsNode = esTreeNodeToTSNodeMap.get(node);
76+
const voidArgs = voidFunctionArguments(checker, tsNode);
77+
if (!voidArgs.size) {
78+
return;
79+
}
80+
81+
for (const [index, argument] of node.arguments.entries()) {
82+
if (!voidArgs.has(index)) {
83+
continue;
84+
}
85+
86+
if (couldReturnObservable(argument)) {
87+
context.report({
88+
messageId: 'forbiddenVoidReturnArgument',
89+
node: argument,
90+
});
91+
}
92+
}
93+
}
94+
6195
return {
6296
...(checksVoidReturn ? voidReturnChecks : {}),
6397
...(checksSpreads ? spreadChecks : {}),
6498
};
6599
},
66100
});
101+
102+
function voidFunctionArguments(
103+
checker: ts.TypeChecker,
104+
tsNode: ts.CallExpression | ts.NewExpression,
105+
): Set<number> {
106+
// let b = new Object;
107+
if (!tsNode.arguments) {
108+
return new Set<number>();
109+
}
110+
111+
const voidReturnIndices = new Set<number>();
112+
const type = checker.getTypeAtLocation(tsNode.expression);
113+
114+
for (const subType of tsutils.unionTypeParts(type)) {
115+
const signatures = ts.isCallExpression(tsNode)
116+
? subType.getCallSignatures()
117+
: subType.getConstructSignatures();
118+
for (const signature of signatures) {
119+
for (const [index, parameter] of signature.parameters.entries()) {
120+
const type = checker.getTypeOfSymbolAtLocation(parameter, tsNode.expression);
121+
if (isVoidReturningFunctionType(type)) {
122+
voidReturnIndices.add(index);
123+
}
124+
}
125+
}
126+
}
127+
128+
return voidReturnIndices;
129+
}
130+
131+
function isVoidReturningFunctionType(
132+
type: ts.Type,
133+
): boolean {
134+
let hasVoidReturn = false;
135+
136+
for (const subType of tsutils.unionTypeParts(type)) {
137+
for (const signature of subType.getCallSignatures()) {
138+
const returnType = signature.getReturnType();
139+
140+
hasVoidReturn ||= tsutils.isTypeFlagSet(returnType, ts.TypeFlags.Void);
141+
}
142+
}
143+
144+
return hasVoidReturn;
145+
}

tests/rules/no-misused-observables.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ import { ruleTester } from '../rule-tester';
55

66
ruleTester({ types: true }).run('no-misused-observables', noMisusedObservablesRule, {
77
valid: [
8+
{
9+
code: stripIndent`
10+
// void return argument; explicitly allowed
11+
import { Observable, of } from "rxjs";
12+
13+
[1, 2, 3].forEach((i): Observable<number> => { return of(i); });
14+
[1, 2, 3].forEach(i => of(i));
15+
`,
16+
options: [{ checksVoidReturn: false }],
17+
},
18+
stripIndent`
19+
// void return argument; unrelated
20+
[1, 2, 3].forEach(i => i);
21+
[1, 2, 3].forEach(i => { return i; });
22+
`,
23+
stripIndent`
24+
// couldReturnType is bugged for block body implicit return types (#57)
25+
import { of } from "rxjs";
26+
27+
[1, 2, 3].forEach(i => { return of(i); });
28+
`,
829
{
930
code: stripIndent`
1031
// spread; explicitly allowed
@@ -16,12 +37,48 @@ ruleTester({ types: true }).run('no-misused-observables', noMisusedObservablesRu
1637
options: [{ checksSpreads: false }],
1738
},
1839
stripIndent`
19-
// unrelated
40+
// spread; unrelated
2041
const foo = { bar: 42 };
2142
const baz = { ...foo };
2243
`,
2344
],
2445
invalid: [
46+
fromFixture(
47+
stripIndent`
48+
// void return argument; block body
49+
import { Observable, of } from "rxjs";
50+
51+
[1, 2, 3].forEach((i): Observable<number> => { return of(i); });
52+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [forbiddenVoidReturnArgument]
53+
`,
54+
),
55+
fromFixture(
56+
stripIndent`
57+
// void return argument; inline body
58+
import { of } from "rxjs";
59+
60+
[1, 2, 3].forEach(i => of(i));
61+
~~~~~~~~~~ [forbiddenVoidReturnArgument]
62+
`,
63+
),
64+
fromFixture(
65+
stripIndent`
66+
// void return argument; block body; union return
67+
import { Observable, of } from "rxjs";
68+
69+
[1, 2, 3].forEach((i): Observable<number> | number => { if (i > 1) { return of(i); } else { return i; } });
70+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [forbiddenVoidReturnArgument]
71+
`,
72+
),
73+
fromFixture(
74+
stripIndent`
75+
// void return argument; inline body; union return
76+
import { of } from "rxjs";
77+
78+
[1, 2, 3].forEach(i => i > 1 ? of(i) : i);
79+
~~~~~~~~~~~~~~~~~~~~~~ [forbiddenVoidReturnArgument]
80+
`,
81+
),
2582
fromFixture(
2683
stripIndent`
2784
// spread variable

0 commit comments

Comments
 (0)