Skip to content

Commit d415a3f

Browse files
committed
feat(linter): introduce noMisusedPromises (#6275)
1 parent e40b9a9 commit d415a3f

19 files changed

Lines changed: 577 additions & 120 deletions

File tree

.changeset/muddy-mules-meander.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@biomejs/biome": minor
3+
---
4+
5+
Added the rule [`noMisusedPromises`](https://biomejs.dev/linter/rules/no-misused-promises/).
6+
7+
It signals `Promise`s in places where conditionals or iterables are expected.
8+
9+
## Invalid examples
10+
11+
```ts
12+
const promise = Promise.resolve('value');
13+
14+
// Using a `Promise` as conditional is always truthy:
15+
if (promise) { /* ... */ }
16+
17+
// Spreading a `Promise` has no effect:
18+
console.log({ foo: 42, ...promise });
19+
```
20+
21+
## Valid examples
22+
23+
```ts
24+
const promise = Promise.resolve('value');
25+
26+
if (await promise) { /* ... */ }
27+
28+
console.log({ foo: 42, ...(await promise) });
29+
```

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 98 additions & 73 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ define_categories! {
163163
"lint/nursery/noInvalidGridAreas": "https://biomejs.dev/linter/rules/use-consistent-grid-areas",
164164
"lint/nursery/noInvalidPositionAtImportRule": "https://biomejs.dev/linter/rules/no-invalid-position-at-import-rule",
165165
"lint/nursery/noMissingGenericFamilyKeyword": "https://biomejs.dev/linter/rules/no-missing-generic-family-keyword",
166+
"lint/nursery/noMisusedPromises": "https://biomejs.dev/linter/rules/no-misused-promises",
166167
"lint/nursery/noNestedComponentDefinitions": "https://biomejs.dev/linter/rules/no-nested-component-definitions",
167168
"lint/nursery/noNoninteractiveElementInteractions": "https://biomejs.dev/linter/rules/no-noninteractive-element-interactions",
168169
"lint/nursery/noProcessGlobal": "https://biomejs.dev/linter/rules/no-process-global",

crates/biome_js_analyze/src/ast_utils.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use biome_js_semantic::{BindingExtensions, SemanticModel};
22
use biome_js_syntax::{
33
AnyJsArrayElement, AnyJsExpression, AnyJsLiteralExpression, AnyJsTemplateElement,
4-
JsAssignmentOperator, JsLanguage, JsLogicalOperator, JsSyntaxNode, JsSyntaxToken,
5-
JsUnaryOperator,
4+
JsArrowFunctionExpression, JsAssignmentOperator, JsFunctionDeclaration, JsLanguage,
5+
JsLogicalOperator, JsMethodClassMember, JsMethodObjectMember, JsSyntaxKind, JsSyntaxNode,
6+
JsSyntaxToken, JsUnaryOperator,
67
};
7-
use biome_rowan::{AstNode, AstSeparatedList, TriviaPiece};
8+
use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TriviaPiece};
89

910
/// Add any leading and trailing trivia from given source node to the token.
1011
///
@@ -317,6 +318,41 @@ fn get_boolean_value(node: &AnyJsLiteralExpression) -> bool {
317318
}
318319
}
319320

321+
/// Checks if the given `JsExpressionStatement` is within an async function.
322+
///
323+
/// This function traverses up the syntax tree from the given expression node
324+
/// to find the nearest function and checks if it is an async function. It
325+
/// supports arrow functions, function declarations, class methods, and object
326+
/// methods.
327+
///
328+
/// # Arguments
329+
///
330+
/// * `node` - A reference to a `JsExpressionStatement` to check.
331+
///
332+
/// # Returns
333+
///
334+
/// * `true` if the expression is within an async function.
335+
/// * `false` otherwise.
336+
pub fn is_in_async_function(node: &JsSyntaxNode) -> bool {
337+
node.ancestors()
338+
.find_map(|ancestor| match ancestor.kind() {
339+
JsSyntaxKind::JS_ARROW_FUNCTION_EXPRESSION => ancestor
340+
.cast::<JsArrowFunctionExpression>()
341+
.and_then(|func| func.async_token()),
342+
JsSyntaxKind::JS_FUNCTION_DECLARATION => ancestor
343+
.cast::<JsFunctionDeclaration>()
344+
.and_then(|func| func.async_token()),
345+
JsSyntaxKind::JS_METHOD_CLASS_MEMBER => ancestor
346+
.cast::<JsMethodClassMember>()
347+
.and_then(|method| method.async_token()),
348+
JsSyntaxKind::JS_METHOD_OBJECT_MEMBER => ancestor
349+
.cast::<JsMethodObjectMember>()
350+
.and_then(|method| method.async_token()),
351+
_ => None,
352+
})
353+
.is_some()
354+
}
355+
320356
#[cfg(test)]
321357
mod tests {
322358
use biome_js_parser::JsParserOptions;

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod no_destructured_props;
1010
pub mod no_floating_promises;
1111
pub mod no_global_dirname_filename;
1212
pub mod no_import_cycles;
13+
pub mod no_misused_promises;
1314
pub mod no_nested_component_definitions;
1415
pub mod no_noninteractive_element_interactions;
1516
pub mod no_process_global;
@@ -41,4 +42,4 @@ pub mod use_single_js_doc_asterisk;
4142
pub mod use_sorted_classes;
4243
pub mod use_symbol_description;
4344
pub mod use_unique_element_ids;
44-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_await_in_loop :: NoAwaitInLoop , self :: no_bitwise_operators :: NoBitwiseOperators , self :: no_constant_binary_expression :: NoConstantBinaryExpression , self :: no_destructured_props :: NoDestructuredProps , self :: no_floating_promises :: NoFloatingPromises , self :: no_global_dirname_filename :: NoGlobalDirnameFilename , self :: no_import_cycles :: NoImportCycles , self :: no_nested_component_definitions :: NoNestedComponentDefinitions , self :: no_noninteractive_element_interactions :: NoNoninteractiveElementInteractions , self :: no_process_global :: NoProcessGlobal , self :: no_react_prop_assign :: NoReactPropAssign , self :: no_restricted_elements :: NoRestrictedElements , self :: no_secrets :: NoSecrets , self :: no_shadow :: NoShadow , self :: no_ts_ignore :: NoTsIgnore , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unwanted_polyfillio :: NoUnwantedPolyfillio , self :: no_useless_backref_in_regex :: NoUselessBackrefInRegex , self :: no_useless_escape_in_string :: NoUselessEscapeInString , self :: no_useless_undefined :: NoUselessUndefined , self :: use_adjacent_getter_setter :: UseAdjacentGetterSetter , self :: use_consistent_object_definition :: UseConsistentObjectDefinition , self :: use_consistent_response :: UseConsistentResponse , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_exports_last :: UseExportsLast , self :: use_for_component :: UseForComponent , self :: use_google_font_preconnect :: UseGoogleFontPreconnect , self :: use_index_of :: UseIndexOf , self :: use_iterable_callback_return :: UseIterableCallbackReturn , self :: use_json_import_attribute :: UseJsonImportAttribute , self :: use_numeric_separators :: UseNumericSeparators , self :: use_object_spread :: UseObjectSpread , self :: use_parse_int_radix :: UseParseIntRadix , self :: use_single_js_doc_asterisk :: UseSingleJsDocAsterisk , self :: use_sorted_classes :: UseSortedClasses , self :: use_symbol_description :: UseSymbolDescription , self :: use_unique_element_ids :: UseUniqueElementIds ,] } }
45+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_await_in_loop :: NoAwaitInLoop , self :: no_bitwise_operators :: NoBitwiseOperators , self :: no_constant_binary_expression :: NoConstantBinaryExpression , self :: no_destructured_props :: NoDestructuredProps , self :: no_floating_promises :: NoFloatingPromises , self :: no_global_dirname_filename :: NoGlobalDirnameFilename , self :: no_import_cycles :: NoImportCycles , self :: no_misused_promises :: NoMisusedPromises , self :: no_nested_component_definitions :: NoNestedComponentDefinitions , self :: no_noninteractive_element_interactions :: NoNoninteractiveElementInteractions , self :: no_process_global :: NoProcessGlobal , self :: no_react_prop_assign :: NoReactPropAssign , self :: no_restricted_elements :: NoRestrictedElements , self :: no_secrets :: NoSecrets , self :: no_shadow :: NoShadow , self :: no_ts_ignore :: NoTsIgnore , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unwanted_polyfillio :: NoUnwantedPolyfillio , self :: no_useless_backref_in_regex :: NoUselessBackrefInRegex , self :: no_useless_escape_in_string :: NoUselessEscapeInString , self :: no_useless_undefined :: NoUselessUndefined , self :: use_adjacent_getter_setter :: UseAdjacentGetterSetter , self :: use_consistent_object_definition :: UseConsistentObjectDefinition , self :: use_consistent_response :: UseConsistentResponse , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_exports_last :: UseExportsLast , self :: use_for_component :: UseForComponent , self :: use_google_font_preconnect :: UseGoogleFontPreconnect , self :: use_index_of :: UseIndexOf , self :: use_iterable_callback_return :: UseIterableCallbackReturn , self :: use_json_import_attribute :: UseJsonImportAttribute , self :: use_numeric_separators :: UseNumericSeparators , self :: use_object_spread :: UseObjectSpread , self :: use_parse_int_radix :: UseParseIntRadix , self :: use_single_js_doc_asterisk :: UseSingleJsDocAsterisk , self :: use_sorted_classes :: UseSortedClasses , self :: use_symbol_description :: UseSymbolDescription , self :: use_unique_element_ids :: UseUniqueElementIds ,] } }

crates/biome_js_analyze/src/lint/nursery/no_floating_promises.rs

Lines changed: 4 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@ use biome_analyze::{
33
};
44
use biome_console::markup;
55
use biome_js_factory::make;
6-
use biome_js_syntax::{
7-
AnyJsExpression, JsArrowFunctionExpression, JsExpressionStatement, JsFunctionDeclaration,
8-
JsMethodClassMember, JsMethodObjectMember, JsSyntaxKind,
9-
};
10-
use biome_rowan::{AstNode, AstSeparatedList, BatchMutationExt, SyntaxNodeCast, TriviaPieceKind};
6+
use biome_js_syntax::{AnyJsExpression, JsExpressionStatement, JsSyntaxKind};
7+
use biome_rowan::{AstNode, AstSeparatedList, BatchMutationExt, TriviaPieceKind};
118

12-
use crate::{JsRuleAction, services::typed::Typed};
9+
use crate::{JsRuleAction, ast_utils::is_in_async_function, services::typed::Typed};
1310

1411
declare_lint_rule! {
1512
/// Require Promise-like statements to be handled appropriately.
@@ -212,7 +209,7 @@ impl Rule for NoFloatingPromises {
212209
fn action(ctx: &RuleContext<Self>, _: &Self::State) -> Option<JsRuleAction> {
213210
let node = ctx.query();
214211

215-
if !is_in_async_function(node) {
212+
if !is_in_async_function(node.syntax()) {
216213
return None;
217214
}
218215

@@ -291,39 +288,3 @@ fn is_handled_promise(expression: AnyJsExpression) -> Option<bool> {
291288

292289
Some(false)
293290
}
294-
295-
/// Checks if the given `JsExpressionStatement` is within an async function.
296-
///
297-
/// This function traverses up the syntax tree from the given expression node
298-
/// to find the nearest function and checks if it is an async function. It
299-
/// supports arrow functions, function declarations, class methods, and object
300-
/// methods.
301-
///
302-
/// # Arguments
303-
///
304-
/// * `node` - A reference to a `JsExpressionStatement` to check.
305-
///
306-
/// # Returns
307-
///
308-
/// * `true` if the expression is within an async function.
309-
/// * `false` otherwise.
310-
fn is_in_async_function(node: &JsExpressionStatement) -> bool {
311-
node.syntax()
312-
.ancestors()
313-
.find_map(|ancestor| match ancestor.kind() {
314-
JsSyntaxKind::JS_ARROW_FUNCTION_EXPRESSION => ancestor
315-
.cast::<JsArrowFunctionExpression>()
316-
.and_then(|func| func.async_token()),
317-
JsSyntaxKind::JS_FUNCTION_DECLARATION => ancestor
318-
.cast::<JsFunctionDeclaration>()
319-
.and_then(|func| func.async_token()),
320-
JsSyntaxKind::JS_METHOD_CLASS_MEMBER => ancestor
321-
.cast::<JsMethodClassMember>()
322-
.and_then(|method| method.async_token()),
323-
JsSyntaxKind::JS_METHOD_OBJECT_MEMBER => ancestor
324-
.cast::<JsMethodObjectMember>()
325-
.and_then(|method| method.async_token()),
326-
_ => None,
327-
})
328-
.is_some()
329-
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
use biome_analyze::{
2+
FixKind, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_js_factory::make;
6+
use biome_js_syntax::{AnyJsExpression, JsSyntaxKind};
7+
use biome_rowan::{AstNode, BatchMutationExt, TriviaPieceKind};
8+
9+
use crate::{JsRuleAction, ast_utils::is_in_async_function, services::typed::Typed};
10+
11+
declare_lint_rule! {
12+
/// Disallow Promises to be used in places where they are almost certainly a
13+
/// mistake.
14+
///
15+
/// In most cases, if you assign a `Promise` somewhere a `Promise` is not
16+
/// allowed, the TypeScript compiler will be able to catch such a mistake.
17+
/// But there are a few places where TypeScript allows them -- they're not
18+
/// _necessarily_ a mistake -- even though they could be considered almost
19+
/// certainly to be one.
20+
///
21+
/// This rule disallows using Promises in such places.
22+
///
23+
/// ## Examples
24+
///
25+
/// ### Invalid
26+
///
27+
/// ```js
28+
/// const promise = Promise.resolve('value');
29+
/// if (promise) { /* Do something */ }
30+
/// ```
31+
///
32+
/// ```js
33+
/// const promise = Promise.resolve('value');
34+
/// const val = promise ? 123 : 456;
35+
/// ```
36+
///
37+
/// ```js
38+
/// const promise = Promise.resolve('value');
39+
/// [1, 2, 3].filter(() => promise);
40+
/// ```
41+
///
42+
/// ```js
43+
/// const promise = Promise.resolve('value');
44+
/// while (promise) { /* Do something */ }
45+
/// ```
46+
///
47+
/// ```js
48+
/// const getData = () => fetch('/');
49+
/// console.log({ foo: 42, ...getData() });
50+
/// ```
51+
///
52+
/// ### Valid
53+
///
54+
/// ```js
55+
/// const promise = Promise.resolve('value');
56+
/// if (await promise) { /* Do something */ }
57+
///
58+
/// const val = (await promise) ? 123 : 456;
59+
///
60+
/// while (await promise) { /* Do something */ }
61+
///
62+
/// const getData = () => fetch('/');
63+
/// console.log({ foo: 42, ...(await getData()) });
64+
/// ```
65+
///
66+
pub NoMisusedPromises {
67+
version: "next",
68+
name: "noMisusedPromises",
69+
language: "ts",
70+
recommended: true,
71+
sources: &[RuleSource::EslintTypeScript("no-misused-promises")],
72+
fix_kind: FixKind::Unsafe,
73+
domains: &[RuleDomain::Project],
74+
}
75+
}
76+
77+
pub enum NoMisusedPromisesState {
78+
Conditional,
79+
Spread,
80+
}
81+
82+
impl Rule for NoMisusedPromises {
83+
type Query = Typed<AnyJsExpression>;
84+
type State = NoMisusedPromisesState;
85+
type Signals = Option<Self::State>;
86+
type Options = ();
87+
88+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
89+
let expression = ctx.query();
90+
91+
let parent = expression.syntax().parent()?;
92+
let state = match parent.kind() {
93+
JsSyntaxKind::JS_CONDITIONAL_EXPRESSION => NoMisusedPromisesState::Conditional,
94+
JsSyntaxKind::JS_DO_WHILE_STATEMENT => NoMisusedPromisesState::Conditional,
95+
JsSyntaxKind::JS_IF_STATEMENT => NoMisusedPromisesState::Conditional,
96+
JsSyntaxKind::JS_SPREAD => NoMisusedPromisesState::Spread,
97+
JsSyntaxKind::JS_WHILE_STATEMENT => NoMisusedPromisesState::Conditional,
98+
_ => return None,
99+
};
100+
101+
let ty = ctx.type_for_expression(expression);
102+
103+
// Uncomment the following line for debugging convenience:
104+
//let printed = format!("type of {expression:?} = {ty:?}");
105+
let should_signal = ty.is_promise_instance()
106+
|| matches!(state, NoMisusedPromisesState::Spread)
107+
&& ty.has_variant(|ty| ty.is_promise_instance());
108+
109+
should_signal.then_some(state)
110+
}
111+
112+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
113+
let node = ctx.query();
114+
match state {
115+
NoMisusedPromisesState::Conditional => Some(
116+
RuleDiagnostic::new(
117+
rule_category!(),
118+
node.range(),
119+
markup! {
120+
"A Promise was found where a conditional was expected."
121+
},
122+
)
123+
.note(markup! {
124+
"A Promise is always truthy, so this is most likely a mistake."
125+
})
126+
.note(markup! {
127+
"You may have intended to `await` the Promise instead."
128+
}),
129+
),
130+
NoMisusedPromisesState::Spread => Some(
131+
RuleDiagnostic::new(
132+
rule_category!(),
133+
node.range(),
134+
markup! {
135+
"A Promise was found where an iterable was expected."
136+
},
137+
)
138+
.note(markup! {
139+
"The spread syntax is used to expand an iterable, but a "
140+
"Promise needs to be `await`-ed to take its value."
141+
}),
142+
),
143+
}
144+
}
145+
146+
fn action(ctx: &RuleContext<Self>, _: &Self::State) -> Option<JsRuleAction> {
147+
let expression = ctx.query();
148+
149+
if !is_in_async_function(expression.syntax()) {
150+
return None;
151+
}
152+
153+
let mut mutation = ctx.root().begin();
154+
let await_expression = AnyJsExpression::JsAwaitExpression(make::js_await_expression(
155+
make::token(JsSyntaxKind::AWAIT_KW)
156+
.with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
157+
expression.clone().trim_leading_trivia()?,
158+
));
159+
160+
mutation.replace_node(expression.clone(), await_expression);
161+
Some(JsRuleAction::new(
162+
ctx.metadata().action_category(ctx.category(), ctx.group()),
163+
ctx.metadata().applicability(),
164+
markup! { "Add await operator." }.to_owned(),
165+
mutation,
166+
))
167+
}
168+
}

crates/biome_js_analyze/src/options.rs

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const promise = Promise.resolve('value');
2+
3+
if (promise) {
4+
// Do something
5+
}
6+
7+
const val = promise ? 123 : 456;
8+
9+
// FIXME: Not yet detected
10+
[1, 2, 3].filter(() => promise);
11+
12+
while (promise) {
13+
// Do something
14+
}

0 commit comments

Comments
 (0)