diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/php-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/php-extractor.test.ts new file mode 100644 index 0000000..d08b4d4 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/php-extractor.test.ts @@ -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(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + it("extracts classes with methods and properties", () => { + const { tree, parser, root } = parse(`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(` 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(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + it("extracts interfaces with method signatures", () => { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` 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(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + const { tree, parser, root } = parse(` { + it("treats top-level functions as exports", () => { + const { tree, parser, root } = parse(` 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(` 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(` { + it("extracts standalone function calls", () => { + const { tree, parser, root } = parse(` 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(`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(` 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(`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(` { + const { tree, parser, root } = parse(` { + it("handles the full test fixture", () => { + const { tree, parser, root } = parse(`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(` "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, + }); + } + } +}