Skip to content

Commit b7ab931

Browse files
ematipicodyc3
andauthored
fix(useOptionalChain): fix negated expressions (#9567)
Co-authored-by: dyc3 <1808807+dyc3@users.noreply.github.com>
1 parent 97b80a8 commit b7ab931

File tree

6 files changed

+559
-6
lines changed

6 files changed

+559
-6
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [#7211](https://github.com/biomejs/biome/issues/7211): [`useOptionalChain`](https://biomejs.dev/linter/rules/use-optional-chain/) now detects negated logical OR chains. The following code is now considered invalid:
6+
7+
```js
8+
!foo || !foo.bar
9+
```

crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs

Lines changed: 222 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ use biome_js_factory::make;
66
use biome_js_semantic::{BindingExtensions, SemanticModel};
77
use biome_js_syntax::{
88
AnyJsExpression, AnyJsMemberExpression, AnyJsName, JsBinaryExpression, JsBinaryOperator,
9-
JsLogicalExpression, JsLogicalOperator, JsUnaryOperator, OperatorPrecedence, T,
9+
JsLogicalExpression, JsLogicalOperator, JsUnaryExpression, JsUnaryOperator, OperatorPrecedence,
10+
T,
1011
};
1112
use biome_rowan::{AstNode, AstNodeExt, BatchMutationExt, SyntaxResult};
1213
use biome_rule_options::use_optional_chain::UseOptionalChainOptions;
@@ -53,6 +54,10 @@ declare_lint_rule! {
5354
/// (((typeof x) as string) || {}).bar;
5455
/// ```
5556
///
57+
/// ```js,expect_diagnostic
58+
/// !foo || !foo.bar
59+
/// ```
60+
///
5661
/// ### Valid
5762
///
5863
/// ```js
@@ -94,6 +99,9 @@ pub struct LogicalAndChainNodes {
9499
/// E.g. in `bar && foo && foo.length`, the prefix is `bar` and the chain
95100
/// produces `foo?.length`, so the final result is `bar && foo?.length`.
96101
prefix: Option<AnyJsExpression>,
102+
/// Whether this chain was derived from a negated `||` chain like `!foo || !foo.bar`.
103+
/// When true, the fix wraps the result in `!` and uses `||` for prefix joining.
104+
negated: bool,
97105
}
98106

99107
pub enum UseOptionalChainState {
@@ -125,6 +133,20 @@ impl Rule for UseOptionalChain {
125133
))
126134
}
127135
JsLogicalOperator::NullishCoalescing | JsLogicalOperator::LogicalOr => {
136+
// Check for negated || chains like `!foo || !foo.bar`
137+
if matches!(operator, JsLogicalOperator::LogicalOr) && is_negated_or_chain(logical)
138+
{
139+
let right = logical.right().ok()?;
140+
let stripped_right = strip_negation(&right)?;
141+
let chain = LogicalAndChain::from_expression(stripped_right).ok()?;
142+
if chain.is_inside_another_negated_chain().unwrap_or(false) {
143+
return None;
144+
}
145+
let chain_nodes =
146+
chain.optional_chain_expression_nodes_from_negated_or(logical, model)?;
147+
return Some(UseOptionalChainState::LogicalAnd(chain_nodes));
148+
}
149+
128150
let chain = LogicalOrLikeChain::from_expression(logical)?;
129151

130152
if chain.is_inside_another_chain() {
@@ -215,16 +237,22 @@ impl Rule for UseOptionalChain {
215237
};
216238

217239
// If there's a prefix (the chain doesn't start at the beginning
218-
// of the `&&` expression), wrap the replacement in a new `&&`
240+
// of the expression), wrap the replacement in a logical expression
219241
// with the prefix on the left.
220242
// E.g. `bar && foo && foo.length` → `bar && foo?.length`
243+
// E.g. `!bar || !foo || !foo.length` → `!bar || !foo?.length`
221244
let replacement = if let Some(prefix) = &chain_nodes.prefix {
222-
let and_token = logical.operator_token().ok()?;
245+
let op_token = logical.operator_token().ok()?;
246+
let join_token = if chain_nodes.negated {
247+
make::token(T![||])
248+
} else {
249+
make::token(T![&&])
250+
};
223251
AnyJsExpression::from(make::js_logical_expression(
224252
prefix.clone(),
225-
make::token(T![&&])
226-
.with_leading_trivia_pieces(and_token.leading_trivia().pieces())
227-
.with_trailing_trivia_pieces(and_token.trailing_trivia().pieces()),
253+
join_token
254+
.with_leading_trivia_pieces(op_token.leading_trivia().pieces())
255+
.with_trailing_trivia_pieces(op_token.trailing_trivia().pieces()),
228256
replacement,
229257
))
230258
} else {
@@ -457,6 +485,55 @@ fn extract_optional_chain_like_typeof(
457485
Ok(None)
458486
}
459487

488+
/// If the expression is `!expr` (possibly parenthesized), returns the inner expression.
489+
fn strip_negation(expression: &AnyJsExpression) -> Option<AnyJsExpression> {
490+
let expr = expression.clone().omit_parentheses();
491+
let unary = expr.as_js_unary_expression()?;
492+
if unary.operator().ok()? == JsUnaryOperator::LogicalNot {
493+
Some(unary.argument().ok()?.omit_parentheses())
494+
} else {
495+
None
496+
}
497+
}
498+
499+
/// Walks a `||` chain and verifies ALL leaf operands are `!expr`.
500+
/// Returns `false` if any operand is not negated or if mixed operators are found.
501+
fn is_negated_or_chain(logical: &JsLogicalExpression) -> bool {
502+
if logical
503+
.right()
504+
.ok()
505+
.as_ref()
506+
.and_then(strip_negation)
507+
.is_none()
508+
{
509+
return false;
510+
}
511+
let mut current = logical.left().ok();
512+
while let Some(expr) = current.take() {
513+
match expr {
514+
AnyJsExpression::JsLogicalExpression(inner) => {
515+
if !matches!(inner.operator(), Ok(JsLogicalOperator::LogicalOr)) {
516+
return false;
517+
}
518+
if inner
519+
.right()
520+
.ok()
521+
.as_ref()
522+
.and_then(strip_negation)
523+
.is_none()
524+
{
525+
return false;
526+
}
527+
current = inner.left().ok();
528+
}
529+
other => {
530+
return strip_negation(&other).is_some();
531+
}
532+
}
533+
}
534+
false
535+
}
536+
460537
/// `LogicalAndChainOrdering` is the result of a comparison between two logical
461538
/// AND chains.
462539
enum LogicalAndChainOrdering {
@@ -628,6 +705,34 @@ impl LogicalAndChain {
628705
Ok(false)
629706
}
630707

708+
/// Like `is_inside_another_chain`, but for negated `||` chains.
709+
/// Navigates: head -> parent `!` -> parent `||` -> grandparent `||`.
710+
/// Returns `None` when the parent structure doesn't exist (no grandparent),
711+
/// which means the chain is NOT inside another chain.
712+
fn is_inside_another_negated_chain(&self) -> Option<bool> {
713+
let unary = self.head.parent::<JsUnaryExpression>()?;
714+
let parent = unary.parent::<JsLogicalExpression>()?;
715+
let grand_parent = parent.parent::<JsLogicalExpression>()?;
716+
if !matches!(grand_parent.operator().ok()?, JsLogicalOperator::LogicalOr) {
717+
return Some(false);
718+
}
719+
if grand_parent
720+
.left()
721+
.ok()
722+
.as_ref()
723+
.and_then(|e| e.as_js_logical_expression())
724+
== Some(&parent)
725+
{
726+
let stripped = strip_negation(&grand_parent.right().ok()?)?;
727+
let gp_right_chain = Self::from_expression(stripped).ok()?;
728+
return match gp_right_chain.cmp_chain(self).ok()? {
729+
LogicalAndChainOrdering::SubChain | LogicalAndChainOrdering::Equal => Some(true),
730+
LogicalAndChainOrdering::Different => Some(false),
731+
};
732+
}
733+
Some(false)
734+
}
735+
631736
/// This function compares two `LogicalAndChain` and returns
632737
/// `LogicalAndChainOrdering` by comparing their `token_text_trimmed` for
633738
/// every `JsAnyExpression` node.
@@ -883,6 +988,117 @@ impl LogicalAndChain {
883988
Some(LogicalAndChainNodes {
884989
nodes: optional_chain_expression_nodes,
885990
prefix,
991+
negated: false,
992+
})
993+
}
994+
995+
/// Like `optional_chain_expression_nodes`, but for negated `||` chains.
996+
/// Walks `!a || !a.b || !a.b.c` by stripping `!` from each operand
997+
/// before comparing chains.
998+
fn optional_chain_expression_nodes_from_negated_or(
999+
mut self,
1000+
logical: &JsLogicalExpression,
1001+
model: &SemanticModel,
1002+
) -> Option<LogicalAndChainNodes> {
1003+
let mut optional_chain_expression_nodes = VecDeque::with_capacity(self.buf.len());
1004+
let mut next_chain_head = logical.left().ok();
1005+
let mut prev_branch: Option<Self> = None;
1006+
let mut prefix = None;
1007+
while let Some(expression) = next_chain_head.take() {
1008+
let original_expression = expression.clone();
1009+
let head = match expression {
1010+
AnyJsExpression::JsLogicalExpression(inner_logical) => {
1011+
if matches!(inner_logical.operator().ok()?, JsLogicalOperator::LogicalOr) {
1012+
next_chain_head = inner_logical.left().ok();
1013+
strip_negation(&inner_logical.right().ok()?)?
1014+
} else {
1015+
return None;
1016+
}
1017+
}
1018+
other => strip_negation(&other)?,
1019+
};
1020+
let branch =
1021+
Self::from_expression(normalized_optional_chain_like(head, model).ok()?).ok()?;
1022+
match self.cmp_chain(&branch).ok()? {
1023+
LogicalAndChainOrdering::SubChain => {
1024+
if let Some(mut prev_branch) = prev_branch {
1025+
let mut parts_to_pop = prev_branch.buf.len() - branch.buf.len() - 1;
1026+
while parts_to_pop > 0 {
1027+
if let (Some(left_part), Some(right_part)) =
1028+
(prev_branch.buf.pop_back(), self.buf.pop_back())
1029+
{
1030+
match left_part {
1031+
AnyJsExpression::JsStaticMemberExpression(ref expr)
1032+
if expr
1033+
.operator_token()
1034+
.is_ok_and(|token| token.kind() == T![?.]) =>
1035+
{
1036+
optional_chain_expression_nodes.push_front(right_part);
1037+
}
1038+
AnyJsExpression::JsComputedMemberExpression(ref expr)
1039+
if expr.optional_chain_token().is_some() =>
1040+
{
1041+
optional_chain_expression_nodes.push_front(right_part);
1042+
}
1043+
AnyJsExpression::JsCallExpression(ref expr)
1044+
if expr.optional_chain_token().is_some() =>
1045+
{
1046+
optional_chain_expression_nodes.push_front(right_part);
1047+
}
1048+
_ => {}
1049+
}
1050+
}
1051+
parts_to_pop -= 1;
1052+
}
1053+
}
1054+
let mut tail = self.buf.split_off(branch.buf.len());
1055+
if let Some(part) = tail.pop_front() {
1056+
optional_chain_expression_nodes.push_front(part);
1057+
}
1058+
prev_branch = Some(branch);
1059+
}
1060+
LogicalAndChainOrdering::Equal => {}
1061+
LogicalAndChainOrdering::Different => {
1062+
prefix = Some(original_expression);
1063+
break;
1064+
}
1065+
}
1066+
}
1067+
1068+
if let Some(mut prev_branch) = prev_branch {
1069+
while let (Some(left_part), Some(right_part)) =
1070+
(prev_branch.buf.pop_back(), self.buf.pop_back())
1071+
{
1072+
match left_part {
1073+
AnyJsExpression::JsStaticMemberExpression(ref expr)
1074+
if expr
1075+
.operator_token()
1076+
.is_ok_and(|token| token.kind() == T![?.]) =>
1077+
{
1078+
optional_chain_expression_nodes.push_front(right_part);
1079+
}
1080+
AnyJsExpression::JsComputedMemberExpression(ref expr)
1081+
if expr.optional_chain_token().is_some() =>
1082+
{
1083+
optional_chain_expression_nodes.push_front(right_part);
1084+
}
1085+
AnyJsExpression::JsCallExpression(ref expr)
1086+
if expr.optional_chain_token().is_some() =>
1087+
{
1088+
optional_chain_expression_nodes.push_front(right_part);
1089+
}
1090+
_ => {}
1091+
}
1092+
}
1093+
}
1094+
1095+
if optional_chain_expression_nodes.is_empty() {
1096+
return None;
1097+
}
1098+
Some(LogicalAndChainNodes {
1099+
nodes: optional_chain_expression_nodes,
1100+
prefix,
1101+
negated: true,
8861102
})
8871103
}
8881104
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* should generate diagnostics */
2+
3+
!foo || !foo.bar;
4+
!foo || !foo.bar || !foo.bar.baz;
5+
!foo.bar || !foo.bar.baz;
6+
!foo || !foo.bar || !foo.bar.baz || !foo.bar.baz.buzz;
7+
!foo || !foo[bar];
8+
!foo || !foo[bar] || !foo[bar].baz;
9+
!foo.bar || !foo.bar();
10+
!a.b || !a.b();
11+
(!foo || !foo.bar) && (!baz || !baz.bar);
12+
!foo || !foo?.bar.baz;

0 commit comments

Comments
 (0)