fix(parser): reject partially parenthesized mixed coalesce expressions (#26394)

The parser currently accepts mixed nullish and logical expressions when parentheses cover only the first operand of the logical expression:

```js
(a) || b ?? c;
a ?? (b) || c;
(a) && b ?? c;
a ?? (b) && c;
```

These expressions require a syntax error because `??` cannot be mixed directly with `||` or `&&`. Parentheses around `a` or `b` do not group the complete logical expression. This change reports the existing mixed-coalesce diagnostic for all four forms.

Fully grouped operands remain valid:

```js
(a || b) ?? c;
a ?? (b || c);
(a && b) ?? c;
a ?? (b && c);
```

### Why the existing check misses these expressions

`parse_binary_expression_rest` used `lhs_parenthesized` and `rhs_parenthesized` flags captured before parsing an operand. Those flags describe whether the initial token is `(`, but the parser can subsequently accumulate a larger logical expression outside those parentheses. Applying the initial flag to that complete expression incorrectly suppresses the diagnostic.

### Complete-operand checks

Check both operands independently for an `And` or `Or` logical expression whose span starts at the operand's starting position. A logical expression accumulated outside initial parentheses starts at that position and must be rejected. Checking both operands also prevents a grouped right operand from hiding an invalid left operand.

This works with either `preserve_parens` setting. When parentheses are preserved, a fully grouped operand is a `ParenthesizedExpression`. When they are omitted, its inner logical expression retains a span beginning after the opening `(`, so its start differs from the operand start and it remains allowed. For `(a) || b`, the complete logical expression instead starts at the opening `(` and is correctly rejected.
This commit is contained in:
camc314
2026-09-07 14:34:52 +00:00
parent 60fa13878c
commit b20fc1950c
16 changed files with 87 additions and 26 deletions
+8 -13
View File
@@ -1391,7 +1391,7 @@ impl<'a, C: Config> ParserImpl<'a, C> {
}
self.bump_any(); // bump operator
let rhs_parenthesized = self.at(Kind::LParen);
let rhs_start = self.cur_start();
let rhs = self.parse_binary_expression_or_higher(left_precedence);
lhs = if kind.is_logical_operator() {
@@ -1399,18 +1399,13 @@ impl<'a, C: Config> ParserImpl<'a, C> {
let op = map_logical_operator(kind);
// check mixed coalesce
if op == LogicalOperator::Coalesce {
let mut maybe_mixed_coalesce_expr = None;
if let Expression::LogicalExpression(rhs) = &rhs {
if !rhs_parenthesized {
maybe_mixed_coalesce_expr = Some(rhs);
}
} else if let Expression::LogicalExpression(lhs) = &lhs
&& !lhs_parenthesized
{
maybe_mixed_coalesce_expr = Some(lhs);
}
if let Some(expr) = maybe_mixed_coalesce_expr
&& matches!(expr.operator, LogicalOperator::And | LogicalOperator::Or)
let is_unparenthesized_logical = |expr: &Expression<'a>, start: u32| {
matches!(expr, Expression::LogicalExpression(expr)
if expr.span.start == start
&& matches!(expr.operator, LogicalOperator::And | LogicalOperator::Or))
};
if is_unparenthesized_logical(&lhs, lhs_start)
|| is_unparenthesized_logical(&rhs, rhs_start)
{
self.error(diagnostics::mixed_coalesce(span));
}
@@ -0,0 +1 @@
a && b ?? (c);
@@ -0,0 +1 @@
a || b ?? (c);
@@ -0,0 +1 @@
a ?? b && (c);
@@ -0,0 +1 @@
a ?? b || (c);
@@ -0,0 +1 @@
(a) && b ?? c;
@@ -0,0 +1 @@
(a) || b ?? c;
@@ -0,0 +1 @@
a ?? (b) && c;
@@ -0,0 +1 @@
a ?? (b) || c;
@@ -0,0 +1,2 @@
a && b || (c);
a || b && (c);
+2 -2
View File
@@ -1,3 +1,3 @@
codegen_misc Summary:
AST Parsed : 84/84 (100.00%)
Positive Passed: 84/84 (100.00%)
AST Parsed : 85/85 (100.00%)
Positive Passed: 85/85 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
formatter_misc Summary:
AST Parsed : 84/84 (100.00%)
Positive Passed: 84/84 (100.00%)
AST Parsed : 85/85 (100.00%)
Positive Passed: 85/85 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
lexer_misc Summary:
AST Parsed : 261/261 (100.00%)
Positive Passed: 261/261 (100.00%)
AST Parsed : 270/270 (100.00%)
Positive Passed: 270/270 (100.00%)
+59 -3
View File
@@ -1,7 +1,7 @@
parser_misc Summary:
AST Parsed : 84/84 (100.00%)
Positive Passed: 84/84 (100.00%)
Negative Passed: 177/177 (100.00%)
AST Parsed : 85/85 (100.00%)
Positive Passed: 85/85 (100.00%)
Negative Passed: 185/185 (100.00%)
× Cannot assign to 'arguments' in strict mode
╭─[misc/fail/arguments-eval-ambient.ts:2:13]
@@ -201,6 +201,62 @@ Negative Passed: 177/177 (100.00%)
· ╰── `;` expected
╰────
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-final-left-and.js:1:1]
1 │ a && b ?? (c);
· ─────────────
╰────
help: Wrap either expression by parentheses
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-final-left-or.js:1:1]
1 │ a || b ?? (c);
· ─────────────
╰────
help: Wrap either expression by parentheses
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-final-right-and.js:1:1]
1 │ a ?? b && (c);
· ─────────────
╰────
help: Wrap either expression by parentheses
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-final-right-or.js:1:1]
1 │ a ?? b || (c);
· ─────────────
╰────
help: Wrap either expression by parentheses
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-left-and.js:1:1]
1 │ (a) && b ?? c;
· ─────────────
╰────
help: Wrap either expression by parentheses
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-left-or.js:1:1]
1 │ (a) || b ?? c;
· ─────────────
╰────
help: Wrap either expression by parentheses
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-right-and.js:1:1]
1 │ a ?? (b) && c;
· ─────────────
╰────
help: Wrap either expression by parentheses
× Logical expressions and coalesce expressions cannot be mixed
╭─[misc/fail/coalesce-partial-parens-right-or.js:1:1]
1 │ a ?? (b) || c;
· ─────────────
╰────
help: Wrap either expression by parentheses
× Cannot use export statement outside a module
╭─[misc/fail/commonjs-export-statement.cjs:2:1]
1 │ // CommonJS does NOT allow export statements (use module.exports instead)
+2 -2
View File
@@ -1,6 +1,6 @@
semantic_misc Summary:
AST Parsed : 84/84 (100.00%)
Positive Passed: 80/84 (95.24%)
AST Parsed : 85/85 (100.00%)
Positive Passed: 81/85 (95.29%)
semantic Error: tasks/coverage/misc/pass/declare-let-private.ts
Bindings mismatch:
after transform: ScopeId(0): ["private"]
@@ -1,3 +1,3 @@
transformer_misc Summary:
AST Parsed : 84/84 (100.00%)
Positive Passed: 84/84 (100.00%)
AST Parsed : 85/85 (100.00%)
Positive Passed: 85/85 (100.00%)