use std::collections::{HashMap, HashSet};
use std::path::Path;
use sqry_core::graph::unified::build::helper::CalleeKindHint;
use sqry_core::graph::unified::edge::kind::{FfiConvention, TypeOfContext};
use sqry_core::graph::unified::{GraphBuildHelper, NodeId, StagingGraph};
use sqry_core::graph::{
GraphBuilder, GraphBuilderError, GraphResult, GraphSnapshot, Language, Span,
};
use tree_sitter::{Node, Tree};
use super::phpdoc_parser::{extract_phpdoc_comment, parse_phpdoc_tags};
use super::type_extractor::{canonical_type_string, extract_type_names};
const DEFAULT_MAX_SCOPE_DEPTH: usize = 5;
#[derive(Debug)]
pub struct PhpGraphBuilder {
pub max_scope_depth: usize,
}
impl Default for PhpGraphBuilder {
fn default() -> Self {
Self {
max_scope_depth: DEFAULT_MAX_SCOPE_DEPTH,
}
}
}
impl GraphBuilder for PhpGraphBuilder {
fn build_graph(
&self,
tree: &Tree,
content: &[u8],
file: &Path,
staging: &mut StagingGraph,
) -> GraphResult<()> {
let mut helper = GraphBuildHelper::new(staging, file, Language::Php);
let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: e,
}
})?;
let mut node_map = HashMap::new();
for context in ast_graph.contexts() {
let qualified_name = &context.qualified_name;
let span = Span::from_bytes(context.span.0, context.span.1);
let node_id = match &context.kind {
ContextKind::Function { is_async } => helper.add_function_with_signature(
qualified_name,
Some(span),
*is_async,
false, None, context.return_type.as_deref(),
),
ContextKind::Method {
is_async,
is_static,
visibility: _,
} => {
helper.add_method_with_signature(
qualified_name,
Some(span),
*is_async,
*is_static,
None, context.return_type.as_deref(),
)
}
ContextKind::Class => helper.add_class(qualified_name, Some(span)),
};
node_map.insert(qualified_name.clone(), node_id);
}
let root = tree.root_node();
walk_tree_for_edges(root, content, &ast_graph, &mut helper, &mut node_map)?;
process_oop_relationships(root, content, &mut helper, &mut node_map);
process_exports(root, content, &mut helper, &mut node_map);
process_phpdoc_annotations(root, content, &mut helper)?;
Ok(())
}
fn language(&self) -> Language {
Language::Php
}
fn detect_cross_language_edges(
&self,
_snapshot: &GraphSnapshot,
) -> GraphResult<Vec<sqry_core::graph::CodeEdge>> {
Ok(vec![])
}
}
#[derive(Debug, Clone)]
enum ContextKind {
Function {
is_async: bool,
},
Method {
is_async: bool,
is_static: bool,
#[allow(dead_code)] visibility: Option<String>,
},
Class,
}
#[derive(Debug, Clone)]
struct CallContext {
qualified_name: String,
span: (usize, usize),
kind: ContextKind,
class_name: Option<String>,
return_type: Option<String>,
}
struct ASTGraph {
contexts: Vec<CallContext>,
node_to_context: HashMap<usize, usize>,
}
impl ASTGraph {
fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
let mut contexts = Vec::new();
let mut node_to_context = HashMap::new();
let mut scope_stack: Vec<String> = Vec::new();
let mut class_stack: Vec<String> = Vec::new();
let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
.map_err(|e| format!("Failed to load recursion limits: {e}"))?;
let file_ops_depth = recursion_limits
.effective_file_ops_depth()
.map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
.map_err(|e| format!("Failed to create recursion guard: {e}"))?;
let mut walk_ctx = WalkContext {
contexts: &mut contexts,
node_to_context: &mut node_to_context,
scope_stack: &mut scope_stack,
class_stack: &mut class_stack,
max_depth,
};
walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
Ok(Self {
contexts,
node_to_context,
})
}
fn contexts(&self) -> &[CallContext] {
&self.contexts
}
fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
self.node_to_context
.get(&node_id)
.and_then(|idx| self.contexts.get(*idx))
}
}
#[allow(
clippy::too_many_lines,
reason = "PHP namespace and scope handling requires a large, unified traversal."
)]
struct WalkContext<'a> {
contexts: &'a mut Vec<CallContext>,
node_to_context: &'a mut HashMap<usize, usize>,
scope_stack: &'a mut Vec<String>,
class_stack: &'a mut Vec<String>,
max_depth: usize,
}
#[allow(clippy::too_many_lines)]
fn walk_ast(
node: Node,
content: &[u8],
ctx: &mut WalkContext,
guard: &mut sqry_core::query::security::RecursionGuard,
) -> Result<(), String> {
guard
.enter()
.map_err(|e| format!("Recursion limit exceeded: {e}"))?;
if ctx.scope_stack.len() > ctx.max_depth {
guard.exit();
return Ok(());
}
match node.kind() {
"program" => {
let mut active_namespace_parts: Vec<String> = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "namespace_definition" {
let has_body = child
.children(&mut child.walk())
.any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
let ns_name = child
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default();
if has_body {
for _ in 0..active_namespace_parts.len() {
ctx.scope_stack.pop();
}
active_namespace_parts.clear();
let ns_parts: Vec<String> = if ns_name.is_empty() {
Vec::new()
} else {
ns_name.split('\\').map(ToString::to_string).collect()
};
for part in &ns_parts {
ctx.scope_stack.push(part.clone());
}
for ns_child in child.children(&mut child.walk()) {
if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
{
for body_child in ns_child.children(&mut ns_child.walk()) {
walk_ast(body_child, content, ctx, guard)?;
}
}
}
for _ in 0..ns_parts.len() {
ctx.scope_stack.pop();
}
} else {
for _ in 0..active_namespace_parts.len() {
ctx.scope_stack.pop();
}
active_namespace_parts = if ns_name.is_empty() {
Vec::new()
} else {
ns_name.split('\\').map(ToString::to_string).collect()
};
for part in &active_namespace_parts {
ctx.scope_stack.push(part.clone());
}
}
} else {
walk_ast(child, content, ctx, guard)?;
}
}
for _ in 0..active_namespace_parts.len() {
ctx.scope_stack.pop();
}
guard.exit();
return Ok(());
}
"namespace_definition" => {
let namespace_name = node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default();
let namespace_parts: Vec<String> = if namespace_name.is_empty() {
Vec::new()
} else {
namespace_name
.split('\\')
.map(ToString::to_string)
.collect()
};
let parts_count = namespace_parts.len();
for part in &namespace_parts {
ctx.scope_stack.push(part.clone());
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if matches!(child.kind(), "compound_statement" | "declaration_list") {
let mut body_cursor = child.walk();
for body_child in child.children(&mut body_cursor) {
walk_ast(body_child, content, ctx, guard)?;
}
}
}
for _ in 0..parts_count {
ctx.scope_stack.pop();
}
}
"class_declaration" => {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| "class_declaration missing name".to_string())?;
let class_name = name_node
.utf8_text(content)
.map_err(|_| "failed to read class name".to_string())?;
let qualified_class = if ctx.scope_stack.is_empty() {
class_name.to_string()
} else {
format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
};
ctx.class_stack.push(qualified_class.clone());
ctx.scope_stack.push(class_name.to_string());
let _context_idx = ctx.contexts.len();
ctx.contexts.push(CallContext {
qualified_name: qualified_class.clone(),
span: (node.start_byte(), node.end_byte()),
kind: ContextKind::Class,
class_name: Some(qualified_class),
return_type: None, });
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "declaration_list" {
let mut body_cursor = child.walk();
for body_child in child.children(&mut body_cursor) {
walk_ast(body_child, content, ctx, guard)?;
}
}
}
ctx.class_stack.pop();
ctx.scope_stack.pop();
}
"function_definition" | "method_declaration" => {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
let func_name = name_node
.utf8_text(content)
.map_err(|_| "failed to read function name".to_string())?;
let is_async = false;
let is_static = node
.children(&mut node.walk())
.any(|child| child.kind() == "static_modifier");
let visibility = extract_visibility(&node, content);
let return_type = extract_return_type(&node, content);
let is_method = !ctx.class_stack.is_empty();
let class_name = ctx.class_stack.last().cloned();
let qualified_func = if is_method {
if let Some(ref class) = class_name {
format!("{class}::{func_name}")
} else {
func_name.to_string()
}
} else {
if ctx.scope_stack.is_empty() {
func_name.to_string()
} else {
format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
}
};
let kind = if is_method {
ContextKind::Method {
is_async,
is_static,
visibility: visibility.clone(),
}
} else {
ContextKind::Function { is_async }
};
let context_idx = ctx.contexts.len();
ctx.contexts.push(CallContext {
qualified_name: qualified_func.clone(),
span: (node.start_byte(), node.end_byte()),
kind,
class_name,
return_type,
});
if let Some(body) = node.child_by_field_name("body") {
associate_descendants(body, context_idx, ctx.node_to_context);
}
ctx.scope_stack.push(func_name.to_string());
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
walk_ast(child, content, ctx, guard)?;
}
}
ctx.scope_stack.pop();
}
_ => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_ast(child, content, ctx, guard)?;
}
}
}
guard.exit();
Ok(())
}
fn associate_descendants(
node: Node,
context_idx: usize,
node_to_context: &mut HashMap<usize, usize>,
) {
node_to_context.insert(node.id(), context_idx);
let mut stack = vec![node];
while let Some(current) = stack.pop() {
node_to_context.insert(current.id(), context_idx);
let mut cursor = current.walk();
for child in current.children(&mut cursor) {
stack.push(child);
}
}
}
#[allow(clippy::only_used_in_recursion)]
fn walk_tree_for_edges(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) -> GraphResult<()> {
match node.kind() {
"function_call_expression" => {
process_function_call(node, content, ast_graph, helper, node_map);
}
"member_call_expression" | "nullsafe_member_call_expression" => {
process_member_call(node, content, ast_graph, helper, node_map);
}
"scoped_call_expression" => {
process_static_call(node, content, ast_graph, helper, node_map);
}
"namespace_use_declaration" => {
process_namespace_use(node, content, helper);
}
"expression_statement" => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"require_expression"
| "require_once_expression"
| "include_expression"
| "include_once_expression" => {
process_file_include(child, content, helper);
}
_ => {}
}
}
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
}
Ok(())
}
fn process_function_call(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let Some(function_node) = node.child_by_field_name("function") else {
return;
};
let Ok(callee_name) = function_node.utf8_text(content) else {
return;
};
let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
return;
};
let source_id = *node_map
.entry(call_context.qualified_name.clone())
.or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
let call_span = span_from_node(node);
let target_id = *node_map
.entry(callee_name.to_string())
.or_insert_with(|| helper.ensure_callee(callee_name, call_span, CalleeKindHint::Function));
let argument_count = count_call_arguments(node);
helper.add_call_edge_full_with_span(
source_id,
target_id,
argument_count,
false,
vec![call_span],
);
}
fn process_member_call(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let Some(method_node) = node.child_by_field_name("name") else {
return;
};
let Ok(method_name) = method_node.utf8_text(content) else {
return;
};
if let Some(object_node) = node.child_by_field_name("object")
&& is_php_ffi_call(object_node, content)
{
process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
return;
}
let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
return;
};
let callee_qualified = if let Some(class_name) = &call_context.class_name {
format!("{class_name}::{method_name}")
} else {
method_name.to_string()
};
let source_id = *node_map
.entry(call_context.qualified_name.clone())
.or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
let call_span = span_from_node(node);
let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
});
let argument_count = count_call_arguments(node);
helper.add_call_edge_full_with_span(
source_id,
target_id,
argument_count,
false,
vec![call_span],
);
}
fn process_static_call(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let Some(scope_node) = node.child_by_field_name("scope") else {
return;
};
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Ok(class_name) = scope_node.utf8_text(content) else {
return;
};
let Ok(method_name) = name_node.utf8_text(content) else {
return;
};
if is_ffi_static_call(class_name, method_name) {
process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
return;
}
let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
return;
};
let callee_qualified = format!("{class_name}::{method_name}");
let source_id = *node_map
.entry(call_context.qualified_name.clone())
.or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
let call_span = span_from_node(node);
let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
});
let argument_count = count_call_arguments(node);
helper.add_call_edge_full_with_span(
source_id,
target_id,
argument_count,
false,
vec![call_span],
);
}
fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
let file_path = helper.file_path().to_string();
let importer_id = helper.add_module(&file_path, None);
let mut prefix = String::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "namespace_name"
&& let Ok(ns) = child.utf8_text(content)
{
prefix = ns.trim().to_string();
break;
}
}
cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"namespace_use_clause" => {
process_use_clause(child, content, helper, importer_id);
}
"namespace_use_group" => {
process_use_group(child, content, helper, importer_id, &prefix);
}
_ => {}
}
}
}
fn process_use_clause(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
import_source_id: NodeId,
) {
process_use_clause_with_prefix(node, content, helper, import_source_id, None);
}
fn process_use_clause_with_prefix(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
import_source_id: NodeId,
prefix: Option<&str>,
) {
let mut qualified_name = None;
let mut alias = None;
let mut found_as = false;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"qualified_name" => {
if let Ok(name) = child.utf8_text(content) {
qualified_name = Some(name.trim().to_string());
}
}
"namespace_name" => {
if qualified_name.is_none()
&& let Ok(name) = child.utf8_text(content)
{
qualified_name = Some(name.trim().to_string());
}
}
"name" => {
if found_as {
if let Ok(alias_text) = child.utf8_text(content) {
alias = Some(alias_text.trim().to_string());
}
} else if qualified_name.is_none() {
if let Ok(name) = child.utf8_text(content) {
qualified_name = Some(name.trim().to_string());
}
}
}
"as" => {
found_as = true;
}
_ => {}
}
}
if let Some(name) = qualified_name
&& !name.is_empty()
{
let full_name = if let Some(pfx) = prefix {
format!("{pfx}\\{name}")
} else {
name
};
let span = span_from_node(node);
let import_node_id = helper.add_import(&full_name, Some(span));
if let Some(alias_str) = alias {
helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
} else {
helper.add_import_edge(import_source_id, import_node_id);
}
}
}
fn process_use_group(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
import_source_id: NodeId,
prefix: &str,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "namespace_use_clause" {
process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
}
}
}
fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
let file_path = helper.file_path().to_string();
let import_source_id = helper.add_module(&file_path, None);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "string"
|| child.kind() == "encapsed_string"
|| child.kind() == "binary_expression"
{
if let Ok(path_text) = child.utf8_text(content) {
let cleaned_path = path_text
.trim()
.trim_start_matches(['\'', '"'])
.trim_end_matches(['\'', '"'])
.to_string();
if !cleaned_path.is_empty() {
let span = span_from_node(node);
let import_node_id = helper.add_import(&cleaned_path, Some(span));
helper.add_import_edge(import_source_id, import_node_id);
}
}
break;
}
}
}
fn process_oop_relationships(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let kind = node.kind();
if kind == "class_declaration" {
process_class_oop(node, content, helper, node_map);
} else if kind == "interface_declaration" {
process_interface_inheritance(node, content, helper, node_map);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
process_oop_relationships(child, content, helper, node_map);
}
}
fn process_class_oop(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Ok(class_name) = name_node.utf8_text(content) else {
return;
};
let class_name = class_name.trim();
let span = span_from_node(node);
let class_id = *node_map
.entry(class_name.to_string())
.or_insert_with(|| helper.add_class(class_name, Some(span)));
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"base_clause" => {
process_extends_clause(child, content, helper, node_map, class_id);
}
"class_interface_clause" => {
process_implements_clause(child, content, helper, node_map, class_id);
}
"declaration_list" => {
process_class_body_traits(child, content, helper, node_map, class_id);
}
_ => {}
}
}
}
fn process_extends_clause(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
class_id: NodeId,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "name"
|| child.kind() == "qualified_name"
|| child.kind() == "namespace_name"
{
if let Ok(parent_name) = child.utf8_text(content) {
let parent_name = parent_name.trim();
if !parent_name.is_empty() {
let span = span_from_node(child);
let parent_id = *node_map
.entry(parent_name.to_string())
.or_insert_with(|| helper.add_class(parent_name, Some(span)));
helper.add_inherits_edge(class_id, parent_id);
}
}
break;
}
}
}
fn process_implements_clause(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
class_id: NodeId,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
&& let Ok(interface_name) = child.utf8_text(content)
{
let interface_name = interface_name.trim();
if !interface_name.is_empty() {
let span = span_from_node(child);
let interface_id = *node_map
.entry(interface_name.to_string())
.or_insert_with(|| helper.add_interface(interface_name, Some(span)));
helper.add_implements_edge(class_id, interface_id);
}
}
}
}
fn process_class_body_traits(
declaration_list: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
class_id: NodeId,
) {
let mut cursor = declaration_list.walk();
for child in declaration_list.children(&mut cursor) {
if child.kind() == "use_declaration" {
process_trait_use(child, content, helper, node_map, class_id);
}
}
}
fn process_trait_use(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
class_id: NodeId,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
&& let Ok(trait_name) = child.utf8_text(content)
{
let trait_name = trait_name.trim();
if !trait_name.is_empty() {
let span = span_from_node(child);
let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
helper.add_node(
trait_name,
Some(span),
sqry_core::graph::unified::node::NodeKind::Trait,
)
});
helper.add_implements_edge(class_id, trait_id);
}
}
}
}
fn process_interface_inheritance(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Ok(interface_name) = name_node.utf8_text(content) else {
return;
};
let interface_name = interface_name.trim();
let span = span_from_node(node);
let interface_id = *node_map
.entry(interface_name.to_string())
.or_insert_with(|| helper.add_interface(interface_name, Some(span)));
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "base_clause" {
let mut base_cursor = child.walk();
for base_child in child.children(&mut base_cursor) {
if matches!(
base_child.kind(),
"name" | "qualified_name" | "namespace_name"
) && let Ok(parent_name) = base_child.utf8_text(content)
{
let parent_name = parent_name.trim();
if !parent_name.is_empty() {
let span = span_from_node(base_child);
let parent_id = *node_map
.entry(parent_name.to_string())
.or_insert_with(|| helper.add_interface(parent_name, Some(span)));
helper.add_inherits_edge(interface_id, parent_id);
}
}
}
}
}
}
fn process_exports(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let file_path = helper.file_path().to_string();
let module_id = helper.add_module(&file_path, None);
if node.kind() != "program" {
return;
}
let mut active_namespace = String::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
process_top_level_for_export(
child,
content,
helper,
node_map,
module_id,
&mut active_namespace,
);
}
}
fn process_top_level_for_export(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
module_id: NodeId,
active_namespace: &mut String,
) {
match node.kind() {
"namespace_definition" => {
let ns_name = node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default();
let has_body = node
.children(&mut node.walk())
.any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
if has_body {
active_namespace.clear();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if matches!(child.kind(), "compound_statement" | "declaration_list") {
let mut body_cursor = child.walk();
for body_child in child.children(&mut body_cursor) {
export_declaration_if_exportable(
body_child, content, helper, node_map, module_id, &ns_name,
);
}
}
}
} else {
*active_namespace = ns_name;
}
}
"class_declaration"
| "interface_declaration"
| "trait_declaration"
| "enum_declaration"
| "function_definition" => {
export_declaration_if_exportable(
node,
content,
helper,
node_map,
module_id,
active_namespace,
);
}
_ => {
}
}
}
fn lookup_or_create_node<F>(
node_map: &mut HashMap<String, NodeId>,
qualified_name: &str,
simple_name: &str,
namespace_prefix: &str,
create_fn: F,
) -> NodeId
where
F: FnOnce() -> NodeId,
{
if let Some(&id) = node_map.get(qualified_name) {
return id;
}
if namespace_prefix.is_empty()
&& let Some(&id) = node_map.get(simple_name)
{
return id;
}
let id = create_fn();
node_map.insert(qualified_name.to_string(), id);
id
}
#[allow(clippy::too_many_lines)] fn export_declaration_if_exportable(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
module_id: NodeId,
namespace_prefix: &str,
) {
match node.kind() {
"class_declaration" => {
if let Some(name_node) = node.child_by_field_name("name")
&& let Ok(class_name) = name_node.utf8_text(content)
{
let simple_name = class_name.trim().to_string();
let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
let span = span_from_node(node);
let class_id = lookup_or_create_node(
node_map,
&qualified_name,
&simple_name,
namespace_prefix,
|| helper.add_class(&qualified_name, Some(span)),
);
helper.add_export_edge(module_id, class_id);
export_public_methods_from_class(
node,
content,
helper,
node_map,
module_id,
&qualified_name,
);
}
}
"interface_declaration" => {
if let Some(name_node) = node.child_by_field_name("name")
&& let Ok(interface_name) = name_node.utf8_text(content)
{
let simple_name = interface_name.trim().to_string();
let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
let span = span_from_node(node);
let interface_id = lookup_or_create_node(
node_map,
&qualified_name,
&simple_name,
namespace_prefix,
|| helper.add_interface(&qualified_name, Some(span)),
);
helper.add_export_edge(module_id, interface_id);
}
}
"trait_declaration" => {
if let Some(name_node) = node.child_by_field_name("name")
&& let Ok(trait_name) = name_node.utf8_text(content)
{
let simple_name = trait_name.trim().to_string();
let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
let span = span_from_node(node);
let trait_id = lookup_or_create_node(
node_map,
&qualified_name,
&simple_name,
namespace_prefix,
|| {
helper.add_node(
&qualified_name,
Some(span),
sqry_core::graph::unified::node::NodeKind::Trait,
)
},
);
helper.add_export_edge(module_id, trait_id);
}
}
"enum_declaration" => {
if let Some(name_node) = node.child_by_field_name("name")
&& let Ok(enum_name) = name_node.utf8_text(content)
{
let simple_name = enum_name.trim().to_string();
let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
let span = span_from_node(node);
let enum_id = lookup_or_create_node(
node_map,
&qualified_name,
&simple_name,
namespace_prefix,
|| helper.add_enum(&qualified_name, Some(span)),
);
helper.add_export_edge(module_id, enum_id);
}
}
"function_definition" => {
if let Some(name_node) = node.child_by_field_name("name")
&& let Ok(func_name) = name_node.utf8_text(content)
{
let simple_name = func_name.trim().to_string();
let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
let span = span_from_node(node);
let func_id = lookup_or_create_node(
node_map,
&qualified_name,
&simple_name,
namespace_prefix,
|| helper.add_function(&qualified_name, Some(span), false, false),
);
helper.add_export_edge(module_id, func_id);
}
}
_ => {
}
}
}
fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
if namespace_prefix.is_empty() {
name.to_string()
} else {
format!("{namespace_prefix}\\{name}")
}
}
fn span_from_node(node: Node<'_>) -> Span {
let start = node.start_position();
let end = node.end_position();
Span::new(
sqry_core::graph::node::Position::new(start.row, start.column),
sqry_core::graph::node::Position::new(end.row, end.column),
)
}
fn count_call_arguments(call_node: Node<'_>) -> u8 {
let args_node = call_node
.child_by_field_name("arguments")
.or_else(|| call_node.child_by_field_name("argument_list"))
.or_else(|| {
let mut cursor = call_node.walk();
call_node
.children(&mut cursor)
.find(|child| child.kind() == "argument_list")
});
let Some(args_node) = args_node else {
return 255;
};
let count = args_node.named_child_count();
if count <= 254 {
u8::try_from(count).unwrap_or(u8::MAX)
} else {
255
}
}
fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"visibility_modifier" => {
if let Ok(vis_text) = child.utf8_text(content) {
return Some(vis_text.trim().to_string());
}
}
"public" | "private" | "protected" => {
if let Ok(vis_text) = child.utf8_text(content) {
return Some(vis_text.trim().to_string());
}
}
_ => {}
}
}
None
}
fn export_public_methods_from_class(
class_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
module_id: NodeId,
class_qualified_name: &str,
) {
let mut cursor = class_node.walk();
for child in class_node.children(&mut cursor) {
if child.kind() == "declaration_list" {
let mut body_cursor = child.walk();
for body_child in child.children(&mut body_cursor) {
if body_child.kind() == "method_declaration" {
let visibility = extract_visibility(&body_child, content);
let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
if is_public {
if let Some(name_node) = body_child.child_by_field_name("name")
&& let Ok(method_name) = name_node.utf8_text(content)
{
let method_name = method_name.trim();
let qualified_method_name =
format!("{class_qualified_name}::{method_name}");
if let Some(&method_id) = node_map.get(&qualified_method_name) {
helper.add_export_edge(module_id, method_id);
}
}
}
}
}
break;
}
}
}
fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
let mut found_colon = false;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if found_colon && child.is_named() {
return extract_type_from_node(&child, content);
}
if child.kind() == ":" {
found_colon = true;
}
}
None
}
fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
match type_node.kind() {
"primitive_type" => {
type_node
.utf8_text(content)
.ok()
.map(|s| s.trim().to_string())
}
"optional_type" => {
let mut cursor = type_node.walk();
for child in type_node.children(&mut cursor) {
if child.kind() != "?" && child.is_named() {
return extract_type_from_node(&child, content);
}
}
None
}
"union_type" => {
type_node
.named_child(0)
.and_then(|first_type| extract_type_from_node(&first_type, content))
}
"named_type" | "qualified_name" => {
type_node
.utf8_text(content)
.ok()
.map(|s| s.trim().to_string())
}
"intersection_type" => {
type_node
.named_child(0)
.and_then(|first_type| extract_type_from_node(&first_type, content))
}
_ => {
type_node
.utf8_text(content)
.ok()
.map(|s| {
let trimmed = s.trim();
trimmed
.split(&['|', '&'][..])
.next()
.unwrap_or(trimmed)
.trim()
.trim_start_matches('(')
.trim_end_matches(')')
.trim()
.to_string()
})
.filter(|s| !s.is_empty())
}
}
}
fn process_phpdoc_annotations(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
Ok(())
}
fn process_phpdoc_pass_a(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
explicit_field_ids: &mut HashSet<NodeId>,
) -> GraphResult<()> {
match node.kind() {
"function_definition" => {
process_function_phpdoc(node, content, helper)?;
}
"method_declaration" => {
process_method_phpdoc(node, content, helper)?;
}
"property_declaration" | "simple_property" => {
let emitted = process_property_declaration(node, content, helper);
explicit_field_ids.extend(emitted);
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
}
Ok(())
}
fn process_phpdoc_pass_b(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
explicit_field_ids: &HashSet<NodeId>,
) {
if node.kind() == "method_declaration" {
process_constructor_promotion(node, content, helper, explicit_field_ids);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
}
}
fn process_function_phpdoc(
func_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
return Ok(());
};
let tags = parse_phpdoc_tags(&phpdoc_text);
let Some(name_node) = func_node.child_by_field_name("name") else {
return Ok(());
};
let function_name = name_node
.utf8_text(content)
.map_err(|_| GraphBuilderError::ParseError {
span: span_from_node(func_node),
reason: "failed to read function name".to_string(),
})?
.trim()
.to_string();
if function_name.is_empty() {
return Ok(());
}
let func_node_id = helper.ensure_callee(
&function_name,
span_from_node(func_node),
CalleeKindHint::Function,
);
let _ast_params = extract_ast_parameters(func_node, content);
for (param_idx, param_tag) in tags.params.iter().enumerate() {
let canonical_type = canonical_type_string(¶m_tag.type_str);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
func_node_id,
type_node_id,
Some(TypeOfContext::Parameter),
param_idx.try_into().ok(), Some(¶m_tag.name),
);
let type_names = extract_type_names(¶m_tag.type_str);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(func_node_id, ref_type_id);
}
}
if let Some(return_type) = &tags.returns {
let canonical_type = canonical_type_string(return_type);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
func_node_id,
type_node_id,
Some(TypeOfContext::Return),
Some(0),
None,
);
let type_names = extract_type_names(return_type);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(func_node_id, ref_type_id);
}
}
Ok(())
}
fn process_method_phpdoc(
method_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
return Ok(());
};
let tags = parse_phpdoc_tags(&phpdoc_text);
let Some(name_node) = method_node.child_by_field_name("name") else {
return Ok(());
};
let method_name = name_node
.utf8_text(content)
.map_err(|_| GraphBuilderError::ParseError {
span: span_from_node(method_node),
reason: "failed to read method name".to_string(),
})?
.trim()
.to_string();
if method_name.is_empty() {
return Ok(());
}
let class_name = get_enclosing_class_name(method_node, content)?;
let Some(class_name) = class_name else {
return Ok(());
};
let qualified_name = format!("{class_name}.{method_name}");
let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
let _ast_params = extract_ast_parameters(method_node, content);
for (param_idx, param_tag) in tags.params.iter().enumerate() {
let canonical_type = canonical_type_string(¶m_tag.type_str);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
method_node_id,
type_node_id,
Some(TypeOfContext::Parameter),
param_idx.try_into().ok(),
Some(¶m_tag.name),
);
let type_names = extract_type_names(¶m_tag.type_str);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(method_node_id, ref_type_id);
}
}
if let Some(return_type) = &tags.returns {
let canonical_type = canonical_type_string(return_type);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
method_node_id,
type_node_id,
Some(TypeOfContext::Return),
Some(0),
None,
);
let type_names = extract_type_names(return_type);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(method_node_id, ref_type_id);
}
}
Ok(())
}
fn process_property_declaration(
prop_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> Vec<NodeId> {
let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
return Vec::new();
};
let mods = extract_property_modifiers(prop_node, content);
let native_type = prop_node
.child_by_field_name("type")
.and_then(|t| extract_type_from_node(&t, content));
let phpdoc_var_type = if native_type.is_none() {
extract_phpdoc_comment(prop_node, content)
.as_deref()
.and_then(|c| parse_phpdoc_tags(c).var_type)
} else {
None
};
let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
let prop_names = extract_property_element_names(prop_node, content);
if prop_names.is_empty() {
return Vec::new();
}
let span = span_from_node(prop_node);
let mut emitted = Vec::with_capacity(prop_names.len());
for prop_name in prop_names {
let qualified_name = format!("{owner_name}.{prop_name}");
let visibility = mods.visibility.as_deref().unwrap_or("public");
let node_id = if mods.is_readonly {
helper.add_constant_with_name_static_and_visibility(
&prop_name,
&qualified_name,
Some(span),
mods.is_static,
Some(visibility),
)
} else {
helper.add_property_with_name_static_and_visibility(
&prop_name,
&qualified_name,
Some(span),
mods.is_static,
Some(visibility),
)
};
if let Some(type_str) = primary_type.as_deref() {
emit_field_type_edges(helper, node_id, &prop_name, type_str);
}
emitted.push(node_id);
}
emitted
}
fn process_constructor_promotion(
method_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
explicit_field_ids: &HashSet<NodeId>,
) {
let Some(name_node) = method_node.child_by_field_name("name") else {
return;
};
let Ok(method_name) = name_node.utf8_text(content) else {
return;
};
if method_name.trim() != "__construct" {
return;
}
let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
return;
};
let Some(params_node) = method_node.child_by_field_name("parameters") else {
return;
};
let mut cursor = params_node.walk();
for param in params_node.children(&mut cursor) {
if param.kind() != "property_promotion_parameter" {
continue;
}
let visibility = param
.child_by_field_name("visibility")
.and_then(|v| v.utf8_text(content).ok())
.map(|s| s.trim().to_string());
let is_readonly = param.child_by_field_name("readonly").is_some()
|| direct_child_of_kind(param, "readonly_modifier").is_some();
let is_static = false;
let native_type = param
.child_by_field_name("type")
.and_then(|t| extract_type_from_node(&t, content));
let Some(prop_name) = promoted_param_name(param, content) else {
continue;
};
let qualified_name = format!("{owner_name}.{prop_name}");
let span = span_from_node(param);
if let Some(existing_id) = helper.get_node(&qualified_name) {
if explicit_field_ids.contains(&existing_id) {
continue;
}
if let Some(t) = native_type {
emit_field_type_edges(helper, existing_id, &prop_name, &t);
}
continue;
}
let visibility_ref = visibility.as_deref().unwrap_or("public");
let node_id = if is_readonly {
helper.add_constant_with_name_static_and_visibility(
&prop_name,
&qualified_name,
Some(span),
is_static,
Some(visibility_ref),
)
} else {
helper.add_property_with_name_static_and_visibility(
&prop_name,
&qualified_name,
Some(span),
is_static,
Some(visibility_ref),
)
};
if let Some(type_str) = native_type {
emit_field_type_edges(helper, node_id, &prop_name, &type_str);
}
}
}
struct PropertyModifiers {
visibility: Option<String>,
is_static: bool,
is_readonly: bool,
}
fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
let mut visibility: Option<String> = None;
let mut is_static = false;
let mut is_readonly = false;
let mut cursor = prop_node.walk();
for child in prop_node.children(&mut cursor) {
match child.kind() {
"visibility_modifier" => {
if let Ok(text) = child.utf8_text(content) {
visibility = Some(text.trim().to_string());
}
}
"var_modifier" => {
if visibility.is_none() {
visibility = Some("public".to_string());
}
}
"static_modifier" => {
is_static = true;
}
"readonly_modifier" => {
is_readonly = true;
}
_ => {}
}
}
PropertyModifiers {
visibility,
is_static,
is_readonly,
}
}
fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
let mut names = Vec::new();
let mut cursor = prop_node.walk();
for child in prop_node.children(&mut cursor) {
if child.kind() != "property_element" {
continue;
}
if let Some(var_node) = child.child_by_field_name("name")
&& let Some(name) = strip_dollar_from_variable(var_node, content)
{
names.push(name);
}
}
names
}
fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
let name_field = param.child_by_field_name("name")?;
let var_node = if name_field.kind() == "variable_name" {
name_field
} else {
let mut cursor = name_field.walk();
name_field
.children(&mut cursor)
.find(|c| c.kind() == "variable_name")?
};
strip_dollar_from_variable(var_node, content)
}
fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
if let Some(name_node) = var_node.child_by_field_name("name")
&& let Ok(text) = name_node.utf8_text(content)
{
return Some(text.trim().to_string());
}
var_node
.utf8_text(content)
.ok()
.map(|s| s.trim().trim_start_matches('$').to_string())
}
fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
let mut cursor = node.walk();
node.children(&mut cursor).find(|c| c.kind() == kind)
}
fn emit_field_type_edges(
helper: &mut GraphBuildHelper,
node_id: NodeId,
prop_name: &str,
type_str: &str,
) {
let canonical_type = canonical_type_string(type_str);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
node_id,
type_node_id,
Some(TypeOfContext::Field),
None,
Some(prop_name),
);
for ref_type_name in extract_type_names(type_str) {
let ref_type_id = helper.add_type(&ref_type_name, None);
helper.add_reference_edge(node_id, ref_type_id);
}
}
fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
let mut current = node;
while let Some(parent) = current.parent() {
if matches!(
parent.kind(),
"class_declaration" | "trait_declaration" | "interface_declaration"
) {
return parent
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok())
.map(|s| s.trim().to_string());
}
current = parent;
}
None
}
fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
let mut params = Vec::new();
let Some(params_node) = func_node.child_by_field_name("parameters") else {
return params;
};
let mut index = 0;
let mut cursor = params_node.walk();
for child in params_node.children(&mut cursor) {
if !child.is_named() {
continue;
}
match child.kind() {
"simple_parameter" => {
let mut param_cursor = child.walk();
for param_child in child.children(&mut param_cursor) {
if param_child.kind() == "variable_name"
&& let Ok(param_text) = param_child.utf8_text(content)
{
params.push((index, param_text.trim().to_string()));
index += 1;
break;
}
}
}
"variadic_parameter" => {
let mut param_cursor = child.walk();
for param_child in child.children(&mut param_cursor) {
if param_child.kind() == "variable_name"
&& let Ok(param_text) = param_child.utf8_text(content)
{
params.push((index, param_text.trim().to_string()));
index += 1;
break;
}
}
}
_ => {}
}
}
params
}
#[allow(clippy::unnecessary_wraps)]
fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
let mut current = node;
while let Some(parent) = current.parent() {
if parent.kind() == "class_declaration" {
if let Some(name_node) = parent.child_by_field_name("name")
&& let Ok(name_text) = name_node.utf8_text(content)
{
return Ok(Some(name_text.trim().to_string()));
}
return Ok(None);
}
current = parent;
}
Ok(None)
}
fn process_ffi_member_call(
node: Node,
method_name: &str,
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
) {
let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
return;
};
let source_id = *node_map
.entry(call_context.qualified_name.clone())
.or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
let ffi_name = format!("native::ffi::{method_name}");
let call_span = span_from_node(node);
let target_id = helper.add_module(&ffi_name, Some(call_span));
helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
}
fn process_ffi_static_call(
node: Node,
method_name: &str,
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
node_map: &mut HashMap<String, NodeId>,
content: &[u8],
) {
let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
return;
};
let source_id = *node_map
.entry(call_context.qualified_name.clone())
.or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
.map_or_else(
|| "unknown".to_string(),
|lib| php_ffi_library_simple_name(&lib),
);
let ffi_name = format!("native::{library_name}");
let call_span = span_from_node(node);
let target_id = helper.add_module(&ffi_name, Some(call_span));
helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
}
fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
if object_node.kind() == "scoped_call_expression"
&& let Some(scope_node) = object_node.child_by_field_name("scope")
&& let Some(name_node) = object_node.child_by_field_name("name")
&& let Ok(scope_text) = scope_node.utf8_text(content)
&& let Ok(name_text) = name_node.utf8_text(content)
&& is_ffi_static_call(scope_text, name_text)
{
return true;
}
if object_node.kind() == "parenthesized_expression"
&& let Some(inner) = object_node.named_child(0)
&& inner.kind() == "scoped_call_expression"
&& let Some(scope_node) = inner.child_by_field_name("scope")
&& let Some(name_node) = inner.child_by_field_name("name")
&& let Ok(scope_text) = scope_node.utf8_text(content)
&& let Ok(name_text) = name_node.utf8_text(content)
&& is_ffi_static_call(scope_text, name_text)
{
return true;
}
let Ok(object_text) = object_node.utf8_text(content) else {
return false;
};
let object_text = object_text.trim();
if object_text == "$ffi" || object_text == "$_ffi" {
return true;
}
if object_text.ends_with("->ffi")
|| object_text.ends_with("::$ffi")
|| object_text.ends_with("->_ffi")
|| object_text.ends_with("::$_ffi")
{
return true;
}
false
}
fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
(scope_text == "FFI" || scope_text == "\\FFI")
&& (method_text == "cdef" || method_text == "load")
}
fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
let args = call_node.child_by_field_name("arguments")?;
let mut cursor = args.walk();
let args_vec: Vec<Node> = args
.children(&mut cursor)
.filter(|child| !matches!(child.kind(), "(" | ")" | ","))
.collect();
let target_arg_name = if is_cdef { "lib" } else { "filename" };
if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
return extract_string_from_argument(named_arg, content);
}
if is_cdef {
args_vec
.get(1)
.and_then(|arg| extract_string_from_argument(*arg, content))
} else {
args_vec
.first()
.and_then(|arg| extract_string_from_argument(*arg, content))
}
}
fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
for arg in args {
if arg.kind() != "argument" {
continue;
}
if arg.named_child_count() < 2 {
continue;
}
if let Some(name_node) = arg.child_by_field_name("name")
&& let Ok(name_text) = name_node.utf8_text(content)
&& name_text == param_name
{
return Some(*arg);
} else if let Some(name_node) = arg.named_child(0)
&& let Ok(name_text) = name_node.utf8_text(content)
&& name_text == param_name
{
return Some(*arg);
}
}
None
}
fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
let value_node = unwrap_argument_node(arg_node)?;
if !is_string_literal_node(value_node) {
return None;
}
if is_interpolated_string(value_node) {
return None;
}
extract_php_string_content(value_node, content)
}
fn unwrap_argument_node(node: Node) -> Option<Node> {
if node.kind() != "argument" {
return Some(node);
}
let name_field_node = node.child_by_field_name("name");
let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
for i in 0..node.named_child_count() {
#[allow(clippy::cast_possible_truncation)] if let Some(child) = node.named_child(i as u32) {
let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
if !is_name_field && !is_ref_modifier {
return Some(child);
}
}
}
None
}
fn is_string_literal_node(node: Node) -> bool {
matches!(
node.kind(),
"string" | "encapsed_string" | "heredoc" | "nowdoc"
)
}
fn is_interpolated_string(node: Node) -> bool {
if !matches!(node.kind(), "encapsed_string" | "heredoc") {
return false;
}
has_variable_node(node)
}
fn has_variable_node(node: Node) -> bool {
if matches!(
node.kind(),
"variable_name" | "simple_variable" | "variable" | "complex_variable"
| "dynamic_variable_name"
| "subscript_expression" | "member_access_expression" | "member_call_expression"
| "function_call_expression"
| "scoped_call_expression" | "scoped_property_access_expression"
| "class_constant_access_expression"
| "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
) {
return true;
}
for i in 0..node.child_count() {
#[allow(clippy::cast_possible_truncation)] if let Some(child) = node.child(i as u32)
&& has_variable_node(child)
{
return true;
}
}
false
}
fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
let Ok(text) = string_node.utf8_text(content) else {
return None;
};
let text = text.trim();
if ((text.starts_with('"') && text.ends_with('"'))
|| (text.starts_with('\'') && text.ends_with('\'')))
&& text.len() >= 2
{
return Some(text[1..text.len() - 1].to_string());
}
Some(text.to_string())
}
fn php_ffi_library_simple_name(library_path: &str) -> String {
use std::path::Path;
let filename = Path::new(library_path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(library_path);
if let Some(so_pos) = filename.find(".so.") {
return filename[..so_pos].to_string();
}
if let Some(dot_pos) = filename.find('.') {
let extension = &filename[dot_pos + 1..];
if extension == "so"
|| extension == "dll"
|| extension == "dylib"
|| extension == "h"
|| extension == "hpp"
{
return filename[..dot_pos].to_string();
}
}
filename.to_string()
}
#[cfg(test)]
mod field_emission_tests {
use sqry_core::graph::GraphBuilder;
use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
use sqry_core::graph::unified::build::test_helpers::{
build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
};
use sqry_core::graph::unified::edge::EdgeKind;
use sqry_core::graph::unified::edge::kind::TypeOfContext;
use sqry_core::graph::unified::node::NodeKind;
use std::path::Path;
use tree_sitter::Parser;
use super::PhpGraphBuilder;
fn parse(source: &str) -> tree_sitter::Tree {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_php::LANGUAGE_PHP.into())
.expect("load PHP grammar");
parser.parse(source, None).expect("parse PHP source")
}
fn build(source: &str) -> StagingGraph {
let tree = parse(source);
let mut staging = StagingGraph::new();
let builder = PhpGraphBuilder::default();
builder
.build_graph(
&tree,
source.as_bytes(),
Path::new("test.php"),
&mut staging,
)
.expect("build graph");
staging
}
fn find_node<'a>(
staging: &'a StagingGraph,
name: &str,
kind: Option<NodeKind>,
) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
let strings = build_string_lookup(staging);
for op in staging.operations() {
if let StagingOp::AddNode { entry, .. } = op {
if let Some(k) = kind
&& entry.kind != k
{
continue;
}
let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
if let Some(s) = strings.get(&name_idx)
&& s == name
{
return Some(entry);
}
}
}
None
}
fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
let strings = build_string_lookup(staging);
staging
.operations()
.iter()
.filter(|op| {
if let StagingOp::AddNode { entry, .. } = op {
let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
strings.get(&name_idx).is_some_and(|s| s == name)
} else {
false
}
})
.count()
}
fn resolve_visibility(
staging: &StagingGraph,
vis: Option<sqry_core::graph::unified::StringId>,
) -> Option<String> {
let strings = build_string_lookup(staging);
vis.and_then(|sid| strings.get(&sid.index()).cloned())
}
fn typeof_edges_for_node(
staging: &StagingGraph,
source_name: &str,
) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
let names = build_node_name_lookup(staging);
let strings = build_string_lookup(staging);
let mut out = Vec::new();
for op in staging.operations() {
if let StagingOp::AddEdge {
source,
target,
kind: EdgeKind::TypeOf { context, name, .. },
..
} = op
{
let src = names.get(source).cloned().unwrap_or_default();
if src != source_name {
continue;
}
let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
let target_name = names.get(target).cloned().unwrap_or_default();
out.push((*context, edge_name, target_name));
}
}
out
}
#[test]
fn req_r0001_property_without_phpdoc_emits_property_node() {
let src = "<?php
class User {
public string $name;
}
";
let staging = build(src);
let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
.expect("User.name Property must be emitted without @var");
assert_eq!(entry.kind, NodeKind::Property);
}
#[test]
fn req_r0001_property_with_phpdoc_still_emits_property_node() {
let src = "<?php
class Repo {
/** @var string */
public string $label;
}
";
let staging = build(src);
find_node(&staging, "Repo.label", Some(NodeKind::Property))
.expect("Repo.label Property must be emitted when @var is present");
}
#[test]
fn req_r0002_qualified_name_uses_class_dot_prop() {
let src = "<?php
class A { public int $x; }
class B { public int $x; }
";
let staging = build(src);
find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
assert!(
find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
"no bare 'x' Property node should leak"
);
}
#[test]
fn req_r0002_visibility_modifiers_round_trip() {
let src = "<?php
class V {
public int $a;
private int $b;
protected int $c;
var $d;
}
";
let staging = build(src);
for (name, expected) in [
("V.a", "public"),
("V.b", "private"),
("V.c", "protected"),
("V.d", "public"),
] {
let entry = find_node(&staging, name, Some(NodeKind::Property))
.unwrap_or_else(|| panic!("missing {name}"));
let got = resolve_visibility(&staging, entry.visibility);
assert_eq!(
got.as_deref(),
Some(expected),
"{name} visibility should be {expected}"
);
}
}
#[test]
fn req_r0002_default_visibility_is_public_when_no_modifier() {
let src = "<?php
class X { static int $count = 0; }
";
let staging = build(src);
let entry =
find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
let vis = resolve_visibility(&staging, entry.visibility);
assert_eq!(
vis.as_deref(),
Some("public"),
"default visibility is public"
);
}
#[test]
fn req_r0003_static_modifier_sets_is_static() {
let src = "<?php
class S {
public static int $count = 0;
public int $instance = 0;
}
";
let staging = build(src);
let s_count =
find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
assert!(s_count.is_static, "S.count should be static");
let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
.expect("S.instance must exist");
assert!(!s_instance.is_static, "S.instance should not be static");
}
#[test]
fn req_r0004_readonly_emits_constant() {
let src = "<?php
class R {
public readonly string $id;
public string $name;
}
";
let staging = build(src);
find_node(&staging, "R.id", Some(NodeKind::Constant))
.expect("R.id must be Constant (readonly)");
find_node(&staging, "R.name", Some(NodeKind::Property))
.expect("R.name must be Property (mutable)");
}
#[test]
fn req_r0005_native_type_takes_precedence_over_phpdoc() {
let src = "<?php
class T {
/** @var {int} */
public string $value;
}
";
let staging = build(src);
let edges = typeof_edges_for_node(&staging, "T.value");
assert!(
!edges.is_empty(),
"T.value should have at least one TypeOf edge"
);
let has_string = edges.iter().any(|(_, _, t)| t == "string");
assert!(
has_string,
"native type 'string' should be the primary TypeOf target, got {edges:?}"
);
let has_int = edges.iter().any(|(_, _, t)| t == "int");
assert!(
!has_int,
"PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
);
}
#[test]
fn req_r0005_phpdoc_fallback_when_no_native_type() {
let src = "<?php
class T {
/** @var {SomeUserType} */
public $value;
}
";
let staging = build(src);
let edges = typeof_edges_for_node(&staging, "T.value");
assert!(
edges.iter().any(|(_, _, t)| t == "SomeUserType"),
"PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
);
}
#[test]
fn req_r0006_typeof_uses_field_context_and_bare_name() {
let src = "<?php
class C {
public string $title;
}
";
let staging = build(src);
let edges = typeof_edges_for_node(&staging, "C.title");
assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
for (ctx, name, _) in &edges {
assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
assert_eq!(
name.as_deref(),
Some("title"),
"edge name must be the bare property name"
);
}
}
#[test]
fn req_r0007_constructor_promotion_emits_property_on_class() {
let src = "<?php
class P {
public function __construct(public int $x, private readonly string $y) {}
}
";
let staging = build(src);
let x = find_node(&staging, "P.x", Some(NodeKind::Property))
.expect("promoted P.x must be a Property");
assert_eq!(
resolve_visibility(&staging, x.visibility).as_deref(),
Some("public"),
"promoted $x visibility"
);
let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
.expect("promoted readonly P.y must be a Constant");
assert_eq!(
resolve_visibility(&staging, y.visibility).as_deref(),
Some("private"),
"promoted $y visibility"
);
}
#[test]
fn req_r0013_explicit_declaration_wins_over_promotion() {
let src = "<?php
class D {
public int $x;
public function __construct(public int $x) {}
}
";
let staging = build(src);
let n = count_nodes_named(&staging, "D.x");
assert_eq!(
n, 1,
"exactly one D.x node when explicit decl + promotion collide, got {n}"
);
find_node(&staging, "D.x", Some(NodeKind::Property))
.expect("D.x must be Property (explicit declaration wins)");
}
#[test]
fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
let src = "<?php
class A {
public function __construct(public string $x) {}
public int $x;
}
";
let staging = build(src);
let n = count_nodes_named(&staging, "A.x");
assert_eq!(
n, 1,
"exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
);
find_node(&staging, "A.x", Some(NodeKind::Property))
.expect("A.x must be Property (explicit declaration wins)");
let edges = typeof_edges_for_node(&staging, "A.x");
let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
assert!(
target_types.contains(&"int"),
"explicit `int` TypeOf must be present, got {target_types:?}",
);
assert!(
!target_types.contains(&"string"),
"promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
);
}
#[test]
fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
let src = "<?php
class B {
public int $x;
public function __construct(public string $x) {}
}
";
let staging = build(src);
let n = count_nodes_named(&staging, "B.x");
assert_eq!(
n, 1,
"exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
);
find_node(&staging, "B.x", Some(NodeKind::Property))
.expect("B.x must be Property (explicit declaration wins)");
let edges = typeof_edges_for_node(&staging, "B.x");
let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
assert!(
target_types.contains(&"int"),
"explicit `int` TypeOf must be present, got {target_types:?}",
);
assert!(
!target_types.contains(&"string"),
"promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
);
}
#[test]
fn req_r0023_span_anchored_on_declaration() {
let src = "<?php
class W {
public string $marker;
}
";
let staging = build(src);
let entry =
find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
assert_eq!(
entry.start_line, 4,
"span start line should match declaration"
);
assert_eq!(entry.end_line, 4, "span end line should match declaration");
assert_eq!(
entry.start_column, 4,
"span start column should match indentation of `public`"
);
assert!(
entry.end_column > entry.start_column,
"span end column must extend past start (got start={}, end={})",
entry.start_column,
entry.end_column,
);
}
#[test]
fn req_r0001_trait_property_emitted() {
let src = "<?php
trait Loggable {
protected ?string $logTag;
}
";
let staging = build(src);
let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
.expect("trait property must be emitted");
let vis = resolve_visibility(&staging, entry.visibility);
assert_eq!(vis.as_deref(), Some("protected"));
}
#[test]
fn no_emission_outside_class_or_trait_or_interface() {
let src = "<?php
$x = 1;
function f() { $y = 2; }
";
let staging = build(src);
assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
}
}