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