Skip to content

Commit

Permalink
Add nil coalescing operator
Browse files Browse the repository at this point in the history
  • Loading branch information
antonmedv committed Feb 3, 2023
1 parent 4c29199 commit 7b5f72b
Show file tree
Hide file tree
Showing 13 changed files with 170 additions and 14 deletions.
18 changes: 15 additions & 3 deletions checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ func Check(tree *parser.Tree, config *conf.Config) (t reflect.Type, err error) {
}
default:
if t != nil {
if t.Kind() == reflect.Interface {
t = t.Elem()
}
if t.Kind() == v.config.Expect {
return t, nil
}
Expand Down Expand Up @@ -358,6 +355,21 @@ func (v *visitor) BinaryNode(node *ast.BinaryNode) (reflect.Type, info) {
return ret, info{}
}

case "??":
if l == nil && r != nil {
return r, info{}
}
if l != nil && r == nil {
return l, info{}
}
if l == nil && r == nil {
return nilType, info{}
}
if r.AssignableTo(l) {
return l, info{}
}
return anyType, info{}

default:
return v.error(node, "unknown operator (%v)", node.Operator)

Expand Down
2 changes: 2 additions & 0 deletions checker/checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ var successTests = []string{
"Duration + Any == Time",
"Any + Duration == Time",
"Any.A?.B == nil",
"(Any.Bool ?? Bool) > 0",
"Bool ?? Bool",
}

func TestCheck(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,13 @@ func (c *compiler) BinaryNode(node *ast.BinaryNode) {
c.compile(node.Right)
c.emit(OpRange)

case "??":
c.compile(node.Left)
end := c.emit(OpJumpIfNotNil, placeholder)
c.emit(OpPop)
c.compile(node.Right)
c.patchJump(end)

default:
panic(fmt.Sprintf("unknown operator (%v)", node.Operator))

Expand Down
19 changes: 19 additions & 0 deletions compiler/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,25 @@ func TestCompile(t *testing.T) {
Arguments: []int{0, 1, 0, 2},
},
},
{
`A ?? 1`,
vm.Program{
Constants: []interface{}{
&runtime.Field{
Index: []int{0},
Path: []string{"A"},
},
1,
},
Bytecode: []vm.Opcode{
vm.OpLoadField,
vm.OpJumpIfNotNil,
vm.OpPop,
vm.OpPush,
},
Arguments: []int{0, 2, 0, 1},
},
},
}

for _, test := range tests {
Expand Down
11 changes: 10 additions & 1 deletion docs/Language-Definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ d>
<tr>
<td>Conditional</td>
<td>
<code>?:</code> (ternary)
<code>?:</code> (ternary), <code>??</code> (nil coalescing)
</td>
</tr>
<tr>
Expand Down Expand Up @@ -147,6 +147,15 @@ without checking if the struct or the map is `nil`. If the struct or the map is
author?.User?.Name
```

#### Nil coalescing

The `??` operator can be used to return the left-hand side if it is not `nil`,
otherwise the right-hand side is returned.

```c++
author?.User?.Name ?? "Anonymous"
```

### Slice Operator

The slice operator `[:]` can be used to access a slice of an array.
Expand Down
36 changes: 36 additions & 0 deletions expr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1710,6 +1710,42 @@ func TestFunction(t *testing.T) {
assert.Equal(t, 20, out)
}

// Nil coalescing operator
func TestRun_NilCoalescingOperator(t *testing.T) {
env := map[string]interface{}{
"foo": map[string]interface{}{
"bar": "value",
},
}

t.Run("value", func(t *testing.T) {
p, err := expr.Compile(`foo.bar ?? "default"`, expr.Env(env))
assert.NoError(t, err)

out, err := expr.Run(p, env)
assert.NoError(t, err)
assert.Equal(t, "value", out)
})

t.Run("default", func(t *testing.T) {
p, err := expr.Compile(`foo.baz ?? "default"`, expr.Env(env))
assert.NoError(t, err)

out, err := expr.Run(p, env)
assert.NoError(t, err)
assert.Equal(t, "default", out)
})

t.Run("default with chain", func(t *testing.T) {
p, err := expr.Compile(`foo?.bar ?? "default"`, expr.Env(env))
assert.NoError(t, err)

out, err := expr.Run(p, map[string]interface{}{})
assert.NoError(t, err)
assert.Equal(t, "default", out)
})
}

// Mock types

type mockEnv struct {
Expand Down
9 changes: 9 additions & 0 deletions parser/lexer/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@ var lexTests = []lexTest{
{Kind: EOF},
},
},
{
`foo ?? bar`,
[]Token{
{Kind: Identifier, Value: "foo"},
{Kind: Operator, Value: "??"},
{Kind: Identifier, Value: "bar"},
{Kind: EOF},
},
},
}

func compareTokens(i1, i2 []Token) bool {
Expand Down
2 changes: 1 addition & 1 deletion parser/lexer/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func not(l *lexer) stateFn {
}

func questionMark(l *lexer) stateFn {
l.accept(".")
l.accept(".?")
l.emit(Operator)
return root
}
Expand Down
30 changes: 21 additions & 9 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ var binaryOperators = map[string]operator{
"%": {60, left},
"**": {100, right},
"^": {100, right},
"??": {500, left},
}

var builtins = map[string]builtin{
Expand Down Expand Up @@ -113,9 +114,13 @@ func Parse(input string) (*Tree, error) {
}

func (p *parser) error(format string, args ...interface{}) {
p.errorAt(p.current, format, args...)
}

func (p *parser) errorAt(token Token, format string, args ...interface{}) {
if p.err == nil { // show first error
p.err = &file.Error{
Location: p.current.Location,
Location: token.Location,
Message: fmt.Sprintf(format, args...),
}
}
Expand Down Expand Up @@ -143,22 +148,28 @@ func (p *parser) expect(kind Kind, values ...string) {
func (p *parser) parseExpression(precedence int) Node {
nodeLeft := p.parsePrimary()

token := p.current
for token.Is(Operator) && p.err == nil {
lastOperator := ""
opToken := p.current
for opToken.Is(Operator) && p.err == nil {
negate := false
var notToken Token

if token.Is(Operator, "not") {
if opToken.Is(Operator, "not") {
p.next()
notToken = p.current
negate = true
token = p.current
opToken = p.current
}

if op, ok := binaryOperators[token.Value]; ok {
if op, ok := binaryOperators[opToken.Value]; ok {
if op.precedence >= precedence {
p.next()

if lastOperator == "??" && opToken.Value != "??" && !opToken.Is(Bracket, "(") {
p.errorAt(opToken, "Operator (%v) and coalesce expressions (??) cannot be mixed. Wrap either by parentheses.", opToken.Value)
break
}

var nodeRight Node
if op.associativity == left {
nodeRight = p.parseExpression(op.precedence + 1)
Expand All @@ -167,11 +178,11 @@ func (p *parser) parseExpression(precedence int) Node {
}

nodeLeft = &BinaryNode{
Operator: token.Value,
Operator: opToken.Value,
Left: nodeLeft,
Right: nodeRight,
}
nodeLeft.SetLocation(token.Location)
nodeLeft.SetLocation(opToken.Location)

if negate {
nodeLeft = &UnaryNode{
Expand All @@ -181,7 +192,8 @@ func (p *parser) parseExpression(precedence int) Node {
nodeLeft.SetLocation(notToken.Location)
}

token = p.current
lastOperator = opToken.Value
opToken = p.current
continue
}
}
Expand Down
41 changes: 41 additions & 0 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,42 @@ func TestParse(t *testing.T) {
"[]",
&ArrayNode{},
},
{
"foo ?? bar",
&BinaryNode{Operator: "??",
Left: &IdentifierNode{Value: "foo"},
Right: &IdentifierNode{Value: "bar"}},
},
{
"foo ?? bar ?? baz",
&BinaryNode{Operator: "??",
Left: &BinaryNode{Operator: "??",
Left: &IdentifierNode{Value: "foo"},
Right: &IdentifierNode{Value: "bar"}},
Right: &IdentifierNode{Value: "baz"}},
},
{
"foo ?? (bar || baz)",
&BinaryNode{Operator: "??",
Left: &IdentifierNode{Value: "foo"},
Right: &BinaryNode{Operator: "||",
Left: &IdentifierNode{Value: "bar"},
Right: &IdentifierNode{Value: "baz"}}},
},
{
"foo || bar ?? baz",
&BinaryNode{Operator: "||",
Left: &IdentifierNode{Value: "foo"},
Right: &BinaryNode{Operator: "??",
Left: &IdentifierNode{Value: "bar"},
Right: &IdentifierNode{Value: "baz"}}},
},
{
"foo ?? bar()",
&BinaryNode{Operator: "??",
Left: &IdentifierNode{Value: "foo"},
Right: &CallNode{Callee: &IdentifierNode{Value: "bar"}}},
},
}
for _, test := range parseTests {
actual, err := parser.Parse(test.input)
Expand Down Expand Up @@ -479,6 +515,11 @@ a map key must be a quoted string, a number, a identifier, or an expression encl
unexpected token Operator(",") (1:16)
| {foo:1, bar:2, ,}
| ...............^
foo ?? bar || baz
Operator (||) and coalesce expressions (??) cannot be mixed. Wrap either by parentheses. (1:12)
| foo ?? bar || baz
| ...........^
`

func TestParse_error(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions vm/opcodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
OpJumpIfTrue
OpJumpIfFalse
OpJumpIfNil
OpJumpIfNotNil
OpJumpIfEnd
OpJumpBackward
OpIn
Expand Down
3 changes: 3 additions & 0 deletions vm/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ func (program *Program) Disassemble() string {
case OpJumpIfNil:
jump("OpJumpIfNil")

case OpJumpIfNotNil:
jump("OpJumpIfNotNil")

case OpJumpIfEnd:
jump("OpJumpIfEnd")

Expand Down
5 changes: 5 additions & 0 deletions vm/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ func (vm *VM) Run(program *Program, env interface{}) (_ interface{}, err error)
vm.ip += arg
}

case OpJumpIfNotNil:
if !runtime.IsNil(vm.current()) {
vm.ip += arg
}

case OpJumpIfEnd:
scope := vm.Scope()
if scope.It >= scope.Len {
Expand Down

0 comments on commit 7b5f72b

Please sign in to comment.