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;
35use std::sync::OnceLock;
36
37use sqry_core::graph::unified::build::helper::CalleeKindHint;
38use sqry_core::graph::unified::build::shape::{CfBucket, ShapeMapping};
39use sqry_core::graph::unified::edge::kind::{FfiConvention, TypeOfContext};
40use sqry_core::graph::unified::storage::shape::SignatureShape;
41use sqry_core::graph::unified::{GraphBuildHelper, NodeId, StagingGraph};
42use sqry_core::graph::{
43    GraphBuilder, GraphBuilderError, GraphResult, GraphSnapshot, Language, Span,
44};
45use tree_sitter::{Node, Tree};
46
47use super::phpdoc_parser::{extract_phpdoc_comment, parse_phpdoc_tags};
48use super::type_extractor::{canonical_type_string, extract_type_names};
49
50/// Maximum namespace nesting depth to prevent pathological cases.
51const DEFAULT_MAX_SCOPE_DEPTH: usize = 5;
52
53/// PHP-specific graph builder.
54#[derive(Debug)]
55pub struct PhpGraphBuilder {
56    pub max_scope_depth: usize,
57}
58
59impl Default for PhpGraphBuilder {
60    fn default() -> Self {
61        Self {
62            max_scope_depth: DEFAULT_MAX_SCOPE_DEPTH,
63        }
64    }
65}
66
67impl GraphBuilder for PhpGraphBuilder {
68    fn build_graph(
69        &self,
70        tree: &Tree,
71        content: &[u8],
72        file: &Path,
73        staging: &mut StagingGraph,
74    ) -> GraphResult<()> {
75        let mut helper = GraphBuildHelper::new(staging, file, Language::Php);
76
77        // Build AST context for O(1) function lookups
78        let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
79            GraphBuilderError::ParseError {
80                span: Span::default(),
81                reason: e,
82            }
83        })?;
84
85        // Map qualified names to NodeIds for call edge creation
86        let mut node_map = HashMap::new();
87
88        // Phase 1: Create function/method/class nodes
89        for context in ast_graph.contexts() {
90            let qualified_name = &context.qualified_name;
91            let span = context.decl_span;
92
93            let node_id = match &context.kind {
94                ContextKind::Function { is_async } => helper.add_function_with_signature(
95                    qualified_name,
96                    Some(span),
97                    *is_async,
98                    false, // PHP functions are not unsafe
99                    None,  // PHP functions don't have visibility modifiers
100                    context.return_type.as_deref(),
101                ),
102                ContextKind::Method {
103                    is_async,
104                    is_static,
105                    visibility: _,
106                } => {
107                    // Note: Visibility metadata is stored in the CallContext and used during export filtering.
108                    // It's not added to the node metadata at this time due to GraphBuildHelper API limitations.
109                    // The export phase (Phase 4) will filter methods based on visibility.
110                    helper.add_method_with_signature(
111                        qualified_name,
112                        Some(span),
113                        *is_async,
114                        *is_static,
115                        None, // Visibility not yet supported in GraphBuildHelper API
116                        context.return_type.as_deref(),
117                    )
118                }
119                ContextKind::Class => helper.add_class(qualified_name, Some(span)),
120            };
121            // issue #394: real declaration; opt dual-use bare helper into is_definition
122            helper.mark_definition(node_id);
123            node_map.insert(qualified_name.clone(), node_id);
124        }
125
126        // Phase 2: Walk the tree to find calls, imports, and OOP relationships
127        let root = tree.root_node();
128        walk_tree_for_edges(root, content, &ast_graph, &mut helper, &mut node_map)?;
129
130        // Phase 3: Process class inheritance and interface implementations
131        process_oop_relationships(root, content, &mut helper, &mut node_map);
132
133        // Phase 4: Generate export edges for all top-level symbols
134        // In PHP, all classes/interfaces/traits/functions are implicitly exported
135        process_exports(root, content, &mut helper, &mut node_map);
136
137        // Phase 5: Process PHPDoc annotations for TypeOf and Reference edges
138        process_phpdoc_annotations(root, content, &mut helper)?;
139
140        Ok(())
141    }
142
143    fn language(&self) -> Language {
144        Language::Php
145    }
146
147    fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
148        Some(php_shape_mapping())
149    }
150
151    fn detect_cross_language_edges(
152        &self,
153        _snapshot: &GraphSnapshot,
154    ) -> GraphResult<Vec<sqry_core::graph::CodeEdge>> {
155        // Cross-file edge detection not implemented by design.
156        // Intra-file FFI detection is implemented in build_graph() above.
157        Ok(vec![])
158    }
159}
160
161// ============================================================================
162// AST Graph - tracks callable contexts (functions, methods, classes)
163// ============================================================================
164
165#[derive(Debug, Clone)]
166enum ContextKind {
167    Function {
168        is_async: bool,
169    },
170    Method {
171        is_async: bool,
172        is_static: bool,
173        #[allow(dead_code)] // Used in export_public_methods_from_class via AST traversal
174        visibility: Option<String>,
175    },
176    Class,
177}
178
179#[derive(Debug, Clone)]
180struct CallContext {
181    qualified_name: String,
182    /// Real line/column span of the declaration; the byte tuple above cannot
183    /// be resolved to one without the file content.
184    decl_span: Span,
185    kind: ContextKind,
186    class_name: Option<String>,
187    return_type: Option<String>,
188}
189
190struct ASTGraph {
191    contexts: Vec<CallContext>,
192    node_to_context: HashMap<usize, usize>,
193}
194
195impl ASTGraph {
196    fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
197        let mut contexts = Vec::new();
198        let mut node_to_context = HashMap::new();
199        let mut scope_stack: Vec<String> = Vec::new();
200        let mut class_stack: Vec<String> = Vec::new();
201
202        // Create recursion guard
203        let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
204            .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
205        let file_ops_depth = recursion_limits
206            .effective_file_ops_depth()
207            .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
208        let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
209            .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
210
211        let mut walk_ctx = WalkContext {
212            contexts: &mut contexts,
213            node_to_context: &mut node_to_context,
214            scope_stack: &mut scope_stack,
215            class_stack: &mut class_stack,
216            max_depth,
217        };
218
219        walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
220
221        Ok(Self {
222            contexts,
223            node_to_context,
224        })
225    }
226
227    fn contexts(&self) -> &[CallContext] {
228        &self.contexts
229    }
230
231    fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
232        self.node_to_context
233            .get(&node_id)
234            .and_then(|idx| self.contexts.get(*idx))
235    }
236}
237
238#[allow(
239    clippy::too_many_lines,
240    reason = "PHP namespace and scope handling requires a large, unified traversal."
241)]
242/// # Errors
243///
244/// Returns error if recursion depth exceeds the guard's limit.
245/// Context for AST walking, bundling mutable state to reduce parameter count.
246struct WalkContext<'a> {
247    contexts: &'a mut Vec<CallContext>,
248    node_to_context: &'a mut HashMap<usize, usize>,
249    scope_stack: &'a mut Vec<String>,
250    class_stack: &'a mut Vec<String>,
251    max_depth: usize,
252}
253
254#[allow(clippy::too_many_lines)]
255fn walk_ast(
256    node: Node,
257    content: &[u8],
258    ctx: &mut WalkContext,
259    guard: &mut sqry_core::query::security::RecursionGuard,
260) -> Result<(), String> {
261    guard
262        .enter()
263        .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
264
265    if ctx.scope_stack.len() > ctx.max_depth {
266        guard.exit();
267        return Ok(());
268    }
269
270    match node.kind() {
271        "program" => {
272            // Special handling for program node to properly track semicolon-style namespaces.
273            // In PHP, `namespace Foo;` affects all subsequent sibling declarations at program level.
274            let mut active_namespace_parts: Vec<String> = Vec::new();
275
276            let mut cursor = node.walk();
277            for child in node.children(&mut cursor) {
278                if child.kind() == "namespace_definition" {
279                    // Check if this is semicolon-style or brace-style
280                    let has_body = child
281                        .children(&mut child.walk())
282                        .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
283
284                    let ns_name = child
285                        .child_by_field_name("name")
286                        .and_then(|n| n.utf8_text(content).ok())
287                        .map(|s| s.trim().to_string())
288                        .unwrap_or_default();
289
290                    if has_body {
291                        // Brace-style: `namespace Foo { ... }` - process with its own scope
292                        //
293                        // Robustness: If a brace-style namespace follows a semicolon-style
294                        // namespace (invalid PHP, but possible in fixtures/partial parses),
295                        // we must first clear the active semicolon namespace to avoid
296                        // scope pollution.
297                        for _ in 0..active_namespace_parts.len() {
298                            ctx.scope_stack.pop();
299                        }
300                        active_namespace_parts.clear();
301
302                        let ns_parts: Vec<String> = if ns_name.is_empty() {
303                            Vec::new()
304                        } else {
305                            ns_name.split('\\').map(ToString::to_string).collect()
306                        };
307
308                        for part in &ns_parts {
309                            ctx.scope_stack.push(part.clone());
310                        }
311
312                        // Process children of the brace body
313                        for ns_child in child.children(&mut child.walk()) {
314                            if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
315                            {
316                                for body_child in ns_child.children(&mut ns_child.walk()) {
317                                    walk_ast(body_child, content, ctx, guard)?;
318                                }
319                            }
320                        }
321
322                        for _ in 0..ns_parts.len() {
323                            ctx.scope_stack.pop();
324                        }
325                    } else {
326                        // Semicolon-style: `namespace Foo;` - update active namespace
327                        // First, pop any previous namespace from scope_stack
328                        for _ in 0..active_namespace_parts.len() {
329                            ctx.scope_stack.pop();
330                        }
331
332                        // Set the new active namespace
333                        active_namespace_parts = if ns_name.is_empty() {
334                            Vec::new()
335                        } else {
336                            ns_name.split('\\').map(ToString::to_string).collect()
337                        };
338
339                        // Push new namespace parts to scope_stack
340                        for part in &active_namespace_parts {
341                            ctx.scope_stack.push(part.clone());
342                        }
343                    }
344                } else {
345                    // Non-namespace declaration at program level - uses current scope
346                    walk_ast(child, content, ctx, guard)?;
347                }
348            }
349
350            // Clean up any remaining namespace from scope_stack
351            for _ in 0..active_namespace_parts.len() {
352                ctx.scope_stack.pop();
353            }
354
355            guard.exit();
356            return Ok(());
357        }
358        "namespace_definition" => {
359            // This branch handles namespace definitions when NOT at program level
360            // (e.g., nested namespaces or when called from other ctx.contexts)
361            let namespace_name = node
362                .child_by_field_name("name")
363                .and_then(|n| n.utf8_text(content).ok())
364                .map(|s| s.trim().to_string())
365                .unwrap_or_default();
366
367            let namespace_parts: Vec<String> = if namespace_name.is_empty() {
368                Vec::new()
369            } else {
370                namespace_name
371                    .split('\\')
372                    .map(ToString::to_string)
373                    .collect()
374            };
375
376            let parts_count = namespace_parts.len();
377            for part in &namespace_parts {
378                ctx.scope_stack.push(part.clone());
379            }
380
381            // Recurse into namespace body (either braced block or rest of file)
382            let mut cursor = node.walk();
383            for child in node.children(&mut cursor) {
384                if matches!(child.kind(), "compound_statement" | "declaration_list") {
385                    let mut body_cursor = child.walk();
386                    for body_child in child.children(&mut body_cursor) {
387                        walk_ast(body_child, content, ctx, guard)?;
388                    }
389                }
390            }
391
392            // Pop namespace parts
393            for _ in 0..parts_count {
394                ctx.scope_stack.pop();
395            }
396        }
397        "class_declaration" => {
398            let name_node = node
399                .child_by_field_name("name")
400                .ok_or_else(|| "class_declaration missing name".to_string())?;
401            let class_name = name_node
402                .utf8_text(content)
403                .map_err(|_| "failed to read class name".to_string())?;
404
405            // Build qualified class name using PHP namespace separator
406            let qualified_class = if ctx.scope_stack.is_empty() {
407                class_name.to_string()
408            } else {
409                format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
410            };
411
412            ctx.class_stack.push(qualified_class.clone());
413            ctx.scope_stack.push(class_name.to_string());
414
415            // Add class context
416            let _context_idx = ctx.contexts.len();
417            ctx.contexts.push(CallContext {
418                qualified_name: qualified_class.clone(),
419                decl_span: Span::from_node(&node),
420                kind: ContextKind::Class,
421                class_name: Some(qualified_class),
422                return_type: None, // Classes don't have return types
423            });
424
425            // Recurse into class body
426            let mut cursor = node.walk();
427            for child in node.children(&mut cursor) {
428                if child.kind() == "declaration_list" {
429                    let mut body_cursor = child.walk();
430                    for body_child in child.children(&mut body_cursor) {
431                        walk_ast(body_child, content, ctx, guard)?;
432                    }
433                }
434            }
435
436            ctx.class_stack.pop();
437            ctx.scope_stack.pop();
438        }
439        "function_definition" | "method_declaration" => {
440            let name_node = node
441                .child_by_field_name("name")
442                .ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
443            let func_name = name_node
444                .utf8_text(content)
445                .map_err(|_| "failed to read function name".to_string())?;
446
447            // Check if async (PHP 8.1+ supports async/await via Fibers)
448            let is_async = false; // PHP doesn't have native async keyword like JS/Python
449
450            // Check if static method
451            let is_static = node
452                .children(&mut node.walk())
453                .any(|child| child.kind() == "static_modifier");
454
455            // Extract visibility modifier for methods (public, private, protected)
456            let visibility = extract_visibility(&node, content);
457
458            // Extract return type annotation (PHP 7.0+)
459            let return_type = extract_return_type(&node, content);
460
461            // Determine if this is a method (inside a class)
462            let is_method = !ctx.class_stack.is_empty();
463            let class_name = ctx.class_stack.last().cloned();
464
465            // Build qualified function/method name
466            // For methods: use ClassName::methodName format (with ::)
467            // For functions: use Namespace\functionName format (with \)
468            let qualified_func = if is_method {
469                // Method: use ClassName::methodName
470                if let Some(ref class) = class_name {
471                    format!("{class}::{func_name}")
472                } else {
473                    func_name.to_string()
474                }
475            } else {
476                // Function: use namespace\function format
477                if ctx.scope_stack.is_empty() {
478                    func_name.to_string()
479                } else {
480                    format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
481                }
482            };
483
484            let kind = if is_method {
485                ContextKind::Method {
486                    is_async,
487                    is_static,
488                    visibility: visibility.clone(),
489                }
490            } else {
491                ContextKind::Function { is_async }
492            };
493
494            let context_idx = ctx.contexts.len();
495            ctx.contexts.push(CallContext {
496                qualified_name: qualified_func.clone(),
497                decl_span: Span::from_node(&node),
498                kind,
499                class_name,
500                return_type,
501            });
502
503            // Associate all descendants with this context
504            if let Some(body) = node.child_by_field_name("body") {
505                associate_descendants(body, context_idx, ctx.node_to_context);
506            }
507
508            ctx.scope_stack.push(func_name.to_string());
509
510            // Recurse into function body to find nested functions
511            if let Some(body) = node.child_by_field_name("body") {
512                let mut cursor = body.walk();
513                for child in body.children(&mut cursor) {
514                    walk_ast(child, content, ctx, guard)?;
515                }
516            }
517
518            ctx.scope_stack.pop();
519        }
520        _ => {
521            // Recurse into children for other node types
522            let mut cursor = node.walk();
523            for child in node.children(&mut cursor) {
524                walk_ast(child, content, ctx, guard)?;
525            }
526        }
527    }
528
529    guard.exit();
530    Ok(())
531}
532
533fn associate_descendants(
534    node: Node,
535    context_idx: usize,
536    node_to_context: &mut HashMap<usize, usize>,
537) {
538    node_to_context.insert(node.id(), context_idx);
539
540    let mut stack = vec![node];
541    while let Some(current) = stack.pop() {
542        node_to_context.insert(current.id(), context_idx);
543
544        let mut cursor = current.walk();
545        for child in current.children(&mut cursor) {
546            stack.push(child);
547        }
548    }
549}
550
551// ============================================================================
552// Edge Building - calls, method calls, static calls
553// ============================================================================
554
555/// Walk the AST tree to create edges (calls, imports)
556#[allow(clippy::only_used_in_recursion)]
557fn walk_tree_for_edges(
558    node: Node,
559    content: &[u8],
560    ast_graph: &ASTGraph,
561    helper: &mut GraphBuildHelper,
562    node_map: &mut HashMap<String, NodeId>,
563) -> GraphResult<()> {
564    match node.kind() {
565        "function_call_expression" => {
566            process_function_call(node, content, ast_graph, helper, node_map);
567        }
568        "member_call_expression" | "nullsafe_member_call_expression" => {
569            process_member_call(node, content, ast_graph, helper, node_map);
570        }
571        "scoped_call_expression" => {
572            process_static_call(node, content, ast_graph, helper, node_map);
573        }
574        // Import edges for namespace use declarations
575        "namespace_use_declaration" => {
576            process_namespace_use(node, content, helper);
577        }
578        // Import edges for require/require_once/include/include_once
579        "expression_statement" => {
580            // Check for require/include expressions within expression statements
581            let mut cursor = node.walk();
582            for child in node.children(&mut cursor) {
583                match child.kind() {
584                    "require_expression"
585                    | "require_once_expression"
586                    | "include_expression"
587                    | "include_once_expression" => {
588                        process_file_include(child, content, helper);
589                    }
590                    _ => {}
591                }
592            }
593        }
594        _ => {}
595    }
596
597    // Recurse into children
598    let mut cursor = node.walk();
599    for child in node.children(&mut cursor) {
600        walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
601    }
602
603    Ok(())
604}
605
606fn process_function_call(
607    node: Node,
608    content: &[u8],
609    ast_graph: &ASTGraph,
610    helper: &mut GraphBuildHelper,
611    node_map: &mut HashMap<String, NodeId>,
612) {
613    let Some(function_node) = node.child_by_field_name("function") else {
614        return;
615    };
616
617    let Ok(callee_name) = function_node.utf8_text(content) else {
618        return;
619    };
620
621    // Get the caller context
622    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
623        return;
624    };
625
626    // Get or create caller node
627    let source_id = *node_map
628        .entry(call_context.qualified_name.clone())
629        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
630
631    // Get or create callee node
632    let call_span = span_from_node(node);
633    let target_id = *node_map
634        .entry(callee_name.to_string())
635        .or_insert_with(|| helper.ensure_callee(callee_name, call_span, CalleeKindHint::Function));
636
637    let argument_count = count_call_arguments(node);
638    helper.add_call_edge_full_with_span(
639        source_id,
640        target_id,
641        argument_count,
642        false,
643        vec![call_span],
644    );
645}
646
647fn process_member_call(
648    node: Node,
649    content: &[u8],
650    ast_graph: &ASTGraph,
651    helper: &mut GraphBuildHelper,
652    node_map: &mut HashMap<String, NodeId>,
653) {
654    let Some(method_node) = node.child_by_field_name("name") else {
655        return;
656    };
657
658    let Ok(method_name) = method_node.utf8_text(content) else {
659        return;
660    };
661
662    // Check if this is an FFI call (e.g., $ffi->crypto_encrypt())
663    if let Some(object_node) = node.child_by_field_name("object")
664        && is_php_ffi_call(object_node, content)
665    {
666        process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
667        return;
668    }
669
670    // Get the caller context
671    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
672        return;
673    };
674
675    // For $this->method(), resolve to ClassName::method using :: separator
676    let callee_qualified = if let Some(class_name) = &call_context.class_name {
677        format!("{class_name}::{method_name}")
678    } else {
679        method_name.to_string()
680    };
681
682    // Get or create caller node
683    let source_id = *node_map
684        .entry(call_context.qualified_name.clone())
685        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
686
687    // Get or create callee node
688    let call_span = span_from_node(node);
689    let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
690        helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
691    });
692
693    let argument_count = count_call_arguments(node);
694    helper.add_call_edge_full_with_span(
695        source_id,
696        target_id,
697        argument_count,
698        false,
699        vec![call_span],
700    );
701}
702
703fn process_static_call(
704    node: Node,
705    content: &[u8],
706    ast_graph: &ASTGraph,
707    helper: &mut GraphBuildHelper,
708    node_map: &mut HashMap<String, NodeId>,
709) {
710    let Some(scope_node) = node.child_by_field_name("scope") else {
711        return;
712    };
713    let Some(name_node) = node.child_by_field_name("name") else {
714        return;
715    };
716
717    let Ok(class_name) = scope_node.utf8_text(content) else {
718        return;
719    };
720    let Ok(method_name) = name_node.utf8_text(content) else {
721        return;
722    };
723
724    // Check if this is an FFI static call (FFI::cdef() or FFI::load())
725    if is_ffi_static_call(class_name, method_name) {
726        process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
727        return;
728    }
729
730    // Get the caller context
731    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
732        return;
733    };
734
735    // Static call: Class::method() - use :: separator for methods
736    let callee_qualified = format!("{class_name}::{method_name}");
737
738    // Get or create caller node
739    let source_id = *node_map
740        .entry(call_context.qualified_name.clone())
741        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
742
743    // Get or create callee node
744    let call_span = span_from_node(node);
745    let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
746        helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
747    });
748
749    let argument_count = count_call_arguments(node);
750    helper.add_call_edge_full_with_span(
751        source_id,
752        target_id,
753        argument_count,
754        false,
755        vec![call_span],
756    );
757}
758
759// ============================================================================
760// Import Edge Building - namespace use, require, include
761// ============================================================================
762
763/// Process PHP `use` declarations for namespace imports.
764///
765/// Handles:
766/// - `use Namespace\Class;` - simple use
767/// - `use Namespace\Class as Alias;` - aliased use
768/// - `use Namespace\{Class1, Class2};` - grouped use
769/// - `use function Namespace\func;` - function use
770/// - `use const Namespace\CONST;` - constant use
771fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
772    // Create a module node for the current file
773    let file_path = helper.file_path().to_string();
774    let importer_id = helper.add_module(&file_path, None);
775
776    // For grouped imports, we need to extract the prefix at the declaration level
777    // AST: namespace_use_declaration > namespace_name > namespace_use_group
778    let mut prefix = String::new();
779    let mut cursor = node.walk();
780    for child in node.children(&mut cursor) {
781        if child.kind() == "namespace_name"
782            && let Ok(ns) = child.utf8_text(content)
783        {
784            prefix = ns.trim().to_string();
785            break;
786        }
787    }
788
789    // Process children for imports
790    cursor = node.walk();
791    for child in node.children(&mut cursor) {
792        match child.kind() {
793            "namespace_use_clause" => {
794                // Simple or aliased use: use Namespace\Class [as Alias];
795                process_use_clause(child, content, helper, importer_id);
796            }
797            "namespace_use_group" => {
798                // Grouped use: use Namespace\{Class1, Class2};
799                // Pass the prefix we extracted at the declaration level
800                process_use_group(child, content, helper, importer_id, &prefix);
801            }
802            _ => {}
803        }
804    }
805}
806
807/// Process a single `use` clause like `Namespace\Class` or `Namespace\Class as Alias`.
808///
809/// AST structure for aliased use (`use App\Services\Mailer as Mail;`):
810/// ```text
811/// namespace_use_clause
812///   qualified_name "App\Services\Mailer"
813///     namespace_name "App\Services"
814///     name "Mailer"
815///   as "as"
816///   name "Mail"   <- this is the alias (sibling, not nested)
817/// ```
818fn process_use_clause(
819    node: Node,
820    content: &[u8],
821    helper: &mut GraphBuildHelper,
822    import_source_id: NodeId,
823) {
824    process_use_clause_with_prefix(node, content, helper, import_source_id, None);
825}
826
827/// Process a use clause with an optional namespace prefix (for grouped imports).
828fn process_use_clause_with_prefix(
829    node: Node,
830    content: &[u8],
831    helper: &mut GraphBuildHelper,
832    import_source_id: NodeId,
833    prefix: Option<&str>,
834) {
835    // Get the qualified name (e.g., "App\Services\Mailer")
836    let mut qualified_name = None;
837    let mut alias = None;
838    let mut found_as = false;
839
840    let mut cursor = node.walk();
841    for child in node.children(&mut cursor) {
842        match child.kind() {
843            "qualified_name" => {
844                // Full qualified name like "App\Services\Mailer"
845                if let Ok(name) = child.utf8_text(content) {
846                    qualified_name = Some(name.trim().to_string());
847                }
848            }
849            "namespace_name" => {
850                // Namespace part - only use if no qualified_name yet
851                if qualified_name.is_none()
852                    && let Ok(name) = child.utf8_text(content)
853                {
854                    qualified_name = Some(name.trim().to_string());
855                }
856            }
857            "name" => {
858                // Could be simple name OR the alias after "as"
859                if found_as {
860                    // This is the alias name
861                    if let Ok(alias_text) = child.utf8_text(content) {
862                        alias = Some(alias_text.trim().to_string());
863                    }
864                } else if qualified_name.is_none() {
865                    // Simple name without namespace
866                    if let Ok(name) = child.utf8_text(content) {
867                        qualified_name = Some(name.trim().to_string());
868                    }
869                }
870            }
871            "as" => {
872                // Mark that the next "name" node is the alias
873                found_as = true;
874            }
875            _ => {}
876        }
877    }
878
879    if let Some(name) = qualified_name
880        && !name.is_empty()
881    {
882        // Apply prefix for grouped imports
883        let full_name = if let Some(pfx) = prefix {
884            format!("{pfx}\\{name}")
885        } else {
886            name
887        };
888
889        // Create an import node for the imported symbol
890        let span = span_from_node(node);
891        let import_node_id = helper.add_import(&full_name, Some(span));
892
893        // Add import edge with optional alias
894        if let Some(alias_str) = alias {
895            helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
896        } else {
897            helper.add_import_edge(import_source_id, import_node_id);
898        }
899    }
900}
901
902/// Process a grouped use declaration like `use Namespace\{Class1, Class2, Class3 as C3}`.
903///
904/// AST structure for grouped use (`use App\Models\{User, Post, Comment};`):
905/// ```text
906/// namespace_use_declaration
907///   use "use"
908///   namespace_name "App\Models"   <- prefix is here, at declaration level
909///   \ "\"
910///   namespace_use_group            <- this is passed to us
911///     { "{"
912///     namespace_use_clause "User"  <- NOT namespace_use_group_clause!
913///       name "User"
914///     , ","
915///     namespace_use_clause "Post"
916///       name "Post"
917///     ...
918///     } "}"
919/// ```
920fn process_use_group(
921    node: Node,
922    content: &[u8],
923    helper: &mut GraphBuildHelper,
924    import_source_id: NodeId,
925    prefix: &str,
926) {
927    // Process each clause in the group
928    let mut cursor = node.walk();
929    for child in node.children(&mut cursor) {
930        // The clauses inside the group are "namespace_use_clause", not "namespace_use_group_clause"
931        if child.kind() == "namespace_use_clause" {
932            // Reuse the same clause processing logic with the prefix
933            process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
934        }
935    }
936}
937
938/// Process file inclusion statements (require, `require_once`, include, `include_once`).
939fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
940    // Create importer node for current file
941    let file_path = helper.file_path().to_string();
942    let import_source_id = helper.add_module(&file_path, None);
943
944    // Extract the file path from the expression
945    // The path is typically a string literal or an expression
946    let mut cursor = node.walk();
947    for child in node.children(&mut cursor) {
948        if child.kind() == "string"
949            || child.kind() == "encapsed_string"
950            || child.kind() == "binary_expression"
951        {
952            if let Ok(path_text) = child.utf8_text(content) {
953                // Clean up the path string (remove quotes)
954                let cleaned_path = path_text
955                    .trim()
956                    .trim_start_matches(['\'', '"'])
957                    .trim_end_matches(['\'', '"'])
958                    .to_string();
959
960                if !cleaned_path.is_empty() {
961                    let span = span_from_node(node);
962                    let import_node_id = helper.add_import(&cleaned_path, Some(span));
963                    helper.add_import_edge(import_source_id, import_node_id);
964                }
965            }
966            break;
967        }
968    }
969}
970
971// ============================================================================
972// OOP Edge Building - inheritance, interfaces, traits
973// ============================================================================
974
975/// Process all class declarations to extract OOP relationships.
976fn process_oop_relationships(
977    node: Node,
978    content: &[u8],
979    helper: &mut GraphBuildHelper,
980    node_map: &mut HashMap<String, NodeId>,
981) {
982    let kind = node.kind();
983    if kind == "class_declaration" {
984        process_class_oop(node, content, helper, node_map);
985    } else if kind == "interface_declaration" {
986        process_interface_inheritance(node, content, helper, node_map);
987    }
988
989    // Recurse into children
990    let mut cursor = node.walk();
991    for child in node.children(&mut cursor) {
992        process_oop_relationships(child, content, helper, node_map);
993    }
994}
995
996/// Process a class declaration to extract inheritance, interface implementation, and trait usage.
997fn process_class_oop(
998    node: Node,
999    content: &[u8],
1000    helper: &mut GraphBuildHelper,
1001    node_map: &mut HashMap<String, NodeId>,
1002) {
1003    // Get the class name
1004    let Some(name_node) = node.child_by_field_name("name") else {
1005        return;
1006    };
1007    let Ok(class_name) = name_node.utf8_text(content) else {
1008        return;
1009    };
1010    let class_name = class_name.trim();
1011
1012    // Get or create the class node
1013    let span = span_from_node(node);
1014    let class_id = *node_map
1015        .entry(class_name.to_string())
1016        .or_insert_with(|| helper.add_class(class_name, Some(span)));
1017    // issue #394: real declaration; opt dual-use bare helper into is_definition
1018    helper.mark_definition(class_id);
1019
1020    // Process children to find base_clause (extends), class_interface_clause (implements), and use_declaration (traits)
1021    let mut cursor = node.walk();
1022    for child in node.children(&mut cursor) {
1023        match child.kind() {
1024            "base_clause" => {
1025                // class Child extends Parent
1026                process_extends_clause(child, content, helper, node_map, class_id);
1027            }
1028            "class_interface_clause" => {
1029                // class Foo implements IBar, IBaz
1030                process_implements_clause(child, content, helper, node_map, class_id);
1031            }
1032            "declaration_list" => {
1033                // Look for trait use declarations inside the class body
1034                process_class_body_traits(child, content, helper, node_map, class_id);
1035            }
1036            _ => {}
1037        }
1038    }
1039}
1040
1041/// Process `extends Parent` clause to create Inherits edge.
1042fn process_extends_clause(
1043    node: Node,
1044    content: &[u8],
1045    helper: &mut GraphBuildHelper,
1046    node_map: &mut HashMap<String, NodeId>,
1047    class_id: NodeId,
1048) {
1049    // base_clause contains the parent class name
1050    let mut cursor = node.walk();
1051    for child in node.children(&mut cursor) {
1052        if child.kind() == "name"
1053            || child.kind() == "qualified_name"
1054            || child.kind() == "namespace_name"
1055        {
1056            if let Ok(parent_name) = child.utf8_text(content) {
1057                let parent_name = parent_name.trim();
1058                if !parent_name.is_empty() {
1059                    let span = span_from_node(child);
1060                    let parent_id = *node_map
1061                        .entry(parent_name.to_string())
1062                        .or_insert_with(|| helper.add_class(parent_name, Some(span)));
1063
1064                    helper.add_inherits_edge(class_id, parent_id);
1065                }
1066            }
1067            break;
1068        }
1069    }
1070}
1071
1072/// Process `implements IFoo, IBar` clause to create Implements edges.
1073fn process_implements_clause(
1074    node: Node,
1075    content: &[u8],
1076    helper: &mut GraphBuildHelper,
1077    node_map: &mut HashMap<String, NodeId>,
1078    class_id: NodeId,
1079) {
1080    // class_interface_clause contains interface names
1081    let mut cursor = node.walk();
1082    for child in node.children(&mut cursor) {
1083        if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1084            && let Ok(interface_name) = child.utf8_text(content)
1085        {
1086            let interface_name = interface_name.trim();
1087            if !interface_name.is_empty() {
1088                let span = span_from_node(child);
1089                let interface_id = *node_map
1090                    .entry(interface_name.to_string())
1091                    .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1092
1093                helper.add_implements_edge(class_id, interface_id);
1094            }
1095        }
1096    }
1097}
1098
1099/// Process trait usage within a class body (`use TraitName;`).
1100fn process_class_body_traits(
1101    declaration_list: Node,
1102    content: &[u8],
1103    helper: &mut GraphBuildHelper,
1104    node_map: &mut HashMap<String, NodeId>,
1105    class_id: NodeId,
1106) {
1107    let mut cursor = declaration_list.walk();
1108    for child in declaration_list.children(&mut cursor) {
1109        if child.kind() == "use_declaration" {
1110            // This is a trait use: use TraitName;
1111            process_trait_use(child, content, helper, node_map, class_id);
1112        }
1113    }
1114}
1115
1116/// Process a single trait use declaration (`use TraitName, AnotherTrait;`).
1117fn process_trait_use(
1118    node: Node,
1119    content: &[u8],
1120    helper: &mut GraphBuildHelper,
1121    node_map: &mut HashMap<String, NodeId>,
1122    class_id: NodeId,
1123) {
1124    // use_declaration contains trait names
1125    let mut cursor = node.walk();
1126    for child in node.children(&mut cursor) {
1127        if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1128            && let Ok(trait_name) = child.utf8_text(content)
1129        {
1130            let trait_name = trait_name.trim();
1131            if !trait_name.is_empty() {
1132                let span = span_from_node(child);
1133                // Use add_node for traits since there's no dedicated add_trait method
1134                // We'll use the Trait NodeKind
1135                let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
1136                    helper.add_node(
1137                        trait_name,
1138                        Some(span),
1139                        sqry_core::graph::unified::node::NodeKind::Trait,
1140                    )
1141                });
1142
1143                // Trait usage is modeled as an Implements edge
1144                // (similar to interface implementation from a semantic perspective)
1145                helper.add_implements_edge(class_id, trait_id);
1146            }
1147        }
1148    }
1149}
1150
1151/// Process interface declaration to handle interface inheritance (`extends`).
1152fn process_interface_inheritance(
1153    node: Node,
1154    content: &[u8],
1155    helper: &mut GraphBuildHelper,
1156    node_map: &mut HashMap<String, NodeId>,
1157) {
1158    // Get the interface name
1159    let Some(name_node) = node.child_by_field_name("name") else {
1160        return;
1161    };
1162    let Ok(interface_name) = name_node.utf8_text(content) else {
1163        return;
1164    };
1165    let interface_name = interface_name.trim();
1166
1167    // Get or create the interface node
1168    let span = span_from_node(node);
1169    let interface_id = *node_map
1170        .entry(interface_name.to_string())
1171        .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1172    // issue #394: real declaration; opt dual-use bare helper into is_definition
1173    helper.mark_definition(interface_id);
1174
1175    // Process base_clause for interface inheritance (interface IFoo extends IBar, IBaz)
1176    let mut cursor = node.walk();
1177    for child in node.children(&mut cursor) {
1178        if child.kind() == "base_clause" {
1179            // Interface extends other interfaces
1180            let mut base_cursor = child.walk();
1181            for base_child in child.children(&mut base_cursor) {
1182                if matches!(
1183                    base_child.kind(),
1184                    "name" | "qualified_name" | "namespace_name"
1185                ) && let Ok(parent_name) = base_child.utf8_text(content)
1186                {
1187                    let parent_name = parent_name.trim();
1188                    if !parent_name.is_empty() {
1189                        let span = span_from_node(base_child);
1190                        let parent_id = *node_map
1191                            .entry(parent_name.to_string())
1192                            .or_insert_with(|| helper.add_interface(parent_name, Some(span)));
1193
1194                        // Interface inheritance uses Inherits edge
1195                        helper.add_inherits_edge(interface_id, parent_id);
1196                    }
1197                }
1198            }
1199        }
1200    }
1201}
1202
1203// ============================================================================
1204// Export Edge Building - PHP implicitly exports all top-level symbols
1205// ============================================================================
1206
1207/// Process all top-level declarations to create export edges.
1208///
1209/// In PHP, all classes, interfaces, traits, enums, and functions defined at the
1210/// top level (or within a namespace) are implicitly exported and visible to other
1211/// files via `require`/`use` statements. This function creates export edges from
1212/// the file module to each such symbol.
1213///
1214/// # Namespace Handling
1215///
1216/// PHP has two namespace forms:
1217/// - **Brace-style**: `namespace Foo { class Bar {} }` - contained declarations
1218/// - **Semicolon-style**: `namespace Foo; class Bar {}` - applies to subsequent siblings
1219///
1220/// This implementation handles both by doing a linear scan of `program` children.
1221fn process_exports(
1222    node: Node,
1223    content: &[u8],
1224    helper: &mut GraphBuildHelper,
1225    node_map: &mut HashMap<String, NodeId>,
1226) {
1227    // Create module node for this file
1228    let file_path = helper.file_path().to_string();
1229    let module_id = helper.add_module(&file_path, None);
1230
1231    // The program node is expected; if not, return early
1232    if node.kind() != "program" {
1233        return;
1234    }
1235
1236    // Track current namespace prefix (for semicolon-style namespaces)
1237    let mut active_namespace = String::new();
1238
1239    // Linear scan of program children to handle semicolon-style namespaces correctly
1240    let mut cursor = node.walk();
1241    for child in node.children(&mut cursor) {
1242        process_top_level_for_export(
1243            child,
1244            content,
1245            helper,
1246            node_map,
1247            module_id,
1248            &mut active_namespace,
1249        );
1250    }
1251}
1252
1253/// Process a single top-level statement for export purposes.
1254///
1255/// This function is called for each direct child of the `program` node.
1256/// It handles:
1257/// - Namespace definitions (both brace and semicolon style)
1258/// - Class, interface, trait, enum, and function declarations
1259///
1260/// It explicitly does NOT recurse into function bodies, class bodies, or
1261/// other nested scopes to avoid incorrectly exporting nested declarations.
1262fn process_top_level_for_export(
1263    node: Node,
1264    content: &[u8],
1265    helper: &mut GraphBuildHelper,
1266    node_map: &mut HashMap<String, NodeId>,
1267    module_id: NodeId,
1268    active_namespace: &mut String,
1269) {
1270    match node.kind() {
1271        "namespace_definition" => {
1272            // Extract namespace name
1273            let ns_name = node
1274                .child_by_field_name("name")
1275                .and_then(|n| n.utf8_text(content).ok())
1276                .map(|s| s.trim().to_string())
1277                .unwrap_or_default();
1278
1279            // Check if this is a brace-style namespace by looking for declaration_list/compound_statement
1280            let has_body = node
1281                .children(&mut node.walk())
1282                .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
1283
1284            if has_body {
1285                // Brace-style namespace: `namespace Foo { ... }`
1286                //
1287                // Robustness: If a brace-style namespace follows a semicolon-style
1288                // namespace (invalid PHP, but possible in fixtures/partial parses),
1289                // clear the active namespace to avoid scope pollution.
1290                active_namespace.clear();
1291
1292                // Process only declarations within the braced body
1293                let mut cursor = node.walk();
1294                for child in node.children(&mut cursor) {
1295                    if matches!(child.kind(), "compound_statement" | "declaration_list") {
1296                        let mut body_cursor = child.walk();
1297                        for body_child in child.children(&mut body_cursor) {
1298                            export_declaration_if_exportable(
1299                                body_child, content, helper, node_map, module_id, &ns_name,
1300                            );
1301                        }
1302                    }
1303                }
1304            } else {
1305                // Semicolon-style namespace: `namespace Foo;`
1306                // Updates the active namespace for subsequent sibling declarations
1307                *active_namespace = ns_name;
1308            }
1309        }
1310        // For top-level declarations, use the active namespace
1311        "class_declaration"
1312        | "interface_declaration"
1313        | "trait_declaration"
1314        | "enum_declaration"
1315        | "function_definition" => {
1316            export_declaration_if_exportable(
1317                node,
1318                content,
1319                helper,
1320                node_map,
1321                module_id,
1322                active_namespace,
1323            );
1324        }
1325        _ => {
1326            // Skip other node types (expression statements, comments, etc.)
1327            // We explicitly DO NOT recurse to avoid exporting nested declarations
1328        }
1329    }
1330}
1331
1332/// Look up a node by qualified name, with restricted fallback to simple name.
1333///
1334/// When in the global namespace (`namespace_prefix` is empty), we allow fallback
1335/// to simple name for backwards compatibility. In namespaced ctx.contexts, we require
1336/// the qualified name to exist to avoid matching the wrong symbol when multiple
1337/// namespaces contain symbols with the same simple name.
1338fn lookup_or_create_node<F>(
1339    node_map: &mut HashMap<String, NodeId>,
1340    qualified_name: &str,
1341    simple_name: &str,
1342    namespace_prefix: &str,
1343    create_fn: F,
1344) -> NodeId
1345where
1346    F: FnOnce() -> NodeId,
1347{
1348    // Always try qualified name first
1349    if let Some(&id) = node_map.get(qualified_name) {
1350        return id;
1351    }
1352
1353    // Fall back to simple name ONLY in global namespace to avoid mismatches
1354    // in namespaced files with repeated simple names across namespaces.
1355    if namespace_prefix.is_empty()
1356        && let Some(&id) = node_map.get(simple_name)
1357    {
1358        return id;
1359    }
1360
1361    // Create new node with qualified name
1362    let id = create_fn();
1363    node_map.insert(qualified_name.to_string(), id);
1364    id
1365}
1366
1367/// Export a single declaration (class, interface, trait, enum, or function).
1368///
1369/// This function handles the actual creation of export edges for top-level
1370/// declarations. It's called from two contexts:
1371/// 1. Direct children of `program` (with `active_namespace` from semicolon-style)
1372/// 2. Children of brace-style namespace bodies (with the namespace name)
1373///
1374/// We look up nodes by their qualified name (which includes namespace) because
1375/// that's what Phase 1 creates in the `node_map`. Fallback to simple name is only
1376/// allowed in the global namespace to prevent matching wrong symbols in namespaced
1377/// files with repeated simple names.
1378///
1379/// For classes, this also exports all public methods found within the class body.
1380#[allow(clippy::too_many_lines)] // Single traversal keeps export logic aligned with phases.
1381fn export_declaration_if_exportable(
1382    node: Node,
1383    content: &[u8],
1384    helper: &mut GraphBuildHelper,
1385    node_map: &mut HashMap<String, NodeId>,
1386    module_id: NodeId,
1387    namespace_prefix: &str,
1388) {
1389    match node.kind() {
1390        "class_declaration" => {
1391            if let Some(name_node) = node.child_by_field_name("name")
1392                && let Ok(class_name) = name_node.utf8_text(content)
1393            {
1394                let simple_name = class_name.trim().to_string();
1395                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1396                let span = span_from_node(node);
1397
1398                let class_id = lookup_or_create_node(
1399                    node_map,
1400                    &qualified_name,
1401                    &simple_name,
1402                    namespace_prefix,
1403                    || helper.add_class(&qualified_name, Some(span)),
1404                );
1405                // issue #394: real declaration; opt dual-use bare helper into is_definition
1406                helper.mark_definition(class_id);
1407
1408                helper.add_export_edge(module_id, class_id);
1409
1410                // Export public methods from the class
1411                export_public_methods_from_class(
1412                    node,
1413                    content,
1414                    helper,
1415                    node_map,
1416                    module_id,
1417                    &qualified_name,
1418                );
1419            }
1420        }
1421        "interface_declaration" => {
1422            if let Some(name_node) = node.child_by_field_name("name")
1423                && let Ok(interface_name) = name_node.utf8_text(content)
1424            {
1425                let simple_name = interface_name.trim().to_string();
1426                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1427                let span = span_from_node(node);
1428
1429                let interface_id = lookup_or_create_node(
1430                    node_map,
1431                    &qualified_name,
1432                    &simple_name,
1433                    namespace_prefix,
1434                    || helper.add_interface(&qualified_name, Some(span)),
1435                );
1436                // issue #394: real declaration; opt dual-use bare helper into is_definition
1437                helper.mark_definition(interface_id);
1438
1439                helper.add_export_edge(module_id, interface_id);
1440            }
1441        }
1442        "trait_declaration" => {
1443            if let Some(name_node) = node.child_by_field_name("name")
1444                && let Ok(trait_name) = name_node.utf8_text(content)
1445            {
1446                let simple_name = trait_name.trim().to_string();
1447                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1448                let span = span_from_node(node);
1449
1450                let trait_id = lookup_or_create_node(
1451                    node_map,
1452                    &qualified_name,
1453                    &simple_name,
1454                    namespace_prefix,
1455                    || {
1456                        helper.add_node(
1457                            &qualified_name,
1458                            Some(span),
1459                            sqry_core::graph::unified::node::NodeKind::Trait,
1460                        )
1461                    },
1462                );
1463                // issue #394: real declaration; opt dual-use bare helper into is_definition
1464                helper.mark_definition(trait_id);
1465
1466                helper.add_export_edge(module_id, trait_id);
1467            }
1468        }
1469        "enum_declaration" => {
1470            // PHP 8.1+ enums - they are top-level types that should be exported
1471            if let Some(name_node) = node.child_by_field_name("name")
1472                && let Ok(enum_name) = name_node.utf8_text(content)
1473            {
1474                let simple_name = enum_name.trim().to_string();
1475                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1476                let span = span_from_node(node);
1477
1478                let enum_id = lookup_or_create_node(
1479                    node_map,
1480                    &qualified_name,
1481                    &simple_name,
1482                    namespace_prefix,
1483                    || helper.add_enum(&qualified_name, Some(span)),
1484                );
1485
1486                helper.add_export_edge(module_id, enum_id);
1487            }
1488        }
1489        "function_definition" => {
1490            // Top-level functions are exported (we only get here for top-level nodes)
1491            if let Some(name_node) = node.child_by_field_name("name")
1492                && let Ok(func_name) = name_node.utf8_text(content)
1493            {
1494                let simple_name = func_name.trim().to_string();
1495                let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1496                let span = span_from_node(node);
1497
1498                let func_id = lookup_or_create_node(
1499                    node_map,
1500                    &qualified_name,
1501                    &simple_name,
1502                    namespace_prefix,
1503                    || helper.add_function(&qualified_name, Some(span), false, false),
1504                );
1505                // issue #394: real declaration; opt dual-use bare helper into is_definition
1506                helper.mark_definition(func_id);
1507
1508                helper.add_export_edge(module_id, func_id);
1509            }
1510        }
1511        _ => {
1512            // Not an exportable declaration type
1513        }
1514    }
1515}
1516
1517/// Build a qualified name with namespace prefix.
1518fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
1519    if namespace_prefix.is_empty() {
1520        name.to_string()
1521    } else {
1522        format!("{namespace_prefix}\\{name}")
1523    }
1524}
1525
1526/// Helper function to create a Span from a tree-sitter Node.
1527fn span_from_node(node: Node<'_>) -> Span {
1528    let start = node.start_position();
1529    let end = node.end_position();
1530    Span::new(
1531        sqry_core::graph::node::Position::new(start.row, start.column),
1532        sqry_core::graph::node::Position::new(end.row, end.column),
1533    )
1534}
1535
1536fn count_call_arguments(call_node: Node<'_>) -> u8 {
1537    let args_node = call_node
1538        .child_by_field_name("arguments")
1539        .or_else(|| call_node.child_by_field_name("argument_list"))
1540        .or_else(|| {
1541            let mut cursor = call_node.walk();
1542            call_node
1543                .children(&mut cursor)
1544                .find(|child| child.kind() == "argument_list")
1545        });
1546
1547    let Some(args_node) = args_node else {
1548        return 255;
1549    };
1550    let count = args_node.named_child_count();
1551    if count <= 254 {
1552        u8::try_from(count).unwrap_or(u8::MAX)
1553    } else {
1554        255
1555    }
1556}
1557
1558/// Extract visibility modifier from a method or property declaration.
1559///
1560/// Returns Some("public"), Some("private"), Some("protected"), or None if no visibility modifier is found.
1561/// In PHP, methods without an explicit visibility modifier are implicitly public.
1562fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
1563    // Look for visibility modifiers in direct children
1564    let mut cursor = node.walk();
1565    for child in node.children(&mut cursor) {
1566        match child.kind() {
1567            "visibility_modifier" => {
1568                // The visibility_modifier node contains the actual keyword
1569                if let Ok(vis_text) = child.utf8_text(content) {
1570                    return Some(vis_text.trim().to_string());
1571                }
1572            }
1573            "public" | "private" | "protected" => {
1574                // Sometimes the visibility is directly as a keyword node
1575                if let Ok(vis_text) = child.utf8_text(content) {
1576                    return Some(vis_text.trim().to_string());
1577                }
1578            }
1579            _ => {}
1580        }
1581    }
1582
1583    // PHP default: methods without explicit visibility are public
1584    // But we return None here to distinguish "explicitly public" from "implicitly public"
1585    // For export purposes, we'll treat None as public
1586    None
1587}
1588
1589/// Export public methods from a class declaration.
1590///
1591/// This function walks the class body and exports only public methods (including
1592/// methods with no explicit visibility modifier, which are implicitly public in PHP).
1593/// Private and protected methods are NOT exported.
1594fn export_public_methods_from_class(
1595    class_node: Node,
1596    content: &[u8],
1597    helper: &mut GraphBuildHelper,
1598    node_map: &mut HashMap<String, NodeId>,
1599    module_id: NodeId,
1600    class_qualified_name: &str,
1601) {
1602    // Find the declaration_list (class body)
1603    let mut cursor = class_node.walk();
1604    for child in class_node.children(&mut cursor) {
1605        if child.kind() == "declaration_list" {
1606            // Walk through the class body to find method declarations
1607            let mut body_cursor = child.walk();
1608            for body_child in child.children(&mut body_cursor) {
1609                if body_child.kind() == "method_declaration" {
1610                    // Extract method visibility
1611                    let visibility = extract_visibility(&body_child, content);
1612
1613                    // Only export public methods (explicit or implicit)
1614                    let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
1615
1616                    if is_public {
1617                        // Extract method name
1618                        if let Some(name_node) = body_child.child_by_field_name("name")
1619                            && let Ok(method_name) = name_node.utf8_text(content)
1620                        {
1621                            let method_name = method_name.trim();
1622                            let qualified_method_name =
1623                                format!("{class_qualified_name}::{method_name}");
1624
1625                            // Look up the method node (should exist from Phase 1)
1626                            if let Some(&method_id) = node_map.get(&qualified_method_name) {
1627                                helper.add_export_edge(module_id, method_id);
1628                            }
1629                        }
1630                    }
1631                }
1632            }
1633            break;
1634        }
1635    }
1636}
1637
1638// ============================================================================
1639// Type Extraction Helpers
1640// ============================================================================
1641
1642/// Extract return type annotation from a PHP function or method declaration.
1643///
1644/// PHP return types appear after the `formal_parameters` and a colon:
1645/// ```php
1646/// function greet(string $name): string { ... }
1647///                              ^^^^^^^
1648/// ```
1649///
1650/// This function:
1651/// 1. Finds the colon (`:`) after the parameters
1652/// 2. Extracts the next named node (the type annotation)
1653/// 3. Normalizes the type (strips nullable `?`, takes first type from unions)
1654///
1655/// Returns `None` if no return type annotation exists (valid in untyped PHP code).
1656fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
1657    // Find colon after formal_parameters
1658    let mut found_colon = false;
1659    let mut cursor = node.walk();
1660    for child in node.children(&mut cursor) {
1661        if found_colon && child.is_named() {
1662            // Next named node after colon is the type annotation
1663            return extract_type_from_node(&child, content);
1664        }
1665        if child.kind() == ":" {
1666            found_colon = true;
1667        }
1668    }
1669    None
1670}
1671
1672/// Extract type string from a PHP type annotation node.
1673///
1674/// Handles different type node kinds from tree-sitter-php:
1675/// - `primitive_type`: `string`, `int`, `float`, `bool`, `array`, etc.
1676/// - `optional_type`: `?string` → strips `?` and returns `string`
1677/// - `union_type`: `string|int` → returns first type `string`
1678/// - `named_type` / `qualified_name`: `User` or `Namespace\User`
1679/// - `intersection_type`: `A&B` → returns first type `A`
1680///
1681/// Design decisions (per SPEC.md):
1682/// - Nullable types: Strip `?` prefix for simplified matching
1683/// - Union types: Take first type only (matches TypeScript plugin approach)
1684/// - Intersection types: Take first type only
1685fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
1686    match type_node.kind() {
1687        "primitive_type" => {
1688            // Basic types: string, int, float, bool, array, void, etc.
1689            type_node
1690                .utf8_text(content)
1691                .ok()
1692                .map(|s| s.trim().to_string())
1693        }
1694        "optional_type" => {
1695            // Nullable type: ?string
1696            // Strip the ? and extract underlying type
1697            let mut cursor = type_node.walk();
1698            for child in type_node.children(&mut cursor) {
1699                if child.kind() != "?" && child.is_named() {
1700                    return extract_type_from_node(&child, content);
1701                }
1702            }
1703            None
1704        }
1705        "union_type" => {
1706            // Union type: string|int
1707            // Take first type only (per SPEC.md design decision)
1708            type_node
1709                .named_child(0)
1710                .and_then(|first_type| extract_type_from_node(&first_type, content))
1711        }
1712        "named_type" | "qualified_name" => {
1713            // Class names: User or Namespace\User
1714            type_node
1715                .utf8_text(content)
1716                .ok()
1717                .map(|s| s.trim().to_string())
1718        }
1719        "intersection_type" => {
1720            // Intersection type: A&B
1721            // Take first type only
1722            type_node
1723                .named_child(0)
1724                .and_then(|first_type| extract_type_from_node(&first_type, content))
1725        }
1726        _ => {
1727            // Fallback: try to get text directly for unknown type nodes
1728            // For future composite types (e.g., DNF types like (A&B)|C),
1729            // normalize by taking first type to stay consistent with
1730            // union/intersection handling.
1731            type_node
1732                .utf8_text(content)
1733                .ok()
1734                .map(|s| {
1735                    let trimmed = s.trim();
1736                    // Split on union (|) or intersection (&) and take first component
1737                    // This handles future PHP grammar additions like DNF types
1738                    trimmed
1739                        .split(&['|', '&'][..])
1740                        .next()
1741                        .unwrap_or(trimmed)
1742                        .trim()
1743                        .trim_start_matches('(')
1744                        .trim_end_matches(')')
1745                        .trim()
1746                        .to_string()
1747                })
1748                .filter(|s| !s.is_empty())
1749        }
1750    }
1751}
1752
1753// ============================================================================
1754// PHPDoc Annotation Processing (Phase 5)
1755// ============================================================================
1756
1757/// Process `PHPDoc` annotations for `TypeOf` and Reference edges.
1758///
1759/// Two-pass walk to make explicit-vs-promoted field collision precedence
1760/// (FR-13) deterministic regardless of source order:
1761///
1762/// 1. **Pass A** — function `PHPDoc`, method `PHPDoc`, and *explicit*
1763///    `property_declaration` / `simple_property` emission. Records every
1764///    explicit-field `NodeId` in `explicit_field_ids`.
1765/// 2. **Pass B** — constructor property promotion. The promoted-side
1766///    consults `explicit_field_ids`; when an existing node is in the set,
1767///    the promotion path skips kind/visibility/static *and* `TypeOf`
1768///    re-emission so the explicit declaration's attributes and declared
1769///    type win unambiguously.
1770///
1771/// This sequencing fixes both FR-13 violations called out by code review:
1772/// (a) source-order dependence — explicit declarations now always run
1773/// before promotions; (b) duplicate `TypeOf` edges from a promoted
1774/// parameter onto an already-typed explicit field.
1775fn process_phpdoc_annotations(
1776    node: Node,
1777    content: &[u8],
1778    helper: &mut GraphBuildHelper,
1779) -> GraphResult<()> {
1780    // Pass A: PHPDoc + explicit property declarations.
1781    let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
1782    process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
1783
1784    // Pass B: constructor property promotion. Explicit fields (Pass A
1785    // output) win on collision; the explicit_field_ids set is read-only
1786    // here, used to gate kind/visibility/static and TypeOf overrides.
1787    process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
1788
1789    Ok(())
1790}
1791
1792/// Pass A — recursive walk that emits PHPDoc-derived edges and explicit
1793/// property nodes. Newly created explicit-field `NodeId`s are tracked in
1794/// `explicit_field_ids` so Pass B can preserve their attributes.
1795fn process_phpdoc_pass_a(
1796    node: Node,
1797    content: &[u8],
1798    helper: &mut GraphBuildHelper,
1799    explicit_field_ids: &mut HashSet<NodeId>,
1800) -> GraphResult<()> {
1801    match node.kind() {
1802        "function_definition" => {
1803            process_function_phpdoc(node, content, helper)?;
1804        }
1805        "method_declaration" => {
1806            // Method-level PHPDoc only in Pass A; constructor promotion
1807            // is deferred to Pass B so explicit declarations always win.
1808            process_method_phpdoc(node, content, helper)?;
1809        }
1810        "property_declaration" | "simple_property" => {
1811            // Unconditional emission (PHPDoc gate removed). Property
1812            // declarations inside class_declaration / trait_declaration /
1813            // interface_declaration become Property or Constant nodes
1814            // with qualified name `Class.prop`.
1815            let emitted = process_property_declaration(node, content, helper);
1816            explicit_field_ids.extend(emitted);
1817        }
1818        _ => {}
1819    }
1820
1821    let mut cursor = node.walk();
1822    for child in node.children(&mut cursor) {
1823        process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
1824    }
1825
1826    Ok(())
1827}
1828
1829/// Pass B — recursive walk that emits constructor-promoted Property /
1830/// Constant nodes. Reads `explicit_field_ids` (populated by Pass A) to
1831/// skip kind/visibility/static *and* `TypeOf` re-emission whenever an
1832/// explicit declaration owns the qualified name. Per cross-language field
1833/// emission design §4.6.
1834fn process_phpdoc_pass_b(
1835    node: Node,
1836    content: &[u8],
1837    helper: &mut GraphBuildHelper,
1838    explicit_field_ids: &HashSet<NodeId>,
1839) {
1840    if node.kind() == "method_declaration" {
1841        process_constructor_promotion(node, content, helper, explicit_field_ids);
1842    }
1843
1844    let mut cursor = node.walk();
1845    for child in node.children(&mut cursor) {
1846        process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
1847    }
1848}
1849
1850/// Process `PHPDoc` for function definitions
1851fn process_function_phpdoc(
1852    func_node: Node,
1853    content: &[u8],
1854    helper: &mut GraphBuildHelper,
1855) -> GraphResult<()> {
1856    // Extract PHPDoc comment
1857    let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1858        return Ok(());
1859    };
1860
1861    // Parse PHPDoc tags
1862    let tags = parse_phpdoc_tags(&phpdoc_text);
1863
1864    // Get function name
1865    let Some(name_node) = func_node.child_by_field_name("name") else {
1866        return Ok(());
1867    };
1868
1869    let function_name = name_node
1870        .utf8_text(content)
1871        .map_err(|_| GraphBuilderError::ParseError {
1872            span: span_from_node(func_node),
1873            reason: "failed to read function name".to_string(),
1874        })?
1875        .trim()
1876        .to_string();
1877
1878    if function_name.is_empty() {
1879        return Ok(());
1880    }
1881
1882    // Get or create function node
1883    let func_node_id = helper.ensure_callee(
1884        &function_name,
1885        span_from_node(func_node),
1886        CalleeKindHint::Function,
1887    );
1888
1889    // Extract AST parameter list with indices for context (not used in Phase 1)
1890    let _ast_params = extract_ast_parameters(func_node, content);
1891
1892    // Process @param tags
1893    // Create TypeOf and Reference edges regardless of whether the parameter exists in AST
1894    // (PHPDoc may contain documentation for parameters that exist in the signature)
1895    for (param_idx, param_tag) in tags.params.iter().enumerate() {
1896        // Create TypeOf edge: function -> parameter type
1897        let canonical_type = canonical_type_string(&param_tag.type_str);
1898        let type_node_id = helper.add_type(&canonical_type, None);
1899        helper.add_typeof_edge_with_context(
1900            func_node_id,
1901            type_node_id,
1902            Some(TypeOfContext::Parameter),
1903            param_idx.try_into().ok(), // Use PHPDoc order as index
1904            Some(&param_tag.name),
1905        );
1906
1907        // Create Reference edges: function -> each referenced type
1908        let type_names = extract_type_names(&param_tag.type_str);
1909        for type_name in type_names {
1910            let ref_type_id = helper.add_type(&type_name, None);
1911            helper.add_reference_edge(func_node_id, ref_type_id);
1912        }
1913    }
1914
1915    // Process @return tag
1916    if let Some(return_type) = &tags.returns {
1917        let canonical_type = canonical_type_string(return_type);
1918        let type_node_id = helper.add_type(&canonical_type, None);
1919        helper.add_typeof_edge_with_context(
1920            func_node_id,
1921            type_node_id,
1922            Some(TypeOfContext::Return),
1923            Some(0),
1924            None,
1925        );
1926
1927        // Create Reference edges for return type
1928        let type_names = extract_type_names(return_type);
1929        for type_name in type_names {
1930            let ref_type_id = helper.add_type(&type_name, None);
1931            helper.add_reference_edge(func_node_id, ref_type_id);
1932        }
1933    }
1934
1935    Ok(())
1936}
1937
1938/// Process `PHPDoc` for method definitions
1939fn process_method_phpdoc(
1940    method_node: Node,
1941    content: &[u8],
1942    helper: &mut GraphBuildHelper,
1943) -> GraphResult<()> {
1944    // Extract PHPDoc comment
1945    let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1946        return Ok(());
1947    };
1948
1949    // Parse PHPDoc tags
1950    let tags = parse_phpdoc_tags(&phpdoc_text);
1951
1952    // Get method name
1953    let Some(name_node) = method_node.child_by_field_name("name") else {
1954        return Ok(());
1955    };
1956
1957    let method_name = name_node
1958        .utf8_text(content)
1959        .map_err(|_| GraphBuilderError::ParseError {
1960            span: span_from_node(method_node),
1961            reason: "failed to read method name".to_string(),
1962        })?
1963        .trim()
1964        .to_string();
1965
1966    if method_name.is_empty() {
1967        return Ok(());
1968    }
1969
1970    // Find the class name by walking up the tree
1971    let class_name = get_enclosing_class_name(method_node, content)?;
1972    let Some(class_name) = class_name else {
1973        return Ok(());
1974    };
1975
1976    // Create qualified method name: ClassName::methodName
1977    let qualified_name = format!("{class_name}.{method_name}");
1978
1979    // Get existing method node (should already exist from main traversal)
1980    // Use ensure_method to handle case where it might not exist yet
1981    let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1982
1983    // Extract AST parameter list with indices for context
1984    let _ast_params = extract_ast_parameters(method_node, content);
1985
1986    // Process @param tags
1987    // Create TypeOf and Reference edges regardless of whether the parameter exists in AST
1988    for (param_idx, param_tag) in tags.params.iter().enumerate() {
1989        // Create TypeOf edge: method -> parameter type
1990        let canonical_type = canonical_type_string(&param_tag.type_str);
1991        let type_node_id = helper.add_type(&canonical_type, None);
1992        helper.add_typeof_edge_with_context(
1993            method_node_id,
1994            type_node_id,
1995            Some(TypeOfContext::Parameter),
1996            param_idx.try_into().ok(),
1997            Some(&param_tag.name),
1998        );
1999
2000        // Create Reference edges: method -> each referenced type
2001        let type_names = extract_type_names(&param_tag.type_str);
2002        for type_name in type_names {
2003            let ref_type_id = helper.add_type(&type_name, None);
2004            helper.add_reference_edge(method_node_id, ref_type_id);
2005        }
2006    }
2007
2008    // Process @return tag
2009    if let Some(return_type) = &tags.returns {
2010        let canonical_type = canonical_type_string(return_type);
2011        let type_node_id = helper.add_type(&canonical_type, None);
2012        helper.add_typeof_edge_with_context(
2013            method_node_id,
2014            type_node_id,
2015            Some(TypeOfContext::Return),
2016            Some(0),
2017            None,
2018        );
2019
2020        // Create Reference edges for return type
2021        let type_names = extract_type_names(return_type);
2022        for type_name in type_names {
2023            let ref_type_id = helper.add_type(&type_name, None);
2024            helper.add_reference_edge(method_node_id, ref_type_id);
2025        }
2026    }
2027
2028    Ok(())
2029}
2030
2031/// Process a `property_declaration` (or legacy `simple_property`) inside a
2032/// class / trait / interface body and emit Property or Constant nodes with
2033/// `Class.prop` qualified names.
2034///
2035/// Returns the `NodeId` of every explicit field emitted by this call.
2036/// Pass A collects these into the explicit-field set so Pass B
2037/// (constructor promotion) can recognize the explicit declaration as
2038/// owner of the qualified name and refrain from overwriting attributes
2039/// or re-emitting `TypeOf` edges (FR-13).
2040///
2041/// Cross-language field emission contract (DAG U10 / `C2_OTHER_PHP`):
2042/// - `PHPDoc` gate removed: emission is unconditional.
2043/// - Visibility from `visibility_modifier` (default `"public"` when absent;
2044///   PHP semantics).
2045/// - `static_modifier` → `is_static = true`.
2046/// - `readonly_modifier` (PHP 8.1+) → `Constant`; otherwise `Property`.
2047/// - Native PHP 7.4+ `type` field → primary `TypeOf` target.
2048/// - `PHPDoc` `@var` is enrichment fallback only when no native type is present.
2049/// - `TypeOf` edge uses `TypeOfContext::Field` and bare property name.
2050/// - Span anchored on the declaration node.
2051fn process_property_declaration(
2052    prop_node: Node,
2053    content: &[u8],
2054    helper: &mut GraphBuildHelper,
2055) -> Vec<NodeId> {
2056    // Find the enclosing owner (class / trait / interface). Without an owner
2057    // we have no qualified-name prefix and emit nothing — matches the
2058    // "no emission outside class/trait/interface" AC.
2059    let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
2060        return Vec::new();
2061    };
2062
2063    // Modifier extraction.
2064    let mods = extract_property_modifiers(prop_node, content);
2065
2066    // Native PHP 7.4+ type annotation lives on the `type` field of
2067    // `property_declaration`.
2068    let native_type = prop_node
2069        .child_by_field_name("type")
2070        .and_then(|t| extract_type_from_node(&t, content));
2071
2072    // PHPDoc @var as enrichment fallback only when no native type present.
2073    let phpdoc_var_type = if native_type.is_none() {
2074        extract_phpdoc_comment(prop_node, content)
2075            .as_deref()
2076            .and_then(|c| parse_phpdoc_tags(c).var_type)
2077    } else {
2078        None
2079    };
2080
2081    let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
2082
2083    let prop_names = extract_property_element_names(prop_node, content);
2084    if prop_names.is_empty() {
2085        return Vec::new();
2086    }
2087
2088    let span = span_from_node(prop_node);
2089    let mut emitted = Vec::with_capacity(prop_names.len());
2090
2091    for prop_name in prop_names {
2092        let qualified_name = format!("{owner_name}.{prop_name}");
2093        let visibility = mods.visibility.as_deref().unwrap_or("public");
2094
2095        let node_id = if mods.is_readonly {
2096            helper.add_constant_with_name_static_and_visibility(
2097                &prop_name,
2098                &qualified_name,
2099                Some(span),
2100                mods.is_static,
2101                Some(visibility),
2102            )
2103        } else {
2104            helper.add_property_with_name_static_and_visibility(
2105                &prop_name,
2106                &qualified_name,
2107                Some(span),
2108                mods.is_static,
2109                Some(visibility),
2110            )
2111        };
2112
2113        if let Some(type_str) = primary_type.as_deref() {
2114            emit_field_type_edges(helper, node_id, &prop_name, type_str);
2115        }
2116
2117        emitted.push(node_id);
2118    }
2119
2120    emitted
2121}
2122
2123/// Walk a `method_declaration` whose name is `__construct` and emit
2124/// Property / Constant nodes for each `property_promotion_parameter` on the
2125/// enclosing class.
2126///
2127/// Collision precedence (FR-13 / AC-8). The two-pass `process_phpdoc_annotations`
2128/// driver guarantees explicit `property_declaration` nodes are emitted in
2129/// Pass A before this Pass-B walker runs. `explicit_field_ids` carries
2130/// every `NodeId` Pass A created; when the promoted side lands on a
2131/// qualified name owned by an explicit declaration we:
2132///
2133/// - skip kind / visibility / static / readonly emission entirely
2134///   (the explicit declaration's attributes are authoritative); and
2135/// - skip `TypeOf` re-emission so the explicit declaration's declared
2136///   type is the only one bound to the field `NodeId` — even when the
2137///   promoted parameter's annotated type would differ.
2138///
2139/// Only when the qualified name is *not* in `explicit_field_ids` does
2140/// this walker create a new Property/Constant node from the promoted
2141/// parameter's modifiers and emit its `TypeOf` edges.
2142fn process_constructor_promotion(
2143    method_node: Node,
2144    content: &[u8],
2145    helper: &mut GraphBuildHelper,
2146    explicit_field_ids: &HashSet<NodeId>,
2147) {
2148    // Constructor identification: name == "__construct".
2149    let Some(name_node) = method_node.child_by_field_name("name") else {
2150        return;
2151    };
2152    let Ok(method_name) = name_node.utf8_text(content) else {
2153        return;
2154    };
2155    if method_name.trim() != "__construct" {
2156        return;
2157    }
2158
2159    let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
2160        return;
2161    };
2162
2163    let Some(params_node) = method_node.child_by_field_name("parameters") else {
2164        return;
2165    };
2166
2167    let mut cursor = params_node.walk();
2168    for param in params_node.children(&mut cursor) {
2169        if param.kind() != "property_promotion_parameter" {
2170            continue;
2171        }
2172
2173        // Promotion-parameter modifiers + name.
2174        let visibility = param
2175            .child_by_field_name("visibility")
2176            .and_then(|v| v.utf8_text(content).ok())
2177            .map(|s| s.trim().to_string());
2178        let is_readonly = param.child_by_field_name("readonly").is_some()
2179            || direct_child_of_kind(param, "readonly_modifier").is_some();
2180        // Static is illegal on promotion parameters — PHP rejects it — but
2181        // honour the bool field for shape-parity with the property path.
2182        let is_static = false;
2183        let native_type = param
2184            .child_by_field_name("type")
2185            .and_then(|t| extract_type_from_node(&t, content));
2186
2187        let Some(prop_name) = promoted_param_name(param, content) else {
2188            continue;
2189        };
2190
2191        let qualified_name = format!("{owner_name}.{prop_name}");
2192        let span = span_from_node(param);
2193
2194        // FR-13 collision precedence: any prior node sharing this
2195        // qualified name belongs to an explicit declaration emitted in
2196        // Pass A (the two-pass driver enforces that ordering). Explicit
2197        // declarations are authoritative — we touch nothing here.
2198        if let Some(existing_id) = helper.get_node(&qualified_name) {
2199            if explicit_field_ids.contains(&existing_id) {
2200                // Explicit declaration owns this name. Skip both
2201                // attribute mutation *and* TypeOf re-emission so we
2202                // never bind a second (possibly conflicting) field
2203                // type to the same NodeId.
2204                continue;
2205            }
2206            // No explicit owner: the existing node was created by an
2207            // earlier promoted parameter (rare — same qualified name
2208            // appearing twice in one promotion list, or another plugin
2209            // path). Re-emit type information defensively only when
2210            // there is one to add; never overwrite kind/visibility.
2211            if let Some(t) = native_type {
2212                emit_field_type_edges(helper, existing_id, &prop_name, &t);
2213            }
2214            continue;
2215        }
2216
2217        let visibility_ref = visibility.as_deref().unwrap_or("public");
2218        let node_id = if is_readonly {
2219            helper.add_constant_with_name_static_and_visibility(
2220                &prop_name,
2221                &qualified_name,
2222                Some(span),
2223                is_static,
2224                Some(visibility_ref),
2225            )
2226        } else {
2227            helper.add_property_with_name_static_and_visibility(
2228                &prop_name,
2229                &qualified_name,
2230                Some(span),
2231                is_static,
2232                Some(visibility_ref),
2233            )
2234        };
2235
2236        if let Some(type_str) = native_type {
2237            emit_field_type_edges(helper, node_id, &prop_name, &type_str);
2238        }
2239    }
2240}
2241
2242/// Aggregate of the property modifiers we care about for emission.
2243struct PropertyModifiers {
2244    visibility: Option<String>,
2245    is_static: bool,
2246    is_readonly: bool,
2247}
2248
2249/// Walk direct children of a `property_declaration` collecting the modifier
2250/// set. Both explicit `var` (legacy public) and missing-modifier cases fall
2251/// through to the caller's `unwrap_or("public")` default.
2252fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
2253    let mut visibility: Option<String> = None;
2254    let mut is_static = false;
2255    let mut is_readonly = false;
2256
2257    let mut cursor = prop_node.walk();
2258    for child in prop_node.children(&mut cursor) {
2259        match child.kind() {
2260            "visibility_modifier" => {
2261                if let Ok(text) = child.utf8_text(content) {
2262                    visibility = Some(text.trim().to_string());
2263                }
2264            }
2265            "var_modifier" => {
2266                // `var` is the legacy spelling of `public` — treat
2267                // identically. Per design §4.4 / AC-2.
2268                if visibility.is_none() {
2269                    visibility = Some("public".to_string());
2270                }
2271            }
2272            "static_modifier" => {
2273                is_static = true;
2274            }
2275            "readonly_modifier" => {
2276                is_readonly = true;
2277            }
2278            _ => {}
2279        }
2280    }
2281
2282    PropertyModifiers {
2283        visibility,
2284        is_static,
2285        is_readonly,
2286    }
2287}
2288
2289/// Extract bare property names from a `property_declaration` by walking its
2290/// `property_element` children. Strips the leading `$` PHP variable sigil so
2291/// the qualified name matches the cross-language `Class.prop` convention.
2292fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2293    let mut names = Vec::new();
2294    let mut cursor = prop_node.walk();
2295    for child in prop_node.children(&mut cursor) {
2296        if child.kind() != "property_element" {
2297            continue;
2298        }
2299        if let Some(var_node) = child.child_by_field_name("name")
2300            && let Some(name) = strip_dollar_from_variable(var_node, content)
2301        {
2302            names.push(name);
2303        }
2304    }
2305    names
2306}
2307
2308/// Pull the bare identifier from a `property_promotion_parameter`'s `name`
2309/// field (the `variable_name`).
2310fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
2311    let name_field = param.child_by_field_name("name")?;
2312    // `by_ref` indirection is rare in promotion; honour both shapes.
2313    let var_node = if name_field.kind() == "variable_name" {
2314        name_field
2315    } else {
2316        // Search child for variable_name.
2317        let mut cursor = name_field.walk();
2318        name_field
2319            .children(&mut cursor)
2320            .find(|c| c.kind() == "variable_name")?
2321    };
2322    strip_dollar_from_variable(var_node, content)
2323}
2324
2325/// Read a `variable_name` node and return its bare identifier (no leading `$`).
2326fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
2327    if let Some(name_node) = var_node.child_by_field_name("name")
2328        && let Ok(text) = name_node.utf8_text(content)
2329    {
2330        return Some(text.trim().to_string());
2331    }
2332    var_node
2333        .utf8_text(content)
2334        .ok()
2335        .map(|s| s.trim().trim_start_matches('$').to_string())
2336}
2337
2338/// Find the first direct child with the given kind, if any.
2339fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
2340    let mut cursor = node.walk();
2341    node.children(&mut cursor).find(|c| c.kind() == kind)
2342}
2343
2344/// Emit the Field-context `TypeOf` edge plus referenced-type Reference
2345/// edges for a property/constant node.
2346fn emit_field_type_edges(
2347    helper: &mut GraphBuildHelper,
2348    node_id: NodeId,
2349    prop_name: &str,
2350    type_str: &str,
2351) {
2352    let canonical_type = canonical_type_string(type_str);
2353    let type_node_id = helper.add_type(&canonical_type, None);
2354    helper.add_typeof_edge_with_context(
2355        node_id,
2356        type_node_id,
2357        Some(TypeOfContext::Field),
2358        None,
2359        Some(prop_name),
2360    );
2361
2362    for ref_type_name in extract_type_names(type_str) {
2363        let ref_type_id = helper.add_type(&ref_type_name, None);
2364        helper.add_reference_edge(node_id, ref_type_id);
2365    }
2366}
2367
2368/// Walk up the AST to find the enclosing class, trait, or interface's name.
2369/// Returns `None` for top-level declarations or anonymous classes.
2370fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
2371    let mut current = node;
2372    while let Some(parent) = current.parent() {
2373        if matches!(
2374            parent.kind(),
2375            "class_declaration" | "trait_declaration" | "interface_declaration"
2376        ) {
2377            return parent
2378                .child_by_field_name("name")
2379                .and_then(|n| n.utf8_text(content).ok())
2380                .map(|s| s.trim().to_string());
2381        }
2382        current = parent;
2383    }
2384    None
2385}
2386
2387/// Extract parameter names and indices from a function/method declaration
2388fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2389    let mut params = Vec::new();
2390
2391    // Find parameters node
2392    let Some(params_node) = func_node.child_by_field_name("parameters") else {
2393        return params;
2394    };
2395
2396    let mut index = 0;
2397    let mut cursor = params_node.walk();
2398
2399    for child in params_node.children(&mut cursor) {
2400        if !child.is_named() {
2401            continue;
2402        }
2403
2404        match child.kind() {
2405            "simple_parameter" => {
2406                // Extract parameter name (typically the second child, which is the variable)
2407                let mut param_cursor = child.walk();
2408                for param_child in child.children(&mut param_cursor) {
2409                    if param_child.kind() == "variable_name"
2410                        && let Ok(param_text) = param_child.utf8_text(content)
2411                    {
2412                        params.push((index, param_text.trim().to_string()));
2413                        index += 1;
2414                        break;
2415                    }
2416                }
2417            }
2418            "variadic_parameter" => {
2419                // Extract parameter name from variadic parameter (e.g., ...$args)
2420                let mut param_cursor = child.walk();
2421                for param_child in child.children(&mut param_cursor) {
2422                    if param_child.kind() == "variable_name"
2423                        && let Ok(param_text) = param_child.utf8_text(content)
2424                    {
2425                        params.push((index, param_text.trim().to_string()));
2426                        index += 1;
2427                        break;
2428                    }
2429                }
2430            }
2431            _ => {}
2432        }
2433    }
2434
2435    params
2436}
2437
2438/// Get the enclosing class name for a method node
2439#[allow(clippy::unnecessary_wraps)]
2440fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2441    let mut current = node;
2442
2443    // Walk up the tree to find the enclosing class
2444    while let Some(parent) = current.parent() {
2445        if parent.kind() == "class_declaration" {
2446            // Found the class, extract its name
2447            if let Some(name_node) = parent.child_by_field_name("name")
2448                && let Ok(name_text) = name_node.utf8_text(content)
2449            {
2450                return Ok(Some(name_text.trim().to_string()));
2451            }
2452            return Ok(None);
2453        }
2454        current = parent;
2455    }
2456
2457    Ok(None)
2458}
2459
2460// ============================================================================
2461// FFI Edge Building
2462// ============================================================================
2463
2464/// Process FFI member call (e.g., `$ffi->crypto_encrypt()`).
2465///
2466/// Creates an `FfiCall` edge from the caller to a native module node.
2467fn process_ffi_member_call(
2468    node: Node,
2469    method_name: &str,
2470    ast_graph: &ASTGraph,
2471    helper: &mut GraphBuildHelper,
2472    node_map: &mut HashMap<String, NodeId>,
2473) {
2474    // Get the caller context
2475    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2476        return;
2477    };
2478
2479    // Get or create caller node
2480    let source_id = *node_map
2481        .entry(call_context.qualified_name.clone())
2482        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2483
2484    // Create a native module node for the C function
2485    let ffi_name = format!("native::ffi::{method_name}");
2486    let call_span = span_from_node(node);
2487    let target_id = helper.add_module(&ffi_name, Some(call_span));
2488
2489    // Add FFI edge (PHP FFI uses C calling convention)
2490    helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2491}
2492
2493/// Process FFI static call (`FFI::cdef()` or `FFI::load()`).
2494///
2495/// Creates an `FfiCall` edge from the caller to a native module representing
2496/// the loaded library.
2497fn process_ffi_static_call(
2498    node: Node,
2499    method_name: &str,
2500    ast_graph: &ASTGraph,
2501    helper: &mut GraphBuildHelper,
2502    node_map: &mut HashMap<String, NodeId>,
2503    content: &[u8],
2504) {
2505    // Get the caller context
2506    let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2507        return;
2508    };
2509
2510    // Get or create caller node
2511    let source_id = *node_map
2512        .entry(call_context.qualified_name.clone())
2513        .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2514
2515    // Extract library name from call arguments
2516    let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2517        .map_or_else(
2518            || "unknown".to_string(),
2519            |lib| php_ffi_library_simple_name(&lib),
2520        );
2521
2522    // Create a native module node for the library
2523    let ffi_name = format!("native::{library_name}");
2524    let call_span = span_from_node(node);
2525    let target_id = helper.add_module(&ffi_name, Some(call_span));
2526
2527    // Add FFI edge (PHP FFI uses C calling convention)
2528    helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2529}
2530
2531// ============================================================================
2532// FFI Detection Helpers
2533// ============================================================================
2534
2535/// Check if a member call is a PHP FFI call (e.g., `$ffi->function_name()`).
2536///
2537/// Returns true for calls on objects that appear to be FFI instances.
2538/// Common patterns:
2539/// - `$ffi->...`, `self::$ffi->...`, `$this->ffi->...`
2540/// - `FFI::cdef(...)->...` (chained call)
2541/// - `FFI::load(...)->...` (chained call)
2542/// - `(FFI::cdef(...))->...` (parenthesized)
2543fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2544    // Check for direct chained FFI call: FFI::cdef(...)->method()
2545    if object_node.kind() == "scoped_call_expression"
2546        && let Some(scope_node) = object_node.child_by_field_name("scope")
2547        && let Some(name_node) = object_node.child_by_field_name("name")
2548        && let Ok(scope_text) = scope_node.utf8_text(content)
2549        && let Ok(name_text) = name_node.utf8_text(content)
2550        && is_ffi_static_call(scope_text, name_text)
2551    {
2552        return true;
2553    }
2554
2555    // Check for parenthesized FFI call: (FFI::cdef(...))->method()
2556    if object_node.kind() == "parenthesized_expression"
2557        && let Some(inner) = object_node.named_child(0)
2558        && inner.kind() == "scoped_call_expression"
2559        && let Some(scope_node) = inner.child_by_field_name("scope")
2560        && let Some(name_node) = inner.child_by_field_name("name")
2561        && let Ok(scope_text) = scope_node.utf8_text(content)
2562        && let Ok(name_text) = name_node.utf8_text(content)
2563        && is_ffi_static_call(scope_text, name_text)
2564    {
2565        return true;
2566    }
2567
2568    // Check text patterns for stored FFI objects
2569    let Ok(object_text) = object_node.utf8_text(content) else {
2570        return false;
2571    };
2572
2573    let object_text = object_text.trim();
2574
2575    // Direct FFI object: $ffi->method()
2576    if object_text == "$ffi" || object_text == "$_ffi" {
2577        return true;
2578    }
2579
2580    // Class property FFI: $this->ffi->method() or self::$ffi->method()
2581    if object_text.ends_with("->ffi")
2582        || object_text.ends_with("::$ffi")
2583        || object_text.ends_with("->_ffi")
2584        || object_text.ends_with("::$_ffi")
2585    {
2586        return true;
2587    }
2588
2589    false
2590}
2591
2592/// Check if a static call is `FFI::cdef()` or `FFI::load()`.
2593///
2594/// Accepts both `FFI` and `\FFI` (fully-qualified) patterns.
2595fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2596    (scope_text == "FFI" || scope_text == "\\FFI")
2597        && (method_text == "cdef" || method_text == "load")
2598}
2599
2600/// Extract library name from FFI call arguments.
2601///
2602/// Handles both positional and named arguments:
2603/// - `FFI::cdef("...", "lib.so")`: positional second argument
2604/// - `FFI::cdef(lib: "lib.so", cdef: "...")`: named `lib` argument
2605/// - `FFI::load("header.h")`: positional first argument
2606/// - `FFI::load(filename: "header.h")`: named `filename` argument
2607fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2608    let args = call_node.child_by_field_name("arguments")?;
2609
2610    let mut cursor = args.walk();
2611    let args_vec: Vec<Node> = args
2612        .children(&mut cursor)
2613        .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2614        .collect();
2615
2616    // For FFI::cdef, look for named "lib" argument first
2617    // For FFI::load, look for named "filename" argument first
2618    let target_arg_name = if is_cdef { "lib" } else { "filename" };
2619
2620    // Try to find argument by name (PHP 8 named arguments)
2621    if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2622        return extract_string_from_argument(named_arg, content);
2623    }
2624
2625    // Fall back to positional arguments (PHP 7 style)
2626    if is_cdef {
2627        // FFI::cdef() - second argument is library path
2628        args_vec
2629            .get(1)
2630            .and_then(|arg| extract_string_from_argument(*arg, content))
2631    } else {
2632        // FFI::load() - first argument is filename
2633        args_vec
2634            .first()
2635            .and_then(|arg| extract_string_from_argument(*arg, content))
2636    }
2637}
2638
2639/// Find a named argument by its parameter name.
2640///
2641/// PHP 8 named arguments: `func(param: value)`
2642/// Tree structure: `argument { name: "param", ":", value }`
2643///
2644/// Uses field-based access for resilience against grammar changes.
2645fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2646    for arg in args {
2647        if arg.kind() != "argument" {
2648            continue;
2649        }
2650
2651        // Check if this is a named argument (has 2+ named children)
2652        // This is a quick check before trying field-based access
2653        if arg.named_child_count() < 2 {
2654            continue;
2655        }
2656
2657        // Try field-based access first (more resilient)
2658        if let Some(name_node) = arg.child_by_field_name("name")
2659            && let Ok(name_text) = name_node.utf8_text(content)
2660            && name_text == param_name
2661        {
2662            return Some(*arg);
2663        } else if let Some(name_node) = arg.named_child(0)
2664            && let Ok(name_text) = name_node.utf8_text(content)
2665            && name_text == param_name
2666        {
2667            // Fallback to child ordering if field not available
2668            return Some(*arg);
2669        }
2670    }
2671
2672    None
2673}
2674
2675/// Extract string literal from an argument node, handling both positional and named arguments.
2676///
2677/// PHP 7.x positional: `argument(1 child) -> value`
2678/// PHP 8.x named: `argument(2+ children) -> name -> value`
2679///
2680/// Returns `None` if the argument is not a valid string literal, for example a variable,
2681/// constant, or interpolated string.
2682fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2683    // Unwrap argument wrappers to get to the actual value expression
2684    let value_node = unwrap_argument_node(arg_node)?;
2685
2686    // Only accept pure string literals, not variables or constants
2687    if !is_string_literal_node(value_node) {
2688        return None;
2689    }
2690
2691    // Reject interpolated strings (e.g., "lib{$var}.so")
2692    if is_interpolated_string(value_node) {
2693        return None;
2694    }
2695
2696    extract_php_string_content(value_node, content)
2697}
2698
2699/// Unwrap PHP argument node wrappers to get to the value expression.
2700///
2701/// Handles:
2702/// - `argument` nodes with 1 child: PHP 7.x positional args (argument -> value)
2703/// - `argument` nodes with 2+ children: PHP 8.x named args (argument -> name -> value)
2704///
2705/// Uses field-based skipping to extract the value child while excluding
2706/// the `name` field (named argument parameter name) and `reference_modifier`
2707/// field (& reference marker). This correctly handles cases where the value
2708/// itself is a `name` node (e.g., `self`, `parent`, `static`, class names).
2709/// Returns the innermost value expression.
2710fn unwrap_argument_node(node: Node) -> Option<Node> {
2711    if node.kind() != "argument" {
2712        // Not a wrapper, return as-is
2713        return Some(node);
2714    }
2715
2716    // Tree-sitter-php 0.24.2 `argument` nodes have:
2717    // - "name" field (for named arguments parameter name)
2718    // - "reference_modifier" field (for & references)
2719    // - No "value" field (must select by exclusion)
2720    //
2721    // Get the field nodes to exclude by identity comparison
2722    let name_field_node = node.child_by_field_name("name");
2723    let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2724
2725    // Find the value child by excluding structural field nodes
2726    for i in 0..node.named_child_count() {
2727        #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
2728        if let Some(child) = node.named_child(i as u32) {
2729            // Skip if this child is the name field or reference_modifier field
2730            let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2731            let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2732
2733            if !is_name_field && !is_ref_modifier {
2734                // This is the value child (expression, variadic_unpacking, or name node like self/parent/static)
2735                return Some(child);
2736            }
2737        }
2738    }
2739
2740    // If no value child found, return None (malformed argument)
2741    None
2742}
2743
2744/// Check if a node is a string literal (not a variable or constant).
2745///
2746/// PHP tree-sitter uses different node kinds for various string types:
2747/// - `string` for single-quoted strings (`'...'`)
2748/// - `encapsed_string` for double-quoted strings (`"..."`)
2749/// - `heredoc` and `nowdoc` for heredoc/nowdoc syntax
2750fn is_string_literal_node(node: Node) -> bool {
2751    matches!(
2752        node.kind(),
2753        "string" | "encapsed_string" | "heredoc" | "nowdoc"
2754    )
2755}
2756
2757/// Check if a string node contains variable interpolation.
2758///
2759/// Double-quoted strings and heredocs can contain interpolation:
2760/// - `lib{$suffix}.so`: simple variable
2761/// - `path/$variable/file`: simple variable
2762/// - `{$arr['key']}`: array access
2763/// - `{$obj->prop}`: property access
2764///
2765/// Single-quoted strings and nowdocs never interpolate, so we only check
2766/// `encapsed_string` and `heredoc` nodes.
2767///
2768/// Scans all descendants recursively to catch complex interpolation patterns.
2769fn is_interpolated_string(node: Node) -> bool {
2770    if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2771        return false;
2772    }
2773
2774    // Recursively check all descendants for variable-bearing nodes
2775    has_variable_node(node)
2776}
2777
2778/// Recursively check if a node or any of its descendants contains variables or dynamic expressions.
2779///
2780/// Detects all forms of interpolation:
2781/// - Direct variables: `$var`, `${expr}`
2782/// - Dynamic variables: `$$var`
2783/// - Array access: `$arr['key']`, `$arr[$index]`
2784/// - Property access: `$obj->prop`
2785/// - Method calls: `$obj->method()`
2786/// - Function calls: `$foo()`
2787/// - Static access: `$Class::$prop`, `$Class::method()`
2788/// - Class constants: `$Class::CONST`
2789/// - Nullsafe variants: `$obj?->prop`
2790/// - Any node containing variables at any depth
2791fn has_variable_node(node: Node) -> bool {
2792    // Check if this node itself is a variable-bearing or dynamic expression node
2793    if matches!(
2794        node.kind(),
2795        // Direct variable nodes
2796        "variable_name" | "simple_variable" | "variable" | "complex_variable"
2797        // Dynamic variables ($$var, ${'expr'})
2798        | "dynamic_variable_name"
2799        // Instance access and calls
2800        | "subscript_expression" | "member_access_expression" | "member_call_expression"
2801        // Function calls (may contain variables)
2802        | "function_call_expression"
2803        // Static/scoped access (may contain variables)
2804        | "scoped_call_expression" | "scoped_property_access_expression"
2805        // Class constant access (may have dynamic class name)
2806        | "class_constant_access_expression"
2807        // Nullsafe variants
2808        | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2809    ) {
2810        return true;
2811    }
2812
2813    // Recursively check all children
2814    for i in 0..node.child_count() {
2815        #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
2816        if let Some(child) = node.child(i as u32)
2817            && has_variable_node(child)
2818        {
2819            return true;
2820        }
2821    }
2822
2823    false
2824}
2825
2826/// Extract content from PHP string literal.
2827///
2828/// Handles single-quoted ('...'), double-quoted ("..."), and heredoc strings.
2829fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2830    let Ok(text) = string_node.utf8_text(content) else {
2831        return None;
2832    };
2833
2834    let text = text.trim();
2835
2836    // Strip quotes for simple strings
2837    if ((text.starts_with('"') && text.ends_with('"'))
2838        || (text.starts_with('\'') && text.ends_with('\'')))
2839        && text.len() >= 2
2840    {
2841        return Some(text[1..text.len() - 1].to_string());
2842    }
2843
2844    // For heredoc/nowdoc, return as-is (tree-sitter handles it)
2845    Some(text.to_string())
2846}
2847
2848/// Simplify library path to base name (e.g., "libfoo.so.1" → "libfoo").
2849fn php_ffi_library_simple_name(library_path: &str) -> String {
2850    use std::path::Path;
2851
2852    // Strip directory components first
2853    let filename = Path::new(library_path)
2854        .file_name()
2855        .and_then(|f| f.to_str())
2856        .unwrap_or(library_path);
2857
2858    // Handle versioned .so files (libfoo.so.1 → libfoo)
2859    if let Some(so_pos) = filename.find(".so.") {
2860        return filename[..so_pos].to_string();
2861    }
2862
2863    // Handle standard library and header extensions
2864    if let Some(dot_pos) = filename.find('.') {
2865        let extension = &filename[dot_pos + 1..];
2866        if extension == "so"
2867            || extension == "dll"
2868            || extension == "dylib"
2869            || extension == "h"
2870            || extension == "hpp"
2871        {
2872            return filename[..dot_pos].to_string();
2873        }
2874    }
2875
2876    filename.to_string()
2877}
2878
2879// ============================================================================
2880// Field emission tests (REQ:R0001..R0007, R0013, R0023)
2881// ============================================================================
2882
2883#[cfg(test)]
2884mod field_emission_tests {
2885    //! Tests for unconditional Property/Constant emission from PHP class /
2886    //! trait / interface property declarations and constructor-promotion
2887    //! parameters (DAG U10 / `C2_OTHER_PHP`).
2888    //!
2889    //! These tests assert the post-fix contract:
2890    //! - `PHPDoc` gate removed: Property/Constant emitted regardless of @var.
2891    //! - Qualified name `Class.prop` (dot separator per design §3.1).
2892    //! - Visibility from `visibility_modifier`; default "public" when absent.
2893    //! - `static_modifier` → `is_static = true`.
2894    //! - `readonly` (PHP 8.1+) → `Constant`; otherwise `Property`.
2895    //! - Native PHP 7.4+ type → primary; `PHPDoc` `@var` is enrichment fallback
2896    //!   only when no native type is present.
2897    //! - `TypeOf` edge uses `TypeOfContext::Field` and bare field-name metadata.
2898    //! - Constructor `property_promotion_parameter` emits a Property on the class.
2899    //! - Collision precedence: explicit declaration wins; promoted dedupes via
2900    //!   `helper.get_node` and only fills `None` attributes.
2901    //! - Span anchored on the property/promotion declaration node.
2902    use sqry_core::graph::GraphBuilder;
2903    use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2904    use sqry_core::graph::unified::build::test_helpers::{
2905        build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
2906    };
2907    use sqry_core::graph::unified::edge::EdgeKind;
2908    use sqry_core::graph::unified::edge::kind::TypeOfContext;
2909    use sqry_core::graph::unified::node::NodeKind;
2910    use std::path::Path;
2911    use tree_sitter::Parser;
2912
2913    use super::PhpGraphBuilder;
2914
2915    fn parse(source: &str) -> tree_sitter::Tree {
2916        let mut parser = Parser::new();
2917        parser
2918            .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
2919            .expect("load PHP grammar");
2920        parser.parse(source, None).expect("parse PHP source")
2921    }
2922
2923    fn build(source: &str) -> StagingGraph {
2924        let tree = parse(source);
2925        let mut staging = StagingGraph::new();
2926        let builder = PhpGraphBuilder::default();
2927        builder
2928            .build_graph(
2929                &tree,
2930                source.as_bytes(),
2931                Path::new("test.php"),
2932                &mut staging,
2933            )
2934            .expect("build graph");
2935        staging
2936    }
2937
2938    /// Look up a node entry by its qualified-or-bare name, optionally requiring a kind.
2939    fn find_node<'a>(
2940        staging: &'a StagingGraph,
2941        name: &str,
2942        kind: Option<NodeKind>,
2943    ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2944        let strings = build_string_lookup(staging);
2945        for op in staging.operations() {
2946            if let StagingOp::AddNode { entry, .. } = op {
2947                if let Some(k) = kind
2948                    && entry.kind != k
2949                {
2950                    continue;
2951                }
2952                let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2953                if let Some(s) = strings.get(&name_idx)
2954                    && s == name
2955                {
2956                    return Some(entry);
2957                }
2958            }
2959        }
2960        None
2961    }
2962
2963    fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
2964        let strings = build_string_lookup(staging);
2965        staging
2966            .operations()
2967            .iter()
2968            .filter(|op| {
2969                if let StagingOp::AddNode { entry, .. } = op {
2970                    let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2971                    strings.get(&name_idx).is_some_and(|s| s == name)
2972                } else {
2973                    false
2974                }
2975            })
2976            .count()
2977    }
2978
2979    fn resolve_visibility(
2980        staging: &StagingGraph,
2981        vis: Option<sqry_core::graph::unified::StringId>,
2982    ) -> Option<String> {
2983        let strings = build_string_lookup(staging);
2984        vis.and_then(|sid| strings.get(&sid.index()).cloned())
2985    }
2986
2987    fn typeof_edges_for_node(
2988        staging: &StagingGraph,
2989        source_name: &str,
2990    ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
2991        let names = build_node_name_lookup(staging);
2992        let strings = build_string_lookup(staging);
2993        let mut out = Vec::new();
2994        for op in staging.operations() {
2995            if let StagingOp::AddEdge {
2996                source,
2997                target,
2998                kind: EdgeKind::TypeOf { context, name, .. },
2999                ..
3000            } = op
3001            {
3002                let src = names.get(source).cloned().unwrap_or_default();
3003                if src != source_name {
3004                    continue;
3005                }
3006                let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3007                let target_name = names.get(target).cloned().unwrap_or_default();
3008                out.push((*context, edge_name, target_name));
3009            }
3010        }
3011        out
3012    }
3013
3014    // -- AC-1: PHPDoc gate removed ------------------------------------------
3015
3016    #[test]
3017    fn req_r0001_property_without_phpdoc_emits_property_node() {
3018        let src = "<?php
3019class User {
3020    public string $name;
3021}
3022";
3023        let staging = build(src);
3024        let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
3025            .expect("User.name Property must be emitted without @var");
3026        assert_eq!(entry.kind, NodeKind::Property);
3027    }
3028
3029    #[test]
3030    fn req_r0001_property_with_phpdoc_still_emits_property_node() {
3031        let src = "<?php
3032class Repo {
3033    /** @var string */
3034    public string $label;
3035}
3036";
3037        let staging = build(src);
3038        find_node(&staging, "Repo.label", Some(NodeKind::Property))
3039            .expect("Repo.label Property must be emitted when @var is present");
3040    }
3041
3042    // -- AC-2: qualified name + default visibility --------------------------
3043
3044    #[test]
3045    fn req_r0002_qualified_name_uses_class_dot_prop() {
3046        let src = "<?php
3047class A { public int $x; }
3048class B { public int $x; }
3049";
3050        let staging = build(src);
3051        find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
3052        find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
3053        assert!(
3054            find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3055            "no bare 'x' Property node should leak"
3056        );
3057    }
3058
3059    #[test]
3060    fn req_r0002_visibility_modifiers_round_trip() {
3061        let src = "<?php
3062class V {
3063    public int $a;
3064    private int $b;
3065    protected int $c;
3066    var $d;
3067}
3068";
3069        let staging = build(src);
3070        for (name, expected) in [
3071            ("V.a", "public"),
3072            ("V.b", "private"),
3073            ("V.c", "protected"),
3074            ("V.d", "public"),
3075        ] {
3076            let entry = find_node(&staging, name, Some(NodeKind::Property))
3077                .unwrap_or_else(|| panic!("missing {name}"));
3078            let got = resolve_visibility(&staging, entry.visibility);
3079            assert_eq!(
3080                got.as_deref(),
3081                Some(expected),
3082                "{name} visibility should be {expected}"
3083            );
3084        }
3085    }
3086
3087    #[test]
3088    fn req_r0002_default_visibility_is_public_when_no_modifier() {
3089        // PHP allows readonly/static-only declarations (no explicit visibility).
3090        let src = "<?php
3091class X { static int $count = 0; }
3092";
3093        let staging = build(src);
3094        let entry =
3095            find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
3096        let vis = resolve_visibility(&staging, entry.visibility);
3097        assert_eq!(
3098            vis.as_deref(),
3099            Some("public"),
3100            "default visibility is public"
3101        );
3102    }
3103
3104    // -- AC-3: static modifier ----------------------------------------------
3105
3106    #[test]
3107    fn req_r0003_static_modifier_sets_is_static() {
3108        let src = "<?php
3109class S {
3110    public static int $count = 0;
3111    public int $instance = 0;
3112}
3113";
3114        let staging = build(src);
3115        let s_count =
3116            find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
3117        assert!(s_count.is_static, "S.count should be static");
3118        let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
3119            .expect("S.instance must exist");
3120        assert!(!s_instance.is_static, "S.instance should not be static");
3121    }
3122
3123    // -- AC-4: readonly → Constant ------------------------------------------
3124
3125    #[test]
3126    fn req_r0004_readonly_emits_constant() {
3127        let src = "<?php
3128class R {
3129    public readonly string $id;
3130    public string $name;
3131}
3132";
3133        let staging = build(src);
3134        find_node(&staging, "R.id", Some(NodeKind::Constant))
3135            .expect("R.id must be Constant (readonly)");
3136        find_node(&staging, "R.name", Some(NodeKind::Property))
3137            .expect("R.name must be Property (mutable)");
3138    }
3139
3140    // -- AC-5: native type primary, PHPDoc fallback --------------------------
3141
3142    #[test]
3143    fn req_r0005_native_type_takes_precedence_over_phpdoc() {
3144        // The PHPDoc parser used by this plugin requires `{...}` around the
3145        // type token. Native-type wins regardless: the @var should be
3146        // ignored entirely when a PHP-level type is present.
3147        let src = "<?php
3148class T {
3149    /** @var {int} */
3150    public string $value;
3151}
3152";
3153        let staging = build(src);
3154        let edges = typeof_edges_for_node(&staging, "T.value");
3155        assert!(
3156            !edges.is_empty(),
3157            "T.value should have at least one TypeOf edge"
3158        );
3159        let has_string = edges.iter().any(|(_, _, t)| t == "string");
3160        assert!(
3161            has_string,
3162            "native type 'string' should be the primary TypeOf target, got {edges:?}"
3163        );
3164        let has_int = edges.iter().any(|(_, _, t)| t == "int");
3165        assert!(
3166            !has_int,
3167            "PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
3168        );
3169    }
3170
3171    #[test]
3172    fn req_r0005_phpdoc_fallback_when_no_native_type() {
3173        // PHPDoc parser requires `{...}` braces around the type identifier.
3174        let src = "<?php
3175class T {
3176    /** @var {SomeUserType} */
3177    public $value;
3178}
3179";
3180        let staging = build(src);
3181        let edges = typeof_edges_for_node(&staging, "T.value");
3182        assert!(
3183            edges.iter().any(|(_, _, t)| t == "SomeUserType"),
3184            "PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
3185        );
3186    }
3187
3188    // -- AC-6: TypeOfContext::Field + bare edge name ------------------------
3189
3190    #[test]
3191    fn req_r0006_typeof_uses_field_context_and_bare_name() {
3192        let src = "<?php
3193class C {
3194    public string $title;
3195}
3196";
3197        let staging = build(src);
3198        let edges = typeof_edges_for_node(&staging, "C.title");
3199        assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
3200        for (ctx, name, _) in &edges {
3201            assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3202            assert_eq!(
3203                name.as_deref(),
3204                Some("title"),
3205                "edge name must be the bare property name"
3206            );
3207        }
3208    }
3209
3210    // -- AC-7: constructor promotion ----------------------------------------
3211
3212    #[test]
3213    fn req_r0007_constructor_promotion_emits_property_on_class() {
3214        let src = "<?php
3215class P {
3216    public function __construct(public int $x, private readonly string $y) {}
3217}
3218";
3219        let staging = build(src);
3220        let x = find_node(&staging, "P.x", Some(NodeKind::Property))
3221            .expect("promoted P.x must be a Property");
3222        assert_eq!(
3223            resolve_visibility(&staging, x.visibility).as_deref(),
3224            Some("public"),
3225            "promoted $x visibility"
3226        );
3227        let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
3228            .expect("promoted readonly P.y must be a Constant");
3229        assert_eq!(
3230            resolve_visibility(&staging, y.visibility).as_deref(),
3231            Some("private"),
3232            "promoted $y visibility"
3233        );
3234    }
3235
3236    // -- AC-8: collision precedence (explicit wins, promoted dedupes) -------
3237
3238    #[test]
3239    fn req_r0013_explicit_declaration_wins_over_promotion() {
3240        let src = "<?php
3241class D {
3242    public int $x;
3243    public function __construct(public int $x) {}
3244}
3245";
3246        let staging = build(src);
3247        let n = count_nodes_named(&staging, "D.x");
3248        assert_eq!(
3249            n, 1,
3250            "exactly one D.x node when explicit decl + promotion collide, got {n}"
3251        );
3252        // Should remain a Property (not switched to anything else).
3253        find_node(&staging, "D.x", Some(NodeKind::Property))
3254            .expect("D.x must be Property (explicit declaration wins)");
3255    }
3256
3257    /// Constructor appears BEFORE the explicit property declaration. The
3258    /// explicit declaration must still win on every dimension —
3259    /// kind/visibility/static — and its declared `int` type must be the
3260    /// only `TypeOf` target bound to `A.x` (the promoted `string` is
3261    /// suppressed). Locks in FR-13 against source-order regression.
3262    #[test]
3263    fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
3264        let src = "<?php
3265class A {
3266    public function __construct(public string $x) {}
3267    public int $x;
3268}
3269";
3270        let staging = build(src);
3271        let n = count_nodes_named(&staging, "A.x");
3272        assert_eq!(
3273            n, 1,
3274            "exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
3275        );
3276        find_node(&staging, "A.x", Some(NodeKind::Property))
3277            .expect("A.x must be Property (explicit declaration wins)");
3278
3279        // TypeOf edges: only the explicit `int` should appear; the
3280        // promoted `string` must NOT be re-emitted onto the explicit
3281        // node.
3282        let edges = typeof_edges_for_node(&staging, "A.x");
3283        let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3284        assert!(
3285            target_types.contains(&"int"),
3286            "explicit `int` TypeOf must be present, got {target_types:?}",
3287        );
3288        assert!(
3289            !target_types.contains(&"string"),
3290            "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3291        );
3292    }
3293
3294    /// Mirror of the above with explicit property declaration appearing
3295    /// BEFORE the constructor. Same outcome required: single Property
3296    /// node with explicit attributes, only the explicit `int` `TypeOf`.
3297    #[test]
3298    fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
3299        let src = "<?php
3300class B {
3301    public int $x;
3302    public function __construct(public string $x) {}
3303}
3304";
3305        let staging = build(src);
3306        let n = count_nodes_named(&staging, "B.x");
3307        assert_eq!(
3308            n, 1,
3309            "exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
3310        );
3311        find_node(&staging, "B.x", Some(NodeKind::Property))
3312            .expect("B.x must be Property (explicit declaration wins)");
3313
3314        let edges = typeof_edges_for_node(&staging, "B.x");
3315        let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3316        assert!(
3317            target_types.contains(&"int"),
3318            "explicit `int` TypeOf must be present, got {target_types:?}",
3319        );
3320        assert!(
3321            !target_types.contains(&"string"),
3322            "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3323        );
3324    }
3325
3326    // -- AC-9: span set from declaration node -------------------------------
3327
3328    #[test]
3329    fn req_r0023_span_anchored_on_declaration() {
3330        let src = "<?php
3331class W {
3332
3333    public string $marker;
3334}
3335";
3336        let staging = build(src);
3337        let entry =
3338            find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
3339        // Source layout (0-based): row 0 `<?php`, row 1 `class W {`, row 2
3340        // blank, row 3 `    public string $marker;`. Helper rebases line
3341        // numbers to 1-based via `saturating_add(1)`, so row 3 → 4.
3342        // (Note: `add_node_internal` only stores line/column from a
3343        // position-only Span, so `end_byte` stays at the default zero —
3344        // we anchor span correctness on the line numbers and column
3345        // extent instead.)
3346        assert_eq!(
3347            entry.start_line, 4,
3348            "span start line should match declaration"
3349        );
3350        assert_eq!(entry.end_line, 4, "span end line should match declaration");
3351        assert_eq!(
3352            entry.start_column, 4,
3353            "span start column should match indentation of `public`"
3354        );
3355        assert!(
3356            entry.end_column > entry.start_column,
3357            "span end column must extend past start (got start={}, end={})",
3358            entry.start_column,
3359            entry.end_column,
3360        );
3361    }
3362
3363    // -- Trait + interface coverage -----------------------------------------
3364
3365    #[test]
3366    fn req_r0001_trait_property_emitted() {
3367        let src = "<?php
3368trait Loggable {
3369    protected ?string $logTag;
3370}
3371";
3372        let staging = build(src);
3373        let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
3374            .expect("trait property must be emitted");
3375        let vis = resolve_visibility(&staging, entry.visibility);
3376        assert_eq!(vis.as_deref(), Some("protected"));
3377    }
3378
3379    #[test]
3380    fn no_emission_outside_class_or_trait_or_interface() {
3381        // Plain global variables are not class properties; the walker must not
3382        // emit Property/Constant for them.
3383        let src = "<?php
3384$x = 1;
3385function f() { $y = 2; }
3386";
3387        let staging = build(src);
3388        assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
3389        assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
3390    }
3391}
3392
3393/// Per-language [`ShapeMapping`] for PHP (identifier-blind body-shape feature).
3394///
3395/// Precomputed `kind_id -> CfBucket` table built once from the tree-sitter-php
3396/// grammar so the shape walk is a single array index per node. Everything except
3397/// this mapping is the shared `compute_shape_descriptor` routine in sqry-core.
3398pub struct PhpShapeMapping {
3399    cf_by_kind_id: Vec<Option<CfBucket>>,
3400}
3401
3402impl PhpShapeMapping {
3403    fn build() -> Self {
3404        let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3405        let count = lang.node_kind_count();
3406        let mut cf_by_kind_id = vec![None; count];
3407        for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
3408            let Ok(kind_id) = u16::try_from(id) else {
3409                break;
3410            };
3411            if !lang.node_kind_is_named(kind_id) {
3412                continue;
3413            }
3414            if let Some(name) = lang.node_kind_for_id(kind_id) {
3415                *slot = cf_bucket_for_php_kind(name);
3416            }
3417        }
3418        Self { cf_by_kind_id }
3419    }
3420}
3421
3422impl ShapeMapping for PhpShapeMapping {
3423    fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
3424        self.cf_by_kind_id
3425            .get(ts_node_kind_id as usize)
3426            .copied()
3427            .flatten()
3428    }
3429
3430    fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
3431        let mut shape = SignatureShape::default();
3432        if let Some(params) = fn_node.child_by_field_name("parameters") {
3433            let mut cursor = params.walk();
3434            for child in params.named_children(&mut cursor) {
3435                match child.kind() {
3436                    "simple_parameter" | "property_promotion_parameter" => {
3437                        shape.arity_positional = shape.arity_positional.saturating_add(1);
3438                        if child.child_by_field_name("default_value").is_some() {
3439                            shape.has_defaults = true;
3440                        }
3441                    }
3442                    "variadic_parameter" => shape.has_varargs = true,
3443                    _ => {}
3444                }
3445            }
3446        }
3447        shape.has_return_annotation = fn_node.child_by_field_name("return_type").is_some();
3448        shape
3449    }
3450}
3451
3452/// Map one tree-sitter-php node-kind name to its canonical control-flow bucket.
3453/// Additive-only against the frozen [`CfBucket`] set.
3454fn cf_bucket_for_php_kind(name: &str) -> Option<CfBucket> {
3455    let bucket = match name {
3456        "if_statement"
3457        | "else_if_clause"
3458        | "else_clause"
3459        | "conditional_expression"
3460        | "match_conditional_expression" => CfBucket::Branch,
3461        "while_statement" | "do_statement" | "for_statement" | "foreach_statement" => {
3462            CfBucket::Loop
3463        }
3464        "switch_statement" | "case_statement" | "default_statement" | "match_expression"
3465        | "match_block" => CfBucket::Match,
3466        "try_statement" => CfBucket::Try,
3467        "catch_clause" => CfBucket::Catch,
3468        "finally_clause" => CfBucket::Resource,
3469        "throw_expression" => CfBucket::Throw,
3470        "return_statement" => CfBucket::Return,
3471        "yield_expression" => CfBucket::Yield,
3472        "break_statement" | "continue_statement" => CfBucket::BreakContinue,
3473        "function_call_expression"
3474        | "member_call_expression"
3475        | "scoped_call_expression"
3476        | "nullsafe_member_call_expression"
3477        | "object_creation_expression" => CfBucket::Call,
3478        "assignment_expression" | "augmented_assignment_expression" => CfBucket::Assign,
3479        "anonymous_function" | "arrow_function" => CfBucket::Closure,
3480        _ => return None,
3481    };
3482    Some(bucket)
3483}
3484
3485/// The process-wide PHP shape mapping, built once on first use.
3486#[must_use]
3487pub fn php_shape_mapping() -> &'static PhpShapeMapping {
3488    static MAPPING: OnceLock<PhpShapeMapping> = OnceLock::new();
3489    MAPPING.get_or_init(PhpShapeMapping::build)
3490}
3491
3492#[cfg(test)]
3493mod shape_tests {
3494    //! Coverage for the PHP [`ShapeMapping`]. Consumes the hand-written
3495    //! control-flow fixture so the test is load-bearing.
3496
3497    use super::{cf_bucket_for_php_kind, php_shape_mapping};
3498    use sqry_core::graph::unified::build::shape::{
3499        CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3500    };
3501    use tree_sitter::{Node, Parser, Tree};
3502
3503    const SAMPLE: &str = include_str!(concat!(
3504        env!("CARGO_MANIFEST_DIR"),
3505        "/../test-fixtures/shape/dynamic/php.php"
3506    ));
3507
3508    fn parse(src: &str) -> Tree {
3509        let mut parser = Parser::new();
3510        parser
3511            .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
3512            .expect("load php grammar");
3513        parser.parse(src, None).expect("parse php")
3514    }
3515
3516    fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
3517        let root = tree.root_node();
3518        let mut cursor = root.walk();
3519        for child in root.named_children(&mut cursor) {
3520            if child.kind() == "function_definition" {
3521                return child;
3522            }
3523        }
3524        panic!("no function_definition in php fixture");
3525    }
3526
3527    #[test]
3528    fn mapping_is_non_empty_and_covers_real_kinds() {
3529        assert_eq!(
3530            cf_bucket_for_php_kind("if_statement"),
3531            Some(CfBucket::Branch)
3532        );
3533        assert_eq!(
3534            cf_bucket_for_php_kind("while_statement"),
3535            Some(CfBucket::Loop)
3536        );
3537        assert_eq!(
3538            cf_bucket_for_php_kind("switch_statement"),
3539            Some(CfBucket::Match)
3540        );
3541        assert_eq!(cf_bucket_for_php_kind("try_statement"), Some(CfBucket::Try));
3542        assert_eq!(
3543            cf_bucket_for_php_kind("catch_clause"),
3544            Some(CfBucket::Catch)
3545        );
3546        assert_eq!(
3547            cf_bucket_for_php_kind("finally_clause"),
3548            Some(CfBucket::Resource)
3549        );
3550        assert_eq!(
3551            cf_bucket_for_php_kind("throw_expression"),
3552            Some(CfBucket::Throw)
3553        );
3554        assert_eq!(
3555            cf_bucket_for_php_kind("anonymous_function"),
3556            Some(CfBucket::Closure)
3557        );
3558        assert_eq!(cf_bucket_for_php_kind("nope"), None);
3559
3560        let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3561        let id = (0..lang.node_kind_count())
3562            .map(|i| i as u16)
3563            .find(|&i| {
3564                lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
3565            })
3566            .expect("grammar exposes named if_statement");
3567        assert_eq!(php_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
3568    }
3569
3570    #[test]
3571    fn descriptor_covers_fixture_control_flow() {
3572        let tree = parse(SAMPLE);
3573        let func = first_function(&tree);
3574        let descriptor = compute_shape_descriptor(
3575            func,
3576            SAMPLE.as_bytes(),
3577            php_shape_mapping(),
3578            &ShapeBudget::default(),
3579        );
3580        let hist = descriptor.cf_histogram;
3581        assert!(hist[CfBucket::Branch.index()] >= 1, "branch");
3582        assert!(hist[CfBucket::Loop.index()] >= 1, "loop");
3583        assert!(hist[CfBucket::Match.index()] >= 1, "switch/case");
3584        assert!(hist[CfBucket::Try.index()] >= 1, "try");
3585        assert!(hist[CfBucket::Catch.index()] >= 1, "catch");
3586        assert!(hist[CfBucket::Resource.index()] >= 1, "finally");
3587        assert!(hist[CfBucket::Throw.index()] >= 1, "throw");
3588        assert!(hist[CfBucket::Return.index()] >= 1, "return");
3589        assert!(hist[CfBucket::Call.index()] >= 1, "call");
3590        assert!(hist[CfBucket::Closure.index()] >= 1, "closure");
3591        assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/continue");
3592    }
3593
3594    #[test]
3595    fn signature_shape_reads_arity_and_return() {
3596        let tree = parse(SAMPLE);
3597        let func = first_function(&tree);
3598        let shape = php_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3599        // `function classify(int $value, string $label = "n/a", ...$rest): string`.
3600        assert_eq!(shape.arity_positional, 2, "value + label");
3601        assert!(shape.has_defaults, "label has a default");
3602        assert!(shape.has_varargs, "...$rest");
3603        assert!(shape.has_return_annotation, ": string");
3604    }
3605}