mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
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:
+604
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user