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