@@ -6,7 +6,8 @@ use biome_js_factory::make;
66use biome_js_semantic:: { BindingExtensions , SemanticModel } ;
77use biome_js_syntax:: {
88 AnyJsExpression , AnyJsMemberExpression , AnyJsName , JsBinaryExpression , JsBinaryOperator ,
9- JsLogicalExpression , JsLogicalOperator , JsUnaryOperator , OperatorPrecedence , T ,
9+ JsLogicalExpression , JsLogicalOperator , JsUnaryExpression , JsUnaryOperator , OperatorPrecedence ,
10+ T ,
1011} ;
1112use biome_rowan:: { AstNode , AstNodeExt , BatchMutationExt , SyntaxResult } ;
1213use 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
99107pub 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.
462539enum 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}
0 commit comments