Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions native/queries/php-calls.scm
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
; =============================================================
; 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

; Grouped use imports: use App\Models\{User, Post};
(namespace_use_declaration
(namespace_use_group
(namespace_use_clause
(name) @import.name))) @import
181 changes: 180 additions & 1 deletion native/src/call_extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result<Vec<CallSite>
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![]),
};

Expand All @@ -51,6 +52,7 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result<Vec<CallSite>
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![]),
};

Expand All @@ -72,6 +74,11 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result<Vec<CallSite>
Language::Python => &["attribute"],
Language::Rust => &["field_expression"],
Language::Go => &["selector_expression"],
Language::Php => &[
"member_call_expression",
"scoped_call_expression",
"nullsafe_member_call_expression",
],
_ => &[],
};

Expand Down Expand Up @@ -158,8 +165,16 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result<Vec<CallSite>
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,
Expand All @@ -174,6 +189,10 @@ pub fn extract_calls(content: &str, language_name: &str) -> Result<Vec<CallSite>
}
}

calls.dedup_by(|a, b| {
a.callee_name == b.callee_name && a.line == b.line && a.column == b.column
});

Ok(calls)
}

Expand Down Expand Up @@ -410,4 +429,164 @@ mod tests {
let calls = extract_calls(code, "html").unwrap();
assert_eq!(calls.len(), 0);
}

#[test]
fn test_php_direct_calls() {
let code = "<?php\nfunction caller() { directCall(); helper(1, 2); }";
let calls = extract_calls(code, "php").unwrap();
assert!(
calls
.iter()
.any(|c| c.callee_name == "directcall" && c.call_type == CallType::Call),
"Expected directcall (lowercased), got: {:?}",
calls
);
assert!(
calls
.iter()
.any(|c| c.callee_name == "helper" && c.call_type == CallType::Call),
"Expected helper call, got: {:?}",
calls
);
}

#[test]
fn test_php_case_insensitive_calls() {
let code = "<?php\nfunction caller() { HELPER(); MyFunc(); }";
let calls = extract_calls(code, "php").unwrap();
assert!(
calls
.iter()
.any(|c| c.callee_name == "helper" && c.call_type == CallType::Call),
"Expected HELPER() normalized to helper, got: {:?}",
calls
);
assert!(
calls
.iter()
.any(|c| c.callee_name == "myfunc" && c.call_type == CallType::Call),
"Expected MyFunc() normalized to myfunc, got: {:?}",
calls
);
}

#[test]
fn test_php_method_calls() {
let code = "<?php\n$obj->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_case_insensitive_method_calls() {
let code = "<?php\n$obj->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 = "<?php\nFoo::bar();\nself::create();";
let calls = extract_calls(code, "php").unwrap();
assert!(
calls
.iter()
.any(|c| c.callee_name == "bar" && c.call_type == CallType::MethodCall),
"Expected static method call, got: {:?}",
calls
);
assert!(
calls
.iter()
.any(|c| c.callee_name == "create" && c.call_type == CallType::MethodCall),
"Expected static method call, got: {:?}",
calls
);
}

#[test]
fn test_php_grouped_imports() {
let code = "<?php\nuse App\\Helpers\\{StringHelper, ArrayHelper};";
let calls = extract_calls(code, "php").unwrap();
assert!(
calls
.iter()
.any(|c| c.callee_name == "StringHelper" && c.call_type == CallType::Import),
"Expected StringHelper import, got: {:?}",
calls
);
assert!(
calls
.iter()
.any(|c| c.callee_name == "ArrayHelper" && c.call_type == CallType::Import),
"Expected ArrayHelper import, got: {:?}",
calls
);
}

#[test]
fn test_php_constructors() {
let code = "<?php\n$obj = new SimpleClass();\n$obj2 = new ClassWithArgs(1, 2);";
let calls = extract_calls(code, "php").unwrap();
assert!(
calls
.iter()
.any(|c| c.callee_name == "SimpleClass" && c.call_type == CallType::Constructor),
"Expected SimpleClass constructor, got: {:?}",
calls
);
assert!(
calls
.iter()
.any(|c| c.callee_name == "ClassWithArgs" && c.call_type == CallType::Constructor),
"Expected ClassWithArgs constructor, got: {:?}",
calls
);
}

#[test]
fn test_php_imports() {
let code = "<?php\nuse App\\Models\\User;\nuse App\\Services\\AuthService;";
let calls = extract_calls(code, "php").unwrap();
assert!(
calls
.iter()
.any(|c| c.callee_name == "User" && c.call_type == CallType::Import),
"Expected User import, got: {:?}",
calls
);
assert!(
calls
.iter()
.any(|c| c.callee_name == "AuthService" && c.call_type == CallType::Import),
"Expected AuthService import, got: {:?}",
calls
);
}
}
5 changes: 3 additions & 2 deletions native/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,7 @@ pub fn upsert_call_edges_batch(conn: &mut Connection, edges: &[CallEdgeRow]) ->
}

/// 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,
Expand All @@ -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
"#,
)?;

Expand Down Expand Up @@ -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
"#,
)?;

Expand Down
3 changes: 2 additions & 1 deletion src/indexer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import type { SymbolData, CallEdgeData } from "../native/index.js";
import { getBranchOrDefault, getBaseBranch, isGitRepo } from "../git/index.js";

const CALL_GRAPH_LANGUAGES = new Set(["typescript", "tsx", "javascript", "jsx", "python", "go", "rust"]);
const CALL_GRAPH_LANGUAGES = new Set(["typescript", "tsx", "javascript", "jsx", "python", "go", "rust", "php"]);
const CALL_GRAPH_SYMBOL_CHUNK_TYPES = new Set([
"function_declaration",
"function",
Expand All @@ -54,6 +54,7 @@ const CALL_GRAPH_SYMBOL_CHUNK_TYPES = new Set([
"enum_item",
"trait_item",
"mod_item",
"trait_declaration",
]);

function float32ArrayToBuffer(arr: number[]): Buffer {
Expand Down
Loading
Loading