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, HashSet};
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.
1735///
1736/// Two-pass walk to make explicit-vs-promoted field collision precedence
1737/// (FR-13) deterministic regardless of source order:
1738///
1739/// 1. **Pass A** — function `PHPDoc`, method `PHPDoc`, and *explicit*
1740///    `property_declaration` / `simple_property` emission. Records every
1741///    explicit-field `NodeId` in `explicit_field_ids`.
1742/// 2. **Pass B** — constructor property promotion. The promoted-side
1743///    consults `explicit_field_ids`; when an existing node is in the set,
1744///    the promotion path skips kind/visibility/static *and* `TypeOf`
1745///    re-emission so the explicit declaration's attributes and declared
1746///    type win unambiguously.
1747///
1748/// This sequencing fixes both FR-13 violations called out by code review:
1749/// (a) source-order dependence — explicit declarations now always run
1750/// before promotions; (b) duplicate `TypeOf` edges from a promoted
1751/// parameter onto an already-typed explicit field.
1752fn process_phpdoc_annotations(
1753    node: Node,
1754    content: &[u8],
1755    helper: &mut GraphBuildHelper,
1756) -> GraphResult<()> {
1757    // Pass A: PHPDoc + explicit property declarations.
1758    let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
1759    process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
1760
1761    // Pass B: constructor property promotion. Explicit fields (Pass A
1762    // output) win on collision; the explicit_field_ids set is read-only
1763    // here, used to gate kind/visibility/static and TypeOf overrides.
1764    process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
1765
1766    Ok(())
1767}
1768
1769/// Pass A — recursive walk that emits PHPDoc-derived edges and explicit
1770/// property nodes. Newly created explicit-field `NodeId`s are tracked in
1771/// `explicit_field_ids` so Pass B can preserve their attributes.
1772fn process_phpdoc_pass_a(
1773    node: Node,
1774    content: &[u8],
1775    helper: &mut GraphBuildHelper,
1776    explicit_field_ids: &mut HashSet<NodeId>,
1777) -> GraphResult<()> {
1778    match node.kind() {
1779        "function_definition" => {
1780            process_function_phpdoc(node, content, helper)?;
1781        }
1782        "method_declaration" => {
1783            // Method-level PHPDoc only in Pass A; constructor promotion
1784            // is deferred to Pass B so explicit declarations always win.
1785            process_method_phpdoc(node, content, helper)?;
1786        }
1787        "property_declaration" | "simple_property" => {
1788            // Unconditional emission (PHPDoc gate removed). Property
1789            // declarations inside class_declaration / trait_declaration /
1790            // interface_declaration become Property or Constant nodes
1791            // with qualified name `Class.prop`.
1792            let emitted = process_property_declaration(node, content, helper);
1793            explicit_field_ids.extend(emitted);
1794        }
1795        _ => {}
1796    }
1797
1798    let mut cursor = node.walk();
1799    for child in node.children(&mut cursor) {
1800        process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
1801    }
1802
1803    Ok(())
1804}
1805
1806/// Pass B — recursive walk that emits constructor-promoted Property /
1807/// Constant nodes. Reads `explicit_field_ids` (populated by Pass A) to
1808/// skip kind/visibility/static *and* `TypeOf` re-emission whenever an
1809/// explicit declaration owns the qualified name. Per cross-language field
1810/// emission design §4.6.
1811fn process_phpdoc_pass_b(
1812    node: Node,
1813    content: &[u8],
1814    helper: &mut GraphBuildHelper,
1815    explicit_field_ids: &HashSet<NodeId>,
1816) {
1817    if node.kind() == "method_declaration" {
1818        process_constructor_promotion(node, content, helper, explicit_field_ids);
1819    }
1820
1821    let mut cursor = node.walk();
1822    for child in node.children(&mut cursor) {
1823        process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
1824    }
1825}
1826
1827/// Process `PHPDoc` for function definitions
1828fn process_function_phpdoc(
1829    func_node: Node,
1830    content: &[u8],
1831    helper: &mut GraphBuildHelper,
1832) -> GraphResult<()> {
1833    // Extract PHPDoc comment
1834    let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1835        return Ok(());
1836    };
1837
1838    // Parse PHPDoc tags
1839    let tags = parse_phpdoc_tags(&phpdoc_text);
1840
1841    // Get function name
1842    let Some(name_node) = func_node.child_by_field_name("name") else {
1843        return Ok(());
1844    };
1845
1846    let function_name = name_node
1847        .utf8_text(content)
1848        .map_err(|_| GraphBuilderError::ParseError {
1849            span: span_from_node(func_node),
1850            reason: "failed to read function name".to_string(),
1851        })?
1852        .trim()
1853        .to_string();
1854
1855    if function_name.is_empty() {
1856        return Ok(());
1857    }
1858
1859    // Get or create function node
1860    let func_node_id = helper.ensure_callee(
1861        &function_name,
1862        span_from_node(func_node),
1863        CalleeKindHint::Function,
1864    );
1865
1866    // Extract AST parameter list with indices for context (not used in Phase 1)
1867    let _ast_params = extract_ast_parameters(func_node, content);
1868
1869    // Process @param tags
1870    // Create TypeOf and Reference edges regardless of whether the parameter exists in AST
1871    // (PHPDoc may contain documentation for parameters that exist in the signature)
1872    for (param_idx, param_tag) in tags.params.iter().enumerate() {
1873        // Create TypeOf edge: function -> parameter type
1874        let canonical_type = canonical_type_string(&param_tag.type_str);
1875        let type_node_id = helper.add_type(&canonical_type, None);
1876        helper.add_typeof_edge_with_context(
1877            func_node_id,
1878            type_node_id,
1879            Some(TypeOfContext::Parameter),
1880            param_idx.try_into().ok(), // Use PHPDoc order as index
1881            Some(&param_tag.name),
1882        );
1883
1884        // Create Reference edges: function -> each referenced type
1885        let type_names = extract_type_names(&param_tag.type_str);
1886        for type_name in type_names {
1887            let ref_type_id = helper.add_type(&type_name, None);
1888            helper.add_reference_edge(func_node_id, ref_type_id);
1889        }
1890    }
1891
1892    // Process @return tag
1893    if let Some(return_type) = &tags.returns {
1894        let canonical_type = canonical_type_string(return_type);
1895        let type_node_id = helper.add_type(&canonical_type, None);
1896        helper.add_typeof_edge_with_context(
1897            func_node_id,
1898            type_node_id,
1899            Some(TypeOfContext::Return),
1900            Some(0),
1901            None,
1902        );
1903
1904        // Create Reference edges for return type
1905        let type_names = extract_type_names(return_type);
1906        for type_name in type_names {
1907            let ref_type_id = helper.add_type(&type_name, None);
1908            helper.add_reference_edge(func_node_id, ref_type_id);
1909        }
1910    }
1911
1912    Ok(())
1913}
1914
1915/// Process `PHPDoc` for method definitions
1916fn process_method_phpdoc(
1917    method_node: Node,
1918    content: &[u8],
1919    helper: &mut GraphBuildHelper,
1920) -> GraphResult<()> {
1921    // Extract PHPDoc comment
1922    let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1923        return Ok(());
1924    };
1925
1926    // Parse PHPDoc tags
1927    let tags = parse_phpdoc_tags(&phpdoc_text);
1928
1929    // Get method name
1930    let Some(name_node) = method_node.child_by_field_name("name") else {
1931        return Ok(());
1932    };
1933
1934    let method_name = name_node
1935        .utf8_text(content)
1936        .map_err(|_| GraphBuilderError::ParseError {
1937            span: span_from_node(method_node),
1938            reason: "failed to read method name".to_string(),
1939        })?
1940        .trim()
1941        .to_string();
1942
1943    if method_name.is_empty() {
1944        return Ok(());
1945    }
1946
1947    // Find the class name by walking up the tree
1948    let class_name = get_enclosing_class_name(method_node, content)?;
1949    let Some(class_name) = class_name else {
1950        return Ok(());
1951    };
1952
1953    // Create qualified method name: ClassName::methodName
1954    let qualified_name = format!("{class_name}.{method_name}");
1955
1956    // Get existing method node (should already exist from main traversal)
1957    // Use ensure_method to handle case where it might not exist yet
1958    let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1959
1960    // Extract AST parameter list with indices for context
1961    let _ast_params = extract_ast_parameters(method_node, content);
1962
1963    // Process @param tags
1964    // Create TypeOf and Reference edges regardless of whether the parameter exists in AST
1965    for (param_idx, param_tag) in tags.params.iter().enumerate() {
1966        // Create TypeOf edge: method -> parameter type
1967        let canonical_type = canonical_type_string(&param_tag.type_str);
1968        let type_node_id = helper.add_type(&canonical_type, None);
1969        helper.add_typeof_edge_with_context(
1970            method_node_id,
1971            type_node_id,
1972            Some(TypeOfContext::Parameter),
1973            param_idx.try_into().ok(),
1974            Some(&param_tag.name),
1975        );
1976
1977        // Create Reference edges: method -> each referenced type
1978        let type_names = extract_type_names(&param_tag.type_str);
1979        for type_name in type_names {
1980            let ref_type_id = helper.add_type(&type_name, None);
1981            helper.add_reference_edge(method_node_id, ref_type_id);
1982        }
1983    }
1984
1985    // Process @return tag
1986    if let Some(return_type) = &tags.returns {
1987        let canonical_type = canonical_type_string(return_type);
1988        let type_node_id = helper.add_type(&canonical_type, None);
1989        helper.add_typeof_edge_with_context(
1990            method_node_id,
1991            type_node_id,
1992            Some(TypeOfContext::Return),
1993            Some(0),
1994            None,
1995        );
1996
1997        // Create Reference edges for return type
1998        let type_names = extract_type_names(return_type);
1999        for type_name in type_names {
2000            let ref_type_id = helper.add_type(&type_name, None);
2001            helper.add_reference_edge(method_node_id, ref_type_id);
2002        }
2003    }
2004
2005    Ok(())
2006}
2007
2008/// Process a `property_declaration` (or legacy `simple_property`) inside a
2009/// class / trait / interface body and emit Property or Constant nodes with
2010/// `Class.prop` qualified names.
2011///
2012/// Returns the `NodeId` of every explicit field emitted by this call.
2013/// Pass A collects these into the explicit-field set so Pass B
2014/// (constructor promotion) can recognize the explicit declaration as
2015/// owner of the qualified name and refrain from overwriting attributes
2016/// or re-emitting `TypeOf` edges (FR-13).
2017///
2018/// Cross-language field emission contract (DAG U10 / `C2_OTHER_PHP`):
2019/// - `PHPDoc` gate removed: emission is unconditional.
2020/// - Visibility from `visibility_modifier` (default `"public"` when absent;
2021///   PHP semantics).
2022/// - `static_modifier` → `is_static = true`.
2023/// - `readonly_modifier` (PHP 8.1+) → `Constant`; otherwise `Property`.
2024/// - Native PHP 7.4+ `type` field → primary `TypeOf` target.
2025/// - `PHPDoc` `@var` is enrichment fallback only when no native type is present.
2026/// - `TypeOf` edge uses `TypeOfContext::Field` and bare property name.
2027/// - Span anchored on the declaration node.
2028fn process_property_declaration(
2029    prop_node: Node,
2030    content: &[u8],
2031    helper: &mut GraphBuildHelper,
2032) -> Vec<NodeId> {
2033    // Find the enclosing owner (class / trait / interface). Without an owner
2034    // we have no qualified-name prefix and emit nothing — matches the
2035    // "no emission outside class/trait/interface" AC.
2036    let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
2037        return Vec::new();
2038    };
2039
2040    // Modifier extraction.
2041    let mods = extract_property_modifiers(prop_node, content);
2042
2043    // Native PHP 7.4+ type annotation lives on the `type` field of
2044    // `property_declaration`.
2045    let native_type = prop_node
2046        .child_by_field_name("type")
2047        .and_then(|t| extract_type_from_node(&t, content));
2048
2049    // PHPDoc @var as enrichment fallback only when no native type present.
2050    let phpdoc_var_type = if native_type.is_none() {
2051        extract_phpdoc_comment(prop_node, content)
2052            .as_deref()
2053            .and_then(|c| parse_phpdoc_tags(c).var_type)
2054    } else {
2055        None
2056    };
2057
2058    let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
2059
2060    let prop_names = extract_property_element_names(prop_node, content);
2061    if prop_names.is_empty() {
2062        return Vec::new();
2063    }
2064
2065    let span = span_from_node(prop_node);
2066    let mut emitted = Vec::with_capacity(prop_names.len());
2067
2068    for prop_name in prop_names {
2069        let qualified_name = format!("{owner_name}.{prop_name}");
2070        let visibility = mods.visibility.as_deref().unwrap_or("public");
2071
2072        let node_id = if mods.is_readonly {
2073            helper.add_constant_with_name_static_and_visibility(
2074                &prop_name,
2075                &qualified_name,
2076                Some(span),
2077                mods.is_static,
2078                Some(visibility),
2079            )
2080        } else {
2081            helper.add_property_with_name_static_and_visibility(
2082                &prop_name,
2083                &qualified_name,
2084                Some(span),
2085                mods.is_static,
2086                Some(visibility),
2087            )
2088        };
2089
2090        if let Some(type_str) = primary_type.as_deref() {
2091            emit_field_type_edges(helper, node_id, &prop_name, type_str);
2092        }
2093
2094        emitted.push(node_id);
2095    }
2096
2097    emitted
2098}
2099
2100/// Walk a `method_declaration` whose name is `__construct` and emit
2101/// Property / Constant nodes for each `property_promotion_parameter` on the
2102/// enclosing class.
2103///
2104/// Collision precedence (FR-13 / AC-8). The two-pass `process_phpdoc_annotations`
2105/// driver guarantees explicit `property_declaration` nodes are emitted in
2106/// Pass A before this Pass-B walker runs. `explicit_field_ids` carries
2107/// every `NodeId` Pass A created; when the promoted side lands on a
2108/// qualified name owned by an explicit declaration we:
2109///
2110/// - skip kind / visibility / static / readonly emission entirely
2111///   (the explicit declaration's attributes are authoritative); and
2112/// - skip `TypeOf` re-emission so the explicit declaration's declared
2113///   type is the only one bound to the field `NodeId` — even when the
2114///   promoted parameter's annotated type would differ.
2115///
2116/// Only when the qualified name is *not* in `explicit_field_ids` does
2117/// this walker create a new Property/Constant node from the promoted
2118/// parameter's modifiers and emit its `TypeOf` edges.
2119fn process_constructor_promotion(
2120    method_node: Node,
2121    content: &[u8],
2122    helper: &mut GraphBuildHelper,
2123    explicit_field_ids: &HashSet<NodeId>,
2124) {
2125    // Constructor identification: name == "__construct".
2126    let Some(name_node) = method_node.child_by_field_name("name") else {
2127        return;
2128    };
2129    let Ok(method_name) = name_node.utf8_text(content) else {
2130        return;
2131    };
2132    if method_name.trim() != "__construct" {
2133        return;
2134    }
2135
2136    let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
2137        return;
2138    };
2139
2140    let Some(params_node) = method_node.child_by_field_name("parameters") else {
2141        return;
2142    };
2143
2144    let mut cursor = params_node.walk();
2145    for param in params_node.children(&mut cursor) {
2146        if param.kind() != "property_promotion_parameter" {
2147            continue;
2148        }
2149
2150        // Promotion-parameter modifiers + name.
2151        let visibility = param
2152            .child_by_field_name("visibility")
2153            .and_then(|v| v.utf8_text(content).ok())
2154            .map(|s| s.trim().to_string());
2155        let is_readonly = param.child_by_field_name("readonly").is_some()
2156            || direct_child_of_kind(param, "readonly_modifier").is_some();
2157        // Static is illegal on promotion parameters — PHP rejects it — but
2158        // honour the bool field for shape-parity with the property path.
2159        let is_static = false;
2160        let native_type = param
2161            .child_by_field_name("type")
2162            .and_then(|t| extract_type_from_node(&t, content));
2163
2164        let Some(prop_name) = promoted_param_name(param, content) else {
2165            continue;
2166        };
2167
2168        let qualified_name = format!("{owner_name}.{prop_name}");
2169        let span = span_from_node(param);
2170
2171        // FR-13 collision precedence: any prior node sharing this
2172        // qualified name belongs to an explicit declaration emitted in
2173        // Pass A (the two-pass driver enforces that ordering). Explicit
2174        // declarations are authoritative — we touch nothing here.
2175        if let Some(existing_id) = helper.get_node(&qualified_name) {
2176            if explicit_field_ids.contains(&existing_id) {
2177                // Explicit declaration owns this name. Skip both
2178                // attribute mutation *and* TypeOf re-emission so we
2179                // never bind a second (possibly conflicting) field
2180                // type to the same NodeId.
2181                continue;
2182            }
2183            // No explicit owner: the existing node was created by an
2184            // earlier promoted parameter (rare — same qualified name
2185            // appearing twice in one promotion list, or another plugin
2186            // path). Re-emit type information defensively only when
2187            // there is one to add; never overwrite kind/visibility.
2188            if let Some(t) = native_type {
2189                emit_field_type_edges(helper, existing_id, &prop_name, &t);
2190            }
2191            continue;
2192        }
2193
2194        let visibility_ref = visibility.as_deref().unwrap_or("public");
2195        let node_id = if is_readonly {
2196            helper.add_constant_with_name_static_and_visibility(
2197                &prop_name,
2198                &qualified_name,
2199                Some(span),
2200                is_static,
2201                Some(visibility_ref),
2202            )
2203        } else {
2204            helper.add_property_with_name_static_and_visibility(
2205                &prop_name,
2206                &qualified_name,
2207                Some(span),
2208                is_static,
2209                Some(visibility_ref),
2210            )
2211        };
2212
2213        if let Some(type_str) = native_type {
2214            emit_field_type_edges(helper, node_id, &prop_name, &type_str);
2215        }
2216    }
2217}
2218
2219/// Aggregate of the property modifiers we care about for emission.
2220struct PropertyModifiers {
2221    visibility: Option<String>,
2222    is_static: bool,
2223    is_readonly: bool,
2224}
2225
2226/// Walk direct children of a `property_declaration` collecting the modifier
2227/// set. Both explicit `var` (legacy public) and missing-modifier cases fall
2228/// through to the caller's `unwrap_or("public")` default.
2229fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
2230    let mut visibility: Option<String> = None;
2231    let mut is_static = false;
2232    let mut is_readonly = false;
2233
2234    let mut cursor = prop_node.walk();
2235    for child in prop_node.children(&mut cursor) {
2236        match child.kind() {
2237            "visibility_modifier" => {
2238                if let Ok(text) = child.utf8_text(content) {
2239                    visibility = Some(text.trim().to_string());
2240                }
2241            }
2242            "var_modifier" => {
2243                // `var` is the legacy spelling of `public` — treat
2244                // identically. Per design §4.4 / AC-2.
2245                if visibility.is_none() {
2246                    visibility = Some("public".to_string());
2247                }
2248            }
2249            "static_modifier" => {
2250                is_static = true;
2251            }
2252            "readonly_modifier" => {
2253                is_readonly = true;
2254            }
2255            _ => {}
2256        }
2257    }
2258
2259    PropertyModifiers {
2260        visibility,
2261        is_static,
2262        is_readonly,
2263    }
2264}
2265
2266/// Extract bare property names from a `property_declaration` by walking its
2267/// `property_element` children. Strips the leading `$` PHP variable sigil so
2268/// the qualified name matches the cross-language `Class.prop` convention.
2269fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2270    let mut names = Vec::new();
2271    let mut cursor = prop_node.walk();
2272    for child in prop_node.children(&mut cursor) {
2273        if child.kind() != "property_element" {
2274            continue;
2275        }
2276        if let Some(var_node) = child.child_by_field_name("name")
2277            && let Some(name) = strip_dollar_from_variable(var_node, content)
2278        {
2279            names.push(name);
2280        }
2281    }
2282    names
2283}
2284
2285/// Pull the bare identifier from a `property_promotion_parameter`'s `name`
2286/// field (the `variable_name`).
2287fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
2288    let name_field = param.child_by_field_name("name")?;
2289    // `by_ref` indirection is rare in promotion; honour both shapes.
2290    let var_node = if name_field.kind() == "variable_name" {
2291        name_field
2292    } else {
2293        // Search child for variable_name.
2294        let mut cursor = name_field.walk();
2295        name_field
2296            .children(&mut cursor)
2297            .find(|c| c.kind() == "variable_name")?
2298    };
2299    strip_dollar_from_variable(var_node, content)
2300}
2301
2302/// Read a `variable_name` node and return its bare identifier (no leading `$`).
2303fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
2304    if let Some(name_node) = var_node.child_by_field_name("name")
2305        && let Ok(text) = name_node.utf8_text(content)
2306    {
2307        return Some(text.trim().to_string());
2308    }
2309    var_node
2310        .utf8_text(content)
2311        .ok()
2312        .map(|s| s.trim().trim_start_matches('$').to_string())
2313}
2314
2315/// Find the first direct child with the given kind, if any.
2316fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
2317    let mut cursor = node.walk();
2318    node.children(&mut cursor).find(|c| c.kind() == kind)
2319}
2320
2321/// Emit the Field-context `TypeOf` edge plus referenced-type Reference
2322/// edges for a property/constant node.
2323fn emit_field_type_edges(
2324    helper: &mut GraphBuildHelper,
2325    node_id: NodeId,
2326    prop_name: &str,
2327    type_str: &str,
2328) {
2329    let canonical_type = canonical_type_string(type_str);
2330    let type_node_id = helper.add_type(&canonical_type, None);
2331    helper.add_typeof_edge_with_context(
2332        node_id,
2333        type_node_id,
2334        Some(TypeOfContext::Field),
2335        None,
2336        Some(prop_name),
2337    );
2338
2339    for ref_type_name in extract_type_names(type_str) {
2340        let ref_type_id = helper.add_type(&ref_type_name, None);
2341        helper.add_reference_edge(node_id, ref_type_id);
2342    }
2343}
2344
2345/// Walk up the AST to find the enclosing class, trait, or interface's name.
2346/// Returns `None` for top-level declarations or anonymous classes.
2347fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
2348    let mut current = node;
2349    while let Some(parent) = current.parent() {
2350        if matches!(
2351            parent.kind(),
2352            "class_declaration" | "trait_declaration" | "interface_declaration"
2353        ) {
2354            return parent
2355                .child_by_field_name("name")
2356                .and_then(|n| n.utf8_text(content).ok())
2357                .map(|s| s.trim().to_string());
2358        }
2359        current = parent;
2360    }
2361    None
2362}
2363
2364/// Extract parameter names and indices from a function/method declaration
2365fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2366    let mut params = Vec::new();
2367
2368    // Find parameters node
2369    let Some(params_node) = func_node.child_by_field_name("parameters") else {
2370        return params;
2371    };
2372
2373    let mut index = 0;
2374    let mut cursor = params_node.walk();
2375
2376    for child in params_node.children(&mut cursor) {
2377        if !child.is_named() {
2378            continue;
2379        }
2380
2381        match child.kind() {
2382            "simple_parameter" => {
2383                // Extract parameter name (typically the second child, which is the variable)
2384                let mut param_cursor = child.walk();
2385                for param_child in child.children(&mut param_cursor) {
2386                    if param_child.kind() == "variable_name"
2387                        && let Ok(param_text) = param_child.utf8_text(content)
2388                    {
2389                        params.push((index, param_text.trim().to_string()));
2390                        index += 1;
2391                        break;
2392                    }
2393                }
2394            }
2395            "variadic_parameter" => {
2396                // Extract parameter name from variadic parameter (e.g., ...$args)
2397                let mut param_cursor = child.walk();
2398                for param_child in child.children(&mut param_cursor) {
2399                    if param_child.kind() == "variable_name"
2400                        && let Ok(param_text) = param_child.utf8_text(content)
2401                    {
2402                        params.push((index, param_text.trim().to_string()));
2403                        index += 1;
2404                        break;
2405                    }
2406                }
2407            }
2408            _ => {}
2409        }
2410    }
2411
2412    params
2413}
2414
2415/// Get the enclosing class name for a method node
2416#[allow(clippy::unnecessary_wraps)]
2417fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2418    let mut current = node;
2419
2420    // Walk up the tree to find the enclosing class
2421    while let Some(parent) = current.parent() {
2422        if parent.kind() == "class_declaration" {
2423            // Found the class, extract its name
2424            if let Some(name_node) = parent.child_by_field_name("name")
2425                && let Ok(name_text) = name_node.utf8_text(content)
2426            {
2427                return Ok(Some(name_text.trim().to_string()));
2428            }
2429            return Ok(None);
2430        }
2431        current = parent;
2432    }
2433
2434    Ok(None)
2435}
2436
2437// ============================================================================
2438// FFI Edge Building
2439// ============================================================================
2440
2441/// Process FFI member call (e.g., `$ffi->crypto_encrypt()`).
2442///
2443/// Creates an `FfiCall` edge from the caller to a native module node.
2444fn process_ffi_member_call(
2445    node: Node,
2446    method_name: &str,
2447    ast_graph: &ASTGraph,
2448    helper: &mut GraphBuildHelper,
2449    node_map: &mut HashMap<String, NodeId>,
2450) {
2451    // Get the caller context
2452    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2453        return;
2454    };
2455
2456    // Get or create caller node
2457    let source_id = *node_map
2458        .entry(call_context.qualified_name.clone())
2459        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2460
2461    // Create a native module node for the C function
2462    let ffi_name = format!("native::ffi::{method_name}");
2463    let call_span = span_from_node(node);
2464    let target_id = helper.add_module(&ffi_name, Some(call_span));
2465
2466    // Add FFI edge (PHP FFI uses C calling convention)
2467    helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2468}
2469
2470/// Process FFI static call (`FFI::cdef()` or `FFI::load()`).
2471///
2472/// Creates an `FfiCall` edge from the caller to a native module representing
2473/// the loaded library.
2474fn process_ffi_static_call(
2475    node: Node,
2476    method_name: &str,
2477    ast_graph: &ASTGraph,
2478    helper: &mut GraphBuildHelper,
2479    node_map: &mut HashMap<String, NodeId>,
2480    content: &[u8],
2481) {
2482    // Get the caller context
2483    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2484        return;
2485    };
2486
2487    // Get or create caller node
2488    let source_id = *node_map
2489        .entry(call_context.qualified_name.clone())
2490        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2491
2492    // Extract library name from call arguments
2493    let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2494        .map_or_else(
2495            || "unknown".to_string(),
2496            |lib| php_ffi_library_simple_name(&lib),
2497        );
2498
2499    // Create a native module node for the library
2500    let ffi_name = format!("native::{library_name}");
2501    let call_span = span_from_node(node);
2502    let target_id = helper.add_module(&ffi_name, Some(call_span));
2503
2504    // Add FFI edge (PHP FFI uses C calling convention)
2505    helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2506}
2507
2508// ============================================================================
2509// FFI Detection Helpers
2510// ============================================================================
2511
2512/// Check if a member call is a PHP FFI call (e.g., `$ffi->function_name()`).
2513///
2514/// Returns true for calls on objects that appear to be FFI instances.
2515/// Common patterns:
2516/// - `$ffi->...`, `self::$ffi->...`, `$this->ffi->...`
2517/// - `FFI::cdef(...)->...` (chained call)
2518/// - `FFI::load(...)->...` (chained call)
2519/// - `(FFI::cdef(...))->...` (parenthesized)
2520fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2521    // Check for direct chained FFI call: FFI::cdef(...)->method()
2522    if object_node.kind() == "scoped_call_expression"
2523        && let Some(scope_node) = object_node.child_by_field_name("scope")
2524        && let Some(name_node) = object_node.child_by_field_name("name")
2525        && let Ok(scope_text) = scope_node.utf8_text(content)
2526        && let Ok(name_text) = name_node.utf8_text(content)
2527        && is_ffi_static_call(scope_text, name_text)
2528    {
2529        return true;
2530    }
2531
2532    // Check for parenthesized FFI call: (FFI::cdef(...))->method()
2533    if object_node.kind() == "parenthesized_expression"
2534        && let Some(inner) = object_node.named_child(0)
2535        && inner.kind() == "scoped_call_expression"
2536        && let Some(scope_node) = inner.child_by_field_name("scope")
2537        && let Some(name_node) = inner.child_by_field_name("name")
2538        && let Ok(scope_text) = scope_node.utf8_text(content)
2539        && let Ok(name_text) = name_node.utf8_text(content)
2540        && is_ffi_static_call(scope_text, name_text)
2541    {
2542        return true;
2543    }
2544
2545    // Check text patterns for stored FFI objects
2546    let Ok(object_text) = object_node.utf8_text(content) else {
2547        return false;
2548    };
2549
2550    let object_text = object_text.trim();
2551
2552    // Direct FFI object: $ffi->method()
2553    if object_text == "$ffi" || object_text == "$_ffi" {
2554        return true;
2555    }
2556
2557    // Class property FFI: $this->ffi->method() or self::$ffi->method()
2558    if object_text.ends_with("->ffi")
2559        || object_text.ends_with("::$ffi")
2560        || object_text.ends_with("->_ffi")
2561        || object_text.ends_with("::$_ffi")
2562    {
2563        return true;
2564    }
2565
2566    false
2567}
2568
2569/// Check if a static call is `FFI::cdef()` or `FFI::load()`.
2570///
2571/// Accepts both `FFI` and `\FFI` (fully-qualified) patterns.
2572fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2573    (scope_text == "FFI" || scope_text == "\\FFI")
2574        && (method_text == "cdef" || method_text == "load")
2575}
2576
2577/// Extract library name from FFI call arguments.
2578///
2579/// Handles both positional and named arguments:
2580/// - `FFI::cdef("...", "lib.so")`: positional second argument
2581/// - `FFI::cdef(lib: "lib.so", cdef: "...")`: named `lib` argument
2582/// - `FFI::load("header.h")`: positional first argument
2583/// - `FFI::load(filename: "header.h")`: named `filename` argument
2584fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2585    let args = call_node.child_by_field_name("arguments")?;
2586
2587    let mut cursor = args.walk();
2588    let args_vec: Vec<Node> = args
2589        .children(&mut cursor)
2590        .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2591        .collect();
2592
2593    // For FFI::cdef, look for named "lib" argument first
2594    // For FFI::load, look for named "filename" argument first
2595    let target_arg_name = if is_cdef { "lib" } else { "filename" };
2596
2597    // Try to find argument by name (PHP 8 named arguments)
2598    if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2599        return extract_string_from_argument(named_arg, content);
2600    }
2601
2602    // Fall back to positional arguments (PHP 7 style)
2603    if is_cdef {
2604        // FFI::cdef() - second argument is library path
2605        args_vec
2606            .get(1)
2607            .and_then(|arg| extract_string_from_argument(*arg, content))
2608    } else {
2609        // FFI::load() - first argument is filename
2610        args_vec
2611            .first()
2612            .and_then(|arg| extract_string_from_argument(*arg, content))
2613    }
2614}
2615
2616/// Find a named argument by its parameter name.
2617///
2618/// PHP 8 named arguments: `func(param: value)`
2619/// Tree structure: `argument { name: "param", ":", value }`
2620///
2621/// Uses field-based access for resilience against grammar changes.
2622fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2623    for arg in args {
2624        if arg.kind() != "argument" {
2625            continue;
2626        }
2627
2628        // Check if this is a named argument (has 2+ named children)
2629        // This is a quick check before trying field-based access
2630        if arg.named_child_count() < 2 {
2631            continue;
2632        }
2633
2634        // Try field-based access first (more resilient)
2635        if let Some(name_node) = arg.child_by_field_name("name")
2636            && let Ok(name_text) = name_node.utf8_text(content)
2637            && name_text == param_name
2638        {
2639            return Some(*arg);
2640        } else if let Some(name_node) = arg.named_child(0)
2641            && let Ok(name_text) = name_node.utf8_text(content)
2642            && name_text == param_name
2643        {
2644            // Fallback to child ordering if field not available
2645            return Some(*arg);
2646        }
2647    }
2648
2649    None
2650}
2651
2652/// Extract string literal from an argument node, handling both positional and named arguments.
2653///
2654/// PHP 7.x positional: `argument(1 child) -> value`
2655/// PHP 8.x named: `argument(2+ children) -> name -> value`
2656///
2657/// Returns `None` if the argument is not a valid string literal, for example a variable,
2658/// constant, or interpolated string.
2659fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2660    // Unwrap argument wrappers to get to the actual value expression
2661    let value_node = unwrap_argument_node(arg_node)?;
2662
2663    // Only accept pure string literals, not variables or constants
2664    if !is_string_literal_node(value_node) {
2665        return None;
2666    }
2667
2668    // Reject interpolated strings (e.g., "lib{$var}.so")
2669    if is_interpolated_string(value_node) {
2670        return None;
2671    }
2672
2673    extract_php_string_content(value_node, content)
2674}
2675
2676/// Unwrap PHP argument node wrappers to get to the value expression.
2677///
2678/// Handles:
2679/// - `argument` nodes with 1 child: PHP 7.x positional args (argument -> value)
2680/// - `argument` nodes with 2+ children: PHP 8.x named args (argument -> name -> value)
2681///
2682/// Uses field-based skipping to extract the value child while excluding
2683/// the `name` field (named argument parameter name) and `reference_modifier`
2684/// field (& reference marker). This correctly handles cases where the value
2685/// itself is a `name` node (e.g., `self`, `parent`, `static`, class names).
2686/// Returns the innermost value expression.
2687fn unwrap_argument_node(node: Node) -> Option<Node> {
2688    if node.kind() != "argument" {
2689        // Not a wrapper, return as-is
2690        return Some(node);
2691    }
2692
2693    // Tree-sitter-php 0.24.2 `argument` nodes have:
2694    // - "name" field (for named arguments parameter name)
2695    // - "reference_modifier" field (for & references)
2696    // - No "value" field (must select by exclusion)
2697    //
2698    // Get the field nodes to exclude by identity comparison
2699    let name_field_node = node.child_by_field_name("name");
2700    let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2701
2702    // Find the value child by excluding structural field nodes
2703    for i in 0..node.named_child_count() {
2704        #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
2705        if let Some(child) = node.named_child(i as u32) {
2706            // Skip if this child is the name field or reference_modifier field
2707            let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2708            let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2709
2710            if !is_name_field && !is_ref_modifier {
2711                // This is the value child (expression, variadic_unpacking, or name node like self/parent/static)
2712                return Some(child);
2713            }
2714        }
2715    }
2716
2717    // If no value child found, return None (malformed argument)
2718    None
2719}
2720
2721/// Check if a node is a string literal (not a variable or constant).
2722///
2723/// PHP tree-sitter uses different node kinds for various string types:
2724/// - `string` for single-quoted strings (`'...'`)
2725/// - `encapsed_string` for double-quoted strings (`"..."`)
2726/// - `heredoc` and `nowdoc` for heredoc/nowdoc syntax
2727fn is_string_literal_node(node: Node) -> bool {
2728    matches!(
2729        node.kind(),
2730        "string" | "encapsed_string" | "heredoc" | "nowdoc"
2731    )
2732}
2733
2734/// Check if a string node contains variable interpolation.
2735///
2736/// Double-quoted strings and heredocs can contain interpolation:
2737/// - `lib{$suffix}.so`: simple variable
2738/// - `path/$variable/file`: simple variable
2739/// - `{$arr['key']}`: array access
2740/// - `{$obj->prop}`: property access
2741///
2742/// Single-quoted strings and nowdocs never interpolate, so we only check
2743/// `encapsed_string` and `heredoc` nodes.
2744///
2745/// Scans all descendants recursively to catch complex interpolation patterns.
2746fn is_interpolated_string(node: Node) -> bool {
2747    if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2748        return false;
2749    }
2750
2751    // Recursively check all descendants for variable-bearing nodes
2752    has_variable_node(node)
2753}
2754
2755/// Recursively check if a node or any of its descendants contains variables or dynamic expressions.
2756///
2757/// Detects all forms of interpolation:
2758/// - Direct variables: `$var`, `${expr}`
2759/// - Dynamic variables: `$$var`
2760/// - Array access: `$arr['key']`, `$arr[$index]`
2761/// - Property access: `$obj->prop`
2762/// - Method calls: `$obj->method()`
2763/// - Function calls: `$foo()`
2764/// - Static access: `$Class::$prop`, `$Class::method()`
2765/// - Class constants: `$Class::CONST`
2766/// - Nullsafe variants: `$obj?->prop`
2767/// - Any node containing variables at any depth
2768fn has_variable_node(node: Node) -> bool {
2769    // Check if this node itself is a variable-bearing or dynamic expression node
2770    if matches!(
2771        node.kind(),
2772        // Direct variable nodes
2773        "variable_name" | "simple_variable" | "variable" | "complex_variable"
2774        // Dynamic variables ($$var, ${'expr'})
2775        | "dynamic_variable_name"
2776        // Instance access and calls
2777        | "subscript_expression" | "member_access_expression" | "member_call_expression"
2778        // Function calls (may contain variables)
2779        | "function_call_expression"
2780        // Static/scoped access (may contain variables)
2781        | "scoped_call_expression" | "scoped_property_access_expression"
2782        // Class constant access (may have dynamic class name)
2783        | "class_constant_access_expression"
2784        // Nullsafe variants
2785        | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2786    ) {
2787        return true;
2788    }
2789
2790    // Recursively check all children
2791    for i in 0..node.child_count() {
2792        #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
2793        if let Some(child) = node.child(i as u32)
2794            && has_variable_node(child)
2795        {
2796            return true;
2797        }
2798    }
2799
2800    false
2801}
2802
2803/// Extract content from PHP string literal.
2804///
2805/// Handles single-quoted ('...'), double-quoted ("..."), and heredoc strings.
2806fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2807    let Ok(text) = string_node.utf8_text(content) else {
2808        return None;
2809    };
2810
2811    let text = text.trim();
2812
2813    // Strip quotes for simple strings
2814    if ((text.starts_with('"') && text.ends_with('"'))
2815        || (text.starts_with('\'') && text.ends_with('\'')))
2816        && text.len() >= 2
2817    {
2818        return Some(text[1..text.len() - 1].to_string());
2819    }
2820
2821    // For heredoc/nowdoc, return as-is (tree-sitter handles it)
2822    Some(text.to_string())
2823}
2824
2825/// Simplify library path to base name (e.g., "libfoo.so.1" → "libfoo").
2826fn php_ffi_library_simple_name(library_path: &str) -> String {
2827    use std::path::Path;
2828
2829    // Strip directory components first
2830    let filename = Path::new(library_path)
2831        .file_name()
2832        .and_then(|f| f.to_str())
2833        .unwrap_or(library_path);
2834
2835    // Handle versioned .so files (libfoo.so.1 → libfoo)
2836    if let Some(so_pos) = filename.find(".so.") {
2837        return filename[..so_pos].to_string();
2838    }
2839
2840    // Handle standard library and header extensions
2841    if let Some(dot_pos) = filename.find('.') {
2842        let extension = &filename[dot_pos + 1..];
2843        if extension == "so"
2844            || extension == "dll"
2845            || extension == "dylib"
2846            || extension == "h"
2847            || extension == "hpp"
2848        {
2849            return filename[..dot_pos].to_string();
2850        }
2851    }
2852
2853    filename.to_string()
2854}
2855
2856// ============================================================================
2857// Field emission tests (REQ:R0001..R0007, R0013, R0023)
2858// ============================================================================
2859
2860#[cfg(test)]
2861mod field_emission_tests {
2862    //! Tests for unconditional Property/Constant emission from PHP class /
2863    //! trait / interface property declarations and constructor-promotion
2864    //! parameters (DAG U10 / `C2_OTHER_PHP`).
2865    //!
2866    //! These tests assert the post-fix contract:
2867    //! - `PHPDoc` gate removed: Property/Constant emitted regardless of @var.
2868    //! - Qualified name `Class.prop` (dot separator per design §3.1).
2869    //! - Visibility from `visibility_modifier`; default "public" when absent.
2870    //! - `static_modifier` → `is_static = true`.
2871    //! - `readonly` (PHP 8.1+) → `Constant`; otherwise `Property`.
2872    //! - Native PHP 7.4+ type → primary; `PHPDoc` `@var` is enrichment fallback
2873    //!   only when no native type is present.
2874    //! - `TypeOf` edge uses `TypeOfContext::Field` and bare field-name metadata.
2875    //! - Constructor `property_promotion_parameter` emits a Property on the class.
2876    //! - Collision precedence: explicit declaration wins; promoted dedupes via
2877    //!   `helper.get_node` and only fills `None` attributes.
2878    //! - Span anchored on the property/promotion declaration node.
2879    use sqry_core::graph::GraphBuilder;
2880    use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2881    use sqry_core::graph::unified::build::test_helpers::{
2882        build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
2883    };
2884    use sqry_core::graph::unified::edge::EdgeKind;
2885    use sqry_core::graph::unified::edge::kind::TypeOfContext;
2886    use sqry_core::graph::unified::node::NodeKind;
2887    use std::path::Path;
2888    use tree_sitter::Parser;
2889
2890    use super::PhpGraphBuilder;
2891
2892    fn parse(source: &str) -> tree_sitter::Tree {
2893        let mut parser = Parser::new();
2894        parser
2895            .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
2896            .expect("load PHP grammar");
2897        parser.parse(source, None).expect("parse PHP source")
2898    }
2899
2900    fn build(source: &str) -> StagingGraph {
2901        let tree = parse(source);
2902        let mut staging = StagingGraph::new();
2903        let builder = PhpGraphBuilder::default();
2904        builder
2905            .build_graph(
2906                &tree,
2907                source.as_bytes(),
2908                Path::new("test.php"),
2909                &mut staging,
2910            )
2911            .expect("build graph");
2912        staging
2913    }
2914
2915    /// Look up a node entry by its qualified-or-bare name, optionally requiring a kind.
2916    fn find_node<'a>(
2917        staging: &'a StagingGraph,
2918        name: &str,
2919        kind: Option<NodeKind>,
2920    ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2921        let strings = build_string_lookup(staging);
2922        for op in staging.operations() {
2923            if let StagingOp::AddNode { entry, .. } = op {
2924                if let Some(k) = kind
2925                    && entry.kind != k
2926                {
2927                    continue;
2928                }
2929                let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2930                if let Some(s) = strings.get(&name_idx)
2931                    && s == name
2932                {
2933                    return Some(entry);
2934                }
2935            }
2936        }
2937        None
2938    }
2939
2940    fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
2941        let strings = build_string_lookup(staging);
2942        staging
2943            .operations()
2944            .iter()
2945            .filter(|op| {
2946                if let StagingOp::AddNode { entry, .. } = op {
2947                    let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2948                    strings.get(&name_idx).is_some_and(|s| s == name)
2949                } else {
2950                    false
2951                }
2952            })
2953            .count()
2954    }
2955
2956    fn resolve_visibility(
2957        staging: &StagingGraph,
2958        vis: Option<sqry_core::graph::unified::StringId>,
2959    ) -> Option<String> {
2960        let strings = build_string_lookup(staging);
2961        vis.and_then(|sid| strings.get(&sid.index()).cloned())
2962    }
2963
2964    fn typeof_edges_for_node(
2965        staging: &StagingGraph,
2966        source_name: &str,
2967    ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
2968        let names = build_node_name_lookup(staging);
2969        let strings = build_string_lookup(staging);
2970        let mut out = Vec::new();
2971        for op in staging.operations() {
2972            if let StagingOp::AddEdge {
2973                source,
2974                target,
2975                kind: EdgeKind::TypeOf { context, name, .. },
2976                ..
2977            } = op
2978            {
2979                let src = names.get(source).cloned().unwrap_or_default();
2980                if src != source_name {
2981                    continue;
2982                }
2983                let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
2984                let target_name = names.get(target).cloned().unwrap_or_default();
2985                out.push((*context, edge_name, target_name));
2986            }
2987        }
2988        out
2989    }
2990
2991    // -- AC-1: PHPDoc gate removed ------------------------------------------
2992
2993    #[test]
2994    fn req_r0001_property_without_phpdoc_emits_property_node() {
2995        let src = "<?php
2996class User {
2997    public string $name;
2998}
2999";
3000        let staging = build(src);
3001        let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
3002            .expect("User.name Property must be emitted without @var");
3003        assert_eq!(entry.kind, NodeKind::Property);
3004    }
3005
3006    #[test]
3007    fn req_r0001_property_with_phpdoc_still_emits_property_node() {
3008        let src = "<?php
3009class Repo {
3010    /** @var string */
3011    public string $label;
3012}
3013";
3014        let staging = build(src);
3015        find_node(&staging, "Repo.label", Some(NodeKind::Property))
3016            .expect("Repo.label Property must be emitted when @var is present");
3017    }
3018
3019    // -- AC-2: qualified name + default visibility --------------------------
3020
3021    #[test]
3022    fn req_r0002_qualified_name_uses_class_dot_prop() {
3023        let src = "<?php
3024class A { public int $x; }
3025class B { public int $x; }
3026";
3027        let staging = build(src);
3028        find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
3029        find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
3030        assert!(
3031            find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3032            "no bare 'x' Property node should leak"
3033        );
3034    }
3035
3036    #[test]
3037    fn req_r0002_visibility_modifiers_round_trip() {
3038        let src = "<?php
3039class V {
3040    public int $a;
3041    private int $b;
3042    protected int $c;
3043    var $d;
3044}
3045";
3046        let staging = build(src);
3047        for (name, expected) in [
3048            ("V.a", "public"),
3049            ("V.b", "private"),
3050            ("V.c", "protected"),
3051            ("V.d", "public"),
3052        ] {
3053            let entry = find_node(&staging, name, Some(NodeKind::Property))
3054                .unwrap_or_else(|| panic!("missing {name}"));
3055            let got = resolve_visibility(&staging, entry.visibility);
3056            assert_eq!(
3057                got.as_deref(),
3058                Some(expected),
3059                "{name} visibility should be {expected}"
3060            );
3061        }
3062    }
3063
3064    #[test]
3065    fn req_r0002_default_visibility_is_public_when_no_modifier() {
3066        // PHP allows readonly/static-only declarations (no explicit visibility).
3067        let src = "<?php
3068class X { static int $count = 0; }
3069";
3070        let staging = build(src);
3071        let entry =
3072            find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
3073        let vis = resolve_visibility(&staging, entry.visibility);
3074        assert_eq!(
3075            vis.as_deref(),
3076            Some("public"),
3077            "default visibility is public"
3078        );
3079    }
3080
3081    // -- AC-3: static modifier ----------------------------------------------
3082
3083    #[test]
3084    fn req_r0003_static_modifier_sets_is_static() {
3085        let src = "<?php
3086class S {
3087    public static int $count = 0;
3088    public int $instance = 0;
3089}
3090";
3091        let staging = build(src);
3092        let s_count =
3093            find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
3094        assert!(s_count.is_static, "S.count should be static");
3095        let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
3096            .expect("S.instance must exist");
3097        assert!(!s_instance.is_static, "S.instance should not be static");
3098    }
3099
3100    // -- AC-4: readonly → Constant ------------------------------------------
3101
3102    #[test]
3103    fn req_r0004_readonly_emits_constant() {
3104        let src = "<?php
3105class R {
3106    public readonly string $id;
3107    public string $name;
3108}
3109";
3110        let staging = build(src);
3111        find_node(&staging, "R.id", Some(NodeKind::Constant))
3112            .expect("R.id must be Constant (readonly)");
3113        find_node(&staging, "R.name", Some(NodeKind::Property))
3114            .expect("R.name must be Property (mutable)");
3115    }
3116
3117    // -- AC-5: native type primary, PHPDoc fallback --------------------------
3118
3119    #[test]
3120    fn req_r0005_native_type_takes_precedence_over_phpdoc() {
3121        // The PHPDoc parser used by this plugin requires `{...}` around the
3122        // type token. Native-type wins regardless: the @var should be
3123        // ignored entirely when a PHP-level type is present.
3124        let src = "<?php
3125class T {
3126    /** @var {int} */
3127    public string $value;
3128}
3129";
3130        let staging = build(src);
3131        let edges = typeof_edges_for_node(&staging, "T.value");
3132        assert!(
3133            !edges.is_empty(),
3134            "T.value should have at least one TypeOf edge"
3135        );
3136        let has_string = edges.iter().any(|(_, _, t)| t == "string");
3137        assert!(
3138            has_string,
3139            "native type 'string' should be the primary TypeOf target, got {edges:?}"
3140        );
3141        let has_int = edges.iter().any(|(_, _, t)| t == "int");
3142        assert!(
3143            !has_int,
3144            "PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
3145        );
3146    }
3147
3148    #[test]
3149    fn req_r0005_phpdoc_fallback_when_no_native_type() {
3150        // PHPDoc parser requires `{...}` braces around the type identifier.
3151        let src = "<?php
3152class T {
3153    /** @var {SomeUserType} */
3154    public $value;
3155}
3156";
3157        let staging = build(src);
3158        let edges = typeof_edges_for_node(&staging, "T.value");
3159        assert!(
3160            edges.iter().any(|(_, _, t)| t == "SomeUserType"),
3161            "PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
3162        );
3163    }
3164
3165    // -- AC-6: TypeOfContext::Field + bare edge name ------------------------
3166
3167    #[test]
3168    fn req_r0006_typeof_uses_field_context_and_bare_name() {
3169        let src = "<?php
3170class C {
3171    public string $title;
3172}
3173";
3174        let staging = build(src);
3175        let edges = typeof_edges_for_node(&staging, "C.title");
3176        assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
3177        for (ctx, name, _) in &edges {
3178            assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3179            assert_eq!(
3180                name.as_deref(),
3181                Some("title"),
3182                "edge name must be the bare property name"
3183            );
3184        }
3185    }
3186
3187    // -- AC-7: constructor promotion ----------------------------------------
3188
3189    #[test]
3190    fn req_r0007_constructor_promotion_emits_property_on_class() {
3191        let src = "<?php
3192class P {
3193    public function __construct(public int $x, private readonly string $y) {}
3194}
3195";
3196        let staging = build(src);
3197        let x = find_node(&staging, "P.x", Some(NodeKind::Property))
3198            .expect("promoted P.x must be a Property");
3199        assert_eq!(
3200            resolve_visibility(&staging, x.visibility).as_deref(),
3201            Some("public"),
3202            "promoted $x visibility"
3203        );
3204        let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
3205            .expect("promoted readonly P.y must be a Constant");
3206        assert_eq!(
3207            resolve_visibility(&staging, y.visibility).as_deref(),
3208            Some("private"),
3209            "promoted $y visibility"
3210        );
3211    }
3212
3213    // -- AC-8: collision precedence (explicit wins, promoted dedupes) -------
3214
3215    #[test]
3216    fn req_r0013_explicit_declaration_wins_over_promotion() {
3217        let src = "<?php
3218class D {
3219    public int $x;
3220    public function __construct(public int $x) {}
3221}
3222";
3223        let staging = build(src);
3224        let n = count_nodes_named(&staging, "D.x");
3225        assert_eq!(
3226            n, 1,
3227            "exactly one D.x node when explicit decl + promotion collide, got {n}"
3228        );
3229        // Should remain a Property (not switched to anything else).
3230        find_node(&staging, "D.x", Some(NodeKind::Property))
3231            .expect("D.x must be Property (explicit declaration wins)");
3232    }
3233
3234    /// Constructor appears BEFORE the explicit property declaration. The
3235    /// explicit declaration must still win on every dimension —
3236    /// kind/visibility/static — and its declared `int` type must be the
3237    /// only `TypeOf` target bound to `A.x` (the promoted `string` is
3238    /// suppressed). Locks in FR-13 against source-order regression.
3239    #[test]
3240    fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
3241        let src = "<?php
3242class A {
3243    public function __construct(public string $x) {}
3244    public int $x;
3245}
3246";
3247        let staging = build(src);
3248        let n = count_nodes_named(&staging, "A.x");
3249        assert_eq!(
3250            n, 1,
3251            "exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
3252        );
3253        find_node(&staging, "A.x", Some(NodeKind::Property))
3254            .expect("A.x must be Property (explicit declaration wins)");
3255
3256        // TypeOf edges: only the explicit `int` should appear; the
3257        // promoted `string` must NOT be re-emitted onto the explicit
3258        // node.
3259        let edges = typeof_edges_for_node(&staging, "A.x");
3260        let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3261        assert!(
3262            target_types.contains(&"int"),
3263            "explicit `int` TypeOf must be present, got {target_types:?}",
3264        );
3265        assert!(
3266            !target_types.contains(&"string"),
3267            "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3268        );
3269    }
3270
3271    /// Mirror of the above with explicit property declaration appearing
3272    /// BEFORE the constructor. Same outcome required: single Property
3273    /// node with explicit attributes, only the explicit `int` `TypeOf`.
3274    #[test]
3275    fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
3276        let src = "<?php
3277class B {
3278    public int $x;
3279    public function __construct(public string $x) {}
3280}
3281";
3282        let staging = build(src);
3283        let n = count_nodes_named(&staging, "B.x");
3284        assert_eq!(
3285            n, 1,
3286            "exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
3287        );
3288        find_node(&staging, "B.x", Some(NodeKind::Property))
3289            .expect("B.x must be Property (explicit declaration wins)");
3290
3291        let edges = typeof_edges_for_node(&staging, "B.x");
3292        let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3293        assert!(
3294            target_types.contains(&"int"),
3295            "explicit `int` TypeOf must be present, got {target_types:?}",
3296        );
3297        assert!(
3298            !target_types.contains(&"string"),
3299            "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3300        );
3301    }
3302
3303    // -- AC-9: span set from declaration node -------------------------------
3304
3305    #[test]
3306    fn req_r0023_span_anchored_on_declaration() {
3307        let src = "<?php
3308class W {
3309
3310    public string $marker;
3311}
3312";
3313        let staging = build(src);
3314        let entry =
3315            find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
3316        // Source layout (0-based): row 0 `<?php`, row 1 `class W {`, row 2
3317        // blank, row 3 `    public string $marker;`. Helper rebases line
3318        // numbers to 1-based via `saturating_add(1)`, so row 3 → 4.
3319        // (Note: `add_node_internal` only stores line/column from a
3320        // position-only Span, so `end_byte` stays at the default zero —
3321        // we anchor span correctness on the line numbers and column
3322        // extent instead.)
3323        assert_eq!(
3324            entry.start_line, 4,
3325            "span start line should match declaration"
3326        );
3327        assert_eq!(entry.end_line, 4, "span end line should match declaration");
3328        assert_eq!(
3329            entry.start_column, 4,
3330            "span start column should match indentation of `public`"
3331        );
3332        assert!(
3333            entry.end_column > entry.start_column,
3334            "span end column must extend past start (got start={}, end={})",
3335            entry.start_column,
3336            entry.end_column,
3337        );
3338    }
3339
3340    // -- Trait + interface coverage -----------------------------------------
3341
3342    #[test]
3343    fn req_r0001_trait_property_emitted() {
3344        let src = "<?php
3345trait Loggable {
3346    protected ?string $logTag;
3347}
3348";
3349        let staging = build(src);
3350        let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
3351            .expect("trait property must be emitted");
3352        let vis = resolve_visibility(&staging, entry.visibility);
3353        assert_eq!(vis.as_deref(), Some("protected"));
3354    }
3355
3356    #[test]
3357    fn no_emission_outside_class_or_trait_or_interface() {
3358        // Plain global variables are not class properties; the walker must not
3359        // emit Property/Constant for them.
3360        let src = "<?php
3361$x = 1;
3362function f() { $y = 2; }
3363";
3364        let staging = build(src);
3365        assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
3366        assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
3367    }
3368}