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