From bfb5a2ee8eb38bf5932f549aeb58dd9f47494a87 Mon Sep 17 00:00:00 2001 From: Helweg Date: Sat, 28 Mar 2026 22:46:34 +0100 Subject: [PATCH 1/2] feat: add PHP call-graph support (#36) - Add native/queries/php-calls.scm with patterns for function calls, method calls, nullsafe calls, static calls, constructors, and use imports - Route PHP in call_extractor.rs (language, query, method_parent_kinds) - Add "php" to CALL_GRAPH_LANGUAGES, "trait_declaration" to CALL_GRAPH_SYMBOL_CHUNK_TYPES - Add PHP test fixtures and call-graph tests --- native/queries/php-calls.scm | 39 +++++++ native/src/call_extractor.rs | 107 ++++++++++++++++++ src/indexer/index.ts | 3 +- tests/call-graph.test.ts | 65 +++++++++++ .../fixtures/call-graph/php-constructors.php | 10 ++ tests/fixtures/call-graph/php-imports.php | 4 + .../fixtures/call-graph/php-method-calls.php | 30 +++++ .../fixtures/call-graph/php-simple-calls.php | 19 ++++ 8 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 native/queries/php-calls.scm create mode 100644 tests/fixtures/call-graph/php-constructors.php create mode 100644 tests/fixtures/call-graph/php-imports.php create mode 100644 tests/fixtures/call-graph/php-method-calls.php create mode 100644 tests/fixtures/call-graph/php-simple-calls.php diff --git a/native/queries/php-calls.scm b/native/queries/php-calls.scm new file mode 100644 index 0000000..8d42c27 --- /dev/null +++ b/native/queries/php-calls.scm @@ -0,0 +1,39 @@ +; ============================================================= +; Tree-sitter query for extracting function calls from PHP +; ============================================================= + +; Direct function calls: foo(), strlen($s) +(function_call_expression + function: (name) @callee.name) @call + +; Qualified function calls: Namespace\foo() +(function_call_expression + function: (qualified_name + (name) @callee.name)) @call + +; Method calls: $obj->method() +(member_call_expression + name: (name) @callee.name) @call + +; Nullsafe method calls: $obj?->method() +(nullsafe_member_call_expression + name: (name) @callee.name) @call + +; Static method calls: Foo::bar(), self::method() +(scoped_call_expression + name: (name) @callee.name) @call + +; Constructor calls: new Foo() +(object_creation_expression + (name) @callee.name) @constructor + +; Qualified constructor: new Namespace\Foo() +(object_creation_expression + (qualified_name + (name) @callee.name)) @constructor + +; Use imports: use App\Models\User; +(namespace_use_declaration + (namespace_use_clause + (qualified_name + (name) @import.name))) @import diff --git a/native/src/call_extractor.rs b/native/src/call_extractor.rs index f6a59c0..e227c3d 100644 --- a/native/src/call_extractor.rs +++ b/native/src/call_extractor.rs @@ -29,6 +29,7 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result Language::Python => tree_sitter_python::LANGUAGE.into(), Language::Rust => tree_sitter_rust::LANGUAGE.into(), Language::Go => tree_sitter_go::LANGUAGE.into(), + Language::Php => tree_sitter_php::LANGUAGE_PHP.into(), _ => return Ok(vec![]), }; @@ -51,6 +52,7 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result Language::Python => include_str!("../queries/python-calls.scm"), Language::Rust => include_str!("../queries/rust-calls.scm"), Language::Go => include_str!("../queries/go-calls.scm"), + Language::Php => include_str!("../queries/php-calls.scm"), _ => return Ok(vec![]), }; @@ -72,6 +74,11 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result Language::Python => &["attribute"], Language::Rust => &["field_expression"], Language::Go => &["selector_expression"], + Language::Php => &[ + "member_call_expression", + "scoped_call_expression", + "nullsafe_member_call_expression", + ], _ => &[], }; @@ -410,4 +417,104 @@ mod tests { let calls = extract_calls(code, "html").unwrap(); assert_eq!(calls.len(), 0); } + + #[test] + fn test_php_direct_calls() { + let code = "method();\n$obj?->safe();"; + let calls = extract_calls(code, "php").unwrap(); + assert!( + calls + .iter() + .any(|c| c.callee_name == "method" && c.call_type == CallType::MethodCall), + "Expected method call, got: {:?}", + calls + ); + assert!( + calls + .iter() + .any(|c| c.callee_name == "safe" && c.call_type == CallType::MethodCall), + "Expected nullsafe method call, got: {:?}", + calls + ); + } + + #[test] + fn test_php_static_calls() { + let code = " { expect(callNames).toContain("cleanup"); expect(callNames).toContain("fetchData"); }); + + describe("php call extraction", () => { + it("should extract direct function calls", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-simple-calls.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const callNames = calls.map((c) => c.calleeName); + expect(callNames).toContain("directCall"); + expect(callNames).toContain("helper"); + expect(callNames).toContain("compute"); + + const directCall = calls.find((c) => c.calleeName === "directCall"); + expect(directCall).toBeDefined(); + expect(directCall!.callType).toBe("Call"); + }); + + it("should extract method calls", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-method-calls.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const methodCalls = calls.filter((c) => c.callType === "MethodCall"); + const methodNames = methodCalls.map((c) => c.calleeName); + expect(methodNames).toContain("validate"); + expect(methodNames).toContain("add"); + expect(methodNames).toContain("subtract"); + }); + + it("should extract nullsafe method calls", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-method-calls.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const resetCall = calls.find((c) => c.calleeName === "reset"); + expect(resetCall).toBeDefined(); + expect(resetCall!.callType).toBe("MethodCall"); + }); + + it("should extract static method calls", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-method-calls.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const createCall = calls.find((c) => c.calleeName === "create"); + expect(createCall).toBeDefined(); + expect(createCall!.callType).toBe("MethodCall"); + }); + + it("should extract constructor calls", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-constructors.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const constructorCalls = calls.filter((c) => c.callType === "Constructor"); + const constructorNames = constructorCalls.map((c) => c.calleeName); + expect(constructorNames).toContain("SimpleClass"); + expect(constructorNames).toContain("ClassWithArgs"); + }); + + it("should extract use imports", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-imports.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const importCalls = calls.filter((c) => c.callType === "Import"); + const importNames = importCalls.map((c) => c.calleeName); + expect(importNames).toContain("User"); + expect(importNames).toContain("AuthService"); + }); + }); }); describe("call graph storage", () => { diff --git a/tests/fixtures/call-graph/php-constructors.php b/tests/fixtures/call-graph/php-constructors.php new file mode 100644 index 0000000..2128a3c --- /dev/null +++ b/tests/fixtures/call-graph/php-constructors.php @@ -0,0 +1,10 @@ +validate(); + return $this; + } + + public function subtract($n) { + return $this; + } + + public function validate() { + return true; + } + + public function reset() { + return $this; + } + + public static function create() { + return new self(); + } +} + +$calc = new Calculator(); +$calc->add(5); +$calc->subtract(2); +$calc?->reset(); +Calculator::create(); diff --git a/tests/fixtures/call-graph/php-simple-calls.php b/tests/fixtures/call-graph/php-simple-calls.php new file mode 100644 index 0000000..a6c38bc --- /dev/null +++ b/tests/fixtures/call-graph/php-simple-calls.php @@ -0,0 +1,19 @@ + Date: Sat, 28 Mar 2026 23:10:28 +0100 Subject: [PATCH 2/2] fix: PHP case-insensitive callers and grouped use imports - Normalize PHP Call/MethodCall callee names to lowercase in Rust extractor so HELPER() matches symbol helper during resolution and lookup - Add COLLATE NOCASE to DB caller queries for case-insensitive matching - Deduplicate call extraction results by (name, line, column) - Add grouped use import query pattern (namespace_use_group) - Add tests for case-insensitive calls, grouped imports, and dedup --- native/queries/php-calls.scm | 6 ++ native/src/call_extractor.rs | 78 ++++++++++++++++++- native/src/db.rs | 5 +- tests/call-graph.test.ts | 22 +++++- tests/fixtures/call-graph/php-imports.php | 1 + .../fixtures/call-graph/php-simple-calls.php | 1 + 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/native/queries/php-calls.scm b/native/queries/php-calls.scm index 8d42c27..e428300 100644 --- a/native/queries/php-calls.scm +++ b/native/queries/php-calls.scm @@ -37,3 +37,9 @@ (namespace_use_clause (qualified_name (name) @import.name))) @import + +; Grouped use imports: use App\Models\{User, Post}; +(namespace_use_declaration + (namespace_use_group + (namespace_use_clause + (name) @import.name))) @import diff --git a/native/src/call_extractor.rs b/native/src/call_extractor.rs index e227c3d..ea42f54 100644 --- a/native/src/call_extractor.rs +++ b/native/src/call_extractor.rs @@ -165,8 +165,16 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result CallType::Call }; + // PHP function/method names are case-insensitive; normalize to lowercase + // so that HELPER() matches symbol helper during resolution and lookup. + let normalized_name = if language == Language::Php { + name.to_lowercase() + } else { + name.clone() + }; + calls.push(CallSite { - callee_name: name.clone(), + callee_name: normalized_name, line: pos.0, column: pos.1, call_type: final_call_type, @@ -181,6 +189,10 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result } } + calls.dedup_by(|a, b| { + a.callee_name == b.callee_name && a.line == b.line && a.column == b.column + }); + Ok(calls) } @@ -425,8 +437,8 @@ mod tests { assert!( calls .iter() - .any(|c| c.callee_name == "directCall" && c.call_type == CallType::Call), - "Expected directCall, got: {:?}", + .any(|c| c.callee_name == "directcall" && c.call_type == CallType::Call), + "Expected directcall (lowercased), got: {:?}", calls ); assert!( @@ -438,6 +450,26 @@ mod tests { ); } + #[test] + fn test_php_case_insensitive_calls() { + let code = "method();\n$obj?->safe();"; @@ -458,6 +490,26 @@ mod tests { ); } + #[test] + fn test_php_case_insensitive_method_calls() { + let code = "Method();\nFoo::Bar();"; + let calls = extract_calls(code, "php").unwrap(); + assert!( + calls + .iter() + .any(|c| c.callee_name == "method" && c.call_type == CallType::MethodCall), + "Expected Method() normalized to method, got: {:?}", + calls + ); + assert!( + calls + .iter() + .any(|c| c.callee_name == "bar" && c.call_type == CallType::MethodCall), + "Expected Bar() normalized to bar, got: {:?}", + calls + ); + } + #[test] fn test_php_static_calls() { let code = " } /// Get all call edges calling a symbol name (filtered by branch) +/// Uses COLLATE NOCASE for target_name to support case-insensitive languages like PHP. pub fn get_callers( conn: &Connection, symbol_name: &str, @@ -995,7 +996,7 @@ pub fn get_callers( FROM call_edges ce INNER JOIN symbols s ON ce.from_symbol_id = s.id INNER JOIN branch_symbols bs ON s.id = bs.symbol_id AND bs.branch = ? - WHERE ce.target_name = ? + WHERE ce.target_name = ? COLLATE NOCASE "#, )?; @@ -1040,7 +1041,7 @@ pub fn get_callers_with_context( FROM call_edges ce INNER JOIN symbols s ON ce.from_symbol_id = s.id INNER JOIN branch_symbols bs ON s.id = bs.symbol_id AND bs.branch = ? - WHERE ce.target_name = ? + WHERE ce.target_name = ? COLLATE NOCASE "#, )?; diff --git a/tests/call-graph.test.ts b/tests/call-graph.test.ts index 068f27c..7cc15d5 100644 --- a/tests/call-graph.test.ts +++ b/tests/call-graph.test.ts @@ -107,15 +107,23 @@ describe("call-graph", () => { const calls = extractCalls(content, "php"); const callNames = calls.map((c) => c.calleeName); - expect(callNames).toContain("directCall"); + expect(callNames).toContain("directcall"); expect(callNames).toContain("helper"); expect(callNames).toContain("compute"); - const directCall = calls.find((c) => c.calleeName === "directCall"); + const directCall = calls.find((c) => c.calleeName === "directcall"); expect(directCall).toBeDefined(); expect(directCall!.callType).toBe("Call"); }); + it("should normalize PHP function names to lowercase", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-simple-calls.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const helperCalls = calls.filter((c) => c.calleeName === "helper" && c.callType === "Call"); + expect(helperCalls.length).toBe(2); + }); + it("should extract method calls", () => { const content = fs.readFileSync(path.join(fixturesDir, "php-method-calls.php"), "utf-8"); const calls = extractCalls(content, "php"); @@ -164,6 +172,16 @@ describe("call-graph", () => { expect(importNames).toContain("User"); expect(importNames).toContain("AuthService"); }); + + it("should extract grouped use imports", () => { + const content = fs.readFileSync(path.join(fixturesDir, "php-imports.php"), "utf-8"); + const calls = extractCalls(content, "php"); + + const importCalls = calls.filter((c) => c.callType === "Import"); + const importNames = importCalls.map((c) => c.calleeName); + expect(importNames).toContain("StringHelper"); + expect(importNames).toContain("ArrayHelper"); + }); }); }); diff --git a/tests/fixtures/call-graph/php-imports.php b/tests/fixtures/call-graph/php-imports.php index e108d82..0f9bb53 100644 --- a/tests/fixtures/call-graph/php-imports.php +++ b/tests/fixtures/call-graph/php-imports.php @@ -2,3 +2,4 @@ use App\Models\User; use App\Services\AuthService; +use App\Helpers\{StringHelper, ArrayHelper}; diff --git a/tests/fixtures/call-graph/php-simple-calls.php b/tests/fixtures/call-graph/php-simple-calls.php index a6c38bc..eb60c2c 100644 --- a/tests/fixtures/call-graph/php-simple-calls.php +++ b/tests/fixtures/call-graph/php-simple-calls.php @@ -4,6 +4,7 @@ function caller() { directCall(); helper(1, 2); $result = compute($data); + HELPER(3, 4); } function directCall() {