diff --git a/pkg/typescope/typescope.go b/pkg/typescope/typescope.go index 5650292..acab447 100644 --- a/pkg/typescope/typescope.go +++ b/pkg/typescope/typescope.go @@ -35,8 +35,9 @@ import ( // declared type of each of its properties — ready to resolve expression types // against. type Scopes struct { - functionScopes []*functionScope - propertyTypes map[string]ast.Vertex + functionScopes []*functionScope + propertyTypes map[string]ast.Vertex + methodReturnTypes map[string]ast.Vertex } // Collect gathers every function-like scope in a class subtree, attributes each @@ -44,10 +45,10 @@ type Scopes struct { // definitions in the scope they actually belong to, and records the declared type // of every property. func Collect(classNode ast.Vertex) Scopes { - collector := &scopeCollector{Null: &visitor.Null{}, propertyTypes: map[string]ast.Vertex{}} + collector := &scopeCollector{Null: &visitor.Null{}, propertyTypes: map[string]ast.Vertex{}, methodReturnTypes: map[string]ast.Vertex{}} traverser.NewTraverser(collector).Traverse(classNode) - scopes := Scopes{functionScopes: collector.scopes, propertyTypes: collector.propertyTypes} + scopes := Scopes{functionScopes: collector.scopes, propertyTypes: collector.propertyTypes, methodReturnTypes: collector.methodReturnTypes} for _, assignment := range collector.assignments { scope := enclosingScope(assignment.position, scopes) if scope == nil { @@ -253,10 +254,38 @@ func (scopes Scopes) inferValueType(expr ast.Vertex, atPosition *position.Positi return classNameFromNew(node) case *ast.ExprFunctionCall: return builtinCallReturnType(node) + case *ast.ExprMethodCall: + return scopes.resolveMethodReturnType(node) } return "", false } +// resolveMethodReturnType reads the declared return type of a `$this->method()` +// call when the method is declared in the collected class subtree. A call on +// anything but $this, a dynamically named method, or a method whose return type +// is absent, a union, or an intersection is unresolved — there is no single +// native type to report. Method names are matched case-insensitively, as PHP +// resolves them. +func (scopes Scopes) resolveMethodReturnType(call *ast.ExprMethodCall) (string, bool) { + receiver, isVariable := call.Var.(*ast.ExprVariable) + if !isVariable { + return "", false + } + receiverName, named := identifierName(receiver.Name) + if !named || receiverName != "$this" { + return "", false + } + method, ok := identifierName(call.Method) + if !ok { + return "", false + } + returnType, ok := scopes.methodReturnTypes[strings.ToLower(method)] + if !ok || returnType == nil { + return "", false + } + return declaredTypeName(returnType) +} + // builtinCallReturnType resolves a call to an internal PHP function whose return // type is a single, unconditional scalar — `string`, `int`, `float`, or `bool` — // to that type. Only a plain single-part name is read, matched case-insensitively @@ -461,9 +490,10 @@ func scopeWidth(scope *functionScope) int { // whole subtree is walked, so nested-scope assignments land in the nested scope. type scopeCollector struct { *visitor.Null - scopes []*functionScope - assignments []*rawAssignment - propertyTypes map[string]ast.Vertex + scopes []*functionScope + assignments []*rawAssignment + propertyTypes map[string]ast.Vertex + methodReturnTypes map[string]ast.Vertex } // StmtPropertyList records the declared type of every property it declares, so a @@ -503,6 +533,9 @@ type rawAssignment struct { func (collector *scopeCollector) StmtClassMethod(node *ast.StmtClassMethod) { collector.addScope(node.Position, node.Params) + if name, ok := identifierName(node.Name); ok { + collector.methodReturnTypes[strings.ToLower(name)] = node.ReturnType + } } func (collector *scopeCollector) ExprClosure(node *ast.ExprClosure) { diff --git a/pkg/typescope/typescope_test.go b/pkg/typescope/typescope_test.go index 5b8f6bb..ba640a6 100644 --- a/pkg/typescope/typescope_test.go +++ b/pkg/typescope/typescope_test.go @@ -331,3 +331,132 @@ class OrderService }) } } + +// TestResolveTypeMethodReturn covers resolving `$this->method()` to the method's +// declared return type: a single native class or scalar resolves; a missing, +// union, or non-$this return is left alone, and a nullable return keeps its +// inner type. +func TestResolveTypeMethodReturn(test *testing.T) { + tests := []struct { + name string + source string + expectedType string + resolved bool + }{ + { + name: "a class-typed return", + source: `getUser(); + } + + private function getUser(): User + { + return $this->user; + } +}`, + expectedType: "User", + resolved: true, + }, + { + name: "a nullable return keeps the inner type", + source: `getUser(); + } + + private function getUser(): ?User + { + } +}`, + expectedType: "User", + resolved: true, + }, + { + name: "a scalar return", + source: `name(); + } + + private function name(): string + { + } +}`, + expectedType: "string", + resolved: true, + }, + { + name: "a union return is unresolved", + source: `getUser(); + } + + private function getUser(): User|Admin + { + } +}`, + resolved: false, + }, + { + name: "a method with no return type is unresolved", + source: `getUser(); + } + + private function getUser() + { + } +}`, + resolved: false, + }, + { + name: "a call on another object is unresolved", + source: `getUser(); + } + + private function getUser(): User + { + } +}`, + resolved: false, + }, + } + + for _, testCase := range tests { + test.Run(testCase.name, func(test *testing.T) { + class, assign := parseClassAndAssign(test, testCase.source) + scopes := typescope.Collect(class) + + typeName, resolved := scopes.ResolveType(assign.Expr, assign.Position) + if resolved != testCase.resolved { + test.Fatalf("resolved = %v, want %v (type %q)", resolved, testCase.resolved, typeName) + } + if resolved && typeName != testCase.expectedType { + test.Errorf("type = %q, want %q", typeName, testCase.expectedType) + } + }) + } +}