feat: add PhpExtractor for tree-sitter PHP structural analysis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-04-15 19:08:51 +08:00
Unverified
parent dc8dd7dd77
commit 4eb35b0437
2 changed files with 1039 additions and 0 deletions
@@ -0,0 +1,604 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { PhpExtractor } from "../php-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + PHP grammar once
let Parser: any;
let Language: any;
let phpLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-php/tree-sitter-php.wasm",
);
phpLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(phpLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("PhpExtractor", () => {
const extractor = new PhpExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["php"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts top-level functions with params and return types", () => {
const { tree, parser, root } = parse(`<?php
function helper(string $x): string {
return strtoupper($x);
}
function greet(string $name, int $times): void {
echo $name;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("helper");
expect(result.functions[0].params).toEqual(["$x"]);
expect(result.functions[0].returnType).toBe("string");
expect(result.functions[1].name).toBe("greet");
expect(result.functions[1].params).toEqual(["$name", "$times"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts functions without return type", () => {
const { tree, parser, root } = parse(`<?php
function noReturn($x) {
echo $x;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noReturn");
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts functions with no parameters", () => {
const { tree, parser, root } = parse(`<?php
function noop(): void {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("reports correct line ranges", () => {
const { tree, parser, root } = parse(`<?php
function multiline(
string $a,
string $b
): string {
$result = $a . $b;
return $result;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts classes with methods and properties", () => {
const { tree, parser, root } = parse(`<?php
class UserService {
private string $name;
protected int $maxRetries;
public function __construct(string $name) {
$this->name = $name;
}
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
private function log(string $message): void {
error_log($message);
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("UserService");
expect(result.classes[0].methods).toContain("__construct");
expect(result.classes[0].methods).toContain("getUser");
expect(result.classes[0].methods).toContain("log");
expect(result.classes[0].methods).toHaveLength(3);
expect(result.classes[0].properties).toContain("name");
expect(result.classes[0].properties).toContain("maxRetries");
expect(result.classes[0].properties).toHaveLength(2);
tree.delete();
parser.delete();
});
it("also adds class methods to the functions array", () => {
const { tree, parser, root } = parse(`<?php
class Svc {
public function run(string $x): string {
return $x;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions.some((f) => f.name === "run")).toBe(true);
expect(result.functions[0].params).toEqual(["$x"]);
expect(result.functions[0].returnType).toBe("string");
tree.delete();
parser.delete();
});
it("extracts classes with static methods", () => {
const { tree, parser, root } = parse(`<?php
class Factory {
public static function create(): self {
return new self();
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].methods).toContain("create");
tree.delete();
parser.delete();
});
it("extracts nullable and optional type properties", () => {
const { tree, parser, root } = parse(`<?php
class Config {
public ?string $nullable;
public static int $counter = 0;
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toContain("nullable");
expect(result.classes[0].properties).toContain("counter");
tree.delete();
parser.delete();
});
it("reports correct class line ranges", () => {
const { tree, parser, root } = parse(`<?php
class MyClass {
public function a(): void {}
public function b(): void {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].lineRange[0]).toBe(2);
expect(result.classes[0].lineRange[1]).toBe(5);
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interfaces with method signatures", () => {
const { tree, parser, root } = parse(`<?php
interface Loggable {
public function log(string $msg): void;
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Loggable");
expect(result.classes[0].methods).toContain("log");
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("treats interfaces as exports", () => {
const { tree, parser, root } = parse(`<?php
interface Repository {
public function find(int $id): mixed;
public function save(object $entity): void;
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Repository");
tree.delete();
parser.delete();
});
});
// ---- Imports (use statements) ----
describe("extractStructure - imports", () => {
it("extracts simple use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
use App\\Contracts\\Repository;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("App\\Models\\User");
expect(result.imports[0].specifiers).toEqual(["User"]);
expect(result.imports[1].source).toBe("App\\Contracts\\Repository");
expect(result.imports[1].specifiers).toEqual(["Repository"]);
tree.delete();
parser.delete();
});
it("extracts grouped use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\{User, Post};
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].specifiers).toEqual(["User", "Post"]);
tree.delete();
parser.delete();
});
it("extracts aliased use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Contracts\\Repository as Repo;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("App\\Contracts\\Repository");
expect(result.imports[0].specifiers).toEqual(["Repository"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
use App\\Models\\Post;
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("treats top-level functions as exports", () => {
const { tree, parser, root } = parse(`<?php
function publicFunc(): void {}
function anotherFunc(string $x): string { return $x; }
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("publicFunc");
expect(exportNames).toContain("anotherFunc");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats classes as exports", () => {
const { tree, parser, root } = parse(`<?php
class MyService {}
class MyModel {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("MyService");
expect(exportNames).toContain("MyModel");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("does not treat use statements as exports", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
function myFunc(): void {}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(1);
expect(result.exports[0].name).toBe("myFunc");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts standalone function calls", () => {
const { tree, parser, root } = parse(`<?php
function process(string $data): string {
$result = transform($data);
return format_output($result);
}
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(result.every((e) => e.caller === "process")).toBe(true);
expect(callees).toContain("transform");
expect(callees).toContain("format_output");
tree.delete();
parser.delete();
});
it("extracts instance method calls ($this->method())", () => {
const { tree, parser, root } = parse(`<?php
class Svc {
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "getUser" && e.callee === "$this->fetchFromDb")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts static method calls (Class::method())", () => {
const { tree, parser, root } = parse(`<?php
class Foo {
public function doWork(): void {
$result = Bar::staticMethod();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "doWork" && e.callee === "Bar::staticMethod")).toBe(true);
tree.delete();
parser.delete();
});
it("tracks correct caller context across nested calls", () => {
const { tree, parser, root } = parse(`<?php
class Service {
public function start(): void {
$this->setup();
run_server();
}
private function setup(): void {
init_config();
}
}
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "$this->setup")).toBe(true);
expect(startCalls.some((e) => e.callee === "run_server")).toBe(true);
const setupCalls = result.filter((e) => e.caller === "setup");
expect(setupCalls.some((e) => e.callee === "init_config")).toBe(true);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`<?php
echo "hello";
main();
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`<?php
function main(): void {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive PHP file", () => {
it("handles the full test fixture", () => {
const { tree, parser, root } = parse(`<?php
namespace App\\Services;
use App\\Models\\User;
use App\\Contracts\\Repository;
class UserService {
private string $name;
protected int $maxRetries;
public function __construct(string $name) {
$this->name = $name;
}
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
private function log(string $message): void {
error_log($message);
}
}
function helper(string $x): string {
return strtoupper($x);
}
`);
const result = extractor.extractStructure(root);
// Functions: __construct, getUser, log (from class), helper (top-level)
const funcNames = result.functions.map((f) => f.name);
expect(funcNames).toContain("__construct");
expect(funcNames).toContain("getUser");
expect(funcNames).toContain("log");
expect(funcNames).toContain("helper");
expect(result.functions).toHaveLength(4);
// Classes: UserService
expect(result.classes).toHaveLength(1);
const userService = result.classes[0];
expect(userService.name).toBe("UserService");
expect(userService.methods).toContain("__construct");
expect(userService.methods).toContain("getUser");
expect(userService.methods).toContain("log");
expect(userService.properties).toContain("name");
expect(userService.properties).toContain("maxRetries");
// Imports: 2 use statements
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("App\\Models\\User");
expect(result.imports[0].specifiers).toEqual(["User"]);
expect(result.imports[1].source).toBe("App\\Contracts\\Repository");
expect(result.imports[1].specifiers).toEqual(["Repository"]);
// Exports: UserService (class) + helper (function)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("helper");
expect(result.exports).toHaveLength(2);
// Return types
const getUser = result.functions.find((f) => f.name === "getUser");
expect(getUser).toBeDefined();
expect(getUser!.returnType).toBe("User");
const log = result.functions.find((f) => f.name === "log");
expect(log).toBeDefined();
expect(log!.returnType).toBe("void");
const helper = result.functions.find((f) => f.name === "helper");
expect(helper).toBeDefined();
expect(helper!.returnType).toBe("string");
// Call graph
const calls = extractor.extractCallGraph(root);
// getUser -> $this->fetchFromDb
const getUserCalls = calls.filter((e) => e.caller === "getUser");
expect(getUserCalls.some((e) => e.callee === "$this->fetchFromDb")).toBe(true);
// log -> error_log
const logCalls = calls.filter((e) => e.caller === "log");
expect(logCalls.some((e) => e.callee === "error_log")).toBe(true);
// helper -> strtoupper
const helperCalls = calls.filter((e) => e.caller === "helper");
expect(helperCalls.some((e) => e.callee === "strtoupper")).toBe(true);
tree.delete();
parser.delete();
});
});
// ---- Nullable return types ----
describe("nullable return types", () => {
it("extracts nullable return type", () => {
const { tree, parser, root } = parse(`<?php
function findUser(int $id): ?User {
return null;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].returnType).toBe("?User");
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,435 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract parameter names from a PHP `formal_parameters` node.
*
* Each child is a `simple_parameter` containing an optional type hint
* and a `variable_name` node (which itself has `$` + `name` children).
* We extract the variable name prefixed with `$`.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
const simpleParams = findChildren(paramsNode, "simple_parameter");
for (const param of simpleParams) {
const varName = findChild(param, "variable_name");
if (varName) {
params.push(varName.text);
}
}
return params;
}
/**
* Extract a return type string from the siblings following the `formal_parameters`
* in a function_definition or method_declaration node.
*
* In tree-sitter-php, the return type appears as a sibling after `:` and can be:
* - `primitive_type` (string, int, void, bool, etc.)
* - `named_type` (User, Repository, etc.)
* - `optional_type` (?string, ?User)
* - `union_type` (string|int)
*/
function extractReturnType(node: TreeSitterNode): string | undefined {
// Walk children looking for the colon separator that precedes the return type.
// The return type node follows the `:` and precedes the `compound_statement` (body).
let foundColon = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === ":" && child.text === ":") {
foundColon = true;
continue;
}
if (foundColon) {
// The next non-punctuation node after `:` is the return type
if (
child.type === "primitive_type" ||
child.type === "named_type" ||
child.type === "optional_type" ||
child.type === "union_type"
) {
return child.text;
}
}
}
return undefined;
}
/**
* Reconstruct a fully-qualified name from a `namespace_use_clause`.
*
* For a simple clause like `use App\Models\User;`, the clause contains
* a `qualified_name` with `namespace_name` segments and a trailing `name`.
* For a grouped clause like `use App\Models\{User, Post};`, the clause
* is a direct child of `namespace_use_group` and may be a bare `name`.
*/
function extractUseName(clause: TreeSitterNode, prefix: string): string {
const qualifiedName = findChild(clause, "qualified_name");
if (qualifiedName) {
return qualifiedName.text;
}
// Inside a grouped use, the clause may just be a `name` node
const nameNode = findChild(clause, "name");
if (nameNode && prefix) {
return prefix + "\\" + nameNode.text;
}
if (nameNode) {
return nameNode.text;
}
return clause.text;
}
/**
* Extract the last segment (class/interface name) from a fully-qualified name.
* e.g., "App\Models\User" -> "User"
*/
function lastSegment(fqn: string): string {
const parts = fqn.split("\\");
return parts[parts.length - 1];
}
/**
* PHP extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles functions, classes, interfaces, use imports, and call graphs
* for PHP source code parsed by tree-sitter-php.
*
* PHP-specific mapping decisions:
* - `function_definition` nodes map to the `functions` array.
* - `class_declaration` and `interface_declaration` map to the `classes` array.
* - `property_declaration` nodes within classes map to class properties.
* - `namespace_use_declaration` nodes (PHP `use` statements) map to imports.
* - PHP has no formal export syntax, so public classes, interfaces, and
* top-level functions are treated as exports.
* - Call graph covers `function_call_expression`, `member_call_expression`,
* and `scoped_call_expression`.
*/
export class PhpExtractor implements LanguageExtractor {
readonly languageIds = ["php"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
// tree-sitter-php wraps everything under `program`. The children of
// `program` include `php_tag`, `namespace_definition`, `namespace_use_declaration`,
// `class_declaration`, `function_definition`, etc.
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "function_definition":
this.extractFunction(node, functions);
exports.push({
name: this.getFunctionName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "class_declaration":
this.extractClass(node, classes, functions);
exports.push({
name: this.getClassName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "interface_declaration":
this.extractInterface(node, classes);
exports.push({
name: this.getInterfaceName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "namespace_use_declaration":
this.extractUseDeclaration(node, imports);
break;
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering function/method definitions
if (node.type === "function_definition" || node.type === "method_declaration") {
const nameNode = findChild(node, "name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
}
// Extract call expressions
if (functionStack.length > 0) {
const caller = functionStack[functionStack.length - 1];
if (node.type === "function_call_expression") {
// Standalone function call: baz($x), strtoupper($x), error_log($msg)
const nameNode = findChild(node, "name");
if (nameNode) {
entries.push({
caller,
callee: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
} else if (node.type === "member_call_expression") {
// Instance method call: $this->fetchFromDb($id)
const nameNode = findChild(node, "name");
if (nameNode) {
// Determine the receiver for more descriptive callee
const firstChild = node.child(0);
const receiver = firstChild ? firstChild.text : "";
const callee = receiver
? receiver + "->" + nameNode.text
: nameNode.text;
entries.push({
caller,
callee,
lineNumber: node.startPosition.row + 1,
});
}
} else if (node.type === "scoped_call_expression") {
// Static method call: Bar::staticMethod()
// Children: [name("Bar"), ::, name("staticMethod"), arguments]
// Both scope and method are `name` nodes, so we pick child[0] for scope
// and child[2] (after `::`) for the method name.
const scopeNode = node.child(0);
const methodNode = node.child(2);
if (scopeNode && methodNode && methodNode.type === "name") {
entries.push({
caller,
callee: scopeNode.text + "::" + methodNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
private getFunctionName(node: TreeSitterNode): string {
const nameNode = findChild(node, "name");
return nameNode ? nameNode.text : "";
}
private getClassName(node: TreeSitterNode): string {
const nameNode = findChild(node, "name");
return nameNode ? nameNode.text : "";
}
private getInterfaceName(node: TreeSitterNode): string {
const nameNode = findChild(node, "name");
return nameNode ? nameNode.text : "";
}
private extractFunction(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode = findChild(node, "name");
if (!nameNode) return;
const paramsNode = findChild(node, "formal_parameters");
const params = extractParams(paramsNode);
const returnType = extractReturnType(node);
functions.push({
name: nameNode.text,
lineRange: [node.startPosition.row + 1, node.endPosition.row + 1],
params,
returnType,
});
}
private extractClass(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
): void {
const name = this.getClassName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const declList = findChild(node, "declaration_list");
if (declList) {
this.extractDeclarationList(declList, methods, properties, functions);
}
classes.push({
name,
lineRange: [node.startPosition.row + 1, node.endPosition.row + 1],
methods,
properties,
});
}
private extractInterface(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
): void {
const name = this.getInterfaceName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const declList = findChild(node, "declaration_list");
if (declList) {
// Interface methods are method_declaration nodes (no bodies, just signatures)
const methodDecls = findChildren(declList, "method_declaration");
for (const methodDecl of methodDecls) {
const methodName = findChild(methodDecl, "name");
if (methodName) {
methods.push(methodName.text);
}
}
}
classes.push({
name,
lineRange: [node.startPosition.row + 1, node.endPosition.row + 1],
methods,
properties,
});
}
/**
* Extract methods and properties from a class `declaration_list`.
* Also pushes each method into the top-level functions array.
*/
private extractDeclarationList(
declList: TreeSitterNode,
methods: string[],
properties: string[],
functions: StructuralAnalysis["functions"],
): void {
for (let i = 0; i < declList.childCount; i++) {
const member = declList.child(i);
if (!member) continue;
if (member.type === "method_declaration") {
const nameNode = findChild(member, "name");
if (nameNode) {
methods.push(nameNode.text);
// Also add to functions array
const paramsNode = findChild(member, "formal_parameters");
const params = extractParams(paramsNode);
const returnType = extractReturnType(member);
functions.push({
name: nameNode.text,
lineRange: [member.startPosition.row + 1, member.endPosition.row + 1],
params,
returnType,
});
}
} else if (member.type === "property_declaration") {
// Extract property name from property_element -> variable_name
const propElement = findChild(member, "property_element");
if (propElement) {
const varName = findChild(propElement, "variable_name");
if (varName) {
// Get just the name part without $
const dollarChild = findChild(varName, "name");
if (dollarChild) {
properties.push(dollarChild.text);
} else {
// Fallback: use the full text and strip $
properties.push(varName.text.replace(/^\$/, ""));
}
}
}
}
}
}
/**
* Extract imports from a `namespace_use_declaration` node.
*
* Handles:
* - Simple: `use App\Models\User;`
* - Aliased: `use App\Contracts\Repository as Repo;`
* - Grouped: `use App\Models\{User, Post};`
*/
private extractUseDeclaration(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
// Check for grouped use: `use Namespace\{A, B};`
const useGroup = findChild(node, "namespace_use_group");
if (useGroup) {
// Reconstruct the prefix from the namespace_name preceding the group
const nsName = findChild(node, "namespace_name");
const prefix = nsName ? nsName.text : "";
const clauses = findChildren(useGroup, "namespace_use_clause");
const specifiers: string[] = [];
for (const clause of clauses) {
const name = extractUseName(clause, prefix);
specifiers.push(lastSegment(name));
}
const source = prefix
? prefix + "\\{" + specifiers.join(", ") + "}"
: specifiers.join(", ");
imports.push({
source,
specifiers,
lineNumber: node.startPosition.row + 1,
});
return;
}
// Simple or aliased use declaration
const clauses = findChildren(node, "namespace_use_clause");
for (const clause of clauses) {
const fqn = extractUseName(clause, "");
const specifier = lastSegment(fqn);
imports.push({
source: fqn,
specifiers: [specifier],
lineNumber: node.startPosition.row + 1,
});
}
}
}