From cf006b6b4d1410767b27f816bcda954af567b59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BCp=20Can=20Akman?= Date: Thu, 19 Mar 2026 13:22:51 +0300 Subject: [PATCH] fix(checker): support optional chaining on slice operations Allow nil?.[from:to] to return nil instead of erroring with "cannot slice unknown". Add Optional field to SliceNode, propagate it through parser, checker, and compiler using the same pattern as MemberNode optional chaining. Fixes #822 --- ast/node.go | 7 +++--- ast/print.go | 12 ++++++--- checker/checker.go | 3 +++ compiler/compiler.go | 4 +++ parser/parser.go | 24 ++++++++++++++---- test/issues/822/issue_test.go | 46 +++++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 test/issues/822/issue_test.go diff --git a/ast/node.go b/ast/node.go index fbb9ae82..3ed49560 100644 --- a/ast/node.go +++ b/ast/node.go @@ -168,9 +168,10 @@ type MemberNode struct { // array[1:4] type SliceNode struct { base - Node Node // Node of the slice. Like "array" in "array[1:4]". - From Node // From an index of the array. Like "1" in "array[1:4]". - To Node // To an index of the array. Like "4" in "array[1:4]". + Node Node // Node of the slice. Like "array" in "array[1:4]". + From Node // From an index of the array. Like "1" in "array[1:4]". + To Node // To an index of the array. Like "4" in "array[1:4]". + Optional bool // If true then the slice access is optional. Like "foo?.[1:4]". } // CallNode represents a function or a method call. diff --git a/ast/print.go b/ast/print.go index 1c197445..0c32fa69 100644 --- a/ast/print.go +++ b/ast/print.go @@ -162,16 +162,20 @@ func (n *MemberNode) String() string { } func (n *SliceNode) String() string { + op := "" + if n.Optional { + op = "?." + } if n.From == nil && n.To == nil { - return fmt.Sprintf("%s[:]", n.Node.String()) + return fmt.Sprintf("%s%s[:]", n.Node.String(), op) } if n.From == nil { - return fmt.Sprintf("%s[:%s]", n.Node.String(), n.To.String()) + return fmt.Sprintf("%s%s[:%s]", n.Node.String(), op, n.To.String()) } if n.To == nil { - return fmt.Sprintf("%s[%s:]", n.Node.String(), n.From.String()) + return fmt.Sprintf("%s%s[%s:]", n.Node.String(), op, n.From.String()) } - return fmt.Sprintf("%s[%s:%s]", n.Node.String(), n.From.String(), n.To.String()) + return fmt.Sprintf("%s%s[%s:%s]", n.Node.String(), op, n.From.String(), n.To.String()) } func (n *CallNode) String() string { diff --git a/checker/checker.go b/checker/checker.go index 3620f207..0aa59629 100644 --- a/checker/checker.go +++ b/checker/checker.go @@ -613,6 +613,9 @@ func (v *Checker) sliceNode(node *ast.SliceNode) Nature { case reflect.String, reflect.Array, reflect.Slice: // ok default: + if node.Optional { + return Nature{} + } return v.error(node, "cannot slice %s", nt.String()) } diff --git a/compiler/compiler.go b/compiler/compiler.go index f66cf9ed..cda972be 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -760,6 +760,10 @@ func (c *compiler) MemberNode(node *ast.MemberNode) { func (c *compiler) SliceNode(node *ast.SliceNode) { c.compile(node.Node) + if node.Optional && len(c.chains) > 0 { + ph := c.emit(OpJumpIfNil, placeholder) + c.chains[len(c.chains)-1] = append(c.chains[len(c.chains)-1], ph) + } if node.To != nil { c.compile(node.To) c.derefInNeeded(node.To) diff --git a/parser/parser.go b/parser/parser.go index 9e24a71e..60f78160 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -848,12 +848,19 @@ func (p *Parser) parsePostfixExpression(node Node) Node { } node = p.createNode(&SliceNode{ - Node: node, - To: to, + Node: node, + To: to, + Optional: optional, }, postfixToken.Location) if node == nil { return nil } + if optional { + node = p.createNode(&ChainNode{Node: node}, postfixToken.Location) + if node == nil { + return nil + } + } p.expect(Bracket, "]") } else { @@ -868,13 +875,20 @@ func (p *Parser) parsePostfixExpression(node Node) Node { } node = p.createNode(&SliceNode{ - Node: node, - From: from, - To: to, + Node: node, + From: from, + To: to, + Optional: optional, }, postfixToken.Location) if node == nil { return nil } + if optional { + node = p.createNode(&ChainNode{Node: node}, postfixToken.Location) + if node == nil { + return nil + } + } p.expect(Bracket, "]") } else { diff --git a/test/issues/822/issue_test.go b/test/issues/822/issue_test.go new file mode 100644 index 00000000..c276d314 --- /dev/null +++ b/test/issues/822/issue_test.go @@ -0,0 +1,46 @@ +package issue_test + +import ( + "testing" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/require" +) + +func TestIssue822(t *testing.T) { + t.Run("nil optional slice returns nil", func(t *testing.T) { + program, err := expr.Compile(`let x = nil; x?.[0:1]`) + require.NoError(t, err) + + out, err := expr.Run(program, nil) + require.NoError(t, err) + require.Nil(t, out) + }) + + t.Run("non-nil optional slice works normally", func(t *testing.T) { + program, err := expr.Compile(`let x = [1, 2, 3]; x?.[0:2]`) + require.NoError(t, err) + + out, err := expr.Run(program, nil) + require.NoError(t, err) + require.Equal(t, []interface{}{1, 2}, out) + }) + + t.Run("nil optional slice without from", func(t *testing.T) { + program, err := expr.Compile(`let x = nil; x?.[:1]`) + require.NoError(t, err) + + out, err := expr.Run(program, nil) + require.NoError(t, err) + require.Nil(t, out) + }) + + t.Run("nil optional slice without to", func(t *testing.T) { + program, err := expr.Compile(`let x = nil; x?.[1:]`) + require.NoError(t, err) + + out, err := expr.Run(program, nil) + require.NoError(t, err) + require.Nil(t, out) + }) +}