Skip to main content

sqry_lang_php/relations/
graph_builder.rs

1//! PHP `GraphBuilder` implementation for tier-2 graph coverage.
2//!
3//! Migrated to use unified `GraphBuildHelper` following Phase 2.
4//!
5//! # Supported Features
6//!
7//! - Function definitions
8//! - Class definitions
9//! - Method definitions (including static methods)
10//! - Function calls
11//! - Method calls
12//! - Static method calls
13//! - Namespace handling
14//! - Import edges:
15//!   - `use Namespace\Class` statements
16//!   - `use Namespace\Class as Alias` aliased imports
17//!   - `use Namespace\{Class1, Class2}` grouped imports
18//!   - `use function Namespace\func` function imports
19//!   - `use const Namespace\CONST` constant imports
20//!   - `require`, `require_once`, `include`, `include_once` statements
21//! - OOP edges:
22//!   - `class Child extends Parent` inheritance
23//!   - `class Foo implements IBar, IBaz` interface implementation
24//!   - `use SomeTrait` trait usage within classes
25//! - Export edges:
26//!   - All top-level classes, interfaces, traits, and functions are exported
27//!   - PHP's module system treats all top-level symbols as implicitly visible
28//! - `TypeOf` and Reference edges:
29//!   - `@param {Type}` `PHPDoc` annotations for function/method parameters
30//!   - `@return {Type}` `PHPDoc` annotations for function/method return types
31//!   - `@var {Type}` `PHPDoc` annotations for variable and property declarations
32
33use std::collections::HashMap;
34use std::path::Path;
35
36use sqry_core::graph::unified::edge::kind::{FfiConvention, TypeOfContext};
37use sqry_core::graph::unified::{GraphBuildHelper, NodeId, StagingGraph};
38use sqry_core::graph::{
39    GraphBuilder, GraphBuilderError, GraphResult, GraphSnapshot, Language, Span,
40};
41use tree_sitter::{Node, Tree};
42
43use super::phpdoc_parser::{extract_phpdoc_comment, parse_phpdoc_tags};
44use super::type_extractor::{canonical_type_string, extract_type_names};
45
46/// Maximum namespace nesting depth to prevent pathological cases.
47const DEFAULT_MAX_SCOPE_DEPTH: usize = 5;
48
49/// PHP-specific graph builder.
50#[derive(Debug)]
51pub struct PhpGraphBuilder {
52    pub max_scope_depth: usize,
53}
54
55impl Default for PhpGraphBuilder {
56    fn default() -> Self {
57        Self {
58            max_scope_depth: DEFAULT_MAX_SCOPE_DEPTH,
59        }
60    }
61}
62
63impl GraphBuilder for PhpGraphBuilder {
64    fn build_graph(
65        &self,
66        tree: &Tree,
67        content: &[u8],
68        file: &Path,
69        staging: &mut StagingGraph,
70    ) -> GraphResult<()> {
71        let mut helper = GraphBuildHelper::new(staging, file, Language::Php);
72
73        // Build AST context for O(1) function lookups
74        let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
75            GraphBuilderError::ParseError {
76                span: Span::default(),
77                reason: e,
78            }
79        })?;
80
81        // Map qualified names to NodeIds for call edge creation
82        let mut node_map = HashMap::new();
83
84        // Phase 1: Create function/method/class nodes
85        for context in ast_graph.contexts() {
86            let qualified_name = &context.qualified_name;
87            let span = Span::from_bytes(context.span.0, context.span.1);
88
89            let node_id = match &context.kind {
90                ContextKind::Function { is_async } => helper.add_function_with_signature(
91                    qualified_name,
92                    Some(span),
93                    *is_async,
94                    false, // PHP functions are not unsafe
95                    None,  // PHP functions don't have visibility modifiers
96                    context.return_type.as_deref(),
97                ),
98                ContextKind::Method {
99                    is_async,
100                    is_static,
101                    visibility: _,
102                } => {
103                    // Note: Visibility metadata is stored in the CallContext and used during export filtering.
104                    // It's not added to the node metadata at this time due to GraphBuildHelper API limitations.
105                    // The export phase (Phase 4) will filter methods based on visibility.
106                    helper.add_method_with_signature(
107                        qualified_name,
108                        Some(span),
109                        *is_async,
110                        *is_static,
111                        None, // Visibility not yet supported in GraphBuildHelper API
112                        context.return_type.as_deref(),
113                    )
114                }
115                ContextKind::Class => helper.add_class(qualified_name, Some(span)),
116            };
117            node_map.insert(qualified_name.clone(), node_id);
118        }
119
120        // Phase 2: Walk the tree to find calls, imports, and OOP relationships
121        let root = tree.root_node();
122        walk_tree_for_edges(root, content, &ast_graph, &mut helper, &mut node_map)?;
123
124        // Phase 3: Process class inheritance and interface implementations
125        process_oop_relationships(root, content, &mut helper, &mut node_map);
126
127        // Phase 4: Generate export edges for all top-level symbols
128        // In PHP, all classes/interfaces/traits/functions are implicitly exported
129        process_exports(root, content, &mut helper, &mut node_map);
130
131        // Phase 5: Process PHPDoc annotations for TypeOf and Reference edges
132        process_phpdoc_annotations(root, content, &mut helper)?;
133
134        Ok(())
135    }
136
137    fn language(&self) -> Language {
138        Language::Php
139    }
140
141    fn detect_cross_language_edges(
142        &self,
143        _snapshot: &GraphSnapshot,
144    ) -> GraphResult<Vec<sqry_core::graph::CodeEdge>> {
145        // Cross-file edge detection not implemented by design.
146        // Intra-file FFI detection is implemented in build_graph() above.
147        Ok(vec![])
148    }
149}
150
151// ============================================================================
152// AST Graph - tracks callable contexts (functions, methods, classes)
153// ============================================================================
154
155#[derive(Debug, Clone)]
156enum ContextKind {
157    Function {
158        is_async: bool,
159    },
160    Method {
161        is_async: bool,
162        is_static: bool,
163        #[allow(dead_code)] // Used in export_public_methods_from_class via AST traversal
164        visibility: Option<String>,
165    },
166    Class,
167}
168
169#[derive(Debug, Clone)]
170struct CallContext {
171    qualified_name: String,
172    span: (usize, usize),
173    kind: ContextKind,
174    class_name: Option<String>,
175    return_type: Option<String>,
176}
177
178struct ASTGraph {
179    contexts: Vec<CallContext>,
180    node_to_context: HashMap<usize, usize>,
181}
182
183impl ASTGraph {
184    fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
185        let mut contexts = Vec::new();
186        let mut node_to_context = HashMap::new();
187        let mut scope_stack: Vec<String> = Vec::new();
188        let mut class_stack: Vec<String> = Vec::new();
189
190        // Create recursion guard
191        let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
192            .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
193        let file_ops_depth = recursion_limits
194            .effective_file_ops_depth()
195            .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
196        let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
197            .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
198
199        let mut walk_ctx = WalkContext {
200            contexts: &mut contexts,
201            node_to_context: &mut node_to_context,
202            scope_stack: &mut scope_stack,
203            class_stack: &mut class_stack,
204            max_depth,
205        };
206
207        walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
208
209        Ok(Self {
210            contexts,
211            node_to_context,
212        })
213    }
214
215    fn contexts(&self) -> &[CallContext] {
216        &self.contexts
217    }
218
219    fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
220        self.node_to_context
221            .get(&node_id)
222            .and_then(|idx| self.contexts.get(*idx))
223    }
224}
225
226#[allow(
227    clippy::too_many_lines,
228    reason = "PHP namespace and scope handling requires a large, unified traversal."
229)]
230/// # Errors
231///
232/// Returns error if recursion depth exceeds the guard's limit.
233/// Context for AST walking, bundling mutable state to reduce parameter count.
234struct WalkContext<'a> {
235    contexts: &'a mut Vec<CallContext>,
236    node_to_context: &'a mut HashMap<usize, usize>,
237    scope_stack: &'a mut Vec<String>,
238    class_stack: &'a mut Vec<String>,
239    max_depth: usize,
240}
241
242#[allow(clippy::too_many_lines)]
243fn walk_ast(
244    node: Node,
245    content: &[u8],
246    ctx: &mut WalkContext,
247    guard: &mut sqry_core::query::security::RecursionGuard,
248) -> Result<(), String> {
249    guard
250        .enter()
251        .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
252
253    if ctx.scope_stack.len() > ctx.max_depth {
254        guard.exit();
255        return Ok(());
256    }
257
258    match node.kind() {
259        "program" => {
260            // Special handling for program node to properly track semicolon-style namespaces.
261            // In PHP, `namespace Foo;` affects all subsequent sibling declarations at program level.
262            let mut active_namespace_parts: Vec<String> = Vec::new();
263
264            let mut cursor = node.walk();
265            for child in node.children(&mut cursor) {
266                if child.kind() == "namespace_definition" {
267                    // Check if this is semicolon-style or brace-style
268                    let has_body = child
269                        .children(&mut child.walk())
270                        .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
271
272                    let ns_name = child
273                        .child_by_field_name("name")
274                        .and_then(|n| n.utf8_text(content).ok())
275                        .map(|s| s.trim().to_string())
276                        .unwrap_or_default();
277
278                    if has_body {
279                        // Brace-style: `namespace Foo { ... }` - process with its own scope
280                        //
281                        // Robustness: If a brace-style namespace follows a semicolon-style
282                        // namespace (invalid PHP, but possible in fixtures/partial parses),
283                        // we must first clear the active semicolon namespace to avoid
284                        // scope pollution.
285                        for _ in 0..active_namespace_parts.len() {
286                            ctx.scope_stack.pop();
287                        }
288                        active_namespace_parts.clear();
289
290                        let ns_parts: Vec<String> = if ns_name.is_empty() {
291                            Vec::new()
292                        } else {
293                            ns_name.split('\\').map(ToString::to_string).collect()
294                        };
295
296                        for part in &ns_parts {
297                            ctx.scope_stack.push(part.clone());
298                        }
299
300                        // Process children of the brace body
301                        for ns_child in child.children(&mut child.walk()) {
302                            if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
303                            {
304                                for body_child in ns_child.children(&mut ns_child.walk()) {
305                                    walk_ast(body_child, content, ctx, guard)?;
306                                }
307                            }
308                        }
309
310                        for _ in 0..ns_parts.len() {
311                            ctx.scope_stack.pop();
312                        }
313                    } else {
314                        // Semicolon-style: `namespace Foo;` - update active namespace
315                        // First, pop any previous namespace from scope_stack
316                        for _ in 0..active_namespace_parts.len() {
317                            ctx.scope_stack.pop();
318                        }
319
320                        // Set the new active namespace
321                        active_namespace_parts = if ns_name.is_empty() {
322                            Vec::new()
323                        } else {
324                            ns_name.split('\\').map(ToString::to_string).collect()
325                        };
326
327                        // Push new namespace parts to scope_stack
328                        for part in &active_namespace_parts {
329                            ctx.scope_stack.push(part.clone());
330                        }
331                    }
332                } else {
333                    // Non-namespace declaration at program level - uses current scope
334                    walk_ast(child, content, ctx, guard)?;
335                }
336            }
337
338            // Clean up any remaining namespace from scope_stack
339            for _ in 0..active_namespace_parts.len() {
340                ctx.scope_stack.pop();
341            }
342
343            guard.exit();
344            return Ok(());
345        }
346        "namespace_definition" => {
347            // This branch handles namespace definitions when NOT at program level
348            // (e.g., nested namespaces or when called from other ctx.contexts)
349            let namespace_name = node
350                .child_by_field_name("name")
351                .and_then(|n| n.utf8_text(content).ok())
352                .map(|s| s.trim().to_string())
353                .unwrap_or_default();
354
355            let namespace_parts: Vec<String> = if namespace_name.is_empty() {
356                Vec::new()
357            } else {
358                namespace_name
359                    .split('\\')
360                    .map(ToString::to_string)
361                    .collect()
362            };
363
364            let parts_count = namespace_parts.len();
365            for part in &namespace_parts {
366                ctx.scope_stack.push(part.clone());
367            }
368
369            // Recurse into namespace body (either braced block or rest of file)
370            let mut cursor = node.walk();
371            for child in node.children(&mut cursor) {
372                if matches!(child.kind(), "compound_statement" | "declaration_list") {
373                    let mut body_cursor = child.walk();
374                    for body_child in child.children(&mut body_cursor) {
375                        walk_ast(body_child, content, ctx, guard)?;
376                    }
377                }
378            }
379
380            // Pop namespace parts
381            for _ in 0..parts_count {
382                ctx.scope_stack.pop();
383            }
384        }
385        "class_declaration" => {
386            let name_node = node
387                .child_by_field_name("name")
388                .ok_or_else(|| "class_declaration missing name".to_string())?;
389            let class_name = name_node
390                .utf8_text(content)
391                .map_err(|_| "failed to read class name".to_string())?;
392
393            // Build qualified class name using PHP namespace separator
394            let qualified_class = if ctx.scope_stack.is_empty() {
395                class_name.to_string()
396            } else {
397                format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
398            };
399
400            ctx.class_stack.push(qualified_class.clone());
401            ctx.scope_stack.push(class_name.to_string());
402
403            // Add class context
404            let _context_idx = ctx.contexts.len();
405            ctx.contexts.push(CallContext {
406                qualified_name: qualified_class.clone(),
407                span: (node.start_byte(), node.end_byte()),
408                kind: ContextKind::Class,
409                class_name: Some(qualified_class),
410                return_type: None, // Classes don't have return types
411            });
412
413            // Recurse into class body
414            let mut cursor = node.walk();
415            for child in node.children(&mut cursor) {
416                if child.kind() == "declaration_list" {
417                    let mut body_cursor = child.walk();
418                    for body_child in child.children(&mut body_cursor) {
419                        walk_ast(body_child, content, ctx, guard)?;
420                    }
421                }
422            }
423
424            ctx.class_stack.pop();
425            ctx.scope_stack.pop();
426        }
427        "function_definition" | "method_declaration" => {
428            let name_node = node
429                .child_by_field_name("name")
430                .ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
431            let func_name = name_node
432                .utf8_text(content)
433                .map_err(|_| "failed to read function name".to_string())?;
434
435            // Check if async (PHP 8.1+ supports async/await via Fibers)
436            let is_async = false; // PHP doesn't have native async keyword like JS/Python
437
438            // Check if static method
439            let is_static = node
440                .children(&mut node.walk())
441                .any(|child| child.kind() == "static_modifier");
442
443            // Extract visibility modifier for methods (public, private, protected)
444            let visibility = extract_visibility(&node, content);
445
446            // Extract return type annotation (PHP 7.0+)
447            let return_type = extract_return_type(&node, content);
448
449            // Determine if this is a method (inside a class)
450            let is_method = !ctx.class_stack.is_empty();
451            let class_name = ctx.class_stack.last().cloned();
452
453            // Build qualified function/method name
454            // For methods: use ClassName::methodName format (with ::)
455            // For functions: use Namespace\functionName format (with \)
456            let qualified_func = if is_method {
457                // Method: use ClassName::methodName
458                if let Some(ref class) = class_name {
459                    format!("{class}::{func_name}")
460                } else {
461                    func_name.to_string()
462                }
463            } else {
464                // Function: use namespace\function format
465                if ctx.scope_stack.is_empty() {
466                    func_name.to_string()
467                } else {
468                    format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
469                }
470            };
471
472            let kind = if is_method {
473                ContextKind::Method {
474                    is_async,
475                    is_static,
476                    visibility: visibility.clone(),
477                }
478            } else {
479                ContextKind::Function { is_async }
480            };
481
482            let context_idx = ctx.contexts.len();
483            ctx.contexts.push(CallContext {
484                qualified_name: qualified_func.clone(),
485                span: (node.start_byte(), node.end_byte()),
486                kind,
487                class_name,
488                return_type,
489            });
490
491            // Associate all descendants with this context
492            if let Some(body) = node.child_by_field_name("body") {
493                associate_descendants(body, context_idx, ctx.node_to_context);
494            }
495
496            ctx.scope_stack.push(func_name.to_string());
497
498            // Recurse into function body to find nested functions
499            if let Some(body) = node.child_by_field_name("body") {
500                let mut cursor = body.walk();
501                for child in body.children(&mut cursor) {
502                    walk_ast(child, content, ctx, guard)?;
503                }
504            }
505
506            ctx.scope_stack.pop();
507        }
508        _ => {
509            // Recurse into children for other node types
510            let mut cursor = node.walk();
511            for child in node.children(&mut cursor) {
512                walk_ast(child, content, ctx, guard)?;
513            }
514        }
515    }
516
517    guard.exit();
518    Ok(())
519}
520
521fn associate_descendants(
522    node: Node,
523    context_idx: usize,
524    node_to_context: &mut HashMap<usize, usize>,
525) {
526    node_to_context.insert(node.id(), context_idx);
527
528    let mut stack = vec![node];
529    while let Some(current) = stack.pop() {
530        node_to_context.insert(current.id(), context_idx);
531
532        let mut cursor = current.walk();
533        for child in current.children(&mut cursor) {
534            stack.push(child);
535        }
536    }
537}
538
539// ============================================================================
540// Edge Building - calls, method calls, static calls
541// ============================================================================
542
543/// Walk the AST tree to create edges (calls, imports)
544#[allow(clippy::only_used_in_recursion)]
545fn walk_tree_for_edges(
546    node: Node,
547    content: &[u8],
548    ast_graph: &ASTGraph,
549    helper: &mut GraphBuildHelper,
550    node_map: &mut HashMap<String, NodeId>,
551) -> GraphResult<()> {
552    match node.kind() {
553        "function_call_expression" => {
554            process_function_call(node, content, ast_graph, helper, node_map);
555        }
556        "member_call_expression" | "nullsafe_member_call_expression" => {
557            process_member_call(node, content, ast_graph, helper, node_map);
558        }
559        "scoped_call_expression" => {
560            process_static_call(node, content, ast_graph, helper, node_map);
561        }
562        // Import edges for namespace use declarations
563        "namespace_use_declaration" => {
564            process_namespace_use(node, content, helper);
565        }
566        // Import edges for require/require_once/include/include_once
567        "expression_statement" => {
568            // Check for require/include expressions within expression statements
569            let mut cursor = node.walk();
570            for child in node.children(&mut cursor) {
571                match child.kind() {
572                    "require_expression"
573                    | "require_once_expression"
574                    | "include_expression"
575                    | "include_once_expression" => {
576                        process_file_include(child, content, helper);
577                    }
578                    _ => {}
579                }
580            }
581        }
582        _ => {}
583    }
584
585    // Recurse into children
586    let mut cursor = node.walk();
587    for child in node.children(&mut cursor) {
588        walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
589    }
590
591    Ok(())
592}
593
594fn process_function_call(
595    node: Node,
596    content: &[u8],
597    ast_graph: &ASTGraph,
598    helper: &mut GraphBuildHelper,
599    node_map: &mut HashMap<String, NodeId>,
600) {
601    let Some(function_node) = node.child_by_field_name("function") else {
602        return;
603    };
604
605    let Ok(callee_name) = function_node.utf8_text(content) else {
606        return;
607    };
608
609    // Get the caller context
610    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
611        return;
612    };
613
614    // Get or create caller node
615    let source_id = *node_map
616        .entry(call_context.qualified_name.clone())
617        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
618
619    // Get or create callee node
620    let target_id = *node_map
621        .entry(callee_name.to_string())
622        .or_insert_with(|| helper.add_function(callee_name, None, false, false));
623
624    let argument_count = count_call_arguments(node);
625    let call_span = span_from_node(node);
626    helper.add_call_edge_full_with_span(
627        source_id,
628        target_id,
629        argument_count,
630        false,
631        vec![call_span],
632    );
633}
634
635fn process_member_call(
636    node: Node,
637    content: &[u8],
638    ast_graph: &ASTGraph,
639    helper: &mut GraphBuildHelper,
640    node_map: &mut HashMap<String, NodeId>,
641) {
642    let Some(method_node) = node.child_by_field_name("name") else {
643        return;
644    };
645
646    let Ok(method_name) = method_node.utf8_text(content) else {
647        return;
648    };
649
650    // Check if this is an FFI call (e.g., $ffi->crypto_encrypt())
651    if let Some(object_node) = node.child_by_field_name("object")
652        && is_php_ffi_call(object_node, content)
653    {
654        process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
655        return;
656    }
657
658    // Get the caller context
659    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
660        return;
661    };
662
663    // For $this->method(), resolve to ClassName::method using :: separator
664    let callee_qualified = if let Some(class_name) = &call_context.class_name {
665        format!("{class_name}::{method_name}")
666    } else {
667        method_name.to_string()
668    };
669
670    // Get or create caller node
671    let source_id = *node_map
672        .entry(call_context.qualified_name.clone())
673        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
674
675    // Get or create callee node
676    let target_id = *node_map
677        .entry(callee_qualified.clone())
678        .or_insert_with(|| helper.add_method(&callee_qualified, None, false, false));
679
680    let argument_count = count_call_arguments(node);
681    let call_span = span_from_node(node);
682    helper.add_call_edge_full_with_span(
683        source_id,
684        target_id,
685        argument_count,
686        false,
687        vec![call_span],
688    );
689}
690
691fn process_static_call(
692    node: Node,
693    content: &[u8],
694    ast_graph: &ASTGraph,
695    helper: &mut GraphBuildHelper,
696    node_map: &mut HashMap<String, NodeId>,
697) {
698    let Some(scope_node) = node.child_by_field_name("scope") else {
699        return;
700    };
701    let Some(name_node) = node.child_by_field_name("name") else {
702        return;
703    };
704
705    let Ok(class_name) = scope_node.utf8_text(content) else {
706        return;
707    };
708    let Ok(method_name) = name_node.utf8_text(content) else {
709        return;
710    };
711
712    // Check if this is an FFI static call (FFI::cdef() or FFI::load())
713    if is_ffi_static_call(class_name, method_name) {
714        process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
715        return;
716    }
717
718    // Get the caller context
719    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
720        return;
721    };
722
723    // Static call: Class::method() - use :: separator for methods
724    let callee_qualified = format!("{class_name}::{method_name}");
725
726    // Get or create caller node
727    let source_id = *node_map
728        .entry(call_context.qualified_name.clone())
729        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
730
731    // Get or create callee node
732    let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
733        helper.add_method(&callee_qualified, None, false, true) // static method
734    });
735
736    let argument_count = count_call_arguments(node);
737    let call_span = span_from_node(node);
738    helper.add_call_edge_full_with_span(
739        source_id,
740        target_id,
741        argument_count,
742        false,
743        vec![call_span],
744    );
745}
746
747// ============================================================================
748// Import Edge Building - namespace use, require, include
749// ============================================================================
750
751/// Process PHP `use` declarations for namespace imports.
752///
753/// Handles:
754/// - `use Namespace\Class;` - simple use
755/// - `use Namespace\Class as Alias;` - aliased use
756/// - `use Namespace\{Class1, Class2};` - grouped use
757/// - `use function Namespace\func;` - function use
758/// - `use const Namespace\CONST;` - constant use
759fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
760    // Create a module node for the current file
761    let file_path = helper.file_path().to_string();
762    let importer_id = helper.add_module(&file_path, None);
763
764    // For grouped imports, we need to extract the prefix at the declaration level
765    // AST: namespace_use_declaration > namespace_name > namespace_use_group
766    let mut prefix = String::new();
767    let mut cursor = node.walk();
768    for child in node.children(&mut cursor) {
769        if child.kind() == "namespace_name"
770            && let Ok(ns) = child.utf8_text(content)
771        {
772            prefix = ns.trim().to_string();
773            break;
774        }
775    }
776
777    // Process children for imports
778    cursor = node.walk();
779    for child in node.children(&mut cursor) {
780        match child.kind() {
781            "namespace_use_clause" => {
782                // Simple or aliased use: use Namespace\Class [as Alias];
783                process_use_clause(child, content, helper, importer_id);
784            }
785            "namespace_use_group" => {
786                // Grouped use: use Namespace\{Class1, Class2};
787                // Pass the prefix we extracted at the declaration level
788                process_use_group(child, content, helper, importer_id, &prefix);
789            }
790            _ => {}
791        }
792    }
793}
794
795/// Process a single `use` clause like `Namespace\Class` or `Namespace\Class as Alias`.
796///
797/// AST structure for aliased use (`use App\Services\Mailer as Mail;`):
798/// ```text
799/// namespace_use_clause
800///   qualified_name "App\Services\Mailer"
801///     namespace_name "App\Services"
802///     name "Mailer"
803///   as "as"
804///   name "Mail"   <- this is the alias (sibling, not nested)
805/// ```
806fn process_use_clause(
807    node: Node,
808    content: &[u8],
809    helper: &mut GraphBuildHelper,
810    import_source_id: NodeId,
811) {
812    process_use_clause_with_prefix(node, content, helper, import_source_id, None);
813}
814
815/// Process a use clause with an optional namespace prefix (for grouped imports).
816fn process_use_clause_with_prefix(
817    node: Node,
818    content: &[u8],
819    helper: &mut GraphBuildHelper,
820    import_source_id: NodeId,
821    prefix: Option<&str>,
822) {
823    // Get the qualified name (e.g., "App\Services\Mailer")
824    let mut qualified_name = None;
825    let mut alias = None;
826    let mut found_as = false;
827
828    let mut cursor = node.walk();
829    for child in node.children(&mut cursor) {
830        match child.kind() {
831            "qualified_name" => {
832                // Full qualified name like "App\Services\Mailer"
833                if let Ok(name) = child.utf8_text(content) {
834                    qualified_name = Some(name.trim().to_string());
835                }
836            }
837            "namespace_name" => {
838                // Namespace part - only use if no qualified_name yet
839                if qualified_name.is_none()
840                    && let Ok(name) = child.utf8_text(content)
841                {
842                    qualified_name = Some(name.trim().to_string());
843                }
844            }
845            "name" => {
846                // Could be simple name OR the alias after "as"
847                if found_as {
848                    // This is the alias name
849                    if let Ok(alias_text) = child.utf8_text(content) {
850                        alias = Some(alias_text.trim().to_string());
851                    }
852                } else if qualified_name.is_none() {
853                    // Simple name without namespace
854                    if let Ok(name) = child.utf8_text(content) {
855                        qualified_name = Some(name.trim().to_string());
856                    }
857                }
858            }
859            "as" => {
860                // Mark that the next "name" node is the alias
861                found_as = true;
862            }
863            _ => {}
864        }
865    }
866
867    if let Some(name) = qualified_name
868        && !name.is_empty()
869    {
870        // Apply prefix for grouped imports
871        let full_name = if let Some(pfx) = prefix {
872            format!("{pfx}\\{name}")
873        } else {
874            name
875        };
876
877        // Create an import node for the imported symbol
878        let span = span_from_node(node);
879        let import_node_id = helper.add_import(&full_name, Some(span));
880
881        // Add import edge with optional alias
882        if let Some(alias_str) = alias {
883            helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
884        } else {
885            helper.add_import_edge(import_source_id, import_node_id);
886        }
887    }
888}
889
890/// Process a grouped use declaration like `use Namespace\{Class1, Class2, Class3 as C3}`.
891///
892/// AST structure for grouped use (`use App\Models\{User, Post, Comment};`):
893/// ```text
894/// namespace_use_declaration
895///   use "use"
896///   namespace_name "App\Models"   <- prefix is here, at declaration level
897///   \ "\"
898///   namespace_use_group            <- this is passed to us
899///     { "{"
900///     namespace_use_clause "User"  <- NOT namespace_use_group_clause!
901///       name "User"
902///     , ","
903///     namespace_use_clause "Post"
904///       name "Post"
905///     ...
906///     } "}"
907/// ```
908fn process_use_group(
909    node: Node,
910    content: &[u8],
911    helper: &mut GraphBuildHelper,
912    import_source_id: NodeId,
913    prefix: &str,
914) {
915    // Process each clause in the group
916    let mut cursor = node.walk();
917    for child in node.children(&mut cursor) {
918        // The clauses inside the group are "namespace_use_clause", not "namespace_use_group_clause"
919        if child.kind() == "namespace_use_clause" {
920            // Reuse the same clause processing logic with the prefix
921            process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
922        }
923    }
924}
925
926/// Process file inclusion statements (require, `require_once`, include, `include_once`).
927fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
928    // Create importer node for current file
929    let file_path = helper.file_path().to_string();
930    let import_source_id = helper.add_module(&file_path, None);
931
932    // Extract the file path from the expression
933    // The path is typically a string literal or an expression
934    let mut cursor = node.walk();
935    for child in node.children(&mut cursor) {
936        if child.kind() == "string"
937            || child.kind() == "encapsed_string"
938            || child.kind() == "binary_expression"
939        {
940            if let Ok(path_text) = child.utf8_text(content) {
941                // Clean up the path string (remove quotes)
942                let cleaned_path = path_text
943                    .trim()
944                    .trim_start_matches(['\'', '"'])
945                    .trim_end_matches(['\'', '"'])
946                    .to_string();
947
948                if !cleaned_path.is_empty() {
949                    let span = span_from_node(node);
950                    let import_node_id = helper.add_import(&cleaned_path, Some(span));
951                    helper.add_import_edge(import_source_id, import_node_id);
952                }
953            }
954            break;
955        }
956    }
957}
958
959// ============================================================================
960// OOP Edge Building - inheritance, interfaces, traits
961// ============================================================================
962
963/// Process all class declarations to extract OOP relationships.
964fn process_oop_relationships(
965    node: Node,
966    content: &[u8],
967    helper: &mut GraphBuildHelper,
968    node_map: &mut HashMap<String, NodeId>,
969) {
970    let kind = node.kind();
971    if kind == "class_declaration" {
972        process_class_oop(node, content, helper, node_map);
973    } else if kind == "interface_declaration" {
974        process_interface_inheritance(node, content, helper, node_map);
975    }
976
977    // Recurse into children
978    let mut cursor = node.walk();
979    for child in node.children(&mut cursor) {
980        process_oop_relationships(child, content, helper, node_map);
981    }
982}
983
984/// Process a class declaration to extract inheritance, interface implementation, and trait usage.
985fn process_class_oop(
986    node: Node,
987    content: &[u8],
988    helper: &mut GraphBuildHelper,
989    node_map: &mut HashMap<String, NodeId>,
990) {
991    // Get the class name
992    let Some(name_node) = node.child_by_field_name("name") else {
993        return;
994    };
995    let Ok(class_name) = name_node.utf8_text(content) else {
996        return;
997    };
998    let class_name = class_name.trim();
999
1000    // Get or create the class node
1001    let span = span_from_node(node);
1002    let class_id = *node_map
1003        .entry(class_name.to_string())
1004        .or_insert_with(|| helper.add_class(class_name, Some(span)));
1005
1006    // Process children to find base_clause (extends), class_interface_clause (implements), and use_declaration (traits)
1007    let mut cursor = node.walk();
1008    for child in node.children(&mut cursor) {
1009        match child.kind() {
1010            "base_clause" => {
1011                // class Child extends Parent
1012                process_extends_clause(child, content, helper, node_map, class_id);
1013            }
1014            "class_interface_clause" => {
1015                // class Foo implements IBar, IBaz
1016                process_implements_clause(child, content, helper, node_map, class_id);
1017            }
1018            "declaration_list" => {
1019                // Look for trait use declarations inside the class body
1020                process_class_body_traits(child, content, helper, node_map, class_id);
1021            }
1022            _ => {}
1023        }
1024    }
1025}
1026
1027/// Process `extends Parent` clause to create Inherits edge.
1028fn process_extends_clause(
1029    node: Node,
1030    content: &[u8],
1031    helper: &mut GraphBuildHelper,
1032    node_map: &mut HashMap<String, NodeId>,
1033    class_id: NodeId,
1034) {
1035    // base_clause contains the parent class name
1036    let mut cursor = node.walk();
1037    for child in node.children(&mut cursor) {
1038        if child.kind() == "name"
1039            || child.kind() == "qualified_name"
1040            || child.kind() == "namespace_name"
1041        {
1042            if let Ok(parent_name) = child.utf8_text(content) {
1043                let parent_name = parent_name.trim();
1044                if !parent_name.is_empty() {
1045                    let span = span_from_node(child);
1046                    let parent_id = *node_map
1047                        .entry(parent_name.to_string())
1048                        .or_insert_with(|| helper.add_class(parent_name, Some(span)));
1049
1050                    helper.add_inherits_edge(class_id, parent_id);
1051                }
1052            }
1053            break;
1054        }
1055    }
1056}
1057
1058/// Process `implements IFoo, IBar` clause to create Implements edges.
1059fn process_implements_clause(
1060    node: Node,
1061    content: &[u8],
1062    helper: &mut GraphBuildHelper,
1063    node_map: &mut HashMap<String, NodeId>,
1064    class_id: NodeId,
1065) {
1066    // class_interface_clause contains interface names
1067    let mut cursor = node.walk();
1068    for child in node.children(&mut cursor) {
1069        if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1070            && let Ok(interface_name) = child.utf8_text(content)
1071        {
1072            let interface_name = interface_name.trim();
1073            if !interface_name.is_empty() {
1074                let span = span_from_node(child);
1075                let interface_id = *node_map
1076                    .entry(interface_name.to_string())
1077                    .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1078
1079                helper.add_implements_edge(class_id, interface_id);
1080            }
1081        }
1082    }
1083}
1084
1085/// Process trait usage within a class body (`use TraitName;`).
1086fn process_class_body_traits(
1087    declaration_list: Node,
1088    content: &[u8],
1089    helper: &mut GraphBuildHelper,
1090    node_map: &mut HashMap<String, NodeId>,
1091    class_id: NodeId,
1092) {
1093    let mut cursor = declaration_list.walk();
1094    for child in declaration_list.children(&mut cursor) {
1095        if child.kind() == "use_declaration" {
1096            // This is a trait use: use TraitName;
1097            process_trait_use(child, content, helper, node_map, class_id);
1098        }
1099    }
1100}
1101
1102/// Process a single trait use declaration (`use TraitName, AnotherTrait;`).
1103fn process_trait_use(
1104    node: Node,
1105    content: &[u8],
1106    helper: &mut GraphBuildHelper,
1107    node_map: &mut HashMap<String, NodeId>,
1108    class_id: NodeId,
1109) {
1110    // use_declaration contains trait names
1111    let mut cursor = node.walk();
1112    for child in node.children(&mut cursor) {
1113        if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1114            && let Ok(trait_name) = child.utf8_text(content)
1115        {
1116            let trait_name = trait_name.trim();
1117            if !trait_name.is_empty() {
1118                let span = span_from_node(child);
1119                // Use add_node for traits since there's no dedicated add_trait method
1120                // We'll use the Trait NodeKind
1121                let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
1122                    helper.add_node(
1123                        trait_name,
1124                        Some(span),
1125                        sqry_core::graph::unified::node::NodeKind::Trait,
1126                    )
1127                });
1128
1129                // Trait usage is modeled as an Implements edge
1130                // (similar to interface implementation from a semantic perspective)
1131                helper.add_implements_edge(class_id, trait_id);
1132            }
1133        }
1134    }
1135}
1136
1137/// Process interface declaration to handle interface inheritance (`extends`).
1138fn process_interface_inheritance(
1139    node: Node,
1140    content: &[u8],
1141    helper: &mut GraphBuildHelper,
1142    node_map: &mut HashMap<String, NodeId>,
1143) {
1144    // Get the interface name
1145    let Some(name_node) = node.child_by_field_name("name") else {
1146        return;
1147    };
1148    let Ok(interface_name) = name_node.utf8_text(content) else {
1149        return;
1150    };
1151    let interface_name = interface_name.trim();
1152
1153    // Get or create the interface node
1154    let span = span_from_node(node);
1155    let interface_id = *node_map
1156        .entry(interface_name.to_string())
1157        .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1158
1159    // Process base_clause for interface inheritance (interface IFoo extends IBar, IBaz)
1160    let mut cursor = node.walk();
1161    for child in node.children(&mut cursor) {
1162        if child.kind() == "base_clause" {
1163            // Interface extends other interfaces
1164            let mut base_cursor = child.walk();
1165            for base_child in child.children(&mut base_cursor) {
1166                if matches!(
1167                    base_child.kind(),
1168                    "name" | "qualified_name" | "namespace_name"
1169                ) && let Ok(parent_name) = base_child.utf8_text(content)
1170                {
1171                    let parent_name = parent_name.trim();
1172                    if !parent_name.is_empty() {
1173                        let span = span_from_node(base_child);
1174                        let parent_id = *node_map
1175                            .entry(parent_name.to_string())
1176                            .or_insert_with(|| helper.add_interface(parent_name, Some(span)));
1177
1178                        // Interface inheritance uses Inherits edge
1179                        helper.add_inherits_edge(interface_id, parent_id);
1180                    }
1181                }
1182            }
1183        }
1184    }
1185}
1186
1187// ============================================================================
1188// Export Edge Building - PHP implicitly exports all top-level symbols
1189// ============================================================================
1190
1191/// Process all top-level declarations to create export edges.
1192///
1193/// In PHP, all classes, interfaces, traits, enums, and functions defined at the
1194/// top level (or within a namespace) are implicitly exported and visible to other
1195/// files via `require`/`use` statements. This function creates export edges from
1196/// the file module to each such symbol.
1197///
1198/// # Namespace Handling
1199///
1200/// PHP has two namespace forms:
1201/// - **Brace-style**: `namespace Foo { class Bar {} }` - contained declarations
1202/// - **Semicolon-style**: `namespace Foo; class Bar {}` - applies to subsequent siblings
1203///
1204/// This implementation handles both by doing a linear scan of `program` children.
1205fn process_exports(
1206    node: Node,
1207    content: &[u8],
1208    helper: &mut GraphBuildHelper,
1209    node_map: &mut HashMap<String, NodeId>,
1210) {
1211    // Create module node for this file
1212    let file_path = helper.file_path().to_string();
1213    let module_id = helper.add_module(&file_path, None);
1214
1215    // The program node is expected; if not, return early
1216    if node.kind() != "program" {
1217        return;
1218    }
1219
1220    // Track current namespace prefix (for semicolon-style namespaces)
1221    let mut active_namespace = String::new();
1222
1223    // Linear scan of program children to handle semicolon-style namespaces correctly
1224    let mut cursor = node.walk();
1225    for child in node.children(&mut cursor) {
1226        process_top_level_for_export(
1227            child,
1228            content,
1229            helper,
1230            node_map,
1231            module_id,
1232            &mut active_namespace,
1233        );
1234    }
1235}
1236
1237/// Process a single top-level statement for export purposes.
1238///
1239/// This function is called for each direct child of the `program` node.
1240/// It handles:
1241/// - Namespace definitions (both brace and semicolon style)
1242/// - Class, interface, trait, enum, and function declarations
1243///
1244/// It explicitly does NOT recurse into function bodies, class bodies, or
1245/// other nested scopes to avoid incorrectly exporting nested declarations.
1246fn process_top_level_for_export(
1247    node: Node,
1248    content: &[u8],
1249    helper: &mut GraphBuildHelper,
1250    node_map: &mut HashMap<String, NodeId>,
1251    module_id: NodeId,
1252    active_namespace: &mut String,
1253) {
1254    match node.kind() {
1255        "namespace_definition" => {
1256            // Extract namespace name
1257            let ns_name = node
1258                .child_by_field_name("name")
1259                .and_then(|n| n.utf8_text(content).ok())
1260                .map(|s| s.trim().to_string())
1261                .unwrap_or_default();
1262
1263            // Check if this is a brace-style namespace by looking for declaration_list/compound_statement
1264            let has_body = node
1265                .children(&mut node.walk())
1266                .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
1267
1268            if has_body {
1269                // Brace-style namespace: `namespace Foo { ... }`
1270                //
1271                // Robustness: If a brace-style namespace follows a semicolon-style
1272                // namespace (invalid PHP, but possible in fixtures/partial parses),
1273                // clear the active namespace to avoid scope pollution.
1274                active_namespace.clear();
1275
1276                // Process only declarations within the braced body
1277                let mut cursor = node.walk();
1278                for child in node.children(&mut cursor) {
1279                    if matches!(child.kind(), "compound_statement" | "declaration_list") {
1280                        let mut body_cursor = child.walk();
1281                        for body_child in child.children(&mut body_cursor) {
1282                            export_declaration_if_exportable(
1283                                body_child, content, helper, node_map, module_id, &ns_name,
1284                            );
1285                        }
1286                    }
1287                }
1288            } else {
1289                // Semicolon-style namespace: `namespace Foo;`
1290                // Updates the active namespace for subsequent sibling declarations
1291                *active_namespace = ns_name;
1292            }
1293        }
1294        // For top-level declarations, use the active namespace
1295        "class_declaration"
1296        | "interface_declaration"
1297        | "trait_declaration"
1298        | "enum_declaration"
1299        | "function_definition" => {
1300            export_declaration_if_exportable(
1301                node,
1302                content,
1303                helper,
1304                node_map,
1305                module_id,
1306                active_namespace,
1307            );
1308        }
1309        _ => {
1310            // Skip other node types (expression statements, comments, etc.)
1311            // We explicitly DO NOT recurse to avoid exporting nested declarations
1312        }
1313    }
1314}
1315
1316/// Look up a node by qualified name, with restricted fallback to simple name.
1317///
1318/// When in the global namespace (`namespace_prefix` is empty), we allow fallback
1319/// to simple name for backwards compatibility. In namespaced ctx.contexts, we require
1320/// the qualified name to exist to avoid matching the wrong symbol when multiple
1321/// namespaces contain symbols with the same simple name.
1322fn lookup_or_create_node<F>(
1323    node_map: &mut HashMap<String, NodeId>,
1324    qualified_name: &str,
1325    simple_name: &str,
1326    namespace_prefix: &str,
1327    create_fn: F,
1328) -> NodeId
1329where
1330    F: FnOnce() -> NodeId,
1331{
1332    // Always try qualified name first
1333    if let Some(&id) = node_map.get(qualified_name) {
1334        return id;
1335    }
1336
1337    // Fall back to simple name ONLY in global namespace to avoid mismatches
1338    // in namespaced files with repeated simple names across namespaces.
1339    if namespace_prefix.is_empty()
1340        && let Some(&id) = node_map.get(simple_name)
1341    {
1342        return id;
1343    }
1344
1345    // Create new node with qualified name
1346    let id = create_fn();
1347    node_map.insert(qualified_name.to_string(), id);
1348    id
1349}
1350
1351/// Export a single declaration (class, interface, trait, enum, or function).
1352///
1353/// This function handles the actual creation of export edges for top-level
1354/// declarations. It's called from two contexts:
1355/// 1. Direct children of `program` (with `active_namespace` from semicolon-style)
1356/// 2. Children of brace-style namespace bodies (with the namespace name)
1357///
1358/// We look up nodes by their qualified name (which includes namespace) because
1359/// that's what Phase 1 creates in the `node_map`. Fallback to simple name is only
1360/// allowed in the global namespace to prevent matching wrong symbols in namespaced
1361/// files with repeated simple names.
1362///
1363/// For classes, this also exports all public methods found within the class body.
1364#[allow(clippy::too_many_lines)] // Single traversal keeps export logic aligned with phases.
1365fn export_declaration_if_exportable(
1366    node: Node,
1367    content: &[u8],
1368    helper: &mut GraphBuildHelper,
1369    node_map: &mut HashMap<String, NodeId>,
1370    module_id: NodeId,
1371    namespace_prefix: &str,
1372) {
1373    match node.kind() {
1374        "class_declaration" => {
1375            if let Some(name_node) = node.child_by_field_name("name")
1376                && let Ok(class_name) = name_node.utf8_text(content)
1377            {
1378                let simple_name = class_name.trim().to_string();
1379                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1380                let span = span_from_node(node);
1381
1382                let class_id = lookup_or_create_node(
1383                    node_map,
1384                    &qualified_name,
1385                    &simple_name,
1386                    namespace_prefix,
1387                    || helper.add_class(&qualified_name, Some(span)),
1388                );
1389
1390                helper.add_export_edge(module_id, class_id);
1391
1392                // Export public methods from the class
1393                export_public_methods_from_class(
1394                    node,
1395                    content,
1396                    helper,
1397                    node_map,
1398                    module_id,
1399                    &qualified_name,
1400                );
1401            }
1402        }
1403        "interface_declaration" => {
1404            if let Some(name_node) = node.child_by_field_name("name")
1405                && let Ok(interface_name) = name_node.utf8_text(content)
1406            {
1407                let simple_name = interface_name.trim().to_string();
1408                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1409                let span = span_from_node(node);
1410
1411                let interface_id = lookup_or_create_node(
1412                    node_map,
1413                    &qualified_name,
1414                    &simple_name,
1415                    namespace_prefix,
1416                    || helper.add_interface(&qualified_name, Some(span)),
1417                );
1418
1419                helper.add_export_edge(module_id, interface_id);
1420            }
1421        }
1422        "trait_declaration" => {
1423            if let Some(name_node) = node.child_by_field_name("name")
1424                && let Ok(trait_name) = name_node.utf8_text(content)
1425            {
1426                let simple_name = trait_name.trim().to_string();
1427                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1428                let span = span_from_node(node);
1429
1430                let trait_id = lookup_or_create_node(
1431                    node_map,
1432                    &qualified_name,
1433                    &simple_name,
1434                    namespace_prefix,
1435                    || {
1436                        helper.add_node(
1437                            &qualified_name,
1438                            Some(span),
1439                            sqry_core::graph::unified::node::NodeKind::Trait,
1440                        )
1441                    },
1442                );
1443
1444                helper.add_export_edge(module_id, trait_id);
1445            }
1446        }
1447        "enum_declaration" => {
1448            // PHP 8.1+ enums - they are top-level types that should be exported
1449            if let Some(name_node) = node.child_by_field_name("name")
1450                && let Ok(enum_name) = name_node.utf8_text(content)
1451            {
1452                let simple_name = enum_name.trim().to_string();
1453                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1454                let span = span_from_node(node);
1455
1456                let enum_id = lookup_or_create_node(
1457                    node_map,
1458                    &qualified_name,
1459                    &simple_name,
1460                    namespace_prefix,
1461                    || helper.add_enum(&qualified_name, Some(span)),
1462                );
1463
1464                helper.add_export_edge(module_id, enum_id);
1465            }
1466        }
1467        "function_definition" => {
1468            // Top-level functions are exported (we only get here for top-level nodes)
1469            if let Some(name_node) = node.child_by_field_name("name")
1470                && let Ok(func_name) = name_node.utf8_text(content)
1471            {
1472                let simple_name = func_name.trim().to_string();
1473                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1474                let span = span_from_node(node);
1475
1476                let func_id = lookup_or_create_node(
1477                    node_map,
1478                    &qualified_name,
1479                    &simple_name,
1480                    namespace_prefix,
1481                    || helper.add_function(&qualified_name, Some(span), false, false),
1482                );
1483
1484                helper.add_export_edge(module_id, func_id);
1485            }
1486        }
1487        _ => {
1488            // Not an exportable declaration type
1489        }
1490    }
1491}
1492
1493/// Build a qualified name with namespace prefix.
1494fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
1495    if namespace_prefix.is_empty() {
1496        name.to_string()
1497    } else {
1498        format!("{namespace_prefix}\\{name}")
1499    }
1500}
1501
1502/// Helper function to create a Span from a tree-sitter Node.
1503fn span_from_node(node: Node<'_>) -> Span {
1504    let start = node.start_position();
1505    let end = node.end_position();
1506    Span::new(
1507        sqry_core::graph::node::Position::new(start.row, start.column),
1508        sqry_core::graph::node::Position::new(end.row, end.column),
1509    )
1510}
1511
1512fn count_call_arguments(call_node: Node<'_>) -> u8 {
1513    let args_node = call_node
1514        .child_by_field_name("arguments")
1515        .or_else(|| call_node.child_by_field_name("argument_list"))
1516        .or_else(|| {
1517            let mut cursor = call_node.walk();
1518            call_node
1519                .children(&mut cursor)
1520                .find(|child| child.kind() == "argument_list")
1521        });
1522
1523    let Some(args_node) = args_node else {
1524        return 255;
1525    };
1526    let count = args_node.named_child_count();
1527    if count <= 254 {
1528        u8::try_from(count).unwrap_or(u8::MAX)
1529    } else {
1530        255
1531    }
1532}
1533
1534/// Extract visibility modifier from a method or property declaration.
1535///
1536/// Returns Some("public"), Some("private"), Some("protected"), or None if no visibility modifier is found.
1537/// In PHP, methods without an explicit visibility modifier are implicitly public.
1538fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
1539    // Look for visibility modifiers in direct children
1540    let mut cursor = node.walk();
1541    for child in node.children(&mut cursor) {
1542        match child.kind() {
1543            "visibility_modifier" => {
1544                // The visibility_modifier node contains the actual keyword
1545                if let Ok(vis_text) = child.utf8_text(content) {
1546                    return Some(vis_text.trim().to_string());
1547                }
1548            }
1549            "public" | "private" | "protected" => {
1550                // Sometimes the visibility is directly as a keyword node
1551                if let Ok(vis_text) = child.utf8_text(content) {
1552                    return Some(vis_text.trim().to_string());
1553                }
1554            }
1555            _ => {}
1556        }
1557    }
1558
1559    // PHP default: methods without explicit visibility are public
1560    // But we return None here to distinguish "explicitly public" from "implicitly public"
1561    // For export purposes, we'll treat None as public
1562    None
1563}
1564
1565/// Export public methods from a class declaration.
1566///
1567/// This function walks the class body and exports only public methods (including
1568/// methods with no explicit visibility modifier, which are implicitly public in PHP).
1569/// Private and protected methods are NOT exported.
1570fn export_public_methods_from_class(
1571    class_node: Node,
1572    content: &[u8],
1573    helper: &mut GraphBuildHelper,
1574    node_map: &mut HashMap<String, NodeId>,
1575    module_id: NodeId,
1576    class_qualified_name: &str,
1577) {
1578    // Find the declaration_list (class body)
1579    let mut cursor = class_node.walk();
1580    for child in class_node.children(&mut cursor) {
1581        if child.kind() == "declaration_list" {
1582            // Walk through the class body to find method declarations
1583            let mut body_cursor = child.walk();
1584            for body_child in child.children(&mut body_cursor) {
1585                if body_child.kind() == "method_declaration" {
1586                    // Extract method visibility
1587                    let visibility = extract_visibility(&body_child, content);
1588
1589                    // Only export public methods (explicit or implicit)
1590                    let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
1591
1592                    if is_public {
1593                        // Extract method name
1594                        if let Some(name_node) = body_child.child_by_field_name("name")
1595                            && let Ok(method_name) = name_node.utf8_text(content)
1596                        {
1597                            let method_name = method_name.trim();
1598                            let qualified_method_name =
1599                                format!("{class_qualified_name}::{method_name}");
1600
1601                            // Look up the method node (should exist from Phase 1)
1602                            if let Some(&method_id) = node_map.get(&qualified_method_name) {
1603                                helper.add_export_edge(module_id, method_id);
1604                            }
1605                        }
1606                    }
1607                }
1608            }
1609            break;
1610        }
1611    }
1612}
1613
1614// ============================================================================
1615// Type Extraction Helpers
1616// ============================================================================
1617
1618/// Extract return type annotation from a PHP function or method declaration.
1619///
1620/// PHP return types appear after the `formal_parameters` and a colon:
1621/// ```php
1622/// function greet(string $name): string { ... }
1623///                              ^^^^^^^
1624/// ```
1625///
1626/// This function:
1627/// 1. Finds the colon (`:`) after the parameters
1628/// 2. Extracts the next named node (the type annotation)
1629/// 3. Normalizes the type (strips nullable `?`, takes first type from unions)
1630///
1631/// Returns `None` if no return type annotation exists (valid in untyped PHP code).
1632fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
1633    // Find colon after formal_parameters
1634    let mut found_colon = false;
1635    let mut cursor = node.walk();
1636    for child in node.children(&mut cursor) {
1637        if found_colon && child.is_named() {
1638            // Next named node after colon is the type annotation
1639            return extract_type_from_node(&child, content);
1640        }
1641        if child.kind() == ":" {
1642            found_colon = true;
1643        }
1644    }
1645    None
1646}
1647
1648/// Extract type string from a PHP type annotation node.
1649///
1650/// Handles different type node kinds from tree-sitter-php:
1651/// - `primitive_type`: `string`, `int`, `float`, `bool`, `array`, etc.
1652/// - `optional_type`: `?string` → strips `?` and returns `string`
1653/// - `union_type`: `string|int` → returns first type `string`
1654/// - `named_type` / `qualified_name`: `User` or `Namespace\User`
1655/// - `intersection_type`: `A&B` → returns first type `A`
1656///
1657/// Design decisions (per SPEC.md):
1658/// - Nullable types: Strip `?` prefix for simplified matching
1659/// - Union types: Take first type only (matches TypeScript plugin approach)
1660/// - Intersection types: Take first type only
1661fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
1662    match type_node.kind() {
1663        "primitive_type" => {
1664            // Basic types: string, int, float, bool, array, void, etc.
1665            type_node
1666                .utf8_text(content)
1667                .ok()
1668                .map(|s| s.trim().to_string())
1669        }
1670        "optional_type" => {
1671            // Nullable type: ?string
1672            // Strip the ? and extract underlying type
1673            let mut cursor = type_node.walk();
1674            for child in type_node.children(&mut cursor) {
1675                if child.kind() != "?" && child.is_named() {
1676                    return extract_type_from_node(&child, content);
1677                }
1678            }
1679            None
1680        }
1681        "union_type" => {
1682            // Union type: string|int
1683            // Take first type only (per SPEC.md design decision)
1684            type_node
1685                .named_child(0)
1686                .and_then(|first_type| extract_type_from_node(&first_type, content))
1687        }
1688        "named_type" | "qualified_name" => {
1689            // Class names: User or Namespace\User
1690            type_node
1691                .utf8_text(content)
1692                .ok()
1693                .map(|s| s.trim().to_string())
1694        }
1695        "intersection_type" => {
1696            // Intersection type: A&B
1697            // Take first type only
1698            type_node
1699                .named_child(0)
1700                .and_then(|first_type| extract_type_from_node(&first_type, content))
1701        }
1702        _ => {
1703            // Fallback: try to get text directly for unknown type nodes
1704            // For future composite types (e.g., DNF types like (A&B)|C),
1705            // normalize by taking first type to stay consistent with
1706            // union/intersection handling.
1707            type_node
1708                .utf8_text(content)
1709                .ok()
1710                .map(|s| {
1711                    let trimmed = s.trim();
1712                    // Split on union (|) or intersection (&) and take first component
1713                    // This handles future PHP grammar additions like DNF types
1714                    trimmed
1715                        .split(&['|', '&'][..])
1716                        .next()
1717                        .unwrap_or(trimmed)
1718                        .trim()
1719                        .trim_start_matches('(')
1720                        .trim_end_matches(')')
1721                        .trim()
1722                        .to_string()
1723                })
1724                .filter(|s| !s.is_empty())
1725        }
1726    }
1727}
1728
1729// ============================================================================
1730// PHPDoc Annotation Processing (Phase 5)
1731// ============================================================================
1732
1733/// Process `PHPDoc` annotations for `TypeOf` and Reference edges
1734fn process_phpdoc_annotations(
1735    node: Node,
1736    content: &[u8],
1737    helper: &mut GraphBuildHelper,
1738) -> GraphResult<()> {
1739    // Recursively walk the tree looking for nodes with PHPDoc
1740    match node.kind() {
1741        "function_definition" => {
1742            process_function_phpdoc(node, content, helper)?;
1743        }
1744        "method_declaration" => {
1745            process_method_phpdoc(node, content, helper)?;
1746        }
1747        "property_declaration" => {
1748            process_property_phpdoc(node, content, helper)?;
1749        }
1750        "simple_property" => {
1751            // Simple property declarations (e.g., inside property groups)
1752            process_property_phpdoc(node, content, helper)?;
1753        }
1754        _ => {}
1755    }
1756
1757    // Recurse into children
1758    let mut cursor = node.walk();
1759    for child in node.children(&mut cursor) {
1760        process_phpdoc_annotations(child, content, helper)?;
1761    }
1762
1763    Ok(())
1764}
1765
1766/// Process `PHPDoc` for function definitions
1767fn process_function_phpdoc(
1768    func_node: Node,
1769    content: &[u8],
1770    helper: &mut GraphBuildHelper,
1771) -> GraphResult<()> {
1772    // Extract PHPDoc comment
1773    let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1774        return Ok(());
1775    };
1776
1777    // Parse PHPDoc tags
1778    let tags = parse_phpdoc_tags(&phpdoc_text);
1779
1780    // Get function name
1781    let Some(name_node) = func_node.child_by_field_name("name") else {
1782        return Ok(());
1783    };
1784
1785    let function_name = name_node
1786        .utf8_text(content)
1787        .map_err(|_| GraphBuilderError::ParseError {
1788            span: span_from_node(func_node),
1789            reason: "failed to read function name".to_string(),
1790        })?
1791        .trim()
1792        .to_string();
1793
1794    if function_name.is_empty() {
1795        return Ok(());
1796    }
1797
1798    // Get or create function node
1799    let func_node_id = helper.ensure_function(&function_name, None, false, false);
1800
1801    // Extract AST parameter list with indices for context (not used in Phase 1)
1802    let _ast_params = extract_ast_parameters(func_node, content);
1803
1804    // Process @param tags
1805    // Create TypeOf and Reference edges regardless of whether the parameter exists in AST
1806    // (PHPDoc may contain documentation for parameters that exist in the signature)
1807    for (param_idx, param_tag) in tags.params.iter().enumerate() {
1808        // Create TypeOf edge: function -> parameter type
1809        let canonical_type = canonical_type_string(&param_tag.type_str);
1810        let type_node_id = helper.add_type(&canonical_type, None);
1811        helper.add_typeof_edge_with_context(
1812            func_node_id,
1813            type_node_id,
1814            Some(TypeOfContext::Parameter),
1815            param_idx.try_into().ok(), // Use PHPDoc order as index
1816            Some(&param_tag.name),
1817        );
1818
1819        // Create Reference edges: function -> each referenced type
1820        let type_names = extract_type_names(&param_tag.type_str);
1821        for type_name in type_names {
1822            let ref_type_id = helper.add_type(&type_name, None);
1823            helper.add_reference_edge(func_node_id, ref_type_id);
1824        }
1825    }
1826
1827    // Process @return tag
1828    if let Some(return_type) = &tags.returns {
1829        let canonical_type = canonical_type_string(return_type);
1830        let type_node_id = helper.add_type(&canonical_type, None);
1831        helper.add_typeof_edge_with_context(
1832            func_node_id,
1833            type_node_id,
1834            Some(TypeOfContext::Return),
1835            Some(0),
1836            None,
1837        );
1838
1839        // Create Reference edges for return type
1840        let type_names = extract_type_names(return_type);
1841        for type_name in type_names {
1842            let ref_type_id = helper.add_type(&type_name, None);
1843            helper.add_reference_edge(func_node_id, ref_type_id);
1844        }
1845    }
1846
1847    Ok(())
1848}
1849
1850/// Process `PHPDoc` for method definitions
1851fn process_method_phpdoc(
1852    method_node: Node,
1853    content: &[u8],
1854    helper: &mut GraphBuildHelper,
1855) -> GraphResult<()> {
1856    // Extract PHPDoc comment
1857    let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1858        return Ok(());
1859    };
1860
1861    // Parse PHPDoc tags
1862    let tags = parse_phpdoc_tags(&phpdoc_text);
1863
1864    // Get method name
1865    let Some(name_node) = method_node.child_by_field_name("name") else {
1866        return Ok(());
1867    };
1868
1869    let method_name = name_node
1870        .utf8_text(content)
1871        .map_err(|_| GraphBuilderError::ParseError {
1872            span: span_from_node(method_node),
1873            reason: "failed to read method name".to_string(),
1874        })?
1875        .trim()
1876        .to_string();
1877
1878    if method_name.is_empty() {
1879        return Ok(());
1880    }
1881
1882    // Find the class name by walking up the tree
1883    let class_name = get_enclosing_class_name(method_node, content)?;
1884    let Some(class_name) = class_name else {
1885        return Ok(());
1886    };
1887
1888    // Create qualified method name: ClassName::methodName
1889    let qualified_name = format!("{class_name}.{method_name}");
1890
1891    // Get existing method node (should already exist from main traversal)
1892    // Use ensure_method to handle case where it might not exist yet
1893    let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1894
1895    // Extract AST parameter list with indices for context
1896    let _ast_params = extract_ast_parameters(method_node, content);
1897
1898    // Process @param tags
1899    // Create TypeOf and Reference edges regardless of whether the parameter exists in AST
1900    for (param_idx, param_tag) in tags.params.iter().enumerate() {
1901        // Create TypeOf edge: method -> parameter type
1902        let canonical_type = canonical_type_string(&param_tag.type_str);
1903        let type_node_id = helper.add_type(&canonical_type, None);
1904        helper.add_typeof_edge_with_context(
1905            method_node_id,
1906            type_node_id,
1907            Some(TypeOfContext::Parameter),
1908            param_idx.try_into().ok(),
1909            Some(&param_tag.name),
1910        );
1911
1912        // Create Reference edges: method -> each referenced type
1913        let type_names = extract_type_names(&param_tag.type_str);
1914        for type_name in type_names {
1915            let ref_type_id = helper.add_type(&type_name, None);
1916            helper.add_reference_edge(method_node_id, ref_type_id);
1917        }
1918    }
1919
1920    // Process @return tag
1921    if let Some(return_type) = &tags.returns {
1922        let canonical_type = canonical_type_string(return_type);
1923        let type_node_id = helper.add_type(&canonical_type, None);
1924        helper.add_typeof_edge_with_context(
1925            method_node_id,
1926            type_node_id,
1927            Some(TypeOfContext::Return),
1928            Some(0),
1929            None,
1930        );
1931
1932        // Create Reference edges for return type
1933        let type_names = extract_type_names(return_type);
1934        for type_name in type_names {
1935            let ref_type_id = helper.add_type(&type_name, None);
1936            helper.add_reference_edge(method_node_id, ref_type_id);
1937        }
1938    }
1939
1940    Ok(())
1941}
1942
1943/// Process `PHPDoc` for property declarations
1944#[allow(clippy::unnecessary_wraps)]
1945fn process_property_phpdoc(
1946    prop_node: Node,
1947    content: &[u8],
1948    helper: &mut GraphBuildHelper,
1949) -> GraphResult<()> {
1950    // Extract PHPDoc comment
1951    let Some(phpdoc_text) = extract_phpdoc_comment(prop_node, content) else {
1952        return Ok(());
1953    };
1954
1955    // Parse PHPDoc tags
1956    let tags = parse_phpdoc_tags(&phpdoc_text);
1957
1958    // Only process @var tags for properties
1959    let Some(var_type) = &tags.var_type else {
1960        return Ok(());
1961    };
1962
1963    // Get property name(s)
1964    let property_names = extract_property_names(prop_node, content);
1965
1966    if property_names.is_empty() {
1967        // Fallback: if we can't extract names, use a generic property identifier
1968        // This ensures we still create edges for the type information
1969        let generic_name = format!("property_{:?}", prop_node.id());
1970        let prop_node_id = helper.add_variable(&generic_name, None);
1971
1972        // Create TypeOf edge: variable -> type
1973        let canonical_type = canonical_type_string(var_type);
1974        let type_node_id = helper.add_type(&canonical_type, None);
1975        helper.add_typeof_edge_with_context(
1976            prop_node_id,
1977            type_node_id,
1978            Some(TypeOfContext::Variable),
1979            None,
1980            Some(&generic_name),
1981        );
1982
1983        // Create Reference edges: variable -> each referenced type
1984        let type_names = extract_type_names(var_type);
1985        for type_name in type_names {
1986            let ref_type_id = helper.add_type(&type_name, None);
1987            helper.add_reference_edge(prop_node_id, ref_type_id);
1988        }
1989
1990        return Ok(());
1991    }
1992
1993    // Process each property name
1994    for prop_name in property_names {
1995        // Get or create property node
1996        // Note: For now we treat properties as simple variables
1997        // Full property tracking may be added in future phases
1998        let prop_node_id = helper.add_variable(&prop_name, None);
1999
2000        // Create TypeOf edge: variable -> type
2001        let canonical_type = canonical_type_string(var_type);
2002        let type_node_id = helper.add_type(&canonical_type, None);
2003        helper.add_typeof_edge_with_context(
2004            prop_node_id,
2005            type_node_id,
2006            Some(TypeOfContext::Variable),
2007            None,
2008            Some(&prop_name),
2009        );
2010
2011        // Create Reference edges: variable -> each referenced type
2012        let type_names = extract_type_names(var_type);
2013        for type_name in type_names {
2014            let ref_type_id = helper.add_type(&type_name, None);
2015            helper.add_reference_edge(prop_node_id, ref_type_id);
2016        }
2017    }
2018
2019    Ok(())
2020}
2021
2022/// Extract parameter names and indices from a function/method declaration
2023fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2024    let mut params = Vec::new();
2025
2026    // Find parameters node
2027    let Some(params_node) = func_node.child_by_field_name("parameters") else {
2028        return params;
2029    };
2030
2031    let mut index = 0;
2032    let mut cursor = params_node.walk();
2033
2034    for child in params_node.children(&mut cursor) {
2035        if !child.is_named() {
2036            continue;
2037        }
2038
2039        match child.kind() {
2040            "simple_parameter" => {
2041                // Extract parameter name (typically the second child, which is the variable)
2042                let mut param_cursor = child.walk();
2043                for param_child in child.children(&mut param_cursor) {
2044                    if param_child.kind() == "variable_name"
2045                        && let Ok(param_text) = param_child.utf8_text(content)
2046                    {
2047                        params.push((index, param_text.trim().to_string()));
2048                        index += 1;
2049                        break;
2050                    }
2051                }
2052            }
2053            "variadic_parameter" => {
2054                // Extract parameter name from variadic parameter (e.g., ...$args)
2055                let mut param_cursor = child.walk();
2056                for param_child in child.children(&mut param_cursor) {
2057                    if param_child.kind() == "variable_name"
2058                        && let Ok(param_text) = param_child.utf8_text(content)
2059                    {
2060                        params.push((index, param_text.trim().to_string()));
2061                        index += 1;
2062                        break;
2063                    }
2064                }
2065            }
2066            _ => {}
2067        }
2068    }
2069
2070    params
2071}
2072
2073/// Get the enclosing class name for a method node
2074#[allow(clippy::unnecessary_wraps)]
2075fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2076    let mut current = node;
2077
2078    // Walk up the tree to find the enclosing class
2079    while let Some(parent) = current.parent() {
2080        if parent.kind() == "class_declaration" {
2081            // Found the class, extract its name
2082            if let Some(name_node) = parent.child_by_field_name("name")
2083                && let Ok(name_text) = name_node.utf8_text(content)
2084            {
2085                return Ok(Some(name_text.trim().to_string()));
2086            }
2087            return Ok(None);
2088        }
2089        current = parent;
2090    }
2091
2092    Ok(None)
2093}
2094
2095/// Extract property names from a property declaration
2096fn extract_property_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2097    let mut names = Vec::new();
2098
2099    match prop_node.kind() {
2100        "property_declaration" => {
2101            // Extract all variable names from the property declaration
2102            // PHP properties can be: property_initializer or simple_property children
2103            let mut cursor = prop_node.walk();
2104            for child in prop_node.children(&mut cursor) {
2105                match child.kind() {
2106                    "property_initializer" => {
2107                        // Property with initializer: $name = value
2108                        if let Some(var_node) = child.child_by_field_name("name")
2109                            && let Ok(var_text) = var_node.utf8_text(content)
2110                        {
2111                            names.push(var_text.trim().to_string());
2112                        }
2113                    }
2114                    "simple_property" => {
2115                        // Simple property without initializer: $name
2116                        if let Some(var_node) = child.child_by_field_name("name")
2117                            && let Ok(var_text) = var_node.utf8_text(content)
2118                        {
2119                            names.push(var_text.trim().to_string());
2120                        }
2121                    }
2122                    _ => {}
2123                }
2124            }
2125        }
2126        "simple_property" => {
2127            // Handle simple property declarations directly
2128            if let Some(var_node) = prop_node.child_by_field_name("name")
2129                && let Ok(var_text) = var_node.utf8_text(content)
2130            {
2131                names.push(var_text.trim().to_string());
2132            }
2133        }
2134        _ => {}
2135    }
2136
2137    names
2138}
2139
2140// ============================================================================
2141// FFI Edge Building
2142// ============================================================================
2143
2144/// Process FFI member call (e.g., `$ffi->crypto_encrypt()`).
2145///
2146/// Creates an `FfiCall` edge from the caller to a native module node.
2147fn process_ffi_member_call(
2148    node: Node,
2149    method_name: &str,
2150    ast_graph: &ASTGraph,
2151    helper: &mut GraphBuildHelper,
2152    node_map: &mut HashMap<String, NodeId>,
2153) {
2154    // Get the caller context
2155    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2156        return;
2157    };
2158
2159    // Get or create caller node
2160    let source_id = *node_map
2161        .entry(call_context.qualified_name.clone())
2162        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2163
2164    // Create a native module node for the C function
2165    let ffi_name = format!("native::ffi::{method_name}");
2166    let call_span = span_from_node(node);
2167    let target_id = helper.add_module(&ffi_name, Some(call_span));
2168
2169    // Add FFI edge (PHP FFI uses C calling convention)
2170    helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2171}
2172
2173/// Process FFI static call (`FFI::cdef()` or `FFI::load()`).
2174///
2175/// Creates an `FfiCall` edge from the caller to a native module representing
2176/// the loaded library.
2177fn process_ffi_static_call(
2178    node: Node,
2179    method_name: &str,
2180    ast_graph: &ASTGraph,
2181    helper: &mut GraphBuildHelper,
2182    node_map: &mut HashMap<String, NodeId>,
2183    content: &[u8],
2184) {
2185    // Get the caller context
2186    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2187        return;
2188    };
2189
2190    // Get or create caller node
2191    let source_id = *node_map
2192        .entry(call_context.qualified_name.clone())
2193        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2194
2195    // Extract library name from call arguments
2196    let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2197        .map(|lib| php_ffi_library_simple_name(&lib))
2198        .unwrap_or_else(|| "unknown".to_string());
2199
2200    // Create a native module node for the library
2201    let ffi_name = format!("native::{library_name}");
2202    let call_span = span_from_node(node);
2203    let target_id = helper.add_module(&ffi_name, Some(call_span));
2204
2205    // Add FFI edge (PHP FFI uses C calling convention)
2206    helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2207}
2208
2209// ============================================================================
2210// FFI Detection Helpers
2211// ============================================================================
2212
2213/// Check if a member call is a PHP FFI call (e.g., `$ffi->function_name()`).
2214///
2215/// Returns true for calls on objects that appear to be FFI instances.
2216/// Common patterns:
2217/// - `$ffi->...`, `self::$ffi->...`, `$this->ffi->...`
2218/// - `FFI::cdef(...)->...` (chained call)
2219/// - `FFI::load(...)->...` (chained call)
2220/// - `(FFI::cdef(...))->...` (parenthesized)
2221fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2222    // Check for direct chained FFI call: FFI::cdef(...)->method()
2223    if object_node.kind() == "scoped_call_expression"
2224        && let Some(scope_node) = object_node.child_by_field_name("scope")
2225        && let Some(name_node) = object_node.child_by_field_name("name")
2226        && let Ok(scope_text) = scope_node.utf8_text(content)
2227        && let Ok(name_text) = name_node.utf8_text(content)
2228        && is_ffi_static_call(scope_text, name_text)
2229    {
2230        return true;
2231    }
2232
2233    // Check for parenthesized FFI call: (FFI::cdef(...))->method()
2234    if object_node.kind() == "parenthesized_expression"
2235        && let Some(inner) = object_node.named_child(0)
2236        && inner.kind() == "scoped_call_expression"
2237        && let Some(scope_node) = inner.child_by_field_name("scope")
2238        && let Some(name_node) = inner.child_by_field_name("name")
2239        && let Ok(scope_text) = scope_node.utf8_text(content)
2240        && let Ok(name_text) = name_node.utf8_text(content)
2241        && is_ffi_static_call(scope_text, name_text)
2242    {
2243        return true;
2244    }
2245
2246    // Check text patterns for stored FFI objects
2247    let Ok(object_text) = object_node.utf8_text(content) else {
2248        return false;
2249    };
2250
2251    let object_text = object_text.trim();
2252
2253    // Direct FFI object: $ffi->method()
2254    if object_text == "$ffi" || object_text == "$_ffi" {
2255        return true;
2256    }
2257
2258    // Class property FFI: $this->ffi->method() or self::$ffi->method()
2259    if object_text.ends_with("->ffi")
2260        || object_text.ends_with("::$ffi")
2261        || object_text.ends_with("->_ffi")
2262        || object_text.ends_with("::$_ffi")
2263    {
2264        return true;
2265    }
2266
2267    false
2268}
2269
2270/// Check if a static call is `FFI::cdef()` or `FFI::load()`.
2271///
2272/// Accepts both `FFI` and `\FFI` (fully-qualified) patterns.
2273fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2274    (scope_text == "FFI" || scope_text == "\\FFI")
2275        && (method_text == "cdef" || method_text == "load")
2276}
2277
2278/// Extract library name from FFI call arguments.
2279///
2280/// Handles both positional and named arguments:
2281/// - `FFI::cdef("...", "lib.so")`: positional second argument
2282/// - `FFI::cdef(lib: "lib.so", cdef: "...")`: named `lib` argument
2283/// - `FFI::load("header.h")`: positional first argument
2284/// - `FFI::load(filename: "header.h")`: named `filename` argument
2285fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2286    let args = call_node.child_by_field_name("arguments")?;
2287
2288    let mut cursor = args.walk();
2289    let args_vec: Vec<Node> = args
2290        .children(&mut cursor)
2291        .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2292        .collect();
2293
2294    // For FFI::cdef, look for named "lib" argument first
2295    // For FFI::load, look for named "filename" argument first
2296    let target_arg_name = if is_cdef { "lib" } else { "filename" };
2297
2298    // Try to find argument by name (PHP 8 named arguments)
2299    if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2300        return extract_string_from_argument(named_arg, content);
2301    }
2302
2303    // Fall back to positional arguments (PHP 7 style)
2304    if is_cdef {
2305        // FFI::cdef() - second argument is library path
2306        args_vec
2307            .get(1)
2308            .and_then(|arg| extract_string_from_argument(*arg, content))
2309    } else {
2310        // FFI::load() - first argument is filename
2311        args_vec
2312            .first()
2313            .and_then(|arg| extract_string_from_argument(*arg, content))
2314    }
2315}
2316
2317/// Find a named argument by its parameter name.
2318///
2319/// PHP 8 named arguments: `func(param: value)`
2320/// Tree structure: `argument { name: "param", ":", value }`
2321///
2322/// Uses field-based access for resilience against grammar changes.
2323fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2324    for arg in args {
2325        if arg.kind() != "argument" {
2326            continue;
2327        }
2328
2329        // Check if this is a named argument (has 2+ named children)
2330        // This is a quick check before trying field-based access
2331        if arg.named_child_count() < 2 {
2332            continue;
2333        }
2334
2335        // Try field-based access first (more resilient)
2336        if let Some(name_node) = arg.child_by_field_name("name")
2337            && let Ok(name_text) = name_node.utf8_text(content)
2338            && name_text == param_name
2339        {
2340            return Some(*arg);
2341        } else if let Some(name_node) = arg.named_child(0)
2342            && let Ok(name_text) = name_node.utf8_text(content)
2343            && name_text == param_name
2344        {
2345            // Fallback to child ordering if field not available
2346            return Some(*arg);
2347        }
2348    }
2349
2350    None
2351}
2352
2353/// Extract string literal from an argument node, handling both positional and named arguments.
2354///
2355/// PHP 7.x positional: `argument(1 child) -> value`
2356/// PHP 8.x named: `argument(2+ children) -> name -> value`
2357///
2358/// Returns `None` if the argument is not a valid string literal, for example a variable,
2359/// constant, or interpolated string.
2360fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2361    // Unwrap argument wrappers to get to the actual value expression
2362    let value_node = unwrap_argument_node(arg_node)?;
2363
2364    // Only accept pure string literals, not variables or constants
2365    if !is_string_literal_node(value_node) {
2366        return None;
2367    }
2368
2369    // Reject interpolated strings (e.g., "lib{$var}.so")
2370    if is_interpolated_string(value_node) {
2371        return None;
2372    }
2373
2374    extract_php_string_content(value_node, content)
2375}
2376
2377/// Unwrap PHP argument node wrappers to get to the value expression.
2378///
2379/// Handles:
2380/// - `argument` nodes with 1 child: PHP 7.x positional args (argument -> value)
2381/// - `argument` nodes with 2+ children: PHP 8.x named args (argument -> name -> value)
2382///
2383/// Uses field-based skipping to extract the value child while excluding
2384/// the `name` field (named argument parameter name) and `reference_modifier`
2385/// field (& reference marker). This correctly handles cases where the value
2386/// itself is a `name` node (e.g., `self`, `parent`, `static`, class names).
2387/// Returns the innermost value expression.
2388fn unwrap_argument_node(node: Node) -> Option<Node> {
2389    if node.kind() != "argument" {
2390        // Not a wrapper, return as-is
2391        return Some(node);
2392    }
2393
2394    // Tree-sitter-php 0.24.2 `argument` nodes have:
2395    // - "name" field (for named arguments parameter name)
2396    // - "reference_modifier" field (for & references)
2397    // - No "value" field (must select by exclusion)
2398    //
2399    // Get the field nodes to exclude by identity comparison
2400    let name_field_node = node.child_by_field_name("name");
2401    let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2402
2403    // Find the value child by excluding structural field nodes
2404    for i in 0..node.named_child_count() {
2405        if let Some(child) = node.named_child(i as u32) {
2406            // Skip if this child is the name field or reference_modifier field
2407            let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2408            let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2409
2410            if !is_name_field && !is_ref_modifier {
2411                // This is the value child (expression, variadic_unpacking, or name node like self/parent/static)
2412                return Some(child);
2413            }
2414        }
2415    }
2416
2417    // If no value child found, return None (malformed argument)
2418    None
2419}
2420
2421/// Check if a node is a string literal (not a variable or constant).
2422///
2423/// PHP tree-sitter uses different node kinds for various string types:
2424/// - `string` for single-quoted strings (`'...'`)
2425/// - `encapsed_string` for double-quoted strings (`"..."`)
2426/// - `heredoc` and `nowdoc` for heredoc/nowdoc syntax
2427fn is_string_literal_node(node: Node) -> bool {
2428    matches!(
2429        node.kind(),
2430        "string" | "encapsed_string" | "heredoc" | "nowdoc"
2431    )
2432}
2433
2434/// Check if a string node contains variable interpolation.
2435///
2436/// Double-quoted strings and heredocs can contain interpolation:
2437/// - `lib{$suffix}.so`: simple variable
2438/// - `path/$variable/file`: simple variable
2439/// - `{$arr['key']}`: array access
2440/// - `{$obj->prop}`: property access
2441///
2442/// Single-quoted strings and nowdocs never interpolate, so we only check
2443/// `encapsed_string` and `heredoc` nodes.
2444///
2445/// Scans all descendants recursively to catch complex interpolation patterns.
2446fn is_interpolated_string(node: Node) -> bool {
2447    if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2448        return false;
2449    }
2450
2451    // Recursively check all descendants for variable-bearing nodes
2452    has_variable_node(node)
2453}
2454
2455/// Recursively check if a node or any of its descendants contains variables or dynamic expressions.
2456///
2457/// Detects all forms of interpolation:
2458/// - Direct variables: `$var`, `${expr}`
2459/// - Dynamic variables: `$$var`
2460/// - Array access: `$arr['key']`, `$arr[$index]`
2461/// - Property access: `$obj->prop`
2462/// - Method calls: `$obj->method()`
2463/// - Function calls: `$foo()`
2464/// - Static access: `$Class::$prop`, `$Class::method()`
2465/// - Class constants: `$Class::CONST`
2466/// - Nullsafe variants: `$obj?->prop`
2467/// - Any node containing variables at any depth
2468fn has_variable_node(node: Node) -> bool {
2469    // Check if this node itself is a variable-bearing or dynamic expression node
2470    if matches!(
2471        node.kind(),
2472        // Direct variable nodes
2473        "variable_name" | "simple_variable" | "variable" | "complex_variable"
2474        // Dynamic variables ($$var, ${'expr'})
2475        | "dynamic_variable_name"
2476        // Instance access and calls
2477        | "subscript_expression" | "member_access_expression" | "member_call_expression"
2478        // Function calls (may contain variables)
2479        | "function_call_expression"
2480        // Static/scoped access (may contain variables)
2481        | "scoped_call_expression" | "scoped_property_access_expression"
2482        // Class constant access (may have dynamic class name)
2483        | "class_constant_access_expression"
2484        // Nullsafe variants
2485        | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2486    ) {
2487        return true;
2488    }
2489
2490    // Recursively check all children
2491    for i in 0..node.child_count() {
2492        if let Some(child) = node.child(i as u32)
2493            && has_variable_node(child)
2494        {
2495            return true;
2496        }
2497    }
2498
2499    false
2500}
2501
2502/// Extract content from PHP string literal.
2503///
2504/// Handles single-quoted ('...'), double-quoted ("..."), and heredoc strings.
2505fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2506    let Ok(text) = string_node.utf8_text(content) else {
2507        return None;
2508    };
2509
2510    let text = text.trim();
2511
2512    // Strip quotes for simple strings
2513    if ((text.starts_with('"') && text.ends_with('"'))
2514        || (text.starts_with('\'') && text.ends_with('\'')))
2515        && text.len() >= 2
2516    {
2517        return Some(text[1..text.len() - 1].to_string());
2518    }
2519
2520    // For heredoc/nowdoc, return as-is (tree-sitter handles it)
2521    Some(text.to_string())
2522}
2523
2524/// Simplify library path to base name (e.g., "libfoo.so.1" → "libfoo").
2525fn php_ffi_library_simple_name(library_path: &str) -> String {
2526    use std::path::Path;
2527
2528    // Strip directory components first
2529    let filename = Path::new(library_path)
2530        .file_name()
2531        .and_then(|f| f.to_str())
2532        .unwrap_or(library_path);
2533
2534    // Handle versioned .so files (libfoo.so.1 → libfoo)
2535    if let Some(so_pos) = filename.find(".so.") {
2536        return filename[..so_pos].to_string();
2537    }
2538
2539    // Handle standard library and header extensions
2540    if let Some(dot_pos) = filename.find('.') {
2541        let extension = &filename[dot_pos + 1..];
2542        if extension == "so"
2543            || extension == "dll"
2544            || extension == "dylib"
2545            || extension == "h"
2546            || extension == "hpp"
2547        {
2548            return filename[..dot_pos].to_string();
2549        }
2550    }
2551
2552    filename.to_string()
2553}