Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

1659 lines
56 KiB
Go

package languages
import (
"fmt"
"regexp"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
"github.com/zzet/gortex/internal/parser/tsitter/php"
)
// PHPExtractor extracts PHP source files.
type PHPExtractor struct {
lang *sitter.Language
}
func NewPHPExtractor() *PHPExtractor {
return &PHPExtractor{lang: php.GetLanguage()}
}
func (e *PHPExtractor) Language() string { return "php" }
func (e *PHPExtractor) Extensions() []string {
// Drupal module files (.module/.install/.inc/.theme/.profile/.engine) are
// PHP source whose function names follow the hook convention.
return []string{".php", ".module", ".install", ".inc", ".theme", ".profile", ".engine"}
}
func (e *PHPExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
tree, err := parser.ParseFile(src, e.lang)
if err != nil {
return nil, err
}
defer tree.Close()
root := tree.RootNode()
result := &parser.ExtractionResult{}
fileNode := &graph.Node{
ID: filePath, Kind: graph.KindFile, Name: filePath,
FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1,
Language: "php",
}
result.Nodes = append(result.Nodes, fileNode)
seen := make(map[string]bool)
// Walk the AST manually since PHP tree-sitter queries can be tricky.
e.walkNode(root, src, filePath, fileNode, result, seen, "")
captureValueRefCandidates(result, root, filePath, src)
captureFnValueCandidates(result, root, filePath, src)
e.capturePHPStringCallables(result, root, filePath, src)
// Expression-site and inheritance-clause reference forms #143's
// type-use pass (typed property / param / return EdgeTypedAs) misses:
// instantiation, extends/implements, instanceof, static / const access,
// and PHP 8 attributes. Runs after the symbol walk so buildFuncRanges /
// buildPHPTypeRanges see every function / method / class node.
emitPHPReferenceForms(root, src, filePath, fileNode.ID, result)
captureLaravelEvents(result, root, filePath, src)
captureDrupalHooks(result, filePath)
return result, nil
}
func (e *PHPExtractor) walkNode(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
currentClass string,
) {
nodeType := node.Type()
switch nodeType {
case "namespace_definition":
e.extractNamespace(node, src, filePath, fileNode, result, seen)
case "class_declaration":
e.extractClass(node, src, filePath, fileNode, result, seen)
case "interface_declaration":
e.extractInterface(node, src, filePath, fileNode, result, seen)
case "trait_declaration":
e.extractTrait(node, src, filePath, fileNode, result, seen)
case "enum_declaration":
e.extractEnum(node, src, filePath, fileNode, result, seen)
case "function_definition":
e.extractFunction(node, src, filePath, fileNode, result, seen)
case "namespace_use_declaration":
e.extractUseImport(node, src, filePath, fileNode, result)
case "expression_statement":
// Check for require/include calls.
e.extractRequireInclude(node, src, filePath, fileNode, result)
// Also walk children for call expressions.
e.walkChildren(node, src, filePath, fileNode, result, seen, currentClass)
return
default:
// For class/interface bodies, walk into children with class context.
e.walkChildren(node, src, filePath, fileNode, result, seen, currentClass)
return
}
}
func (e *PHPExtractor) walkChildren(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
currentClass string,
) {
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
e.walkNode(child, src, filePath, fileNode, result, seen, currentClass)
}
}
func (e *PHPExtractor) extractNamespace(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
) {
name := e.findChildByType(node, "namespace_name")
if name == nil {
return
}
nsName := name.Content(src)
id := filePath + "::" + nsName
if seen[id] {
return
}
seen[id] = true
startLine := int(node.StartPoint().Row) + 1
endLine := int(node.EndPoint().Row) + 1
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindPackage, Name: nsName,
FilePath: filePath, StartLine: startLine, EndLine: endLine,
Language: "php",
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
})
// Walk children of namespace body.
body := e.findChildByType(node, "compound_statement")
if body != nil {
for i, _nc := 0, int(body.NamedChildCount()); i < _nc; i++ {
child := body.NamedChild(i)
e.walkNode(child, src, filePath, fileNode, result, seen, "")
}
}
// Some namespaces don't use braces; walk remaining children.
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
if child.Type() != "namespace_name" && child.Type() != "compound_statement" {
e.walkNode(child, src, filePath, fileNode, result, seen, "")
}
}
}
func (e *PHPExtractor) extractClass(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
) {
nameNode := e.findChildByFieldName(node, "name")
if nameNode == nil {
return
}
className := nameNode.Content(src)
id := filePath + "::" + className
if seen[id] {
return
}
seen[id] = true
startLine := int(node.StartPoint().Row) + 1
endLine := int(node.EndPoint().Row) + 1
meta := map[string]any{"visibility": VisibilityPublic}
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
if parent := extractPhpParentClass(node, src); parent != "" {
meta["scope_parent"] = parent
}
if ifaces := phpTypeClauseNames(node, src, "class_interface_clause"); len(ifaces) > 0 {
meta["scope_interfaces"] = strings.Join(ifaces, ",")
}
meta["type_flavor"] = "class"
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindType, Name: className,
FilePath: filePath, StartLine: startLine, EndLine: endLine,
Language: "php",
Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
})
annotationSeen := map[string]bool{}
emitPHPAnnotationEdgesFromAttrs(collectPhpAttributes(node, src), id, filePath, result, annotationSeen)
// Extract methods, constants, properties, and trait uses inside the body.
body := e.findChildByType(node, "declaration_list")
if body == nil {
return
}
methodNodes := e.extractPhpMembers(body, src, filePath, fileNode, result, seen, className, id)
// Magic-method surface: a class declaring __call / __callStatic can receive
// arbitrary member calls the extractor cannot mint nodes for; mark it so the
// resolver/read path knows unresolved members on it are not necessarily a gap.
if phpDeclaresMagicCall(methodNodes) {
meta["has_magic_call"] = true
}
// @method phpdoc virtuals: docblock-declared methods (magic accessors,
// mixins) that have no method_declaration node. Mint them so their ids
// resolve and member calls to them bind.
e.emitPHPDocMethods(node, src, filePath, fileNode, className, id, methodNodes, result, seen)
// Laravel-specific dispatch passes — run after methods are in the
// graph so action-method IDs are resolvable by name:
// 1. Controller middleware: `$this->middleware(X)->only([...])`
// in the constructor → edges from each action to X.handle.
// 2. Service provider bindings: `$this->app->bind/singleton(A,B)`
// in register() → EdgeProvides from the provider class.
e.emitLaravelMiddleware(methodNodes, src, filePath, className, result)
e.emitLaravelBindings(methodNodes, src, filePath, className, result)
// Symfony attribute-based dispatch: #[AsEventListener] binds a
// method (or whole class) to an event class as a listener. The
// framework calls the listener at event-dispatch time with no
// explicit call site; the attribute is the only static signal.
e.emitSymfonyAttributeDispatch(node, methodNodes, src, filePath, className, result)
}
func (e *PHPExtractor) extractInterface(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
) {
nameNode := e.findChildByFieldName(node, "name")
if nameNode == nil {
return
}
ifaceName := nameNode.Content(src)
id := filePath + "::" + ifaceName
if seen[id] {
return
}
seen[id] = true
startLine := int(node.StartPoint().Row) + 1
endLine := int(node.EndPoint().Row) + 1
meta := map[string]any{"visibility": VisibilityPublic}
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
meta["type_flavor"] = "interface"
// An interface's `extends A, B` is modelled as a base_clause in
// tree-sitter-php, so its parent interfaces land under scope_interfaces —
// feeding the dispatch resolver's ancestor closure.
if parents := phpTypeClauseNames(node, src, "base_clause"); len(parents) > 0 {
meta["scope_interfaces"] = strings.Join(parents, ",")
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindInterface, Name: ifaceName,
FilePath: filePath, StartLine: startLine, EndLine: endLine,
Language: "php",
Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
})
// Extract method signatures and constants inside the interface body.
body := e.findChildByType(node, "declaration_list")
if body == nil {
return
}
e.extractPhpMembers(body, src, filePath, fileNode, result, seen, ifaceName, id)
}
// extractPhpMembers walks a class/trait/interface/enum body, emitting methods,
// class constants, properties, trait-use edges, and enum cases. Returns the
// method_declaration nodes by name for the framework-dispatch post-passes.
func (e *PHPExtractor) extractPhpMembers(
body *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
ownerName, ownerID string,
) map[string]*sitter.Node {
methodNodes := make(map[string]*sitter.Node)
for i, _nc := 0, int(body.NamedChildCount()); i < _nc; i++ {
child := body.NamedChild(i)
switch child.Type() {
case "method_declaration":
if n := e.findChildByFieldName(child, "name"); n != nil {
methodNodes[n.Content(src)] = child
}
e.extractMethod(child, src, filePath, fileNode, result, seen, ownerName)
case "const_declaration":
e.extractPhpClassConst(child, src, filePath, fileNode, result, seen, ownerName, ownerID)
case "property_declaration":
e.extractPhpProperty(child, src, filePath, fileNode, result, seen, ownerName, ownerID)
case "use_declaration":
e.extractPhpTraitUse(child, src, filePath, ownerID, result)
case "enum_case":
e.extractPhpEnumCase(child, src, filePath, fileNode, result, seen, ownerName, ownerID)
}
}
return methodNodes
}
// extractTrait emits a trait as a navigable type plus all its members; a class
// that `use`s the trait gains a trait-composition edge (see extractPhpTraitUse).
func (e *PHPExtractor) extractTrait(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
) {
nameNode := e.findChildByFieldName(node, "name")
if nameNode == nil {
return
}
name := nameNode.Content(src)
id := filePath + "::" + name
if seen[id] {
return
}
seen[id] = true
startLine := int(node.StartPoint().Row) + 1
meta := map[string]any{"visibility": VisibilityPublic, "kind": "trait", "type_flavor": "trait"}
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindType, Name: name,
FilePath: filePath, StartLine: startLine, EndLine: int(node.EndPoint().Row) + 1,
Language: "php", Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
})
if body := e.findChildByType(node, "declaration_list"); body != nil {
methodNodes := e.extractPhpMembers(body, src, filePath, fileNode, result, seen, name, id)
if phpDeclaresMagicCall(methodNodes) {
meta["has_magic_call"] = true
}
e.emitPHPDocMethods(node, src, filePath, fileNode, name, id, methodNodes, result, seen)
}
}
// extractEnum emits a PHP enum (cases, backing type, and methods).
func (e *PHPExtractor) extractEnum(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
) {
nameNode := e.findChildByFieldName(node, "name")
if nameNode == nil {
return
}
name := nameNode.Content(src)
id := filePath + "::" + name
if seen[id] {
return
}
seen[id] = true
startLine := int(node.StartPoint().Row) + 1
meta := map[string]any{"visibility": VisibilityPublic, "kind": "enum", "type_flavor": "enum"}
if bt := e.findChildByType(node, "primitive_type"); bt != nil {
meta["backing_type"] = strings.TrimSpace(bt.Content(src))
}
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindType, Name: name,
FilePath: filePath, StartLine: startLine, EndLine: int(node.EndPoint().Row) + 1,
Language: "php", Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
})
if body := e.findChildByType(node, "enum_declaration_list"); body != nil {
e.extractPhpMembers(body, src, filePath, fileNode, result, seen, name, id)
}
}
// extractPhpClassConst emits each `const NAME = ...` in a class/interface/enum
// body as a class-scoped constant member.
func (e *PHPExtractor) extractPhpClassConst(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
ownerName, ownerID string,
) {
vis := phpMemberVisibility(node, src)
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
ce := node.NamedChild(i)
if ce.Type() != "const_element" {
continue
}
nameNode := e.findChildByType(ce, "name")
if nameNode == nil {
continue
}
name := nameNode.Content(src)
line := int(ce.StartPoint().Row) + 1
id, ok := disambiguateID(seen, filePath+"::"+ownerName+"."+name, line)
if !ok {
continue
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindConstant, Name: name,
FilePath: filePath, StartLine: line, EndLine: line, Language: "php",
Meta: map[string]any{"receiver": ownerName, "visibility": vis},
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: line,
})
result.Edges = append(result.Edges, &graph.Edge{
From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: line,
})
}
}
// extractPhpProperty emits each typed property in a class/trait body as a field.
func (e *PHPExtractor) extractPhpProperty(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
ownerName, ownerID string,
) {
vis := phpMemberVisibility(node, src)
propType := phpPropertyType(node, src)
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
pe := node.NamedChild(i)
if pe.Type() != "property_element" {
continue
}
vn := e.findChildByType(pe, "variable_name")
if vn == nil {
continue
}
nameNode := e.findChildByType(vn, "name")
if nameNode == nil {
continue
}
name := nameNode.Content(src)
line := int(pe.StartPoint().Row) + 1
id, ok := disambiguateID(seen, filePath+"::"+ownerName+"."+name, line)
if !ok {
continue
}
meta := map[string]any{"receiver": ownerName, "visibility": vis}
if propType != "" {
meta["field_type"] = propType
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindField, Name: name,
FilePath: filePath, StartLine: line, EndLine: line, Language: "php", Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: line,
})
result.Edges = append(result.Edges, &graph.Edge{
From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: line,
})
// Typed property (`private Session $s;`): emit a usage edge from
// the field to the declared class so find_usages lands it without
// an LSP. Builtins / nullable / union / namespace are handled by
// emitPHPTypeUseEdges.
emitPHPTypeUseEdges(id, propType, filePath, line, result)
}
}
// extractPhpTraitUse emits a composition edge from a class to each trait it
// `use`s — modeling trait composition the way a mixin is modeled, so the
// composed members are reachable through the class.
func (e *PHPExtractor) extractPhpTraitUse(node *sitter.Node, src []byte, filePath, ownerID string, result *parser.ExtractionResult) {
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
c := node.NamedChild(i)
if c.Type() != "name" && c.Type() != "qualified_name" {
continue
}
trait := strings.TrimSpace(c.Content(src))
if trait == "" {
continue
}
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID, To: "unresolved::" + trait, Kind: graph.EdgeExtends,
FilePath: filePath, Line: int(c.StartPoint().Row) + 1,
Meta: map[string]any{"via": "trait"},
})
}
}
// extractPhpEnumCase emits one enum case as an enum member of its enum.
func (e *PHPExtractor) extractPhpEnumCase(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
ownerName, ownerID string,
) {
nameNode := e.findChildByType(node, "name")
if nameNode == nil {
return
}
name := nameNode.Content(src)
line := int(node.StartPoint().Row) + 1
id, ok := disambiguateID(seen, filePath+"::"+ownerName+"."+name, line)
if !ok {
return
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindEnumMember, Name: name,
FilePath: filePath, StartLine: line, EndLine: line, Language: "php",
Meta: map[string]any{"enum": ownerID},
})
result.Edges = append(result.Edges, &graph.Edge{
From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: line,
})
}
// phpPropertyType returns the declared type of a property_declaration (the type
// node before the first property_element), or "".
func phpPropertyType(node *sitter.Node, src []byte) string {
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
c := node.NamedChild(i)
switch c.Type() {
case "primitive_type", "named_type", "union_type", "nullable_type", "intersection_type", "optional_type", "qualified_name":
return strings.TrimSpace(c.Content(src))
case "property_element":
return ""
}
}
return ""
}
// phpBuiltinType reports whether a type name is a PHP builtin (no node exists
// for it, so it should not produce an unresolved type-reference edge).
func phpBuiltinType(t string) bool {
switch strings.ToLower(strings.TrimPrefix(t, "?")) {
case "int", "integer", "string", "bool", "boolean", "float", "double",
"void", "array", "object", "mixed", "callable", "iterable",
"self", "static", "parent", "never", "null", "false", "true":
return true
}
return false
}
// canonicalizePHPTypeRef reduces one PHP type atom (no `|`/`&` left) to its
// bare class name: it strips the nullable `?` prefix and reduces a namespaced
// reference (`\App\Http\HttpResponse`, `App\HttpResponse`) to its last segment,
// so the name-based resolver can land it against the `Type` node defined under
// its short name. Returns "" for an empty atom.
func canonicalizePHPTypeRef(t string) string {
t = strings.TrimSpace(t)
t = strings.TrimPrefix(t, "?")
t = strings.TrimSpace(t)
if i := strings.LastIndex(t, `\`); i >= 0 {
t = t[i+1:]
}
return strings.TrimSpace(t)
}
// emitPHPTypeUseEdges parses a PHP type declaration used in property /
// parameter position and emits one EdgeTypedAs from ownerID to the bare named
// type for every non-builtin branch. PHP 8 union (`Foo|Bar`) and intersection
// (`Foo&Bar`) declarations each contribute one branch; the nullable `?Foo`
// shorthand and namespace prefixes are stripped by canonicalizePHPTypeRef.
// Edges ride at OriginASTInferred — the binding is a tree-sitter inference, not
// an LSP-checked fact — so a class used only in a type declaration becomes a
// traversable cross-file reference find_usages can land without a language
// server. Builtin scalar/pseudo types (int, string, void, self, …) are skipped.
func emitPHPTypeUseEdges(ownerID, typeText, filePath string, line int, result *parser.ExtractionResult) {
if ownerID == "" || typeText == "" {
return
}
seen := map[string]bool{}
for _, atom := range strings.FieldsFunc(typeText, func(r rune) bool { return r == '|' || r == '&' }) {
t := canonicalizePHPTypeRef(atom)
if t == "" || phpBuiltinType(t) || seen[t] {
continue
}
seen[t] = true
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::" + t,
Kind: graph.EdgeTypedAs,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTInferred,
})
}
}
// phpReturnType returns the declared return type of a method/function (the type
// node after formal_parameters, before the body), or "".
func phpReturnType(node *sitter.Node, src []byte) string {
seenParams := false
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
c := node.NamedChild(i)
t := c.Type()
if t == "formal_parameters" {
seenParams = true
continue
}
if !seenParams {
continue
}
switch t {
case "primitive_type", "named_type", "union_type", "nullable_type", "intersection_type", "optional_type", "qualified_name", "bottom_type":
return strings.TrimSpace(c.Content(src))
case "compound_statement":
return ""
}
}
return ""
}
// emitPHPParamTypeUseEdges walks a function/method's formal_parameters and
// emits an EdgeTypedAs from ownerID to each non-builtin parameter type. PHP
// has no separate param node in this extractor, so the usage is attributed to
// the enclosing function/method — enough for find_usages to land a class used
// only in a parameter type declaration (`function f(HttpResponse $r)`).
func (e *PHPExtractor) emitPHPParamTypeUseEdges(node *sitter.Node, src []byte, ownerID, filePath string, result *parser.ExtractionResult) {
params := e.findChildByType(node, "formal_parameters")
if params == nil {
return
}
for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ {
p := params.NamedChild(i)
switch p.Type() {
case "simple_parameter", "variadic_parameter", "property_promotion_parameter":
default:
continue
}
pt := phpParameterType(p, src)
if pt == "" {
continue
}
emitPHPTypeUseEdges(ownerID, pt, filePath, int(p.StartPoint().Row)+1, result)
}
}
// phpParameterType returns the declared type of a parameter node (the type
// node before its variable_name), or "".
func phpParameterType(node *sitter.Node, src []byte) string {
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
c := node.NamedChild(i)
switch c.Type() {
case "primitive_type", "named_type", "union_type", "nullable_type",
"intersection_type", "optional_type", "qualified_name":
return strings.TrimSpace(c.Content(src))
case "variable_name":
return ""
}
}
return ""
}
func (e *PHPExtractor) extractFunction(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
) {
nameNode := e.findChildByFieldName(node, "name")
if nameNode == nil {
return
}
funcName := nameNode.Content(src)
id := filePath + "::" + funcName
if seen[id] {
return
}
seen[id] = true
startLine := int(node.StartPoint().Row) + 1
endLine := int(node.EndPoint().Row) + 1
meta := map[string]any{"visibility": VisibilityPublic}
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
rt := phpReturnType(node, src)
if rt != "" {
meta["return_type"] = rt
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindFunction, Name: funcName,
FilePath: filePath, StartLine: startLine, EndLine: endLine,
Language: "php",
Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
})
if rt != "" && !phpBuiltinType(rt) {
result.Edges = append(result.Edges, &graph.Edge{
From: id, To: "unresolved::" + rt, Kind: graph.EdgeReturns, FilePath: filePath, Line: startLine,
})
}
// Typed parameters → EdgeTypedAs usage edges on the function.
e.emitPHPParamTypeUseEdges(node, src, id, filePath, result)
// Extract call sites within the function body.
body := e.findChildByType(node, "compound_statement")
if body != nil {
e.extractCallSites(body, src, filePath, id, result)
}
}
func (e *PHPExtractor) extractMethod(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
className string,
) {
nameNode := e.findChildByFieldName(node, "name")
if nameNode == nil {
return
}
methodName := nameNode.Content(src)
id := filePath + "::" + className + "." + methodName
startLine := int(node.StartPoint().Row) + 1
endLine := int(node.EndPoint().Row) + 1
if seen[id] {
id = filePath + "::" + className + "." + methodName + "_L" + fmt.Sprint(startLine)
}
if seen[id] {
return
}
seen[id] = true
meta := map[string]any{
"receiver": className,
"scope_class": className,
"visibility": phpMemberVisibility(node, src),
}
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
// Return type: stamped so the chained-factory resolver (helpers_chaintype)
// can infer `Foo::make()->...` receivers, and emitted as a return edge.
rt := phpReturnType(node, src)
if rt != "" {
meta["return_type"] = rt
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindMethod, Name: methodName,
FilePath: filePath, StartLine: startLine, EndLine: endLine,
Language: "php",
Meta: meta,
})
if rt != "" && !phpBuiltinType(rt) {
result.Edges = append(result.Edges, &graph.Edge{
From: id, To: "unresolved::" + rt, Kind: graph.EdgeReturns, FilePath: filePath, Line: startLine,
})
}
// Typed parameters → EdgeTypedAs usage edges on the method.
e.emitPHPParamTypeUseEdges(node, src, id, filePath, result)
annotationSeen := map[string]bool{}
emitPHPAnnotationEdgesFromAttrs(collectPhpAttributes(node, src), id, filePath, result, annotationSeen)
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
})
// MemberOf edge to containing class/interface.
classID := filePath + "::" + className
result.Edges = append(result.Edges, &graph.Edge{
From: id, To: classID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine,
})
// Extract call sites within the method body.
body := e.findChildByType(node, "compound_statement")
if body != nil {
e.extractCallSites(body, src, filePath, id, result)
}
}
func (e *PHPExtractor) extractUseImport(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult,
) {
// use_declaration children can be namespace_use_clause or namespace_name.
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
var importPath string
switch child.Type() {
case "namespace_use_clause":
nameNode := e.findChildByType(child, "qualified_name")
if nameNode == nil {
nameNode = e.findChildByType(child, "namespace_name")
}
if nameNode != nil {
importPath = nameNode.Content(src)
} else {
importPath = child.Content(src)
}
case "qualified_name", "namespace_name":
importPath = child.Content(src)
default:
continue
}
if importPath == "" {
continue
}
importPath = strings.TrimLeft(importPath, "\\")
importPath = strings.ReplaceAll(importPath, "\\", "/")
line := int(child.StartPoint().Row) + 1
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: "unresolved::import::" + importPath,
Kind: graph.EdgeImports, FilePath: filePath, Line: line,
})
}
}
func (e *PHPExtractor) extractRequireInclude(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult,
) {
// Look for require, require_once, include, include_once expressions.
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
ct := child.Type()
if ct == "require_expression" || ct == "require_once_expression" ||
ct == "include_expression" || ct == "include_once_expression" {
// The path is a string/encapsed_string containing a string_content child.
path := e.extractStringContent(child, src)
if path != "" {
line := int(child.StartPoint().Row) + 1
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID, To: "unresolved::import::" + path,
Kind: graph.EdgeImports, FilePath: filePath, Line: line,
})
}
}
}
}
func (e *PHPExtractor) extractCallSites(
node *sitter.Node, src []byte,
filePath string, callerID string,
result *parser.ExtractionResult,
) {
switch node.Type() {
case "function_call_expression":
funcNode := node.ChildByFieldName("function")
if funcNode != nil {
name := funcNode.Content(src)
if idx := strings.LastIndex(name, "\\"); idx >= 0 {
name = name[idx+1:]
}
line := int(node.StartPoint().Row) + 1
result.Edges = append(result.Edges, &graph.Edge{
From: callerID, To: "unresolved::*." + name,
Kind: graph.EdgeCalls, FilePath: filePath, Line: line,
})
}
case "member_call_expression", "scoped_call_expression":
nameNode := node.ChildByFieldName("name")
if nameNode != nil {
name := nameNode.Content(src)
line := int(node.StartPoint().Row) + 1
edge := &graph.Edge{
From: callerID, To: "unresolved::*." + name,
Kind: graph.EdgeCalls, FilePath: filePath, Line: line,
}
// PHP scope tagging for the static resolver: a
// `parent::foo()` / `self::foo()` / `static::foo()`
// scoped_call_expression names its scope under the
// `scope` field. Stamping it on the edge lets the
// resolver walk the inheritance chain (`parent`) or
// pin to the enclosing class (`self` / `static`).
if node.Type() == "scoped_call_expression" {
if scope := node.ChildByFieldName("scope"); scope != nil {
switch strings.ToLower(scope.Content(src)) {
case "parent":
edge.Meta = map[string]any{"scope_kind": "parent"}
case "self", "static":
edge.Meta = map[string]any{"scope_kind": "self"}
default:
scopeName := scope.Content(src)
// Laravel facade (`Cache::get()`): the static call
// proxies to the backing service the facade returns.
// Stamp that class as the receiver type so the resolver
// binds the call to its method rather than a name-only
// match against an unrelated `get`.
if backing, ok := phpFacadeBackingClass(scopeName); ok {
if edge.Meta == nil {
edge.Meta = map[string]any{}
}
edge.Meta["receiver_type"] = backing
edge.Meta["facade"] = strings.TrimPrefix(scopeName, "\\")
} else if model, ok := phpEloquentModelCall(scopeName, name); ok {
// Eloquent finder (`User::find(1)`): the method lives on
// the inherited query builder, not the model, so bind
// the call to the model class node instead of an
// unresolvable `find`.
edge.To = "unresolved::" + model
if edge.Meta == nil {
edge.Meta = map[string]any{}
}
edge.Meta["eloquent_model"] = model
edge.Meta["eloquent_method"] = name
}
}
}
}
// Chained-factory receiver: `make()->with()->build()` -- carry the
// receiver object (`->` normalised to `.`) so the shared walker can
// type the chain or preserve it for the graph-aware resolver.
if node.Type() == "member_call_expression" {
obj := node.ChildByFieldName("object")
if obj == nil && node.NamedChildCount() > 0 {
obj = node.NamedChild(0)
}
if obj != nil {
recv := strings.ReplaceAll(strings.TrimSpace(obj.Content(src)), "->", ".")
if strings.Contains(recv, ".") || strings.Contains(recv, "(") {
stampFactoryChainReceiver(edge, recv, resolveChainType(recv, nil, result))
}
}
}
result.Edges = append(result.Edges, edge)
}
}
// Recurse into children.
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
e.extractCallSites(child, src, filePath, callerID, result)
}
}
// findChildByType finds the first named child with the given type.
func (e *PHPExtractor) findChildByType(node *sitter.Node, typeName string) *sitter.Node {
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
if child.Type() == typeName {
return child
}
}
return nil
}
// findChildByFieldName finds a child by its field name.
func (e *PHPExtractor) findChildByFieldName(node *sitter.Node, fieldName string) *sitter.Node {
return node.ChildByFieldName(fieldName)
}
// extractStringContent recursively finds the first string_content node and returns its text.
func (e *PHPExtractor) extractStringContent(node *sitter.Node, src []byte) string {
if node.Type() == "string_content" {
return node.Content(src)
}
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
if result := e.extractStringContent(node.NamedChild(i), src); result != "" {
return result
}
}
return ""
}
// emitLaravelMiddleware scans the constructor body of a class for
// `$this->middleware(X)` calls, optionally followed by ->only([...])
// or ->except([...]) chains. For each discovered middleware class
// it emits one EdgeCalls per (action_method, middleware.handle)
// pair, honouring the only:/except: filters. Laravel invokes the
// middleware's handle() before each action at request time with no
// explicit call site.
func (e *PHPExtractor) emitLaravelMiddleware(methodNodes map[string]*sitter.Node, src []byte, filePath, className string, result *parser.ExtractionResult) {
ctor, ok := methodNodes["__construct"]
if !ok {
return
}
body := e.findChildByType(ctor, "compound_statement")
if body == nil {
return
}
// Collect a set of action methods — every public-looking method
// on the class except __construct, magic methods, and methods
// that look like middleware-helpers themselves. In a real
// Laravel controller, most non-ctor methods are actions; we
// filter by leading underscore (magic methods start with __)
// and the constructor.
var actions []string
for name := range methodNodes {
if name == "__construct" || len(name) == 0 {
continue
}
if len(name) >= 2 && name[0] == '_' && name[1] == '_' {
continue
}
actions = append(actions, name)
}
if len(actions) == 0 {
return
}
// Walk the ctor body for member_call_expression chains whose
// outermost call participates in the middleware()[->only/except]
// sequence. We process only the outermost call in each chain —
// otherwise a chain like `middleware(X)->only([...])` would be
// visited twice: once as the outer only() call (which descends
// inward to find the middleware class) and once more as the inner
// middleware() call on its own (producing a duplicate, UNfiltered
// edge set).
processed := make(map[uintptr]struct{})
var walk func(*sitter.Node)
walk = func(n *sitter.Node) {
if n == nil {
return
}
if n.Type() == "member_call_expression" {
if _, dup := processed[n.Id()]; !dup {
// Only process if the parent isn't another
// member_call_expression whose object is us —
// that would mean we're the inner call of a filter
// chain, already handled above.
parent := n.Parent()
if parent == nil || parent.Type() != "member_call_expression" || !parent.ChildByFieldName("object").Equal(n) {
mw, chainCalls := parseLaravelMiddlewareCall(n, src)
for _, c := range chainCalls {
processed[c.Id()] = struct{}{}
}
if mw.class != "" {
for _, action := range actions {
if len(mw.only) > 0 {
if _, ok := mw.only[action]; !ok {
continue
}
}
if len(mw.except) > 0 {
if _, ok := mw.except[action]; ok {
continue
}
}
actionID := filePath + "::" + className + "." + action
result.Edges = append(result.Edges, &graph.Edge{
From: actionID,
To: "unresolved::*.handle",
Kind: graph.EdgeCalls,
FilePath: filePath,
Line: int(n.StartPoint().Row) + 1,
Meta: map[string]any{
"receiver_type": mw.class,
"dispatch_macro": "middleware",
"laravel_middleware": mw.class,
},
})
}
}
}
}
}
for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ {
walk(n.Child(i))
}
}
walk(body)
}
type laravelMiddleware struct {
class string
only map[string]struct{}
except map[string]struct{}
}
// parseLaravelMiddlewareCall walks a member_call_expression. If the
// expression is of the form `$this->middleware(X)` (possibly chained
// with ->only([...]) / ->except([...])), returns the middleware
// class name and any action filters. The outermost call is passed in;
// we unwrap nested member_call_expressions to find both the filter
// chain and the original middleware() call.
func parseLaravelMiddlewareCall(outer *sitter.Node, src []byte) (laravelMiddleware, []*sitter.Node) {
var out laravelMiddleware
var chain []*sitter.Node
cur := outer
for cur != nil && cur.Type() == "member_call_expression" {
chain = append(chain, cur)
name := phpMethodName(cur, src)
if name == "middleware" {
// Found the base call. Extract the first argument.
args := phpCallArgs(cur)
if args != nil && args.NamedChildCount() > 0 {
out.class = phpExtractClassRef(args.NamedChild(0), src)
}
return out, chain
}
if name == "only" || name == "except" {
args := phpCallArgs(cur)
if args != nil && args.NamedChildCount() > 0 {
set := phpExtractStringArray(args.NamedChild(0), src)
if name == "only" {
out.only = set
} else {
out.except = set
}
}
}
// Follow the chain — the inner object of this call is the
// next member_call_expression to inspect.
inner := cur.ChildByFieldName("object")
if inner == nil {
return laravelMiddleware{}, chain
}
cur = inner
}
return laravelMiddleware{}, chain
}
// emitLaravelBindings scans a service provider's register() method for
// $this->app->bind / ->singleton / ->instance calls and emits an
// EdgeProvides from the provider class to the second argument's class
// (the implementation or binding target). Tagged binding="useClass"
// so the contracts tool treats these alongside NestJS useClass edges.
func (e *PHPExtractor) emitLaravelBindings(methodNodes map[string]*sitter.Node, src []byte, filePath, className string, result *parser.ExtractionResult) {
register, ok := methodNodes["register"]
if !ok {
return
}
body := e.findChildByType(register, "compound_statement")
if body == nil {
return
}
classID := filePath + "::" + className
var walk func(*sitter.Node)
walk = func(n *sitter.Node) {
if n == nil {
return
}
if n.Type() == "member_call_expression" {
method := phpMethodName(n, src)
switch method {
case "bind", "singleton", "instance":
args := phpCallArgs(n)
if args == nil || args.NamedChildCount() < 1 {
break
}
first := phpExtractClassRef(args.NamedChild(0), src)
if first == "" {
break
}
// Second arg may be an identifier (class ref) or a
// closure — we only emit the useClass-style edge
// when it's a class reference. Factory closures have
// their own call edges from the closure body.
var impl string
if args.NamedChildCount() >= 2 {
impl = phpExtractClassRef(args.NamedChild(1), src)
}
line := int(n.StartPoint().Row) + 1
if impl != "" && impl != first {
// useClass-style: AppServiceProvider → Concrete,
// tagged with provides_for=Abstract. The resolver
// uses this to rewrite abstract-typed call sites
// to the concrete implementation.
result.Edges = append(result.Edges, &graph.Edge{
From: classID,
To: "unresolved::" + impl,
Kind: graph.EdgeProvides,
FilePath: filePath,
Line: line,
Meta: map[string]any{
"provides_for": first,
"binding": "useClass",
"origin": method,
},
})
}
// find_usages visibility: also emit a provider edge
// pointing at the binding target itself (the abstract
// / token). Lets callers ask "who provides this
// interface?" and get the service provider class
// directly, regardless of the concrete impl.
result.Edges = append(result.Edges, &graph.Edge{
From: classID,
To: "unresolved::" + first,
Kind: graph.EdgeProvides,
FilePath: filePath,
Line: line,
Meta: map[string]any{
"di_token": first,
"binding": method,
"origin": method,
},
})
}
}
for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ {
walk(n.Child(i))
}
}
walk(body)
}
// phpMethodName returns the method identifier in a member_call_expression.
func phpMethodName(call *sitter.Node, src []byte) string {
nameNode := call.ChildByFieldName("name")
if nameNode != nil {
return nameNode.Content(src)
}
return ""
}
// phpCallArgs returns the arguments node of a member_call_expression.
func phpCallArgs(call *sitter.Node) *sitter.Node {
return call.ChildByFieldName("arguments")
}
// phpExtractClassRef pulls a class name out of an argument node. Handles
// the `X::class` shape (scoped_call_expression / class_constant_access),
// bare identifiers, and simple string literals (middleware aliases).
// Returns "" when the argument isn't a static class reference.
func phpExtractClassRef(arg *sitter.Node, src []byte) string {
if arg == nil {
return ""
}
switch arg.Type() {
case "argument":
// Argument wrapper — unwrap to the contained expression.
if arg.NamedChildCount() > 0 {
return phpExtractClassRef(arg.NamedChild(0), src)
}
return ""
case "class_constant_access_expression", "scoped_call_expression":
// X::class — the class name is the first named child.
if arg.NamedChildCount() > 0 {
return arg.NamedChild(0).Content(src)
}
case "name", "qualified_name":
return arg.Content(src)
case "string":
// middleware('auth') alias form — return the alias verbatim.
for i, _nc := 0, int(arg.NamedChildCount()); i < _nc; i++ {
c := arg.NamedChild(i)
if c != nil && c.Type() == "string_content" {
return c.Content(src)
}
}
}
return ""
}
// phpExtractStringArray collects string values or X::class names out
// of an `array(...)` / `[...]` expression for `->only([...])` /
// `->except([...])` filter chains. Returns a set keyed by method name.
func phpExtractStringArray(arg *sitter.Node, src []byte) map[string]struct{} {
out := make(map[string]struct{})
if arg == nil {
return out
}
// Unwrap argument wrapper.
if arg.Type() == "argument" {
if arg.NamedChildCount() > 0 {
return phpExtractStringArray(arg.NamedChild(0), src)
}
return out
}
if arg.Type() != "array_creation_expression" {
return out
}
for i, _nc := 0, int(arg.NamedChildCount()); i < _nc; i++ {
el := arg.NamedChild(i)
if el == nil {
continue
}
if el.Type() == "array_element_initializer" {
// The actual value is the first named child.
if el.NamedChildCount() > 0 {
el = el.NamedChild(0)
}
}
if el == nil {
continue
}
switch el.Type() {
case "string":
for j, _nc := 0, int(el.NamedChildCount()); j < _nc; j++ {
c := el.NamedChild(j)
if c != nil && c.Type() == "string_content" {
out[c.Content(src)] = struct{}{}
}
}
case "class_constant_access_expression":
if el.NamedChildCount() > 0 {
out[el.NamedChild(0).Content(src)] = struct{}{}
}
}
}
return out
}
// emitSymfonyAttributeDispatch walks a class's attribute_list and the
// attribute_list of each method_declaration, emitting EdgeConsumes
// edges for every #[AsEventListener(event: X)] binding. The edge
// points from the listener method (or the class itself when the
// attribute is class-level) to the event class, so find_usages on
// the event surfaces the listener as a consumer. Meta identifies the
// attribute name so callers can distinguish these from plain
// references.
func (e *PHPExtractor) emitSymfonyAttributeDispatch(classNode *sitter.Node, methodNodes map[string]*sitter.Node, src []byte, filePath, className string, result *parser.ExtractionResult) {
classID := filePath + "::" + className
// Class-level attributes apply to the class as a whole. Symfony
// treats a class-level #[AsEventListener] as shorthand for "this
// class has one or more listener methods" — the dispatch
// signature is discovered via the method attributes. We still
// emit a class-level consumer edge so find_usages(Event) returns
// the class even when every listener lives on a method.
if clsAttrs := collectPhpAttributes(classNode, src); len(clsAttrs) > 0 {
emitAttributeEdges(clsAttrs, classID, filePath, int(classNode.StartPoint().Row)+1, result)
}
// Method-level attributes. Each method_declaration has its own
// attribute_list immediately before its modifiers.
for name, methodNode := range methodNodes {
attrs := collectPhpAttributes(methodNode, src)
if len(attrs) == 0 {
continue
}
methodID := filePath + "::" + className + "." + name
emitAttributeEdges(attrs, methodID, filePath, int(methodNode.StartPoint().Row)+1, result)
}
}
// phpAttribute captures the parsed form of one #[Attr(...)] instance —
// just what we need for dispatch extraction.
type phpAttribute struct {
name string
line int
args map[string]string // key → value (class ref / string literal)
}
// emitPHPAnnotationEdgesFromAttrs turns parsed phpAttribute records
// into EdgeAnnotated edges. Args are flattened to a "k=v, k=v" string
// for storage on the edge — preserves intent without duplicating the
// keyed-args structure the dispatch detectors already consume.
func emitPHPAnnotationEdgesFromAttrs(attrs []phpAttribute, fromID, filePath string, result *parser.ExtractionResult, seen map[string]bool) {
for _, a := range attrs {
if a.name == "" {
continue
}
var args string
if len(a.args) > 0 {
parts := make([]string, 0, len(a.args))
for k, v := range a.args {
if k == "" {
parts = append(parts, v)
} else {
parts = append(parts, k+"="+v)
}
}
args = strings.Join(parts, ", ")
}
EmitAnnotationEdge(fromID, "php", a.name, args, filePath, a.line, result, seen)
}
}
// collectPhpAttributes scans a class_declaration or method_declaration
// for its attribute_list children and parses every attribute inside
// each group. Returns nil when the node carries no attributes.
func collectPhpAttributes(node *sitter.Node, src []byte) []phpAttribute {
var out []phpAttribute
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
c := node.NamedChild(i)
if c == nil || c.Type() != "attribute_list" {
continue
}
for j, _nc := 0, int(c.NamedChildCount()); j < _nc; j++ {
group := c.NamedChild(j)
if group == nil || group.Type() != "attribute_group" {
continue
}
for k, _nc := 0, int(group.NamedChildCount()); k < _nc; k++ {
attr := group.NamedChild(k)
if attr == nil || attr.Type() != "attribute" {
continue
}
parsed := parsePhpAttribute(attr, src)
if parsed.name != "" {
out = append(out, parsed)
}
}
}
}
return out
}
// parsePhpAttribute turns an `attribute` node (the part inside the
// `#[ ]` wrapper) into a phpAttribute. Pulls named arguments the
// attribute's constructor expects (event:, method:, priority:) when
// they're simple class refs or string literals.
func parsePhpAttribute(attr *sitter.Node, src []byte) phpAttribute {
var out phpAttribute
nameNode := attr.ChildByFieldName("name")
if nameNode == nil {
// Grammar sometimes lacks the field name; fall back to the
// first child of type "name".
for i, _nc := 0, int(attr.NamedChildCount()); i < _nc; i++ {
c := attr.NamedChild(i)
if c != nil && (c.Type() == "name" || c.Type() == "qualified_name") {
nameNode = c
break
}
}
}
if nameNode == nil {
return out
}
out.name = nameNode.Content(src)
out.line = int(attr.StartPoint().Row) + 1
args := attr.ChildByFieldName("arguments")
if args == nil {
// Some grammars expose arguments as a positional NamedChild.
for i, _nc := 0, int(attr.NamedChildCount()); i < _nc; i++ {
c := attr.NamedChild(i)
if c != nil && c.Type() == "arguments" {
args = c
break
}
}
}
if args == nil {
return out
}
out.args = make(map[string]string)
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
arg := args.NamedChild(i)
if arg == nil || arg.Type() != "argument" {
continue
}
// Named arguments have a `name` child first, then the value.
var key string
var valNode *sitter.Node
for j, _nc := 0, int(arg.NamedChildCount()); j < _nc; j++ {
c := arg.NamedChild(j)
if c == nil {
continue
}
if c.Type() == "name" && key == "" {
key = c.Content(src)
continue
}
valNode = c
break
}
if valNode == nil {
continue
}
if v := phpExtractClassRef(valNode, src); v != "" {
out.args[key] = v
}
}
return out
}
// symfonyDispatchAttributes names the attributes we extract dispatch
// edges for. Currently only AsEventListener — the most common Symfony
// DI attribute and the one where static dispatch makes a real
// difference. AsController / AsCommand are recognisable but don't
// produce dispatch edges (AsController is a marker; AsCommand binds
// a command name that isn't a node in the graph).
var symfonyDispatchAttributes = map[string]string{
"AsEventListener": "event",
}
func emitAttributeEdges(attrs []phpAttribute, fromID, filePath string, fallbackLine int, result *parser.ExtractionResult) {
for _, a := range attrs {
targetKey, ok := symfonyDispatchAttributes[a.name]
if !ok {
continue
}
target := a.args[targetKey]
if target == "" {
continue
}
line := a.line
if line == 0 {
line = fallbackLine
}
result.Edges = append(result.Edges, &graph.Edge{
From: fromID,
To: "unresolved::" + target,
Kind: graph.EdgeConsumes,
FilePath: filePath,
Line: line,
Meta: map[string]any{
"dispatch_attribute": a.name,
"symfony_event": target,
},
})
}
}
// extractPhpParentClass returns the immediate base class for a PHP
// class_declaration, or "" when the class has no `extends` clause.
// PHP supports single inheritance, so the `base_clause` child holds
// exactly one name. Used by the scope-based static resolver to
// answer `parent::method()` calls.
func extractPhpParentClass(classNode *sitter.Node, src []byte) string {
if classNode == nil {
return ""
}
for i, _nc := 0, int(classNode.NamedChildCount()); i < _nc; i++ {
child := classNode.NamedChild(i)
if child.Type() != "base_clause" {
continue
}
for j, _nc := 0, int(child.NamedChildCount()); j < _nc; j++ {
sub := child.NamedChild(j)
switch sub.Type() {
case "name", "qualified_name":
text := strings.TrimSpace(sub.Content(src))
if i := strings.LastIndex(text, `\`); i >= 0 {
text = text[i+1:]
}
return text
}
}
}
return ""
}
// phpTypeClauseNames returns the type names listed in the first direct child
// of clauseType — "class_interface_clause" for a class's `implements I, J`,
// "base_clause" for an interface's `extends A, B`. Names keep their namespace
// qualification; the dispatch resolver reduces them to simple names. Nil when
// there is no such clause.
func phpTypeClauseNames(node *sitter.Node, src []byte, clauseType string) []string {
if node == nil {
return nil
}
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
if child != nil && child.Type() == clauseType {
return phpClauseTypeNames(child, src)
}
}
return nil
}
// phpDocMethodRe matches a phpdoc `@method` tag: an optional `static`, an
// optional return type token, then the method name immediately before `(`.
// Return types with `|` / `?` / `\` / `[]` are captured as one token; the
// name is always the identifier right before the parameter list.
var phpDocMethodRe = regexp.MustCompile(`@method\s+(?:(static)\s+)?(?:([^\s()]+)\s+)?(\w+)\s*\(`)
// phpDeclaresMagicCall reports whether a class/trait body declared __call or
// __callStatic — the PHP magic-method dispatch hooks.
func phpDeclaresMagicCall(methodNodes map[string]*sitter.Node) bool {
if methodNodes == nil {
return false
}
_, call := methodNodes["__call"]
_, callStatic := methodNodes["__callStatic"]
return call || callStatic
}
// phpDocBlockAbove returns the raw `/** ... */` docblock immediately preceding
// a declaration, or "" when the preceding sibling is not a doc comment. Unlike
// ExtractDocAbove (which keeps only the first paragraph) this returns the full
// block so @method tags below the summary survive.
func phpDocBlockAbove(node *sitter.Node, src []byte) string {
sib := node.PrevNamedSibling()
if sib == nil || sib.Type() != "comment" {
return ""
}
txt := sib.Content(src)
if !strings.HasPrefix(strings.TrimSpace(txt), "/**") {
return ""
}
return txt
}
// emitPHPDocMethods mints KindMethod nodes for `@method` tags in a class/trait
// docblock — magic accessors and mixin methods that have no method_declaration
// and would otherwise resolve not_found. A real declaration of the same name
// always wins (the tag is skipped). Nodes are positioned on the class
// declaration line (a positionless decl breaks the LSP hover path) and carry
// virtual=phpdoc_method so consumers can tell them from concrete methods.
func (e *PHPExtractor) emitPHPDocMethods(
node *sitter.Node, src []byte, filePath string, fileNode *graph.Node,
className, classID string, methodNodes map[string]*sitter.Node,
result *parser.ExtractionResult, seen map[string]bool,
) {
doc := phpDocBlockAbove(node, src)
if doc == "" || !strings.Contains(doc, "@method") {
return
}
classLine := int(node.StartPoint().Row) + 1
minted := map[string]bool{}
for _, m := range phpDocMethodRe.FindAllStringSubmatch(doc, -1) {
isStatic, retType, name := m[1] != "", m[2], m[3]
if name == "" || minted[name] {
continue
}
minted[name] = true
if _, real := methodNodes[name]; real {
continue // a concrete declaration wins over the docblock virtual
}
id := filePath + "::" + className + "." + name
if seen[id] {
continue
}
seen[id] = true
meta := map[string]any{
"receiver": className,
"scope_class": className,
"visibility": VisibilityPublic,
"virtual": "phpdoc_method",
}
if isStatic {
meta["static"] = true
}
if retType != "" {
meta["return_type"] = retType
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindMethod, Name: name,
FilePath: filePath, StartLine: classLine, EndLine: classLine,
Language: "php", Meta: meta,
})
result.Edges = append(result.Edges,
&graph.Edge{From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: classLine},
&graph.Edge{From: id, To: classID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: classLine},
)
}
}
// phpMemberVisibility returns the access modifier for a PHP class
// member (method or property). PHP defaults to "public" when no
// visibility modifier is present.
func phpMemberVisibility(member *sitter.Node, src []byte) string {
if member == nil {
return VisibilityPublic
}
for i, _nc := 0, int(member.ChildCount()); i < _nc; i++ {
c := member.Child(i)
if c == nil {
continue
}
if c.Type() != "visibility_modifier" {
continue
}
switch strings.TrimSpace(c.Content(src)) {
case "public":
return VisibilityPublic
case "private":
return VisibilityPrivate
case "protected":
return VisibilityProtected
}
}
return VisibilityPublic
}