Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ast/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ type BoolNode struct {
// StringNode represents a string.
type StringNode struct {
base
Value string // Value of the string.
Value string // Value of the string.
Optional bool // If true then the property is optional. Like "foo.bar?.baz".
}

// ConstantNode represents a constant.
Expand Down
12 changes: 12 additions & 0 deletions checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,12 @@ func (v *Checker) memberNode(node *ast.MemberNode) Nature {
}

base := v.visit(node.Node)

// If the base is optional and the property is not found, we need to skip the property access.
if base.Skip {
return Nature{Skip: true}
}

prop := v.visit(node.Property)

if base.IsUnknown(&v.config.NtCache) {
Expand Down Expand Up @@ -573,6 +579,12 @@ func (v *Checker) memberNode(node *ast.MemberNode) Nature {
if field, ok := base.FieldByName(&v.config.NtCache, propertyName); ok {
return v.config.NtCache.FromType(field.Type)
}
// If the property access is optional (via ?.) or the property itself is marked optional,
// allow it to be unknown for optional chaining support
if name.Optional || node.Optional {
base.Skip = true
return Nature{Skip: true}
}
if node.Method {
return v.error(node, "type %v has no method %v", base.String(), propertyName)
}
Expand Down
1 change: 1 addition & 0 deletions checker/nature/nature.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type Nature struct {
// - Array-like types: then Ref is the Elem nature of array type (usually Type is []any, but ArrayOf can be any nature).
Ref *Nature

Skip bool // If the property access is optional (via ?.) and the property is not found, skip the error.
Nil bool // If value is nil.
Strict bool // If map is types.StrictMap.
Method bool // If value retrieved from method. Usually used to determine amount of in arguments.
Expand Down
5 changes: 5 additions & 0 deletions compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,11 @@ func (c *compiler) MemberNode(node *ast.MemberNode) {
}

if op == OpFetch {
// If the field is optional, we need to jump over the fetch operation.
if node.Nature().Skip {
c.emit(OpNil)
return
}
c.compile(node.Property)
c.emit(OpFetch)
} else {
Expand Down
43 changes: 43 additions & 0 deletions expr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,49 @@ func TestExpr_optional_chaining_array(t *testing.T) {
assert.Equal(t, nil, got)
}

func TestIssue854_optional_chaining_typechecker(t *testing.T) {
type User struct {
Id int
Name string
Group string
// Profile field is missing
}

env := map[string]any{
"user": User{
Id: 1,
Name: "John Doe",
Group: "admin",
},
}

tests := []struct {
code string
want string
}{
{
code: `user.Profile?.Address ?? "Unknown address"`,
want: "Unknown address",
},
// TODO: This case will be covered in a future update.
// {
// code: `user.Profile[0]?.Address ?? "Unknown address"`,
// want: "Unknown address",
// },
}

for _, tt := range tests {
t.Run(tt.code, func(t *testing.T) {
program, err := expr.Compile(tt.code, expr.Env(env))
require.NoError(t, err)

got, err := expr.Run(program, env)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestExpr_eval_with_env(t *testing.T) {
_, err := expr.Eval("true", expr.Env(map[string]any{}))
assert.Error(t, err)
Expand Down
2 changes: 1 addition & 1 deletion parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ func (p *Parser) parsePostfixExpression(node Node) Node {
p.error("expected name")
}

property := p.createNode(&StringNode{Value: propertyToken.Value}, propertyToken.Location)
property := p.createNode(&StringNode{Value: propertyToken.Value, Optional: p.current.Value == "?."}, propertyToken.Location)
if property == nil {
return nil
}
Expand Down
8 changes: 4 additions & 4 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,7 @@ func TestParse_optional_chaining(t *testing.T) {
Node: &MemberNode{
Node: &MemberNode{
Node: &IdentifierNode{Value: "foo"},
Property: &StringNode{Value: "bar"},
Property: &StringNode{Value: "bar", Optional: true},
},
Property: &StringNode{Value: "baz"},
Optional: true,
Expand All @@ -1076,7 +1076,7 @@ func TestParse_optional_chaining(t *testing.T) {
Node: &MemberNode{
Node: &MemberNode{
Node: &IdentifierNode{Value: "foo"},
Property: &StringNode{Value: "bar"},
Property: &StringNode{Value: "bar", Optional: true},
Optional: true,
},
Property: &StringNode{Value: "baz"},
Expand Down Expand Up @@ -1107,7 +1107,7 @@ func TestParse_optional_chaining(t *testing.T) {
Node: &MemberNode{
Node: &MemberNode{
Node: &IdentifierNode{Value: "foo"},
Property: &StringNode{Value: "bar"},
Property: &StringNode{Value: "bar", Optional: false}, // TODO: Optional chaining for indexed property access should be determined during semantic analysis, not parsing.
},
Property: &ChainNode{
Node: &MemberNode{
Expand All @@ -1128,7 +1128,7 @@ func TestParse_optional_chaining(t *testing.T) {
Node: &MemberNode{
Node: &MemberNode{
Node: &IdentifierNode{Value: "foo"},
Property: &StringNode{Value: "bar"},
Property: &StringNode{Value: "bar", Optional: true},
},
Property: &IntegerNode{Value: 0},
Optional: true,
Expand Down
Loading