1use std::collections::{HashMap, HashSet};
34use std::path::Path;
35
36use sqry_core::graph::unified::build::helper::CalleeKindHint;
37use sqry_core::graph::unified::edge::kind::{FfiConvention, TypeOfContext};
38use sqry_core::graph::unified::{GraphBuildHelper, NodeId, StagingGraph};
39use sqry_core::graph::{
40 GraphBuilder, GraphBuilderError, GraphResult, GraphSnapshot, Language, Span,
41};
42use tree_sitter::{Node, Tree};
43
44use super::phpdoc_parser::{extract_phpdoc_comment, parse_phpdoc_tags};
45use super::type_extractor::{canonical_type_string, extract_type_names};
46
47const DEFAULT_MAX_SCOPE_DEPTH: usize = 5;
49
50#[derive(Debug)]
52pub struct PhpGraphBuilder {
53 pub max_scope_depth: usize,
54}
55
56impl Default for PhpGraphBuilder {
57 fn default() -> Self {
58 Self {
59 max_scope_depth: DEFAULT_MAX_SCOPE_DEPTH,
60 }
61 }
62}
63
64impl GraphBuilder for PhpGraphBuilder {
65 fn build_graph(
66 &self,
67 tree: &Tree,
68 content: &[u8],
69 file: &Path,
70 staging: &mut StagingGraph,
71 ) -> GraphResult<()> {
72 let mut helper = GraphBuildHelper::new(staging, file, Language::Php);
73
74 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
76 GraphBuilderError::ParseError {
77 span: Span::default(),
78 reason: e,
79 }
80 })?;
81
82 let mut node_map = HashMap::new();
84
85 for context in ast_graph.contexts() {
87 let qualified_name = &context.qualified_name;
88 let span = Span::from_bytes(context.span.0, context.span.1);
89
90 let node_id = match &context.kind {
91 ContextKind::Function { is_async } => helper.add_function_with_signature(
92 qualified_name,
93 Some(span),
94 *is_async,
95 false, None, context.return_type.as_deref(),
98 ),
99 ContextKind::Method {
100 is_async,
101 is_static,
102 visibility: _,
103 } => {
104 helper.add_method_with_signature(
108 qualified_name,
109 Some(span),
110 *is_async,
111 *is_static,
112 None, context.return_type.as_deref(),
114 )
115 }
116 ContextKind::Class => helper.add_class(qualified_name, Some(span)),
117 };
118 node_map.insert(qualified_name.clone(), node_id);
119 }
120
121 let root = tree.root_node();
123 walk_tree_for_edges(root, content, &ast_graph, &mut helper, &mut node_map)?;
124
125 process_oop_relationships(root, content, &mut helper, &mut node_map);
127
128 process_exports(root, content, &mut helper, &mut node_map);
131
132 process_phpdoc_annotations(root, content, &mut helper)?;
134
135 Ok(())
136 }
137
138 fn language(&self) -> Language {
139 Language::Php
140 }
141
142 fn detect_cross_language_edges(
143 &self,
144 _snapshot: &GraphSnapshot,
145 ) -> GraphResult<Vec<sqry_core::graph::CodeEdge>> {
146 Ok(vec![])
149 }
150}
151
152#[derive(Debug, Clone)]
157enum ContextKind {
158 Function {
159 is_async: bool,
160 },
161 Method {
162 is_async: bool,
163 is_static: bool,
164 #[allow(dead_code)] visibility: Option<String>,
166 },
167 Class,
168}
169
170#[derive(Debug, Clone)]
171struct CallContext {
172 qualified_name: String,
173 span: (usize, usize),
174 kind: ContextKind,
175 class_name: Option<String>,
176 return_type: Option<String>,
177}
178
179struct ASTGraph {
180 contexts: Vec<CallContext>,
181 node_to_context: HashMap<usize, usize>,
182}
183
184impl ASTGraph {
185 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
186 let mut contexts = Vec::new();
187 let mut node_to_context = HashMap::new();
188 let mut scope_stack: Vec<String> = Vec::new();
189 let mut class_stack: Vec<String> = Vec::new();
190
191 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
193 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
194 let file_ops_depth = recursion_limits
195 .effective_file_ops_depth()
196 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
197 let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
198 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
199
200 let mut walk_ctx = WalkContext {
201 contexts: &mut contexts,
202 node_to_context: &mut node_to_context,
203 scope_stack: &mut scope_stack,
204 class_stack: &mut class_stack,
205 max_depth,
206 };
207
208 walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
209
210 Ok(Self {
211 contexts,
212 node_to_context,
213 })
214 }
215
216 fn contexts(&self) -> &[CallContext] {
217 &self.contexts
218 }
219
220 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
221 self.node_to_context
222 .get(&node_id)
223 .and_then(|idx| self.contexts.get(*idx))
224 }
225}
226
227#[allow(
228 clippy::too_many_lines,
229 reason = "PHP namespace and scope handling requires a large, unified traversal."
230)]
231struct WalkContext<'a> {
236 contexts: &'a mut Vec<CallContext>,
237 node_to_context: &'a mut HashMap<usize, usize>,
238 scope_stack: &'a mut Vec<String>,
239 class_stack: &'a mut Vec<String>,
240 max_depth: usize,
241}
242
243#[allow(clippy::too_many_lines)]
244fn walk_ast(
245 node: Node,
246 content: &[u8],
247 ctx: &mut WalkContext,
248 guard: &mut sqry_core::query::security::RecursionGuard,
249) -> Result<(), String> {
250 guard
251 .enter()
252 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
253
254 if ctx.scope_stack.len() > ctx.max_depth {
255 guard.exit();
256 return Ok(());
257 }
258
259 match node.kind() {
260 "program" => {
261 let mut active_namespace_parts: Vec<String> = Vec::new();
264
265 let mut cursor = node.walk();
266 for child in node.children(&mut cursor) {
267 if child.kind() == "namespace_definition" {
268 let has_body = child
270 .children(&mut child.walk())
271 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
272
273 let ns_name = child
274 .child_by_field_name("name")
275 .and_then(|n| n.utf8_text(content).ok())
276 .map(|s| s.trim().to_string())
277 .unwrap_or_default();
278
279 if has_body {
280 for _ in 0..active_namespace_parts.len() {
287 ctx.scope_stack.pop();
288 }
289 active_namespace_parts.clear();
290
291 let ns_parts: Vec<String> = if ns_name.is_empty() {
292 Vec::new()
293 } else {
294 ns_name.split('\\').map(ToString::to_string).collect()
295 };
296
297 for part in &ns_parts {
298 ctx.scope_stack.push(part.clone());
299 }
300
301 for ns_child in child.children(&mut child.walk()) {
303 if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
304 {
305 for body_child in ns_child.children(&mut ns_child.walk()) {
306 walk_ast(body_child, content, ctx, guard)?;
307 }
308 }
309 }
310
311 for _ in 0..ns_parts.len() {
312 ctx.scope_stack.pop();
313 }
314 } else {
315 for _ in 0..active_namespace_parts.len() {
318 ctx.scope_stack.pop();
319 }
320
321 active_namespace_parts = if ns_name.is_empty() {
323 Vec::new()
324 } else {
325 ns_name.split('\\').map(ToString::to_string).collect()
326 };
327
328 for part in &active_namespace_parts {
330 ctx.scope_stack.push(part.clone());
331 }
332 }
333 } else {
334 walk_ast(child, content, ctx, guard)?;
336 }
337 }
338
339 for _ in 0..active_namespace_parts.len() {
341 ctx.scope_stack.pop();
342 }
343
344 guard.exit();
345 return Ok(());
346 }
347 "namespace_definition" => {
348 let namespace_name = node
351 .child_by_field_name("name")
352 .and_then(|n| n.utf8_text(content).ok())
353 .map(|s| s.trim().to_string())
354 .unwrap_or_default();
355
356 let namespace_parts: Vec<String> = if namespace_name.is_empty() {
357 Vec::new()
358 } else {
359 namespace_name
360 .split('\\')
361 .map(ToString::to_string)
362 .collect()
363 };
364
365 let parts_count = namespace_parts.len();
366 for part in &namespace_parts {
367 ctx.scope_stack.push(part.clone());
368 }
369
370 let mut cursor = node.walk();
372 for child in node.children(&mut cursor) {
373 if matches!(child.kind(), "compound_statement" | "declaration_list") {
374 let mut body_cursor = child.walk();
375 for body_child in child.children(&mut body_cursor) {
376 walk_ast(body_child, content, ctx, guard)?;
377 }
378 }
379 }
380
381 for _ in 0..parts_count {
383 ctx.scope_stack.pop();
384 }
385 }
386 "class_declaration" => {
387 let name_node = node
388 .child_by_field_name("name")
389 .ok_or_else(|| "class_declaration missing name".to_string())?;
390 let class_name = name_node
391 .utf8_text(content)
392 .map_err(|_| "failed to read class name".to_string())?;
393
394 let qualified_class = if ctx.scope_stack.is_empty() {
396 class_name.to_string()
397 } else {
398 format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
399 };
400
401 ctx.class_stack.push(qualified_class.clone());
402 ctx.scope_stack.push(class_name.to_string());
403
404 let _context_idx = ctx.contexts.len();
406 ctx.contexts.push(CallContext {
407 qualified_name: qualified_class.clone(),
408 span: (node.start_byte(), node.end_byte()),
409 kind: ContextKind::Class,
410 class_name: Some(qualified_class),
411 return_type: None, });
413
414 let mut cursor = node.walk();
416 for child in node.children(&mut cursor) {
417 if child.kind() == "declaration_list" {
418 let mut body_cursor = child.walk();
419 for body_child in child.children(&mut body_cursor) {
420 walk_ast(body_child, content, ctx, guard)?;
421 }
422 }
423 }
424
425 ctx.class_stack.pop();
426 ctx.scope_stack.pop();
427 }
428 "function_definition" | "method_declaration" => {
429 let name_node = node
430 .child_by_field_name("name")
431 .ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
432 let func_name = name_node
433 .utf8_text(content)
434 .map_err(|_| "failed to read function name".to_string())?;
435
436 let is_async = false; let is_static = node
441 .children(&mut node.walk())
442 .any(|child| child.kind() == "static_modifier");
443
444 let visibility = extract_visibility(&node, content);
446
447 let return_type = extract_return_type(&node, content);
449
450 let is_method = !ctx.class_stack.is_empty();
452 let class_name = ctx.class_stack.last().cloned();
453
454 let qualified_func = if is_method {
458 if let Some(ref class) = class_name {
460 format!("{class}::{func_name}")
461 } else {
462 func_name.to_string()
463 }
464 } else {
465 if ctx.scope_stack.is_empty() {
467 func_name.to_string()
468 } else {
469 format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
470 }
471 };
472
473 let kind = if is_method {
474 ContextKind::Method {
475 is_async,
476 is_static,
477 visibility: visibility.clone(),
478 }
479 } else {
480 ContextKind::Function { is_async }
481 };
482
483 let context_idx = ctx.contexts.len();
484 ctx.contexts.push(CallContext {
485 qualified_name: qualified_func.clone(),
486 span: (node.start_byte(), node.end_byte()),
487 kind,
488 class_name,
489 return_type,
490 });
491
492 if let Some(body) = node.child_by_field_name("body") {
494 associate_descendants(body, context_idx, ctx.node_to_context);
495 }
496
497 ctx.scope_stack.push(func_name.to_string());
498
499 if let Some(body) = node.child_by_field_name("body") {
501 let mut cursor = body.walk();
502 for child in body.children(&mut cursor) {
503 walk_ast(child, content, ctx, guard)?;
504 }
505 }
506
507 ctx.scope_stack.pop();
508 }
509 _ => {
510 let mut cursor = node.walk();
512 for child in node.children(&mut cursor) {
513 walk_ast(child, content, ctx, guard)?;
514 }
515 }
516 }
517
518 guard.exit();
519 Ok(())
520}
521
522fn associate_descendants(
523 node: Node,
524 context_idx: usize,
525 node_to_context: &mut HashMap<usize, usize>,
526) {
527 node_to_context.insert(node.id(), context_idx);
528
529 let mut stack = vec![node];
530 while let Some(current) = stack.pop() {
531 node_to_context.insert(current.id(), context_idx);
532
533 let mut cursor = current.walk();
534 for child in current.children(&mut cursor) {
535 stack.push(child);
536 }
537 }
538}
539
540#[allow(clippy::only_used_in_recursion)]
546fn walk_tree_for_edges(
547 node: Node,
548 content: &[u8],
549 ast_graph: &ASTGraph,
550 helper: &mut GraphBuildHelper,
551 node_map: &mut HashMap<String, NodeId>,
552) -> GraphResult<()> {
553 match node.kind() {
554 "function_call_expression" => {
555 process_function_call(node, content, ast_graph, helper, node_map);
556 }
557 "member_call_expression" | "nullsafe_member_call_expression" => {
558 process_member_call(node, content, ast_graph, helper, node_map);
559 }
560 "scoped_call_expression" => {
561 process_static_call(node, content, ast_graph, helper, node_map);
562 }
563 "namespace_use_declaration" => {
565 process_namespace_use(node, content, helper);
566 }
567 "expression_statement" => {
569 let mut cursor = node.walk();
571 for child in node.children(&mut cursor) {
572 match child.kind() {
573 "require_expression"
574 | "require_once_expression"
575 | "include_expression"
576 | "include_once_expression" => {
577 process_file_include(child, content, helper);
578 }
579 _ => {}
580 }
581 }
582 }
583 _ => {}
584 }
585
586 let mut cursor = node.walk();
588 for child in node.children(&mut cursor) {
589 walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
590 }
591
592 Ok(())
593}
594
595fn process_function_call(
596 node: Node,
597 content: &[u8],
598 ast_graph: &ASTGraph,
599 helper: &mut GraphBuildHelper,
600 node_map: &mut HashMap<String, NodeId>,
601) {
602 let Some(function_node) = node.child_by_field_name("function") else {
603 return;
604 };
605
606 let Ok(callee_name) = function_node.utf8_text(content) else {
607 return;
608 };
609
610 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
612 return;
613 };
614
615 let source_id = *node_map
617 .entry(call_context.qualified_name.clone())
618 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
619
620 let call_span = span_from_node(node);
622 let target_id = *node_map
623 .entry(callee_name.to_string())
624 .or_insert_with(|| helper.ensure_callee(callee_name, call_span, CalleeKindHint::Function));
625
626 let argument_count = count_call_arguments(node);
627 helper.add_call_edge_full_with_span(
628 source_id,
629 target_id,
630 argument_count,
631 false,
632 vec![call_span],
633 );
634}
635
636fn process_member_call(
637 node: Node,
638 content: &[u8],
639 ast_graph: &ASTGraph,
640 helper: &mut GraphBuildHelper,
641 node_map: &mut HashMap<String, NodeId>,
642) {
643 let Some(method_node) = node.child_by_field_name("name") else {
644 return;
645 };
646
647 let Ok(method_name) = method_node.utf8_text(content) else {
648 return;
649 };
650
651 if let Some(object_node) = node.child_by_field_name("object")
653 && is_php_ffi_call(object_node, content)
654 {
655 process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
656 return;
657 }
658
659 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
661 return;
662 };
663
664 let callee_qualified = if let Some(class_name) = &call_context.class_name {
666 format!("{class_name}::{method_name}")
667 } else {
668 method_name.to_string()
669 };
670
671 let source_id = *node_map
673 .entry(call_context.qualified_name.clone())
674 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
675
676 let call_span = span_from_node(node);
678 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
679 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
680 });
681
682 let argument_count = count_call_arguments(node);
683 helper.add_call_edge_full_with_span(
684 source_id,
685 target_id,
686 argument_count,
687 false,
688 vec![call_span],
689 );
690}
691
692fn process_static_call(
693 node: Node,
694 content: &[u8],
695 ast_graph: &ASTGraph,
696 helper: &mut GraphBuildHelper,
697 node_map: &mut HashMap<String, NodeId>,
698) {
699 let Some(scope_node) = node.child_by_field_name("scope") else {
700 return;
701 };
702 let Some(name_node) = node.child_by_field_name("name") else {
703 return;
704 };
705
706 let Ok(class_name) = scope_node.utf8_text(content) else {
707 return;
708 };
709 let Ok(method_name) = name_node.utf8_text(content) else {
710 return;
711 };
712
713 if is_ffi_static_call(class_name, method_name) {
715 process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
716 return;
717 }
718
719 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
721 return;
722 };
723
724 let callee_qualified = format!("{class_name}::{method_name}");
726
727 let source_id = *node_map
729 .entry(call_context.qualified_name.clone())
730 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
731
732 let call_span = span_from_node(node);
734 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
735 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
736 });
737
738 let argument_count = count_call_arguments(node);
739 helper.add_call_edge_full_with_span(
740 source_id,
741 target_id,
742 argument_count,
743 false,
744 vec![call_span],
745 );
746}
747
748fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
761 let file_path = helper.file_path().to_string();
763 let importer_id = helper.add_module(&file_path, None);
764
765 let mut prefix = String::new();
768 let mut cursor = node.walk();
769 for child in node.children(&mut cursor) {
770 if child.kind() == "namespace_name"
771 && let Ok(ns) = child.utf8_text(content)
772 {
773 prefix = ns.trim().to_string();
774 break;
775 }
776 }
777
778 cursor = node.walk();
780 for child in node.children(&mut cursor) {
781 match child.kind() {
782 "namespace_use_clause" => {
783 process_use_clause(child, content, helper, importer_id);
785 }
786 "namespace_use_group" => {
787 process_use_group(child, content, helper, importer_id, &prefix);
790 }
791 _ => {}
792 }
793 }
794}
795
796fn process_use_clause(
808 node: Node,
809 content: &[u8],
810 helper: &mut GraphBuildHelper,
811 import_source_id: NodeId,
812) {
813 process_use_clause_with_prefix(node, content, helper, import_source_id, None);
814}
815
816fn process_use_clause_with_prefix(
818 node: Node,
819 content: &[u8],
820 helper: &mut GraphBuildHelper,
821 import_source_id: NodeId,
822 prefix: Option<&str>,
823) {
824 let mut qualified_name = None;
826 let mut alias = None;
827 let mut found_as = false;
828
829 let mut cursor = node.walk();
830 for child in node.children(&mut cursor) {
831 match child.kind() {
832 "qualified_name" => {
833 if let Ok(name) = child.utf8_text(content) {
835 qualified_name = Some(name.trim().to_string());
836 }
837 }
838 "namespace_name" => {
839 if qualified_name.is_none()
841 && let Ok(name) = child.utf8_text(content)
842 {
843 qualified_name = Some(name.trim().to_string());
844 }
845 }
846 "name" => {
847 if found_as {
849 if let Ok(alias_text) = child.utf8_text(content) {
851 alias = Some(alias_text.trim().to_string());
852 }
853 } else if qualified_name.is_none() {
854 if let Ok(name) = child.utf8_text(content) {
856 qualified_name = Some(name.trim().to_string());
857 }
858 }
859 }
860 "as" => {
861 found_as = true;
863 }
864 _ => {}
865 }
866 }
867
868 if let Some(name) = qualified_name
869 && !name.is_empty()
870 {
871 let full_name = if let Some(pfx) = prefix {
873 format!("{pfx}\\{name}")
874 } else {
875 name
876 };
877
878 let span = span_from_node(node);
880 let import_node_id = helper.add_import(&full_name, Some(span));
881
882 if let Some(alias_str) = alias {
884 helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
885 } else {
886 helper.add_import_edge(import_source_id, import_node_id);
887 }
888 }
889}
890
891fn process_use_group(
910 node: Node,
911 content: &[u8],
912 helper: &mut GraphBuildHelper,
913 import_source_id: NodeId,
914 prefix: &str,
915) {
916 let mut cursor = node.walk();
918 for child in node.children(&mut cursor) {
919 if child.kind() == "namespace_use_clause" {
921 process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
923 }
924 }
925}
926
927fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
929 let file_path = helper.file_path().to_string();
931 let import_source_id = helper.add_module(&file_path, None);
932
933 let mut cursor = node.walk();
936 for child in node.children(&mut cursor) {
937 if child.kind() == "string"
938 || child.kind() == "encapsed_string"
939 || child.kind() == "binary_expression"
940 {
941 if let Ok(path_text) = child.utf8_text(content) {
942 let cleaned_path = path_text
944 .trim()
945 .trim_start_matches(['\'', '"'])
946 .trim_end_matches(['\'', '"'])
947 .to_string();
948
949 if !cleaned_path.is_empty() {
950 let span = span_from_node(node);
951 let import_node_id = helper.add_import(&cleaned_path, Some(span));
952 helper.add_import_edge(import_source_id, import_node_id);
953 }
954 }
955 break;
956 }
957 }
958}
959
960fn process_oop_relationships(
966 node: Node,
967 content: &[u8],
968 helper: &mut GraphBuildHelper,
969 node_map: &mut HashMap<String, NodeId>,
970) {
971 let kind = node.kind();
972 if kind == "class_declaration" {
973 process_class_oop(node, content, helper, node_map);
974 } else if kind == "interface_declaration" {
975 process_interface_inheritance(node, content, helper, node_map);
976 }
977
978 let mut cursor = node.walk();
980 for child in node.children(&mut cursor) {
981 process_oop_relationships(child, content, helper, node_map);
982 }
983}
984
985fn process_class_oop(
987 node: Node,
988 content: &[u8],
989 helper: &mut GraphBuildHelper,
990 node_map: &mut HashMap<String, NodeId>,
991) {
992 let Some(name_node) = node.child_by_field_name("name") else {
994 return;
995 };
996 let Ok(class_name) = name_node.utf8_text(content) else {
997 return;
998 };
999 let class_name = class_name.trim();
1000
1001 let span = span_from_node(node);
1003 let class_id = *node_map
1004 .entry(class_name.to_string())
1005 .or_insert_with(|| helper.add_class(class_name, Some(span)));
1006
1007 let mut cursor = node.walk();
1009 for child in node.children(&mut cursor) {
1010 match child.kind() {
1011 "base_clause" => {
1012 process_extends_clause(child, content, helper, node_map, class_id);
1014 }
1015 "class_interface_clause" => {
1016 process_implements_clause(child, content, helper, node_map, class_id);
1018 }
1019 "declaration_list" => {
1020 process_class_body_traits(child, content, helper, node_map, class_id);
1022 }
1023 _ => {}
1024 }
1025 }
1026}
1027
1028fn process_extends_clause(
1030 node: Node,
1031 content: &[u8],
1032 helper: &mut GraphBuildHelper,
1033 node_map: &mut HashMap<String, NodeId>,
1034 class_id: NodeId,
1035) {
1036 let mut cursor = node.walk();
1038 for child in node.children(&mut cursor) {
1039 if child.kind() == "name"
1040 || child.kind() == "qualified_name"
1041 || child.kind() == "namespace_name"
1042 {
1043 if let Ok(parent_name) = child.utf8_text(content) {
1044 let parent_name = parent_name.trim();
1045 if !parent_name.is_empty() {
1046 let span = span_from_node(child);
1047 let parent_id = *node_map
1048 .entry(parent_name.to_string())
1049 .or_insert_with(|| helper.add_class(parent_name, Some(span)));
1050
1051 helper.add_inherits_edge(class_id, parent_id);
1052 }
1053 }
1054 break;
1055 }
1056 }
1057}
1058
1059fn process_implements_clause(
1061 node: Node,
1062 content: &[u8],
1063 helper: &mut GraphBuildHelper,
1064 node_map: &mut HashMap<String, NodeId>,
1065 class_id: NodeId,
1066) {
1067 let mut cursor = node.walk();
1069 for child in node.children(&mut cursor) {
1070 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1071 && let Ok(interface_name) = child.utf8_text(content)
1072 {
1073 let interface_name = interface_name.trim();
1074 if !interface_name.is_empty() {
1075 let span = span_from_node(child);
1076 let interface_id = *node_map
1077 .entry(interface_name.to_string())
1078 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1079
1080 helper.add_implements_edge(class_id, interface_id);
1081 }
1082 }
1083 }
1084}
1085
1086fn process_class_body_traits(
1088 declaration_list: Node,
1089 content: &[u8],
1090 helper: &mut GraphBuildHelper,
1091 node_map: &mut HashMap<String, NodeId>,
1092 class_id: NodeId,
1093) {
1094 let mut cursor = declaration_list.walk();
1095 for child in declaration_list.children(&mut cursor) {
1096 if child.kind() == "use_declaration" {
1097 process_trait_use(child, content, helper, node_map, class_id);
1099 }
1100 }
1101}
1102
1103fn process_trait_use(
1105 node: Node,
1106 content: &[u8],
1107 helper: &mut GraphBuildHelper,
1108 node_map: &mut HashMap<String, NodeId>,
1109 class_id: NodeId,
1110) {
1111 let mut cursor = node.walk();
1113 for child in node.children(&mut cursor) {
1114 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1115 && let Ok(trait_name) = child.utf8_text(content)
1116 {
1117 let trait_name = trait_name.trim();
1118 if !trait_name.is_empty() {
1119 let span = span_from_node(child);
1120 let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
1123 helper.add_node(
1124 trait_name,
1125 Some(span),
1126 sqry_core::graph::unified::node::NodeKind::Trait,
1127 )
1128 });
1129
1130 helper.add_implements_edge(class_id, trait_id);
1133 }
1134 }
1135 }
1136}
1137
1138fn process_interface_inheritance(
1140 node: Node,
1141 content: &[u8],
1142 helper: &mut GraphBuildHelper,
1143 node_map: &mut HashMap<String, NodeId>,
1144) {
1145 let Some(name_node) = node.child_by_field_name("name") else {
1147 return;
1148 };
1149 let Ok(interface_name) = name_node.utf8_text(content) else {
1150 return;
1151 };
1152 let interface_name = interface_name.trim();
1153
1154 let span = span_from_node(node);
1156 let interface_id = *node_map
1157 .entry(interface_name.to_string())
1158 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1159
1160 let mut cursor = node.walk();
1162 for child in node.children(&mut cursor) {
1163 if child.kind() == "base_clause" {
1164 let mut base_cursor = child.walk();
1166 for base_child in child.children(&mut base_cursor) {
1167 if matches!(
1168 base_child.kind(),
1169 "name" | "qualified_name" | "namespace_name"
1170 ) && let Ok(parent_name) = base_child.utf8_text(content)
1171 {
1172 let parent_name = parent_name.trim();
1173 if !parent_name.is_empty() {
1174 let span = span_from_node(base_child);
1175 let parent_id = *node_map
1176 .entry(parent_name.to_string())
1177 .or_insert_with(|| helper.add_interface(parent_name, Some(span)));
1178
1179 helper.add_inherits_edge(interface_id, parent_id);
1181 }
1182 }
1183 }
1184 }
1185 }
1186}
1187
1188fn process_exports(
1207 node: Node,
1208 content: &[u8],
1209 helper: &mut GraphBuildHelper,
1210 node_map: &mut HashMap<String, NodeId>,
1211) {
1212 let file_path = helper.file_path().to_string();
1214 let module_id = helper.add_module(&file_path, None);
1215
1216 if node.kind() != "program" {
1218 return;
1219 }
1220
1221 let mut active_namespace = String::new();
1223
1224 let mut cursor = node.walk();
1226 for child in node.children(&mut cursor) {
1227 process_top_level_for_export(
1228 child,
1229 content,
1230 helper,
1231 node_map,
1232 module_id,
1233 &mut active_namespace,
1234 );
1235 }
1236}
1237
1238fn process_top_level_for_export(
1248 node: Node,
1249 content: &[u8],
1250 helper: &mut GraphBuildHelper,
1251 node_map: &mut HashMap<String, NodeId>,
1252 module_id: NodeId,
1253 active_namespace: &mut String,
1254) {
1255 match node.kind() {
1256 "namespace_definition" => {
1257 let ns_name = node
1259 .child_by_field_name("name")
1260 .and_then(|n| n.utf8_text(content).ok())
1261 .map(|s| s.trim().to_string())
1262 .unwrap_or_default();
1263
1264 let has_body = node
1266 .children(&mut node.walk())
1267 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
1268
1269 if has_body {
1270 active_namespace.clear();
1276
1277 let mut cursor = node.walk();
1279 for child in node.children(&mut cursor) {
1280 if matches!(child.kind(), "compound_statement" | "declaration_list") {
1281 let mut body_cursor = child.walk();
1282 for body_child in child.children(&mut body_cursor) {
1283 export_declaration_if_exportable(
1284 body_child, content, helper, node_map, module_id, &ns_name,
1285 );
1286 }
1287 }
1288 }
1289 } else {
1290 *active_namespace = ns_name;
1293 }
1294 }
1295 "class_declaration"
1297 | "interface_declaration"
1298 | "trait_declaration"
1299 | "enum_declaration"
1300 | "function_definition" => {
1301 export_declaration_if_exportable(
1302 node,
1303 content,
1304 helper,
1305 node_map,
1306 module_id,
1307 active_namespace,
1308 );
1309 }
1310 _ => {
1311 }
1314 }
1315}
1316
1317fn lookup_or_create_node<F>(
1324 node_map: &mut HashMap<String, NodeId>,
1325 qualified_name: &str,
1326 simple_name: &str,
1327 namespace_prefix: &str,
1328 create_fn: F,
1329) -> NodeId
1330where
1331 F: FnOnce() -> NodeId,
1332{
1333 if let Some(&id) = node_map.get(qualified_name) {
1335 return id;
1336 }
1337
1338 if namespace_prefix.is_empty()
1341 && let Some(&id) = node_map.get(simple_name)
1342 {
1343 return id;
1344 }
1345
1346 let id = create_fn();
1348 node_map.insert(qualified_name.to_string(), id);
1349 id
1350}
1351
1352#[allow(clippy::too_many_lines)] fn export_declaration_if_exportable(
1367 node: Node,
1368 content: &[u8],
1369 helper: &mut GraphBuildHelper,
1370 node_map: &mut HashMap<String, NodeId>,
1371 module_id: NodeId,
1372 namespace_prefix: &str,
1373) {
1374 match node.kind() {
1375 "class_declaration" => {
1376 if let Some(name_node) = node.child_by_field_name("name")
1377 && let Ok(class_name) = name_node.utf8_text(content)
1378 {
1379 let simple_name = class_name.trim().to_string();
1380 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1381 let span = span_from_node(node);
1382
1383 let class_id = lookup_or_create_node(
1384 node_map,
1385 &qualified_name,
1386 &simple_name,
1387 namespace_prefix,
1388 || helper.add_class(&qualified_name, Some(span)),
1389 );
1390
1391 helper.add_export_edge(module_id, class_id);
1392
1393 export_public_methods_from_class(
1395 node,
1396 content,
1397 helper,
1398 node_map,
1399 module_id,
1400 &qualified_name,
1401 );
1402 }
1403 }
1404 "interface_declaration" => {
1405 if let Some(name_node) = node.child_by_field_name("name")
1406 && let Ok(interface_name) = name_node.utf8_text(content)
1407 {
1408 let simple_name = interface_name.trim().to_string();
1409 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1410 let span = span_from_node(node);
1411
1412 let interface_id = lookup_or_create_node(
1413 node_map,
1414 &qualified_name,
1415 &simple_name,
1416 namespace_prefix,
1417 || helper.add_interface(&qualified_name, Some(span)),
1418 );
1419
1420 helper.add_export_edge(module_id, interface_id);
1421 }
1422 }
1423 "trait_declaration" => {
1424 if let Some(name_node) = node.child_by_field_name("name")
1425 && let Ok(trait_name) = name_node.utf8_text(content)
1426 {
1427 let simple_name = trait_name.trim().to_string();
1428 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1429 let span = span_from_node(node);
1430
1431 let trait_id = lookup_or_create_node(
1432 node_map,
1433 &qualified_name,
1434 &simple_name,
1435 namespace_prefix,
1436 || {
1437 helper.add_node(
1438 &qualified_name,
1439 Some(span),
1440 sqry_core::graph::unified::node::NodeKind::Trait,
1441 )
1442 },
1443 );
1444
1445 helper.add_export_edge(module_id, trait_id);
1446 }
1447 }
1448 "enum_declaration" => {
1449 if let Some(name_node) = node.child_by_field_name("name")
1451 && let Ok(enum_name) = name_node.utf8_text(content)
1452 {
1453 let simple_name = enum_name.trim().to_string();
1454 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1455 let span = span_from_node(node);
1456
1457 let enum_id = lookup_or_create_node(
1458 node_map,
1459 &qualified_name,
1460 &simple_name,
1461 namespace_prefix,
1462 || helper.add_enum(&qualified_name, Some(span)),
1463 );
1464
1465 helper.add_export_edge(module_id, enum_id);
1466 }
1467 }
1468 "function_definition" => {
1469 if let Some(name_node) = node.child_by_field_name("name")
1471 && let Ok(func_name) = name_node.utf8_text(content)
1472 {
1473 let simple_name = func_name.trim().to_string();
1474 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1475 let span = span_from_node(node);
1476
1477 let func_id = lookup_or_create_node(
1478 node_map,
1479 &qualified_name,
1480 &simple_name,
1481 namespace_prefix,
1482 || helper.add_function(&qualified_name, Some(span), false, false),
1483 );
1484
1485 helper.add_export_edge(module_id, func_id);
1486 }
1487 }
1488 _ => {
1489 }
1491 }
1492}
1493
1494fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
1496 if namespace_prefix.is_empty() {
1497 name.to_string()
1498 } else {
1499 format!("{namespace_prefix}\\{name}")
1500 }
1501}
1502
1503fn span_from_node(node: Node<'_>) -> Span {
1505 let start = node.start_position();
1506 let end = node.end_position();
1507 Span::new(
1508 sqry_core::graph::node::Position::new(start.row, start.column),
1509 sqry_core::graph::node::Position::new(end.row, end.column),
1510 )
1511}
1512
1513fn count_call_arguments(call_node: Node<'_>) -> u8 {
1514 let args_node = call_node
1515 .child_by_field_name("arguments")
1516 .or_else(|| call_node.child_by_field_name("argument_list"))
1517 .or_else(|| {
1518 let mut cursor = call_node.walk();
1519 call_node
1520 .children(&mut cursor)
1521 .find(|child| child.kind() == "argument_list")
1522 });
1523
1524 let Some(args_node) = args_node else {
1525 return 255;
1526 };
1527 let count = args_node.named_child_count();
1528 if count <= 254 {
1529 u8::try_from(count).unwrap_or(u8::MAX)
1530 } else {
1531 255
1532 }
1533}
1534
1535fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
1540 let mut cursor = node.walk();
1542 for child in node.children(&mut cursor) {
1543 match child.kind() {
1544 "visibility_modifier" => {
1545 if let Ok(vis_text) = child.utf8_text(content) {
1547 return Some(vis_text.trim().to_string());
1548 }
1549 }
1550 "public" | "private" | "protected" => {
1551 if let Ok(vis_text) = child.utf8_text(content) {
1553 return Some(vis_text.trim().to_string());
1554 }
1555 }
1556 _ => {}
1557 }
1558 }
1559
1560 None
1564}
1565
1566fn export_public_methods_from_class(
1572 class_node: Node,
1573 content: &[u8],
1574 helper: &mut GraphBuildHelper,
1575 node_map: &mut HashMap<String, NodeId>,
1576 module_id: NodeId,
1577 class_qualified_name: &str,
1578) {
1579 let mut cursor = class_node.walk();
1581 for child in class_node.children(&mut cursor) {
1582 if child.kind() == "declaration_list" {
1583 let mut body_cursor = child.walk();
1585 for body_child in child.children(&mut body_cursor) {
1586 if body_child.kind() == "method_declaration" {
1587 let visibility = extract_visibility(&body_child, content);
1589
1590 let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
1592
1593 if is_public {
1594 if let Some(name_node) = body_child.child_by_field_name("name")
1596 && let Ok(method_name) = name_node.utf8_text(content)
1597 {
1598 let method_name = method_name.trim();
1599 let qualified_method_name =
1600 format!("{class_qualified_name}::{method_name}");
1601
1602 if let Some(&method_id) = node_map.get(&qualified_method_name) {
1604 helper.add_export_edge(module_id, method_id);
1605 }
1606 }
1607 }
1608 }
1609 }
1610 break;
1611 }
1612 }
1613}
1614
1615fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
1634 let mut found_colon = false;
1636 let mut cursor = node.walk();
1637 for child in node.children(&mut cursor) {
1638 if found_colon && child.is_named() {
1639 return extract_type_from_node(&child, content);
1641 }
1642 if child.kind() == ":" {
1643 found_colon = true;
1644 }
1645 }
1646 None
1647}
1648
1649fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
1663 match type_node.kind() {
1664 "primitive_type" => {
1665 type_node
1667 .utf8_text(content)
1668 .ok()
1669 .map(|s| s.trim().to_string())
1670 }
1671 "optional_type" => {
1672 let mut cursor = type_node.walk();
1675 for child in type_node.children(&mut cursor) {
1676 if child.kind() != "?" && child.is_named() {
1677 return extract_type_from_node(&child, content);
1678 }
1679 }
1680 None
1681 }
1682 "union_type" => {
1683 type_node
1686 .named_child(0)
1687 .and_then(|first_type| extract_type_from_node(&first_type, content))
1688 }
1689 "named_type" | "qualified_name" => {
1690 type_node
1692 .utf8_text(content)
1693 .ok()
1694 .map(|s| s.trim().to_string())
1695 }
1696 "intersection_type" => {
1697 type_node
1700 .named_child(0)
1701 .and_then(|first_type| extract_type_from_node(&first_type, content))
1702 }
1703 _ => {
1704 type_node
1709 .utf8_text(content)
1710 .ok()
1711 .map(|s| {
1712 let trimmed = s.trim();
1713 trimmed
1716 .split(&['|', '&'][..])
1717 .next()
1718 .unwrap_or(trimmed)
1719 .trim()
1720 .trim_start_matches('(')
1721 .trim_end_matches(')')
1722 .trim()
1723 .to_string()
1724 })
1725 .filter(|s| !s.is_empty())
1726 }
1727 }
1728}
1729
1730fn process_phpdoc_annotations(
1753 node: Node,
1754 content: &[u8],
1755 helper: &mut GraphBuildHelper,
1756) -> GraphResult<()> {
1757 let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
1759 process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
1760
1761 process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
1765
1766 Ok(())
1767}
1768
1769fn process_phpdoc_pass_a(
1773 node: Node,
1774 content: &[u8],
1775 helper: &mut GraphBuildHelper,
1776 explicit_field_ids: &mut HashSet<NodeId>,
1777) -> GraphResult<()> {
1778 match node.kind() {
1779 "function_definition" => {
1780 process_function_phpdoc(node, content, helper)?;
1781 }
1782 "method_declaration" => {
1783 process_method_phpdoc(node, content, helper)?;
1786 }
1787 "property_declaration" | "simple_property" => {
1788 let emitted = process_property_declaration(node, content, helper);
1793 explicit_field_ids.extend(emitted);
1794 }
1795 _ => {}
1796 }
1797
1798 let mut cursor = node.walk();
1799 for child in node.children(&mut cursor) {
1800 process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
1801 }
1802
1803 Ok(())
1804}
1805
1806fn process_phpdoc_pass_b(
1812 node: Node,
1813 content: &[u8],
1814 helper: &mut GraphBuildHelper,
1815 explicit_field_ids: &HashSet<NodeId>,
1816) {
1817 if node.kind() == "method_declaration" {
1818 process_constructor_promotion(node, content, helper, explicit_field_ids);
1819 }
1820
1821 let mut cursor = node.walk();
1822 for child in node.children(&mut cursor) {
1823 process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
1824 }
1825}
1826
1827fn process_function_phpdoc(
1829 func_node: Node,
1830 content: &[u8],
1831 helper: &mut GraphBuildHelper,
1832) -> GraphResult<()> {
1833 let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1835 return Ok(());
1836 };
1837
1838 let tags = parse_phpdoc_tags(&phpdoc_text);
1840
1841 let Some(name_node) = func_node.child_by_field_name("name") else {
1843 return Ok(());
1844 };
1845
1846 let function_name = name_node
1847 .utf8_text(content)
1848 .map_err(|_| GraphBuilderError::ParseError {
1849 span: span_from_node(func_node),
1850 reason: "failed to read function name".to_string(),
1851 })?
1852 .trim()
1853 .to_string();
1854
1855 if function_name.is_empty() {
1856 return Ok(());
1857 }
1858
1859 let func_node_id = helper.ensure_callee(
1861 &function_name,
1862 span_from_node(func_node),
1863 CalleeKindHint::Function,
1864 );
1865
1866 let _ast_params = extract_ast_parameters(func_node, content);
1868
1869 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1873 let canonical_type = canonical_type_string(¶m_tag.type_str);
1875 let type_node_id = helper.add_type(&canonical_type, None);
1876 helper.add_typeof_edge_with_context(
1877 func_node_id,
1878 type_node_id,
1879 Some(TypeOfContext::Parameter),
1880 param_idx.try_into().ok(), Some(¶m_tag.name),
1882 );
1883
1884 let type_names = extract_type_names(¶m_tag.type_str);
1886 for type_name in type_names {
1887 let ref_type_id = helper.add_type(&type_name, None);
1888 helper.add_reference_edge(func_node_id, ref_type_id);
1889 }
1890 }
1891
1892 if let Some(return_type) = &tags.returns {
1894 let canonical_type = canonical_type_string(return_type);
1895 let type_node_id = helper.add_type(&canonical_type, None);
1896 helper.add_typeof_edge_with_context(
1897 func_node_id,
1898 type_node_id,
1899 Some(TypeOfContext::Return),
1900 Some(0),
1901 None,
1902 );
1903
1904 let type_names = extract_type_names(return_type);
1906 for type_name in type_names {
1907 let ref_type_id = helper.add_type(&type_name, None);
1908 helper.add_reference_edge(func_node_id, ref_type_id);
1909 }
1910 }
1911
1912 Ok(())
1913}
1914
1915fn process_method_phpdoc(
1917 method_node: Node,
1918 content: &[u8],
1919 helper: &mut GraphBuildHelper,
1920) -> GraphResult<()> {
1921 let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1923 return Ok(());
1924 };
1925
1926 let tags = parse_phpdoc_tags(&phpdoc_text);
1928
1929 let Some(name_node) = method_node.child_by_field_name("name") else {
1931 return Ok(());
1932 };
1933
1934 let method_name = name_node
1935 .utf8_text(content)
1936 .map_err(|_| GraphBuilderError::ParseError {
1937 span: span_from_node(method_node),
1938 reason: "failed to read method name".to_string(),
1939 })?
1940 .trim()
1941 .to_string();
1942
1943 if method_name.is_empty() {
1944 return Ok(());
1945 }
1946
1947 let class_name = get_enclosing_class_name(method_node, content)?;
1949 let Some(class_name) = class_name else {
1950 return Ok(());
1951 };
1952
1953 let qualified_name = format!("{class_name}.{method_name}");
1955
1956 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1959
1960 let _ast_params = extract_ast_parameters(method_node, content);
1962
1963 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1966 let canonical_type = canonical_type_string(¶m_tag.type_str);
1968 let type_node_id = helper.add_type(&canonical_type, None);
1969 helper.add_typeof_edge_with_context(
1970 method_node_id,
1971 type_node_id,
1972 Some(TypeOfContext::Parameter),
1973 param_idx.try_into().ok(),
1974 Some(¶m_tag.name),
1975 );
1976
1977 let type_names = extract_type_names(¶m_tag.type_str);
1979 for type_name in type_names {
1980 let ref_type_id = helper.add_type(&type_name, None);
1981 helper.add_reference_edge(method_node_id, ref_type_id);
1982 }
1983 }
1984
1985 if let Some(return_type) = &tags.returns {
1987 let canonical_type = canonical_type_string(return_type);
1988 let type_node_id = helper.add_type(&canonical_type, None);
1989 helper.add_typeof_edge_with_context(
1990 method_node_id,
1991 type_node_id,
1992 Some(TypeOfContext::Return),
1993 Some(0),
1994 None,
1995 );
1996
1997 let type_names = extract_type_names(return_type);
1999 for type_name in type_names {
2000 let ref_type_id = helper.add_type(&type_name, None);
2001 helper.add_reference_edge(method_node_id, ref_type_id);
2002 }
2003 }
2004
2005 Ok(())
2006}
2007
2008fn process_property_declaration(
2029 prop_node: Node,
2030 content: &[u8],
2031 helper: &mut GraphBuildHelper,
2032) -> Vec<NodeId> {
2033 let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
2037 return Vec::new();
2038 };
2039
2040 let mods = extract_property_modifiers(prop_node, content);
2042
2043 let native_type = prop_node
2046 .child_by_field_name("type")
2047 .and_then(|t| extract_type_from_node(&t, content));
2048
2049 let phpdoc_var_type = if native_type.is_none() {
2051 extract_phpdoc_comment(prop_node, content)
2052 .as_deref()
2053 .and_then(|c| parse_phpdoc_tags(c).var_type)
2054 } else {
2055 None
2056 };
2057
2058 let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
2059
2060 let prop_names = extract_property_element_names(prop_node, content);
2061 if prop_names.is_empty() {
2062 return Vec::new();
2063 }
2064
2065 let span = span_from_node(prop_node);
2066 let mut emitted = Vec::with_capacity(prop_names.len());
2067
2068 for prop_name in prop_names {
2069 let qualified_name = format!("{owner_name}.{prop_name}");
2070 let visibility = mods.visibility.as_deref().unwrap_or("public");
2071
2072 let node_id = if mods.is_readonly {
2073 helper.add_constant_with_name_static_and_visibility(
2074 &prop_name,
2075 &qualified_name,
2076 Some(span),
2077 mods.is_static,
2078 Some(visibility),
2079 )
2080 } else {
2081 helper.add_property_with_name_static_and_visibility(
2082 &prop_name,
2083 &qualified_name,
2084 Some(span),
2085 mods.is_static,
2086 Some(visibility),
2087 )
2088 };
2089
2090 if let Some(type_str) = primary_type.as_deref() {
2091 emit_field_type_edges(helper, node_id, &prop_name, type_str);
2092 }
2093
2094 emitted.push(node_id);
2095 }
2096
2097 emitted
2098}
2099
2100fn process_constructor_promotion(
2120 method_node: Node,
2121 content: &[u8],
2122 helper: &mut GraphBuildHelper,
2123 explicit_field_ids: &HashSet<NodeId>,
2124) {
2125 let Some(name_node) = method_node.child_by_field_name("name") else {
2127 return;
2128 };
2129 let Ok(method_name) = name_node.utf8_text(content) else {
2130 return;
2131 };
2132 if method_name.trim() != "__construct" {
2133 return;
2134 }
2135
2136 let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
2137 return;
2138 };
2139
2140 let Some(params_node) = method_node.child_by_field_name("parameters") else {
2141 return;
2142 };
2143
2144 let mut cursor = params_node.walk();
2145 for param in params_node.children(&mut cursor) {
2146 if param.kind() != "property_promotion_parameter" {
2147 continue;
2148 }
2149
2150 let visibility = param
2152 .child_by_field_name("visibility")
2153 .and_then(|v| v.utf8_text(content).ok())
2154 .map(|s| s.trim().to_string());
2155 let is_readonly = param.child_by_field_name("readonly").is_some()
2156 || direct_child_of_kind(param, "readonly_modifier").is_some();
2157 let is_static = false;
2160 let native_type = param
2161 .child_by_field_name("type")
2162 .and_then(|t| extract_type_from_node(&t, content));
2163
2164 let Some(prop_name) = promoted_param_name(param, content) else {
2165 continue;
2166 };
2167
2168 let qualified_name = format!("{owner_name}.{prop_name}");
2169 let span = span_from_node(param);
2170
2171 if let Some(existing_id) = helper.get_node(&qualified_name) {
2176 if explicit_field_ids.contains(&existing_id) {
2177 continue;
2182 }
2183 if let Some(t) = native_type {
2189 emit_field_type_edges(helper, existing_id, &prop_name, &t);
2190 }
2191 continue;
2192 }
2193
2194 let visibility_ref = visibility.as_deref().unwrap_or("public");
2195 let node_id = if is_readonly {
2196 helper.add_constant_with_name_static_and_visibility(
2197 &prop_name,
2198 &qualified_name,
2199 Some(span),
2200 is_static,
2201 Some(visibility_ref),
2202 )
2203 } else {
2204 helper.add_property_with_name_static_and_visibility(
2205 &prop_name,
2206 &qualified_name,
2207 Some(span),
2208 is_static,
2209 Some(visibility_ref),
2210 )
2211 };
2212
2213 if let Some(type_str) = native_type {
2214 emit_field_type_edges(helper, node_id, &prop_name, &type_str);
2215 }
2216 }
2217}
2218
2219struct PropertyModifiers {
2221 visibility: Option<String>,
2222 is_static: bool,
2223 is_readonly: bool,
2224}
2225
2226fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
2230 let mut visibility: Option<String> = None;
2231 let mut is_static = false;
2232 let mut is_readonly = false;
2233
2234 let mut cursor = prop_node.walk();
2235 for child in prop_node.children(&mut cursor) {
2236 match child.kind() {
2237 "visibility_modifier" => {
2238 if let Ok(text) = child.utf8_text(content) {
2239 visibility = Some(text.trim().to_string());
2240 }
2241 }
2242 "var_modifier" => {
2243 if visibility.is_none() {
2246 visibility = Some("public".to_string());
2247 }
2248 }
2249 "static_modifier" => {
2250 is_static = true;
2251 }
2252 "readonly_modifier" => {
2253 is_readonly = true;
2254 }
2255 _ => {}
2256 }
2257 }
2258
2259 PropertyModifiers {
2260 visibility,
2261 is_static,
2262 is_readonly,
2263 }
2264}
2265
2266fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2270 let mut names = Vec::new();
2271 let mut cursor = prop_node.walk();
2272 for child in prop_node.children(&mut cursor) {
2273 if child.kind() != "property_element" {
2274 continue;
2275 }
2276 if let Some(var_node) = child.child_by_field_name("name")
2277 && let Some(name) = strip_dollar_from_variable(var_node, content)
2278 {
2279 names.push(name);
2280 }
2281 }
2282 names
2283}
2284
2285fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
2288 let name_field = param.child_by_field_name("name")?;
2289 let var_node = if name_field.kind() == "variable_name" {
2291 name_field
2292 } else {
2293 let mut cursor = name_field.walk();
2295 name_field
2296 .children(&mut cursor)
2297 .find(|c| c.kind() == "variable_name")?
2298 };
2299 strip_dollar_from_variable(var_node, content)
2300}
2301
2302fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
2304 if let Some(name_node) = var_node.child_by_field_name("name")
2305 && let Ok(text) = name_node.utf8_text(content)
2306 {
2307 return Some(text.trim().to_string());
2308 }
2309 var_node
2310 .utf8_text(content)
2311 .ok()
2312 .map(|s| s.trim().trim_start_matches('$').to_string())
2313}
2314
2315fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
2317 let mut cursor = node.walk();
2318 node.children(&mut cursor).find(|c| c.kind() == kind)
2319}
2320
2321fn emit_field_type_edges(
2324 helper: &mut GraphBuildHelper,
2325 node_id: NodeId,
2326 prop_name: &str,
2327 type_str: &str,
2328) {
2329 let canonical_type = canonical_type_string(type_str);
2330 let type_node_id = helper.add_type(&canonical_type, None);
2331 helper.add_typeof_edge_with_context(
2332 node_id,
2333 type_node_id,
2334 Some(TypeOfContext::Field),
2335 None,
2336 Some(prop_name),
2337 );
2338
2339 for ref_type_name in extract_type_names(type_str) {
2340 let ref_type_id = helper.add_type(&ref_type_name, None);
2341 helper.add_reference_edge(node_id, ref_type_id);
2342 }
2343}
2344
2345fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
2348 let mut current = node;
2349 while let Some(parent) = current.parent() {
2350 if matches!(
2351 parent.kind(),
2352 "class_declaration" | "trait_declaration" | "interface_declaration"
2353 ) {
2354 return parent
2355 .child_by_field_name("name")
2356 .and_then(|n| n.utf8_text(content).ok())
2357 .map(|s| s.trim().to_string());
2358 }
2359 current = parent;
2360 }
2361 None
2362}
2363
2364fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2366 let mut params = Vec::new();
2367
2368 let Some(params_node) = func_node.child_by_field_name("parameters") else {
2370 return params;
2371 };
2372
2373 let mut index = 0;
2374 let mut cursor = params_node.walk();
2375
2376 for child in params_node.children(&mut cursor) {
2377 if !child.is_named() {
2378 continue;
2379 }
2380
2381 match child.kind() {
2382 "simple_parameter" => {
2383 let mut param_cursor = child.walk();
2385 for param_child in child.children(&mut param_cursor) {
2386 if param_child.kind() == "variable_name"
2387 && let Ok(param_text) = param_child.utf8_text(content)
2388 {
2389 params.push((index, param_text.trim().to_string()));
2390 index += 1;
2391 break;
2392 }
2393 }
2394 }
2395 "variadic_parameter" => {
2396 let mut param_cursor = child.walk();
2398 for param_child in child.children(&mut param_cursor) {
2399 if param_child.kind() == "variable_name"
2400 && let Ok(param_text) = param_child.utf8_text(content)
2401 {
2402 params.push((index, param_text.trim().to_string()));
2403 index += 1;
2404 break;
2405 }
2406 }
2407 }
2408 _ => {}
2409 }
2410 }
2411
2412 params
2413}
2414
2415#[allow(clippy::unnecessary_wraps)]
2417fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2418 let mut current = node;
2419
2420 while let Some(parent) = current.parent() {
2422 if parent.kind() == "class_declaration" {
2423 if let Some(name_node) = parent.child_by_field_name("name")
2425 && let Ok(name_text) = name_node.utf8_text(content)
2426 {
2427 return Ok(Some(name_text.trim().to_string()));
2428 }
2429 return Ok(None);
2430 }
2431 current = parent;
2432 }
2433
2434 Ok(None)
2435}
2436
2437fn process_ffi_member_call(
2445 node: Node,
2446 method_name: &str,
2447 ast_graph: &ASTGraph,
2448 helper: &mut GraphBuildHelper,
2449 node_map: &mut HashMap<String, NodeId>,
2450) {
2451 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2453 return;
2454 };
2455
2456 let source_id = *node_map
2458 .entry(call_context.qualified_name.clone())
2459 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2460
2461 let ffi_name = format!("native::ffi::{method_name}");
2463 let call_span = span_from_node(node);
2464 let target_id = helper.add_module(&ffi_name, Some(call_span));
2465
2466 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2468}
2469
2470fn process_ffi_static_call(
2475 node: Node,
2476 method_name: &str,
2477 ast_graph: &ASTGraph,
2478 helper: &mut GraphBuildHelper,
2479 node_map: &mut HashMap<String, NodeId>,
2480 content: &[u8],
2481) {
2482 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2484 return;
2485 };
2486
2487 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 let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2494 .map_or_else(
2495 || "unknown".to_string(),
2496 |lib| php_ffi_library_simple_name(&lib),
2497 );
2498
2499 let ffi_name = format!("native::{library_name}");
2501 let call_span = span_from_node(node);
2502 let target_id = helper.add_module(&ffi_name, Some(call_span));
2503
2504 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2506}
2507
2508fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2521 if object_node.kind() == "scoped_call_expression"
2523 && let Some(scope_node) = object_node.child_by_field_name("scope")
2524 && let Some(name_node) = object_node.child_by_field_name("name")
2525 && let Ok(scope_text) = scope_node.utf8_text(content)
2526 && let Ok(name_text) = name_node.utf8_text(content)
2527 && is_ffi_static_call(scope_text, name_text)
2528 {
2529 return true;
2530 }
2531
2532 if object_node.kind() == "parenthesized_expression"
2534 && let Some(inner) = object_node.named_child(0)
2535 && inner.kind() == "scoped_call_expression"
2536 && let Some(scope_node) = inner.child_by_field_name("scope")
2537 && let Some(name_node) = inner.child_by_field_name("name")
2538 && let Ok(scope_text) = scope_node.utf8_text(content)
2539 && let Ok(name_text) = name_node.utf8_text(content)
2540 && is_ffi_static_call(scope_text, name_text)
2541 {
2542 return true;
2543 }
2544
2545 let Ok(object_text) = object_node.utf8_text(content) else {
2547 return false;
2548 };
2549
2550 let object_text = object_text.trim();
2551
2552 if object_text == "$ffi" || object_text == "$_ffi" {
2554 return true;
2555 }
2556
2557 if object_text.ends_with("->ffi")
2559 || object_text.ends_with("::$ffi")
2560 || object_text.ends_with("->_ffi")
2561 || object_text.ends_with("::$_ffi")
2562 {
2563 return true;
2564 }
2565
2566 false
2567}
2568
2569fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2573 (scope_text == "FFI" || scope_text == "\\FFI")
2574 && (method_text == "cdef" || method_text == "load")
2575}
2576
2577fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2585 let args = call_node.child_by_field_name("arguments")?;
2586
2587 let mut cursor = args.walk();
2588 let args_vec: Vec<Node> = args
2589 .children(&mut cursor)
2590 .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2591 .collect();
2592
2593 let target_arg_name = if is_cdef { "lib" } else { "filename" };
2596
2597 if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2599 return extract_string_from_argument(named_arg, content);
2600 }
2601
2602 if is_cdef {
2604 args_vec
2606 .get(1)
2607 .and_then(|arg| extract_string_from_argument(*arg, content))
2608 } else {
2609 args_vec
2611 .first()
2612 .and_then(|arg| extract_string_from_argument(*arg, content))
2613 }
2614}
2615
2616fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2623 for arg in args {
2624 if arg.kind() != "argument" {
2625 continue;
2626 }
2627
2628 if arg.named_child_count() < 2 {
2631 continue;
2632 }
2633
2634 if let Some(name_node) = arg.child_by_field_name("name")
2636 && let Ok(name_text) = name_node.utf8_text(content)
2637 && name_text == param_name
2638 {
2639 return Some(*arg);
2640 } else if let Some(name_node) = arg.named_child(0)
2641 && let Ok(name_text) = name_node.utf8_text(content)
2642 && name_text == param_name
2643 {
2644 return Some(*arg);
2646 }
2647 }
2648
2649 None
2650}
2651
2652fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2660 let value_node = unwrap_argument_node(arg_node)?;
2662
2663 if !is_string_literal_node(value_node) {
2665 return None;
2666 }
2667
2668 if is_interpolated_string(value_node) {
2670 return None;
2671 }
2672
2673 extract_php_string_content(value_node, content)
2674}
2675
2676fn unwrap_argument_node(node: Node) -> Option<Node> {
2688 if node.kind() != "argument" {
2689 return Some(node);
2691 }
2692
2693 let name_field_node = node.child_by_field_name("name");
2700 let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2701
2702 for i in 0..node.named_child_count() {
2704 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.named_child(i as u32) {
2706 let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2708 let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2709
2710 if !is_name_field && !is_ref_modifier {
2711 return Some(child);
2713 }
2714 }
2715 }
2716
2717 None
2719}
2720
2721fn is_string_literal_node(node: Node) -> bool {
2728 matches!(
2729 node.kind(),
2730 "string" | "encapsed_string" | "heredoc" | "nowdoc"
2731 )
2732}
2733
2734fn is_interpolated_string(node: Node) -> bool {
2747 if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2748 return false;
2749 }
2750
2751 has_variable_node(node)
2753}
2754
2755fn has_variable_node(node: Node) -> bool {
2769 if matches!(
2771 node.kind(),
2772 "variable_name" | "simple_variable" | "variable" | "complex_variable"
2774 | "dynamic_variable_name"
2776 | "subscript_expression" | "member_access_expression" | "member_call_expression"
2778 | "function_call_expression"
2780 | "scoped_call_expression" | "scoped_property_access_expression"
2782 | "class_constant_access_expression"
2784 | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2786 ) {
2787 return true;
2788 }
2789
2790 for i in 0..node.child_count() {
2792 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.child(i as u32)
2794 && has_variable_node(child)
2795 {
2796 return true;
2797 }
2798 }
2799
2800 false
2801}
2802
2803fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2807 let Ok(text) = string_node.utf8_text(content) else {
2808 return None;
2809 };
2810
2811 let text = text.trim();
2812
2813 if ((text.starts_with('"') && text.ends_with('"'))
2815 || (text.starts_with('\'') && text.ends_with('\'')))
2816 && text.len() >= 2
2817 {
2818 return Some(text[1..text.len() - 1].to_string());
2819 }
2820
2821 Some(text.to_string())
2823}
2824
2825fn php_ffi_library_simple_name(library_path: &str) -> String {
2827 use std::path::Path;
2828
2829 let filename = Path::new(library_path)
2831 .file_name()
2832 .and_then(|f| f.to_str())
2833 .unwrap_or(library_path);
2834
2835 if let Some(so_pos) = filename.find(".so.") {
2837 return filename[..so_pos].to_string();
2838 }
2839
2840 if let Some(dot_pos) = filename.find('.') {
2842 let extension = &filename[dot_pos + 1..];
2843 if extension == "so"
2844 || extension == "dll"
2845 || extension == "dylib"
2846 || extension == "h"
2847 || extension == "hpp"
2848 {
2849 return filename[..dot_pos].to_string();
2850 }
2851 }
2852
2853 filename.to_string()
2854}
2855
2856#[cfg(test)]
2861mod field_emission_tests {
2862 use sqry_core::graph::GraphBuilder;
2880 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2881 use sqry_core::graph::unified::build::test_helpers::{
2882 build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
2883 };
2884 use sqry_core::graph::unified::edge::EdgeKind;
2885 use sqry_core::graph::unified::edge::kind::TypeOfContext;
2886 use sqry_core::graph::unified::node::NodeKind;
2887 use std::path::Path;
2888 use tree_sitter::Parser;
2889
2890 use super::PhpGraphBuilder;
2891
2892 fn parse(source: &str) -> tree_sitter::Tree {
2893 let mut parser = Parser::new();
2894 parser
2895 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
2896 .expect("load PHP grammar");
2897 parser.parse(source, None).expect("parse PHP source")
2898 }
2899
2900 fn build(source: &str) -> StagingGraph {
2901 let tree = parse(source);
2902 let mut staging = StagingGraph::new();
2903 let builder = PhpGraphBuilder::default();
2904 builder
2905 .build_graph(
2906 &tree,
2907 source.as_bytes(),
2908 Path::new("test.php"),
2909 &mut staging,
2910 )
2911 .expect("build graph");
2912 staging
2913 }
2914
2915 fn find_node<'a>(
2917 staging: &'a StagingGraph,
2918 name: &str,
2919 kind: Option<NodeKind>,
2920 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2921 let strings = build_string_lookup(staging);
2922 for op in staging.operations() {
2923 if let StagingOp::AddNode { entry, .. } = op {
2924 if let Some(k) = kind
2925 && entry.kind != k
2926 {
2927 continue;
2928 }
2929 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2930 if let Some(s) = strings.get(&name_idx)
2931 && s == name
2932 {
2933 return Some(entry);
2934 }
2935 }
2936 }
2937 None
2938 }
2939
2940 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
2941 let strings = build_string_lookup(staging);
2942 staging
2943 .operations()
2944 .iter()
2945 .filter(|op| {
2946 if let StagingOp::AddNode { entry, .. } = op {
2947 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2948 strings.get(&name_idx).is_some_and(|s| s == name)
2949 } else {
2950 false
2951 }
2952 })
2953 .count()
2954 }
2955
2956 fn resolve_visibility(
2957 staging: &StagingGraph,
2958 vis: Option<sqry_core::graph::unified::StringId>,
2959 ) -> Option<String> {
2960 let strings = build_string_lookup(staging);
2961 vis.and_then(|sid| strings.get(&sid.index()).cloned())
2962 }
2963
2964 fn typeof_edges_for_node(
2965 staging: &StagingGraph,
2966 source_name: &str,
2967 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
2968 let names = build_node_name_lookup(staging);
2969 let strings = build_string_lookup(staging);
2970 let mut out = Vec::new();
2971 for op in staging.operations() {
2972 if let StagingOp::AddEdge {
2973 source,
2974 target,
2975 kind: EdgeKind::TypeOf { context, name, .. },
2976 ..
2977 } = op
2978 {
2979 let src = names.get(source).cloned().unwrap_or_default();
2980 if src != source_name {
2981 continue;
2982 }
2983 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
2984 let target_name = names.get(target).cloned().unwrap_or_default();
2985 out.push((*context, edge_name, target_name));
2986 }
2987 }
2988 out
2989 }
2990
2991 #[test]
2994 fn req_r0001_property_without_phpdoc_emits_property_node() {
2995 let src = "<?php
2996class User {
2997 public string $name;
2998}
2999";
3000 let staging = build(src);
3001 let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
3002 .expect("User.name Property must be emitted without @var");
3003 assert_eq!(entry.kind, NodeKind::Property);
3004 }
3005
3006 #[test]
3007 fn req_r0001_property_with_phpdoc_still_emits_property_node() {
3008 let src = "<?php
3009class Repo {
3010 /** @var string */
3011 public string $label;
3012}
3013";
3014 let staging = build(src);
3015 find_node(&staging, "Repo.label", Some(NodeKind::Property))
3016 .expect("Repo.label Property must be emitted when @var is present");
3017 }
3018
3019 #[test]
3022 fn req_r0002_qualified_name_uses_class_dot_prop() {
3023 let src = "<?php
3024class A { public int $x; }
3025class B { public int $x; }
3026";
3027 let staging = build(src);
3028 find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
3029 find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
3030 assert!(
3031 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3032 "no bare 'x' Property node should leak"
3033 );
3034 }
3035
3036 #[test]
3037 fn req_r0002_visibility_modifiers_round_trip() {
3038 let src = "<?php
3039class V {
3040 public int $a;
3041 private int $b;
3042 protected int $c;
3043 var $d;
3044}
3045";
3046 let staging = build(src);
3047 for (name, expected) in [
3048 ("V.a", "public"),
3049 ("V.b", "private"),
3050 ("V.c", "protected"),
3051 ("V.d", "public"),
3052 ] {
3053 let entry = find_node(&staging, name, Some(NodeKind::Property))
3054 .unwrap_or_else(|| panic!("missing {name}"));
3055 let got = resolve_visibility(&staging, entry.visibility);
3056 assert_eq!(
3057 got.as_deref(),
3058 Some(expected),
3059 "{name} visibility should be {expected}"
3060 );
3061 }
3062 }
3063
3064 #[test]
3065 fn req_r0002_default_visibility_is_public_when_no_modifier() {
3066 let src = "<?php
3068class X { static int $count = 0; }
3069";
3070 let staging = build(src);
3071 let entry =
3072 find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
3073 let vis = resolve_visibility(&staging, entry.visibility);
3074 assert_eq!(
3075 vis.as_deref(),
3076 Some("public"),
3077 "default visibility is public"
3078 );
3079 }
3080
3081 #[test]
3084 fn req_r0003_static_modifier_sets_is_static() {
3085 let src = "<?php
3086class S {
3087 public static int $count = 0;
3088 public int $instance = 0;
3089}
3090";
3091 let staging = build(src);
3092 let s_count =
3093 find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
3094 assert!(s_count.is_static, "S.count should be static");
3095 let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
3096 .expect("S.instance must exist");
3097 assert!(!s_instance.is_static, "S.instance should not be static");
3098 }
3099
3100 #[test]
3103 fn req_r0004_readonly_emits_constant() {
3104 let src = "<?php
3105class R {
3106 public readonly string $id;
3107 public string $name;
3108}
3109";
3110 let staging = build(src);
3111 find_node(&staging, "R.id", Some(NodeKind::Constant))
3112 .expect("R.id must be Constant (readonly)");
3113 find_node(&staging, "R.name", Some(NodeKind::Property))
3114 .expect("R.name must be Property (mutable)");
3115 }
3116
3117 #[test]
3120 fn req_r0005_native_type_takes_precedence_over_phpdoc() {
3121 let src = "<?php
3125class T {
3126 /** @var {int} */
3127 public string $value;
3128}
3129";
3130 let staging = build(src);
3131 let edges = typeof_edges_for_node(&staging, "T.value");
3132 assert!(
3133 !edges.is_empty(),
3134 "T.value should have at least one TypeOf edge"
3135 );
3136 let has_string = edges.iter().any(|(_, _, t)| t == "string");
3137 assert!(
3138 has_string,
3139 "native type 'string' should be the primary TypeOf target, got {edges:?}"
3140 );
3141 let has_int = edges.iter().any(|(_, _, t)| t == "int");
3142 assert!(
3143 !has_int,
3144 "PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
3145 );
3146 }
3147
3148 #[test]
3149 fn req_r0005_phpdoc_fallback_when_no_native_type() {
3150 let src = "<?php
3152class T {
3153 /** @var {SomeUserType} */
3154 public $value;
3155}
3156";
3157 let staging = build(src);
3158 let edges = typeof_edges_for_node(&staging, "T.value");
3159 assert!(
3160 edges.iter().any(|(_, _, t)| t == "SomeUserType"),
3161 "PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
3162 );
3163 }
3164
3165 #[test]
3168 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3169 let src = "<?php
3170class C {
3171 public string $title;
3172}
3173";
3174 let staging = build(src);
3175 let edges = typeof_edges_for_node(&staging, "C.title");
3176 assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
3177 for (ctx, name, _) in &edges {
3178 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3179 assert_eq!(
3180 name.as_deref(),
3181 Some("title"),
3182 "edge name must be the bare property name"
3183 );
3184 }
3185 }
3186
3187 #[test]
3190 fn req_r0007_constructor_promotion_emits_property_on_class() {
3191 let src = "<?php
3192class P {
3193 public function __construct(public int $x, private readonly string $y) {}
3194}
3195";
3196 let staging = build(src);
3197 let x = find_node(&staging, "P.x", Some(NodeKind::Property))
3198 .expect("promoted P.x must be a Property");
3199 assert_eq!(
3200 resolve_visibility(&staging, x.visibility).as_deref(),
3201 Some("public"),
3202 "promoted $x visibility"
3203 );
3204 let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
3205 .expect("promoted readonly P.y must be a Constant");
3206 assert_eq!(
3207 resolve_visibility(&staging, y.visibility).as_deref(),
3208 Some("private"),
3209 "promoted $y visibility"
3210 );
3211 }
3212
3213 #[test]
3216 fn req_r0013_explicit_declaration_wins_over_promotion() {
3217 let src = "<?php
3218class D {
3219 public int $x;
3220 public function __construct(public int $x) {}
3221}
3222";
3223 let staging = build(src);
3224 let n = count_nodes_named(&staging, "D.x");
3225 assert_eq!(
3226 n, 1,
3227 "exactly one D.x node when explicit decl + promotion collide, got {n}"
3228 );
3229 find_node(&staging, "D.x", Some(NodeKind::Property))
3231 .expect("D.x must be Property (explicit declaration wins)");
3232 }
3233
3234 #[test]
3240 fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
3241 let src = "<?php
3242class A {
3243 public function __construct(public string $x) {}
3244 public int $x;
3245}
3246";
3247 let staging = build(src);
3248 let n = count_nodes_named(&staging, "A.x");
3249 assert_eq!(
3250 n, 1,
3251 "exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
3252 );
3253 find_node(&staging, "A.x", Some(NodeKind::Property))
3254 .expect("A.x must be Property (explicit declaration wins)");
3255
3256 let edges = typeof_edges_for_node(&staging, "A.x");
3260 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3261 assert!(
3262 target_types.contains(&"int"),
3263 "explicit `int` TypeOf must be present, got {target_types:?}",
3264 );
3265 assert!(
3266 !target_types.contains(&"string"),
3267 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3268 );
3269 }
3270
3271 #[test]
3275 fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
3276 let src = "<?php
3277class B {
3278 public int $x;
3279 public function __construct(public string $x) {}
3280}
3281";
3282 let staging = build(src);
3283 let n = count_nodes_named(&staging, "B.x");
3284 assert_eq!(
3285 n, 1,
3286 "exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
3287 );
3288 find_node(&staging, "B.x", Some(NodeKind::Property))
3289 .expect("B.x must be Property (explicit declaration wins)");
3290
3291 let edges = typeof_edges_for_node(&staging, "B.x");
3292 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3293 assert!(
3294 target_types.contains(&"int"),
3295 "explicit `int` TypeOf must be present, got {target_types:?}",
3296 );
3297 assert!(
3298 !target_types.contains(&"string"),
3299 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3300 );
3301 }
3302
3303 #[test]
3306 fn req_r0023_span_anchored_on_declaration() {
3307 let src = "<?php
3308class W {
3309
3310 public string $marker;
3311}
3312";
3313 let staging = build(src);
3314 let entry =
3315 find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
3316 assert_eq!(
3324 entry.start_line, 4,
3325 "span start line should match declaration"
3326 );
3327 assert_eq!(entry.end_line, 4, "span end line should match declaration");
3328 assert_eq!(
3329 entry.start_column, 4,
3330 "span start column should match indentation of `public`"
3331 );
3332 assert!(
3333 entry.end_column > entry.start_column,
3334 "span end column must extend past start (got start={}, end={})",
3335 entry.start_column,
3336 entry.end_column,
3337 );
3338 }
3339
3340 #[test]
3343 fn req_r0001_trait_property_emitted() {
3344 let src = "<?php
3345trait Loggable {
3346 protected ?string $logTag;
3347}
3348";
3349 let staging = build(src);
3350 let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
3351 .expect("trait property must be emitted");
3352 let vis = resolve_visibility(&staging, entry.visibility);
3353 assert_eq!(vis.as_deref(), Some("protected"));
3354 }
3355
3356 #[test]
3357 fn no_emission_outside_class_or_trait_or_interface() {
3358 let src = "<?php
3361$x = 1;
3362function f() { $y = 2; }
3363";
3364 let staging = build(src);
3365 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
3366 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
3367 }
3368}