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