Skip to content
Merged
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
47 changes: 40 additions & 7 deletions pkg/typescope/typescope.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,20 @@ 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
// simple assignment to its enclosing scope, so later resolution sees a variable's
// 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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
129 changes: 129 additions & 0 deletions pkg/typescope/typescope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<?php
class OrderService
{
public function boot()
{
$local = $this->getUser();
}

private function getUser(): User
{
return $this->user;
}
}`,
expectedType: "User",
resolved: true,
},
{
name: "a nullable return keeps the inner type",
source: `<?php
class OrderService
{
public function boot()
{
$local = $this->getUser();
}

private function getUser(): ?User
{
}
}`,
expectedType: "User",
resolved: true,
},
{
name: "a scalar return",
source: `<?php
class OrderService
{
public function boot()
{
$local = $this->name();
}

private function name(): string
{
}
}`,
expectedType: "string",
resolved: true,
},
{
name: "a union return is unresolved",
source: `<?php
class OrderService
{
public function boot()
{
$local = $this->getUser();
}

private function getUser(): User|Admin
{
}
}`,
resolved: false,
},
{
name: "a method with no return type is unresolved",
source: `<?php
class OrderService
{
public function boot()
{
$local = $this->getUser();
}

private function getUser()
{
}
}`,
resolved: false,
},
{
name: "a call on another object is unresolved",
source: `<?php
class OrderService
{
public function boot($other)
{
$local = $other->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)
}
})
}
}
Loading