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
127 changes: 111 additions & 16 deletions pkg/visitor/classresolver/classresolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,16 @@ import (

// classData holds the resolved information for a single class name.
type classData struct {
parent string // fully qualified parent name, empty when none
interfaces []string // fully qualified names implemented, or extended by an interface
isInterface bool // set for an interface declaration, which is no class to extend
methods map[string][]string // method name -> declared parameter names (with leading $)
methodNodes map[string]*ast.StmtClassMethod // method name -> declaring node, for body inspection
visibility map[string]string // method name -> "public"/"protected"/"private"
traits []string // fully qualified names of the traits used here
properties map[string]bool // property names declared here (no leading $), promotion included
ambiguous bool // set when the name was declared more than once
parent string // fully qualified parent name, empty when none
interfaces []string // fully qualified names implemented, or extended by an interface
isInterface bool // set for an interface declaration, which is no class to extend
methods map[string][]string // method name -> declared parameter names (with leading $)
methodNodes map[string]*ast.StmtClassMethod // method name -> declaring node, for body inspection
visibility map[string]string // method name -> "public"/"protected"/"private"
traits []string // fully qualified names of the traits used here
properties map[string]bool // property names declared here (no leading $), promotion included
propertyVisibility map[string]string // property name -> "public"/"protected"/"private"
ambiguous bool // set when the name was declared more than once
}

// Registry holds resolved class signatures and hierarchy, keyed by fully
Expand Down Expand Up @@ -286,6 +287,38 @@ func (registry *Registry) InheritsVisibleMethod(startFQN, methodName string) (fo
return false, true
}

// InheritsVisibleProperty resolves propertyName starting at startFQN and walking
// up the parent chain, reporting whether an ancestor declares it protected or
// public — the visibilities that a subclass redeclaration may not reduce below,
// exactly as for a method. Pass the parent of the class in question as startFQN,
// so the class's own declaration is excluded.
//
// found is true once such a declaration is met; a private ancestor declaration
// imposes no constraint, so the walk continues past it. resolvable is false when
// a class in the chain is unknown or ambiguous, meaning the ancestors cannot be
// proven — a caller that must stay safe treats that like a constraint. The seen
// set guards against cyclic extends declarations.
func (registry *Registry) InheritsVisibleProperty(startFQN, propertyName string) (found bool, resolvable bool) {
seen := make(map[string]bool)

for startFQN != "" && !seen[startFQN] {
seen[startFQN] = true

data, ok := registry.classes[startFQN]
if !ok || data.ambiguous {
return false, false
}

if visibility := data.propertyVisibility[propertyName]; visibility == "protected" || visibility == "public" {
return true, true
}

startFQN = data.parent
}

return false, true
}

// ExtendedClassNames returns the set of fully qualified names that at least one
// collected class extends, anonymous classes included. A name in this set has a
// child, so it is not a leaf of the class hierarchy. Names that could not be
Expand Down Expand Up @@ -628,13 +661,14 @@ func (collector *classCollector) StmtClass(node *ast.StmtClass) {
}

data := &classData{
methods: make(map[string][]string),
methodNodes: make(map[string]*ast.StmtClassMethod),
visibility: make(map[string]string),
parent: parentFQN,
interfaces: collector.resolvedList(node.Implements),
traits: collector.traitFQNs(node.Stmts),
properties: propertyNamesFromStmts(node.Stmts),
methods: make(map[string][]string),
methodNodes: make(map[string]*ast.StmtClassMethod),
visibility: make(map[string]string),
parent: parentFQN,
interfaces: collector.resolvedList(node.Implements),
traits: collector.traitFQNs(node.Stmts),
properties: propertyNamesFromStmts(node.Stmts),
propertyVisibility: propertyVisibilitiesFromStmts(node.Stmts),
}

for _, stmt := range node.Stmts {
Expand Down Expand Up @@ -764,6 +798,67 @@ func propertyNamesFromStmts(stmts []ast.Vertex) map[string]bool {
return names
}

// propertyVisibilitiesFromStmts records the declared visibility of each property
// a class body declares, keyed by name without the leading $, covering both plain
// property declarations and constructor-promoted parameters. A declaration with
// no visibility modifier defaults to "public", as PHP does.
func propertyVisibilitiesFromStmts(stmts []ast.Vertex) map[string]string {
visibilities := make(map[string]string)
for _, stmt := range stmts {
switch typed := stmt.(type) {
case *ast.StmtPropertyList:
visibility := modifiersVisibility(typed.Modifiers)
for _, property := range typed.Props {
declared, ok := property.(*ast.StmtProperty)
if !ok {
continue
}
if name, ok := propertyVariableName(declared.Var); ok {
visibilities[name] = visibility
}
}
case *ast.StmtClassMethod:
methodName, ok := identifierName(typed.Name)
if !ok || !strings.EqualFold(methodName, "__construct") {
continue
}
for _, parameter := range typed.Params {
promoted, ok := parameter.(*ast.Parameter)
if !ok || len(promoted.Modifiers) == 0 {
continue // a parameter with no modifier promotes nothing
}
if name, ok := propertyVariableName(promoted.Var); ok {
visibilities[name] = modifiersVisibility(promoted.Modifiers)
}
}
}
}
if len(visibilities) == 0 {
return nil
}
return visibilities
}

// modifiersVisibility returns the visibility a modifier list declares, defaulting
// to "public" when none of public/protected/private is present.
func modifiersVisibility(modifiers []ast.Vertex) string {
for _, modifier := range modifiers {
identifier, ok := modifier.(*ast.Identifier)
if !ok {
continue
}
switch strings.ToLower(string(identifier.Value)) {
case "public":
return "public"
case "protected":
return "protected"
case "private":
return "private"
}
}
return "public"
}

// propertyVariableName returns a property variable's name without its $, so it
// compares equal to the names the readonly rule works with.
func propertyVariableName(node ast.Vertex) (string, bool) {
Expand Down
60 changes: 60 additions & 0 deletions pkg/visitor/classresolver/classresolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,66 @@ class Child extends \Vendor\Base {}`)
}
}

func TestRegistry_inheritsVisiblePropertyProtected(test *testing.T) {
registry := parse(test, `<?php
class Base
{
protected $value;
}

class Child extends Base {}`)

found, resolvable := registry.InheritsVisibleProperty("Child", "value")
if !found || !resolvable {
test.Errorf("expected protected ancestor property to be found (found=%v, resolvable=%v)", found, resolvable)
}
}

func TestRegistry_inheritsVisiblePropertyPrivateImposesNoConstraint(test *testing.T) {
registry := parse(test, `<?php
class Base
{
private $value;
}

class Child extends Base {}`)

found, resolvable := registry.InheritsVisibleProperty("Child", "value")
if found || !resolvable {
test.Errorf("expected private ancestor property to impose no constraint (found=%v, resolvable=%v)", found, resolvable)
}
}

func TestRegistry_inheritsVisiblePropertyUnknownAncestor(test *testing.T) {
registry := parse(test, `<?php
class Child extends \Vendor\Base {}`)

if _, resolvable := registry.InheritsVisibleProperty("Vendor\\Base", "value"); resolvable {
test.Error("expected an unknown ancestor to be unresolvable")
}
}

func TestRegistry_inheritsVisiblePropertyPromoted(test *testing.T) {
rootNode, err := parser.Parse([]byte(`<?php
class Base
{
public function __construct(protected string $value) {}
}

class Child extends Base {}`), conf.Config{Version: &version.Version{Major: 8, Minor: 0}})
if err != nil {
test.Fatalf("parse error: %v", err)
}

registry := classresolver.NewRegistry()
registry.Collect(rootNode)

found, resolvable := registry.InheritsVisibleProperty("Child", "value")
if !found || !resolvable {
test.Errorf("expected protected promoted ancestor property to be found (found=%v, resolvable=%v)", found, resolvable)
}
}

func TestRegistry_descendantDeclaresMethodDirectChild(test *testing.T) {
registry := parse(test, `<?php
class Base
Expand Down
Loading