diff --git a/internal/collect/collect.go b/internal/collect/collect.go index bf622ed8..b3fe637d 100644 --- a/internal/collect/collect.go +++ b/internal/collect/collect.go @@ -325,16 +325,26 @@ func (c *collector) record(args []ast.Vertex, base Record, sc scope) { continue } - typeName := c.argType(arg.Expr, sc) - if typeName == "" { - continue + for _, typeName := range c.argTypes(arg.Expr, sc) { + record := base + record.Position = position + record.Type = typeName + c.records = append(c.records, record) } + } +} - record := base - record.Position = position - record.Type = typeName - c.records = append(c.records, record) +// argTypes returns the type(s) an argument contributes. A coalesce expression +// (`$x ?? null`) contributes the types of both sides, so `... ?? null` makes the +// parameter nullable; every other expression contributes at most one type. +func (c *collector) argTypes(expr ast.Vertex, sc scope) []string { + if coalesce, ok := expr.(*ast.ExprBinaryCoalesce); ok { + return append(c.argTypes(coalesce.Left, sc), c.argTypes(coalesce.Right, sc)...) + } + if typeName := c.argType(expr, sc); typeName != "" { + return []string{typeName} } + return nil } // argType returns the type of an argument value as a fully qualified object @@ -346,6 +356,13 @@ func (c *collector) argType(expr ast.Vertex, sc scope) string { return "object:" + class } + switch expr.(type) { + case *ast.ExprMethodCall, *ast.ExprNullsafeMethodCall: + if class := c.newChainClass(expr); class != "" { + return "object:" + class + } + } + typeName := c.symbols.TypeOfExpr(expr, sc.class) if !strings.HasPrefix(typeName, "object:") { return typeName @@ -372,6 +389,32 @@ func (c *collector) objectFQCN(expr ast.Vertex, sc scope) string { return c.names[classNode] } +// newChainClass returns the fully qualified class of the object a method chain +// is rooted at, when that root is a `new X()`. Fluent methods are assumed to +// return their receiver, so `new X()->modify(...)` has type X. Empty when the +// chain is not rooted at a new, or the class is self/static/parent. +func (c *collector) newChainClass(expr ast.Vertex) string { + for { + switch typed := expr.(type) { + case *ast.ExprMethodCall: + expr = typed.Var + case *ast.ExprNullsafeMethodCall: + expr = typed.Var + case *ast.ExprNew: + switch strings.ToLower(phpast.ShortName(typed.Class)) { + case "", "self", "static", "parent": + return "" + } + if fqcn := c.names[typed.Class]; fqcn != "" { + return fqcn + } + return phpast.ShortName(typed.Class) + default: + return "" + } + } +} + // argClass resolves the fully qualified class of a variable or `$this->prop` // argument: `$this`, a typed parameter, a `new X()` local, or a typed property. func argClass(expr ast.Vertex, sc scope) string { diff --git a/internal/collect/collect_test.go b/internal/collect/collect_test.go index 07c78c59..132a98e8 100644 --- a/internal/collect/collect_test.go +++ b/internal/collect/collect_test.go @@ -103,6 +103,29 @@ func TestFromSource(t *testing.T) { src: "set(new \\DateTime()->modify('+2 hours')); }\n}", + want: []collect.Record{{Class: "A", Name: "set", Position: 0, Type: "object:DateTime"}}, + }, + { + name: "nullsafe method chain rooted at new resolves to receiver class", + src: "set(new \\DateTime()?->modify('+2 hours')); }\n}", + want: []collect.Record{{Class: "A", Name: "set", Position: 0, Type: "object:DateTime"}}, + }, + { + name: "coalesce contributes both sides", + src: "set('x' ?? null); }\n}", + want: []collect.Record{ + {Class: "A", Name: "set", Position: 0, Type: "string"}, + {Class: "A", Name: "set", Position: 0, Type: "null"}, + }, + }, + { + name: "coalesce with unresolvable left contributes null only", + src: "set($e['k'] ?? null); }\n}", + want: []collect.Record{{Class: "A", Name: "set", Position: 0, Type: "null"}}, + }, { name: "skips parent static call", src: "