Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/unused-underlings-underperform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@biomejs/biome": patch
---

Added nursery rule [`noUnusedExpressions`](https://biomejs.dev/linter/rules/no-unused-expressions/) to flag expressions used as a statement that is neither an assignment nor a function call.

#### Invalid examples

```js
f // intended to call `f()` instead
```

```js
function foo() {
0 // intended to `return 0` instead
}
```

#### Valid examples

```js
f()
```

```js
function foo() {
return 0;
}
```
12 changes: 12 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 54 additions & 33 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ define_categories! {
"lint/correctness/useValidTypeof": "https://biomejs.dev/linter/rules/use-valid-typeof",
"lint/correctness/useYield": "https://biomejs.dev/linter/rules/use-yield",
"lint/nursery/noColorInvalidHex": "https://biomejs.dev/linter/rules/no-color-invalid-hex",
"lint/nursery/noDuplicateDependencies": "https://biomejs.dev/linter/rules/no-duplicate-dependencies",
"lint/nursery/noFloatingPromises": "https://biomejs.dev/linter/rules/no-floating-promises",
"lint/nursery/noImplicitCoercion": "https://biomejs.dev/linter/rules/no-implicit-coercion",
"lint/nursery/noImportCycles": "https://biomejs.dev/linter/rules/no-import-cycles",
Expand All @@ -175,6 +176,7 @@ define_categories! {
"lint/nursery/noShadow": "https://biomejs.dev/linter/rules/no-shadow",
"lint/nursery/noUnnecessaryConditions": "https://biomejs.dev/linter/rules/no-unnecessary-conditions",
"lint/nursery/noUnresolvedImports": "https://biomejs.dev/linter/rules/no-unresolved-imports",
"lint/nursery/noUnusedExpressions": "https://biomejs.dev/linter/rules/no-unused-expressions",
"lint/nursery/noUnwantedPolyfillio": "https://biomejs.dev/linter/rules/no-unwanted-polyfillio",
"lint/nursery/noUselessBackrefInRegex": "https://biomejs.dev/linter/rules/no-useless-backref-in-regex",
"lint/nursery/noUselessCatchBinding": "https://biomejs.dev/linter/rules/no-useless-catch-binding",
Expand Down Expand Up @@ -312,7 +314,6 @@ define_categories! {
"lint/suspicious/noDuplicateCase": "https://biomejs.dev/linter/rules/no-duplicate-case",
"lint/suspicious/noDuplicateClassMembers": "https://biomejs.dev/linter/rules/no-duplicate-class-members",
"lint/suspicious/noDuplicateCustomProperties": "https://biomejs.dev/linter/rules/no-duplicate-custom-properties",
"lint/nursery/noDuplicateDependencies": "https://biomejs.dev/linter/rules/no-duplicate-dependencies",
"lint/suspicious/noDuplicateElseIf": "https://biomejs.dev/linter/rules/no-duplicate-else-if",
"lint/suspicious/noDuplicateFields": "https://biomejs.dev/linter/rules/no-duplicate-fields",
"lint/suspicious/noDuplicateFontNames": "https://biomejs.dev/linter/rules/no-font-family-duplicate-names",
Expand Down
3 changes: 2 additions & 1 deletion crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod no_secrets;
pub mod no_shadow;
pub mod no_unnecessary_conditions;
pub mod no_unresolved_imports;
pub mod no_unused_expressions;
pub mod no_useless_catch_binding;
pub mod no_useless_undefined;
pub mod no_vue_data_object_declaration;
Expand All @@ -30,4 +31,4 @@ pub mod use_qwik_classlist;
pub mod use_react_function_components;
pub mod use_sorted_classes;
pub mod use_vue_multi_word_component_names;
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChain , self :: no_qwik_use_visible_task :: NoQwikUseVisibleTask , self :: no_secrets :: NoSecrets , self :: no_shadow :: NoShadow , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_anchor_href :: UseAnchorHref , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_consistent_type_definitions :: UseConsistentTypeDefinitions , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_image_size :: UseImageSize , self :: use_max_params :: UseMaxParams , self :: use_qwik_classlist :: UseQwikClasslist , self :: use_react_function_components :: UseReactFunctionComponents , self :: use_sorted_classes :: UseSortedClasses , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChain , self :: no_qwik_use_visible_task :: NoQwikUseVisibleTask , self :: no_secrets :: NoSecrets , self :: no_shadow :: NoShadow , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_anchor_href :: UseAnchorHref , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_consistent_type_definitions :: UseConsistentTypeDefinitions , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_image_size :: UseImageSize , self :: use_max_params :: UseMaxParams , self :: use_qwik_classlist :: UseQwikClasslist , self :: use_react_function_components :: UseReactFunctionComponents , self :: use_sorted_classes :: UseSortedClasses , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
301 changes: 301 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/no_unused_expressions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
use std::borrow::Cow;

use biome_analyze::{
Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_js_syntax::{
AnyJsExpression, AnyJsFunction, AnyJsLiteralExpression, AnyJsRoot, JsBlockStatement,
JsExpressionStatement, JsSyntaxNode, JsUnaryOperator, TsModuleBlock,
};
use biome_rowan::{AstNode, SyntaxResult};
use biome_rule_options::no_unused_expressions::NoUnusedExpressionsOptions;

declare_lint_rule! {
/// Disallow expression statements that are neither a function call nor an
/// assignment.
///
/// When an expression is used as a statement, it should be explicitly clear
/// what the intention behind the expression is. This is clear for function
/// calls and assignments, because the call or the assignment itself is the
/// primary intention behind the statement. For other expression kinds, the
/// intention is much more ambiguous; it could be the expression contains
/// side-effects that are not very explicit, but it could also be that it is
/// an error where the author forgot to use the result of the expression,
/// such as a forgotten `return` keyword, or it could point to a function
/// that the author forgot to call.
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// 0
/// ```
///
/// ```js,expect_diagnostic
/// if(0) 0
/// ```
///
/// ```js,expect_diagnostic
/// {0}
/// ```
///
/// ```js,expect_diagnostic
/// f(0), {}
/// ```
///
/// ```js,expect_diagnostic
/// a && b()
/// ```
///
/// ```js,expect_diagnostic
/// a, b()
/// ```
///
/// ```js,expect_diagnostic
/// c = a, b
/// ```
///
/// ```js,expect_diagnostic
/// a() && function namedFunctionInExpressionContext () {f();}
/// ```
///
/// ```js,expect_diagnostic
/// (function anIncompleteIIFE () {});
/// ```
///
/// ```js,expect_diagnostic
/// injectGlobal`body{ color: red; }`
/// ```
///
/// ```ts,expect_diagnostic
/// Set<number>
/// ```
///
/// ```ts,expect_diagnostic
/// 1 as number
/// ```
///
/// ```ts,expect_diagnostic
/// window!
/// ```
///
/// JSX expressions are considered invalid when used as a statement too:
///
/// ```jsx,expect_diagnostic
/// <MyComponent />
/// ```
///
/// ```jsx,expect_diagnostic
/// <></>
/// ```
///
/// ### Valid
///
/// ```js
/// {} // In this context, this is a block statement, not an object literal
///
/// { myLabel: foo() } // In this context, this is a block statement with a label and expression, not an object literal
///
/// function namedFunctionDeclaration () {}
///
/// (function aGenuineIIFE () {}());
///
/// f()
///
/// a = 0
///
/// new C
///
/// delete a.b
///
/// void a
/// ```
///
/// ### Handling of Directives
///
/// Any stand-alone string at the start of a script, module, or function is
/// considered a directive and is therefore allowed.
///
/// ```js
/// "use strict";
/// "use asm"
/// "use stricter";
/// "use babel"
/// "any other strings like this in the directive prologue";
/// "this is still the directive prologue";
///
/// function foo() {
/// "bar";
/// }
///
/// class Foo {
/// someMethod() {
/// "use strict";
/// }
/// }
/// ```
///
/// The following are **not** considered valid directives:
///
/// ```js,expect_diagnostic
/// doSomething();
/// "use strict"; // this isn't in a directive prologue, because there is a non-directive statement before it
/// ```
///
/// ```js,expect_diagnostic
/// function foo() {
/// "bar" + 1;
/// }
/// ```
///
/// ```js,expect_diagnostic
/// class Foo {
/// static {
/// "use strict"; // class static blocks do not have directive prologues
/// }
/// }
/// ```
pub NoUnusedExpressions {
version: "next",
name: "noUnusedExpressions",
language: "js",
sources: &[RuleSource::Eslint("no-unused-expressions").same()],
recommended: false,
}
}

impl Rule for NoUnusedExpressions {
type Query = Ast<JsExpressionStatement>;
type State = ();
type Signals = Option<Self::State>;
type Options = NoUnusedExpressionsOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();

if is_directive(node) {
return None;
}

is_disallowed(&node.expression().ok()?).ok()?.then_some(())
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"Expected an assignment or function call but found an expression instead."
},
)
.note(markup! {
"This expression may be unintentionally unused or it might be a function that you forgot to call."
}),
)
}
}

/// Determines if a node should be treated as a directive.
///
/// Only string literals are treated as directives, and only if they are not
/// preceeded by any non-directive nodes.
fn is_directive(node: &JsExpressionStatement) -> bool {
if node
.syntax()
.parent()
.is_some_and(|parent| !is_at_top_level(&parent))
{
return false;
}

let mut node = Cow::Borrowed(node);
loop {
if !looks_like_directive(&node) {
break false;
}

let Some(prev_node) = node.syntax().prev_sibling() else {
break true;
};
match JsExpressionStatement::cast(prev_node) {
Some(prev_expr) => node = Cow::Owned(prev_expr),
None => break false,
}
}
}

/// Determines if a node exists at the top-level based on its `parent` node.
fn is_at_top_level(parent: &JsSyntaxNode) -> bool {
AnyJsRoot::can_cast(parent.kind())
|| TsModuleBlock::can_cast(parent.kind())
|| JsBlockStatement::cast_ref(parent).is_some_and(|block| {
block
.syntax()
.parent()
.is_some_and(|parent: JsSyntaxNode| AnyJsFunction::can_cast(parent.kind()))
})
}
Comment on lines +231 to +241
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Don’t treat TS namespace/module blocks as directive contexts.

Directive prologues exist only for Script/Module roots and Function bodies. Allowing them inside TsModuleBlock deviates from JS semantics and can hide unused string expressions in namespace/module bodies.

Apply this diff:

 fn is_at_top_level(parent: &JsSyntaxNode) -> bool {
     AnyJsRoot::can_cast(parent.kind())
-        || TsModuleBlock::can_cast(parent.kind())
         || JsBlockStatement::cast_ref(parent).is_some_and(|block| {
             block
                 .syntax()
                 .parent()
                 .is_some_and(|parent: JsSyntaxNode| AnyJsFunction::can_cast(parent.kind()))
         })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Determines if a node exists at the top-level based on its `parent` node.
fn is_at_top_level(parent: &JsSyntaxNode) -> bool {
AnyJsRoot::can_cast(parent.kind())
|| TsModuleBlock::can_cast(parent.kind())
|| JsBlockStatement::cast_ref(parent).is_some_and(|block| {
block
.syntax()
.parent()
.is_some_and(|parent: JsSyntaxNode| AnyJsFunction::can_cast(parent.kind()))
})
}
/// Determines if a node exists at the top-level based on its `parent` node.
fn is_at_top_level(parent: &JsSyntaxNode) -> bool {
AnyJsRoot::can_cast(parent.kind())
|| JsBlockStatement::cast_ref(parent).is_some_and(|block| {
block
.syntax()
.parent()
.is_some_and(|parent: JsSyntaxNode| AnyJsFunction::can_cast(parent.kind()))
})
}
🤖 Prompt for AI Agents
In crates/biome_js_analyze/src/lint/nursery/no_unused_expressions.rs around
lines 231-241, the current is_at_top_level function incorrectly treats
TsModuleBlock as a directive-context; remove the
TsModuleBlock::can_cast(parent.kind()) check so only AnyJsRoot and function-body
block checks count as top-level/directive contexts, ensuring namespace/module
bodies are not treated as directive prologues.


fn looks_like_directive(node: &JsExpressionStatement) -> bool {
matches!(
node.expression(),
Ok(AnyJsExpression::AnyJsLiteralExpression(
AnyJsLiteralExpression::JsStringLiteralExpression(_)
))
)
}

fn is_disallowed(expr: &AnyJsExpression) -> SyntaxResult<bool> {
let is_disallowed = match expr {
AnyJsExpression::AnyJsLiteralExpression(_)
| AnyJsExpression::JsArrayExpression(_)
| AnyJsExpression::JsArrowFunctionExpression(_)
| AnyJsExpression::JsBinaryExpression(_)
| AnyJsExpression::JsClassExpression(_)
| AnyJsExpression::JsComputedMemberExpression(_)
| AnyJsExpression::JsConditionalExpression(_)
| AnyJsExpression::JsFunctionExpression(_)
| AnyJsExpression::JsIdentifierExpression(_)
| AnyJsExpression::JsImportMetaExpression(_)
| AnyJsExpression::JsInExpression(_)
| AnyJsExpression::JsInstanceofExpression(_)
| AnyJsExpression::JsLogicalExpression(_)
| AnyJsExpression::JsNewTargetExpression(_)
| AnyJsExpression::JsObjectExpression(_)
| AnyJsExpression::JsSequenceExpression(_)
| AnyJsExpression::JsStaticMemberExpression(_)
| AnyJsExpression::JsSuperExpression(_)
| AnyJsExpression::JsTemplateExpression(_)
| AnyJsExpression::JsThisExpression(_)
| AnyJsExpression::JsxTagExpression(_) => true,
AnyJsExpression::JsAwaitExpression(_)
| AnyJsExpression::JsAssignmentExpression(_)
| AnyJsExpression::JsCallExpression(_)
| AnyJsExpression::JsImportCallExpression(_)
| AnyJsExpression::JsNewExpression(_)
| AnyJsExpression::JsYieldExpression(_)
| AnyJsExpression::JsPostUpdateExpression(_)
| AnyJsExpression::JsPreUpdateExpression(_)
| AnyJsExpression::JsBogusExpression(_)
| AnyJsExpression::JsMetavariable(_) => false,
AnyJsExpression::JsParenthesizedExpression(expr) => is_disallowed(&expr.expression()?)?,
AnyJsExpression::JsUnaryExpression(expr) => match expr.operator()? {
JsUnaryOperator::BitwiseNot
| JsUnaryOperator::LogicalNot
| JsUnaryOperator::Minus
| JsUnaryOperator::Plus
| JsUnaryOperator::Typeof => true,
JsUnaryOperator::Delete | JsUnaryOperator::Void => false,
},
AnyJsExpression::TsAsExpression(expr) => is_disallowed(&expr.expression()?)?,
AnyJsExpression::TsInstantiationExpression(expr) => is_disallowed(&expr.expression()?)?,
AnyJsExpression::TsNonNullAssertionExpression(expr) => is_disallowed(&expr.expression()?)?,
AnyJsExpression::TsSatisfiesExpression(expr) => is_disallowed(&expr.expression()?)?,
AnyJsExpression::TsTypeAssertionExpression(expr) => is_disallowed(&expr.expression()?)?,
};
Ok(is_disallowed)
}
Loading
Loading