Skip to content

Commit 1ad169d

Browse files
authored
parser: parenthesize unary operands that would reparse differently (#1434)
* parser: parenthesize unary operands that would reparse differently visitCallUnary decides whether to parenthesize its operand with isComplexOperator, which only returns true for a call with two or more arguments. It was added by #262 to fix `-(1 + 2)`, and never covered an operand that is itself a unary call or a negative numeric literal. Two grammar rules make that unsound. `unary` parses a run of leading '-' or '!' tokens as one expression and drops the operator when the count is even, and `literal` binds a leading '-' into an int or double constant. So the unparser emits text that either means something else or does not parse: !(!a) -> !!a reparses as a -(-a) -> --a reparses as a -(-(-a)) -> ---a reparses as -a -(!a) -> -!a syntax error !(-a) -> !-a syntax error -(-1) -> --1 reparses as 1 -(-9223372036854775808) -> --9223372036854775808 invalid int literal Since AstToString is the public way to render a checked AST back to source, round-tripping changes evaluation: `-(-x)` with x = MinInt64 must raise integer overflow but returns -9223372036854775808 afterwards, and `!(!a)` on a non-bool must raise no such overload but returns a value. Parenthesize an operand that is a single-argument logical-not or negate call, or a negative int or double literal. math.Signbit is used for doubles so negative zero is covered too. * parser: simplify the unary operand check to the function name The member-function and argument-count guards cannot change the outcome, since the logical-not and negate operator names are unary by definition.
1 parent d9ae4c8 commit 1ad169d

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

parser/unparser.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package parser
1717
import (
1818
"errors"
1919
"fmt"
20+
"math"
2021
"regexp"
2122
"strconv"
2223
"strings"
@@ -270,7 +271,7 @@ func (un *unparser) visitCallUnary(expr ast.Expr) error {
270271
return fmt.Errorf("cannot unmangle operator: %s", fun)
271272
}
272273
un.str.WriteString(unmangled)
273-
nested := isComplexOperator(args[0])
274+
nested := isComplexOperator(args[0]) || isAmbiguousUnaryOperand(args[0])
274275
return un.visitMaybeNested(args[0], nested)
275276
}
276277

@@ -507,6 +508,30 @@ func isComplexOperator(expr ast.Expr) bool {
507508
return false
508509
}
509510

511+
// Indicates whether the operand of a unary operator must be wrapped in parentheses in order for
512+
// the unparsed expression to parse back to an equivalent AST.
513+
//
514+
// The CEL grammar parses a run of leading '!' or '-' tokens as a single unary expression and drops
515+
// the operators when their count is even, and it also treats a leading '-' as part of an int or
516+
// double literal. Emitting such an operand without parentheses either changes the expression, e.g.
517+
// `!(!a)` would unparse to `!!a` which parses back to `a`, or produces output which does not parse
518+
// at all, e.g. `-(!a)` would unparse to `-!a`.
519+
func isAmbiguousUnaryOperand(expr ast.Expr) bool {
520+
switch expr.Kind() {
521+
case ast.CallKind:
522+
fun := expr.AsCall().FunctionName()
523+
return fun == operators.LogicalNot || fun == operators.Negate
524+
case ast.LiteralKind:
525+
switch lit := expr.AsLiteral().(type) {
526+
case types.Int:
527+
return lit < 0
528+
case types.Double:
529+
return math.Signbit(float64(lit))
530+
}
531+
}
532+
return false
533+
}
534+
510535
// Indicates whether it is a complex operation compared to another.
511536
// expr is *not* considered complex if it is not a call expression or has
512537
// less than two arguments, or if it has a higher precedence than op.

parser/unparser_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ func TestUnparse(t *testing.T) {
113113
{name: "select_quoted", in: "a.`b-c`"},
114114
{name: "opt_select_quoted", in: "a.?`b.c`"},
115115
{name: "message_create_quoted", in: "MyType{`in`: false}"},
116+
// A unary operator whose operand is another unary operator, or a negative numeric
117+
// literal, must keep the operand parenthesized. The grammar parses a run of leading
118+
// '!' or '-' tokens as a single unary expression and cancels out pairs of them, and
119+
// it also treats a leading '-' as part of an int or double literal.
120+
{name: "call_not_not", in: `!(!a)`},
121+
{name: "call_neg_neg", in: `-(-a)`},
122+
{name: "call_neg_not", in: `-(!a)`},
123+
{name: "call_not_neg", in: `!(-a)`},
124+
{name: "call_neg_neg_neg", in: `-(-(-a))`},
125+
{name: "call_neg_lit_int", in: `-(-1)`},
126+
{name: "call_neg_lit_int_min", in: `-(-9223372036854775808)`},
127+
{name: "call_neg_lit_double", in: `-(-1.5)`},
128+
{name: "call_neg_lit_int_nested", in: `1 + -(-1)`},
116129

117130
// Equivalent expressions form unparse which do not match the originals.
118131
{name: "call_add_equiv", in: `a+b-c`, out: `a + b - c`},

0 commit comments

Comments
 (0)