1use std::collections::{HashMap, HashSet};
34use std::path::Path;
35use std::sync::OnceLock;
36
37use sqry_core::graph::unified::build::helper::CalleeKindHint;
38use sqry_core::graph::unified::build::shape::{CfBucket, ShapeMapping};
39use sqry_core::graph::unified::edge::kind::{FfiConvention, TypeOfContext};
40use sqry_core::graph::unified::storage::shape::SignatureShape;
41use sqry_core::graph::unified::{GraphBuildHelper, NodeId, StagingGraph};
42use sqry_core::graph::{
43 GraphBuilder, GraphBuilderError, GraphResult, GraphSnapshot, Language, Span,
44};
45use tree_sitter::{Node, Tree};
46
47use super::phpdoc_parser::{extract_phpdoc_comment, parse_phpdoc_tags};
48use super::type_extractor::{canonical_type_string, extract_type_names};
49
50const DEFAULT_MAX_SCOPE_DEPTH: usize = 5;
52
53#[derive(Debug)]
55pub struct PhpGraphBuilder {
56 pub max_scope_depth: usize,
57}
58
59impl Default for PhpGraphBuilder {
60 fn default() -> Self {
61 Self {
62 max_scope_depth: DEFAULT_MAX_SCOPE_DEPTH,
63 }
64 }
65}
66
67impl GraphBuilder for PhpGraphBuilder {
68 fn build_graph(
69 &self,
70 tree: &Tree,
71 content: &[u8],
72 file: &Path,
73 staging: &mut StagingGraph,
74 ) -> GraphResult<()> {
75 let mut helper = GraphBuildHelper::new(staging, file, Language::Php);
76
77 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
79 GraphBuilderError::ParseError {
80 span: Span::default(),
81 reason: e,
82 }
83 })?;
84
85 let mut node_map = HashMap::new();
87
88 for context in ast_graph.contexts() {
90 let qualified_name = &context.qualified_name;
91 let span = context.decl_span;
92
93 let node_id = match &context.kind {
94 ContextKind::Function { is_async } => helper.add_function_with_signature(
95 qualified_name,
96 Some(span),
97 *is_async,
98 false, None, context.return_type.as_deref(),
101 ),
102 ContextKind::Method {
103 is_async,
104 is_static,
105 visibility: _,
106 } => {
107 helper.add_method_with_signature(
111 qualified_name,
112 Some(span),
113 *is_async,
114 *is_static,
115 None, context.return_type.as_deref(),
117 )
118 }
119 ContextKind::Class => helper.add_class(qualified_name, Some(span)),
120 };
121 helper.mark_definition(node_id);
123 node_map.insert(qualified_name.clone(), node_id);
124 }
125
126 let root = tree.root_node();
128 walk_tree_for_edges(root, content, &ast_graph, &mut helper, &mut node_map)?;
129
130 process_oop_relationships(root, content, &mut helper, &mut node_map);
132
133 process_exports(root, content, &mut helper, &mut node_map);
136
137 process_phpdoc_annotations(root, content, &mut helper)?;
139
140 Ok(())
141 }
142
143 fn language(&self) -> Language {
144 Language::Php
145 }
146
147 fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
148 Some(php_shape_mapping())
149 }
150
151 fn detect_cross_language_edges(
152 &self,
153 _snapshot: &GraphSnapshot,
154 ) -> GraphResult<Vec<sqry_core::graph::CodeEdge>> {
155 Ok(vec![])
158 }
159}
160
161#[derive(Debug, Clone)]
166enum ContextKind {
167 Function {
168 is_async: bool,
169 },
170 Method {
171 is_async: bool,
172 is_static: bool,
173 #[allow(dead_code)] visibility: Option<String>,
175 },
176 Class,
177}
178
179#[derive(Debug, Clone)]
180struct CallContext {
181 qualified_name: String,
182 decl_span: Span,
185 kind: ContextKind,
186 class_name: Option<String>,
187 return_type: Option<String>,
188}
189
190struct ASTGraph {
191 contexts: Vec<CallContext>,
192 node_to_context: HashMap<usize, usize>,
193}
194
195impl ASTGraph {
196 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
197 let mut contexts = Vec::new();
198 let mut node_to_context = HashMap::new();
199 let mut scope_stack: Vec<String> = Vec::new();
200 let mut class_stack: Vec<String> = Vec::new();
201
202 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
204 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
205 let file_ops_depth = recursion_limits
206 .effective_file_ops_depth()
207 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
208 let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
209 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
210
211 let mut walk_ctx = WalkContext {
212 contexts: &mut contexts,
213 node_to_context: &mut node_to_context,
214 scope_stack: &mut scope_stack,
215 class_stack: &mut class_stack,
216 max_depth,
217 };
218
219 walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
220
221 Ok(Self {
222 contexts,
223 node_to_context,
224 })
225 }
226
227 fn contexts(&self) -> &[CallContext] {
228 &self.contexts
229 }
230
231 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
232 self.node_to_context
233 .get(&node_id)
234 .and_then(|idx| self.contexts.get(*idx))
235 }
236}
237
238#[allow(
239 clippy::too_many_lines,
240 reason = "PHP namespace and scope handling requires a large, unified traversal."
241)]
242struct WalkContext<'a> {
247 contexts: &'a mut Vec<CallContext>,
248 node_to_context: &'a mut HashMap<usize, usize>,
249 scope_stack: &'a mut Vec<String>,
250 class_stack: &'a mut Vec<String>,
251 max_depth: usize,
252}
253
254#[allow(clippy::too_many_lines)]
255fn walk_ast(
256 node: Node,
257 content: &[u8],
258 ctx: &mut WalkContext,
259 guard: &mut sqry_core::query::security::RecursionGuard,
260) -> Result<(), String> {
261 guard
262 .enter()
263 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
264
265 if ctx.scope_stack.len() > ctx.max_depth {
266 guard.exit();
267 return Ok(());
268 }
269
270 match node.kind() {
271 "program" => {
272 let mut active_namespace_parts: Vec<String> = Vec::new();
275
276 let mut cursor = node.walk();
277 for child in node.children(&mut cursor) {
278 if child.kind() == "namespace_definition" {
279 let has_body = child
281 .children(&mut child.walk())
282 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
283
284 let ns_name = child
285 .child_by_field_name("name")
286 .and_then(|n| n.utf8_text(content).ok())
287 .map(|s| s.trim().to_string())
288 .unwrap_or_default();
289
290 if has_body {
291 for _ in 0..active_namespace_parts.len() {
298 ctx.scope_stack.pop();
299 }
300 active_namespace_parts.clear();
301
302 let ns_parts: Vec<String> = if ns_name.is_empty() {
303 Vec::new()
304 } else {
305 ns_name.split('\\').map(ToString::to_string).collect()
306 };
307
308 for part in &ns_parts {
309 ctx.scope_stack.push(part.clone());
310 }
311
312 for ns_child in child.children(&mut child.walk()) {
314 if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
315 {
316 for body_child in ns_child.children(&mut ns_child.walk()) {
317 walk_ast(body_child, content, ctx, guard)?;
318 }
319 }
320 }
321
322 for _ in 0..ns_parts.len() {
323 ctx.scope_stack.pop();
324 }
325 } else {
326 for _ in 0..active_namespace_parts.len() {
329 ctx.scope_stack.pop();
330 }
331
332 active_namespace_parts = if ns_name.is_empty() {
334 Vec::new()
335 } else {
336 ns_name.split('\\').map(ToString::to_string).collect()
337 };
338
339 for part in &active_namespace_parts {
341 ctx.scope_stack.push(part.clone());
342 }
343 }
344 } else {
345 walk_ast(child, content, ctx, guard)?;
347 }
348 }
349
350 for _ in 0..active_namespace_parts.len() {
352 ctx.scope_stack.pop();
353 }
354
355 guard.exit();
356 return Ok(());
357 }
358 "namespace_definition" => {
359 let namespace_name = node
362 .child_by_field_name("name")
363 .and_then(|n| n.utf8_text(content).ok())
364 .map(|s| s.trim().to_string())
365 .unwrap_or_default();
366
367 let namespace_parts: Vec<String> = if namespace_name.is_empty() {
368 Vec::new()
369 } else {
370 namespace_name
371 .split('\\')
372 .map(ToString::to_string)
373 .collect()
374 };
375
376 let parts_count = namespace_parts.len();
377 for part in &namespace_parts {
378 ctx.scope_stack.push(part.clone());
379 }
380
381 let mut cursor = node.walk();
383 for child in node.children(&mut cursor) {
384 if matches!(child.kind(), "compound_statement" | "declaration_list") {
385 let mut body_cursor = child.walk();
386 for body_child in child.children(&mut body_cursor) {
387 walk_ast(body_child, content, ctx, guard)?;
388 }
389 }
390 }
391
392 for _ in 0..parts_count {
394 ctx.scope_stack.pop();
395 }
396 }
397 "class_declaration" => {
398 let name_node = node
399 .child_by_field_name("name")
400 .ok_or_else(|| "class_declaration missing name".to_string())?;
401 let class_name = name_node
402 .utf8_text(content)
403 .map_err(|_| "failed to read class name".to_string())?;
404
405 let qualified_class = if ctx.scope_stack.is_empty() {
407 class_name.to_string()
408 } else {
409 format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
410 };
411
412 ctx.class_stack.push(qualified_class.clone());
413 ctx.scope_stack.push(class_name.to_string());
414
415 let _context_idx = ctx.contexts.len();
417 ctx.contexts.push(CallContext {
418 qualified_name: qualified_class.clone(),
419 decl_span: Span::from_node(&node),
420 kind: ContextKind::Class,
421 class_name: Some(qualified_class),
422 return_type: None, });
424
425 let mut cursor = node.walk();
427 for child in node.children(&mut cursor) {
428 if child.kind() == "declaration_list" {
429 let mut body_cursor = child.walk();
430 for body_child in child.children(&mut body_cursor) {
431 walk_ast(body_child, content, ctx, guard)?;
432 }
433 }
434 }
435
436 ctx.class_stack.pop();
437 ctx.scope_stack.pop();
438 }
439 "function_definition" | "method_declaration" => {
440 let name_node = node
441 .child_by_field_name("name")
442 .ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
443 let func_name = name_node
444 .utf8_text(content)
445 .map_err(|_| "failed to read function name".to_string())?;
446
447 let is_async = false; let is_static = node
452 .children(&mut node.walk())
453 .any(|child| child.kind() == "static_modifier");
454
455 let visibility = extract_visibility(&node, content);
457
458 let return_type = extract_return_type(&node, content);
460
461 let is_method = !ctx.class_stack.is_empty();
463 let class_name = ctx.class_stack.last().cloned();
464
465 let qualified_func = if is_method {
469 if let Some(ref class) = class_name {
471 format!("{class}::{func_name}")
472 } else {
473 func_name.to_string()
474 }
475 } else {
476 if ctx.scope_stack.is_empty() {
478 func_name.to_string()
479 } else {
480 format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
481 }
482 };
483
484 let kind = if is_method {
485 ContextKind::Method {
486 is_async,
487 is_static,
488 visibility: visibility.clone(),
489 }
490 } else {
491 ContextKind::Function { is_async }
492 };
493
494 let context_idx = ctx.contexts.len();
495 ctx.contexts.push(CallContext {
496 qualified_name: qualified_func.clone(),
497 decl_span: Span::from_node(&node),
498 kind,
499 class_name,
500 return_type,
501 });
502
503 if let Some(body) = node.child_by_field_name("body") {
505 associate_descendants(body, context_idx, ctx.node_to_context);
506 }
507
508 ctx.scope_stack.push(func_name.to_string());
509
510 if let Some(body) = node.child_by_field_name("body") {
512 let mut cursor = body.walk();
513 for child in body.children(&mut cursor) {
514 walk_ast(child, content, ctx, guard)?;
515 }
516 }
517
518 ctx.scope_stack.pop();
519 }
520 _ => {
521 let mut cursor = node.walk();
523 for child in node.children(&mut cursor) {
524 walk_ast(child, content, ctx, guard)?;
525 }
526 }
527 }
528
529 guard.exit();
530 Ok(())
531}
532
533fn associate_descendants(
534 node: Node,
535 context_idx: usize,
536 node_to_context: &mut HashMap<usize, usize>,
537) {
538 node_to_context.insert(node.id(), context_idx);
539
540 let mut stack = vec![node];
541 while let Some(current) = stack.pop() {
542 node_to_context.insert(current.id(), context_idx);
543
544 let mut cursor = current.walk();
545 for child in current.children(&mut cursor) {
546 stack.push(child);
547 }
548 }
549}
550
551#[allow(clippy::only_used_in_recursion)]
557fn walk_tree_for_edges(
558 node: Node,
559 content: &[u8],
560 ast_graph: &ASTGraph,
561 helper: &mut GraphBuildHelper,
562 node_map: &mut HashMap<String, NodeId>,
563) -> GraphResult<()> {
564 match node.kind() {
565 "function_call_expression" => {
566 process_function_call(node, content, ast_graph, helper, node_map);
567 }
568 "member_call_expression" | "nullsafe_member_call_expression" => {
569 process_member_call(node, content, ast_graph, helper, node_map);
570 }
571 "scoped_call_expression" => {
572 process_static_call(node, content, ast_graph, helper, node_map);
573 }
574 "namespace_use_declaration" => {
576 process_namespace_use(node, content, helper);
577 }
578 "expression_statement" => {
580 let mut cursor = node.walk();
582 for child in node.children(&mut cursor) {
583 match child.kind() {
584 "require_expression"
585 | "require_once_expression"
586 | "include_expression"
587 | "include_once_expression" => {
588 process_file_include(child, content, helper);
589 }
590 _ => {}
591 }
592 }
593 }
594 _ => {}
595 }
596
597 let mut cursor = node.walk();
599 for child in node.children(&mut cursor) {
600 walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
601 }
602
603 Ok(())
604}
605
606fn process_function_call(
607 node: Node,
608 content: &[u8],
609 ast_graph: &ASTGraph,
610 helper: &mut GraphBuildHelper,
611 node_map: &mut HashMap<String, NodeId>,
612) {
613 let Some(function_node) = node.child_by_field_name("function") else {
614 return;
615 };
616
617 let Ok(callee_name) = function_node.utf8_text(content) else {
618 return;
619 };
620
621 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
623 return;
624 };
625
626 let source_id = *node_map
628 .entry(call_context.qualified_name.clone())
629 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
630
631 let call_span = span_from_node(node);
633 let target_id = *node_map
634 .entry(callee_name.to_string())
635 .or_insert_with(|| helper.ensure_callee(callee_name, call_span, CalleeKindHint::Function));
636
637 let argument_count = count_call_arguments(node);
638 helper.add_call_edge_full_with_span(
639 source_id,
640 target_id,
641 argument_count,
642 false,
643 vec![call_span],
644 );
645}
646
647fn process_member_call(
648 node: Node,
649 content: &[u8],
650 ast_graph: &ASTGraph,
651 helper: &mut GraphBuildHelper,
652 node_map: &mut HashMap<String, NodeId>,
653) {
654 let Some(method_node) = node.child_by_field_name("name") else {
655 return;
656 };
657
658 let Ok(method_name) = method_node.utf8_text(content) else {
659 return;
660 };
661
662 if let Some(object_node) = node.child_by_field_name("object")
664 && is_php_ffi_call(object_node, content)
665 {
666 process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
667 return;
668 }
669
670 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
672 return;
673 };
674
675 let callee_qualified = if let Some(class_name) = &call_context.class_name {
677 format!("{class_name}::{method_name}")
678 } else {
679 method_name.to_string()
680 };
681
682 let source_id = *node_map
684 .entry(call_context.qualified_name.clone())
685 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
686
687 let call_span = span_from_node(node);
689 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
690 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
691 });
692
693 let argument_count = count_call_arguments(node);
694 helper.add_call_edge_full_with_span(
695 source_id,
696 target_id,
697 argument_count,
698 false,
699 vec![call_span],
700 );
701}
702
703fn process_static_call(
704 node: Node,
705 content: &[u8],
706 ast_graph: &ASTGraph,
707 helper: &mut GraphBuildHelper,
708 node_map: &mut HashMap<String, NodeId>,
709) {
710 let Some(scope_node) = node.child_by_field_name("scope") else {
711 return;
712 };
713 let Some(name_node) = node.child_by_field_name("name") else {
714 return;
715 };
716
717 let Ok(class_name) = scope_node.utf8_text(content) else {
718 return;
719 };
720 let Ok(method_name) = name_node.utf8_text(content) else {
721 return;
722 };
723
724 if is_ffi_static_call(class_name, method_name) {
726 process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
727 return;
728 }
729
730 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
732 return;
733 };
734
735 let callee_qualified = format!("{class_name}::{method_name}");
737
738 let source_id = *node_map
740 .entry(call_context.qualified_name.clone())
741 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
742
743 let call_span = span_from_node(node);
745 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
746 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
747 });
748
749 let argument_count = count_call_arguments(node);
750 helper.add_call_edge_full_with_span(
751 source_id,
752 target_id,
753 argument_count,
754 false,
755 vec![call_span],
756 );
757}
758
759fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
772 let file_path = helper.file_path().to_string();
774 let importer_id = helper.add_module(&file_path, None);
775
776 let mut prefix = String::new();
779 let mut cursor = node.walk();
780 for child in node.children(&mut cursor) {
781 if child.kind() == "namespace_name"
782 && let Ok(ns) = child.utf8_text(content)
783 {
784 prefix = ns.trim().to_string();
785 break;
786 }
787 }
788
789 cursor = node.walk();
791 for child in node.children(&mut cursor) {
792 match child.kind() {
793 "namespace_use_clause" => {
794 process_use_clause(child, content, helper, importer_id);
796 }
797 "namespace_use_group" => {
798 process_use_group(child, content, helper, importer_id, &prefix);
801 }
802 _ => {}
803 }
804 }
805}
806
807fn process_use_clause(
819 node: Node,
820 content: &[u8],
821 helper: &mut GraphBuildHelper,
822 import_source_id: NodeId,
823) {
824 process_use_clause_with_prefix(node, content, helper, import_source_id, None);
825}
826
827fn process_use_clause_with_prefix(
829 node: Node,
830 content: &[u8],
831 helper: &mut GraphBuildHelper,
832 import_source_id: NodeId,
833 prefix: Option<&str>,
834) {
835 let mut qualified_name = None;
837 let mut alias = None;
838 let mut found_as = false;
839
840 let mut cursor = node.walk();
841 for child in node.children(&mut cursor) {
842 match child.kind() {
843 "qualified_name" => {
844 if let Ok(name) = child.utf8_text(content) {
846 qualified_name = Some(name.trim().to_string());
847 }
848 }
849 "namespace_name" => {
850 if qualified_name.is_none()
852 && let Ok(name) = child.utf8_text(content)
853 {
854 qualified_name = Some(name.trim().to_string());
855 }
856 }
857 "name" => {
858 if found_as {
860 if let Ok(alias_text) = child.utf8_text(content) {
862 alias = Some(alias_text.trim().to_string());
863 }
864 } else if qualified_name.is_none() {
865 if let Ok(name) = child.utf8_text(content) {
867 qualified_name = Some(name.trim().to_string());
868 }
869 }
870 }
871 "as" => {
872 found_as = true;
874 }
875 _ => {}
876 }
877 }
878
879 if let Some(name) = qualified_name
880 && !name.is_empty()
881 {
882 let full_name = if let Some(pfx) = prefix {
884 format!("{pfx}\\{name}")
885 } else {
886 name
887 };
888
889 let span = span_from_node(node);
891 let import_node_id = helper.add_import(&full_name, Some(span));
892
893 if let Some(alias_str) = alias {
895 helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
896 } else {
897 helper.add_import_edge(import_source_id, import_node_id);
898 }
899 }
900}
901
902fn process_use_group(
921 node: Node,
922 content: &[u8],
923 helper: &mut GraphBuildHelper,
924 import_source_id: NodeId,
925 prefix: &str,
926) {
927 let mut cursor = node.walk();
929 for child in node.children(&mut cursor) {
930 if child.kind() == "namespace_use_clause" {
932 process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
934 }
935 }
936}
937
938fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
940 let file_path = helper.file_path().to_string();
942 let import_source_id = helper.add_module(&file_path, None);
943
944 let mut cursor = node.walk();
947 for child in node.children(&mut cursor) {
948 if child.kind() == "string"
949 || child.kind() == "encapsed_string"
950 || child.kind() == "binary_expression"
951 {
952 if let Ok(path_text) = child.utf8_text(content) {
953 let cleaned_path = path_text
955 .trim()
956 .trim_start_matches(['\'', '"'])
957 .trim_end_matches(['\'', '"'])
958 .to_string();
959
960 if !cleaned_path.is_empty() {
961 let span = span_from_node(node);
962 let import_node_id = helper.add_import(&cleaned_path, Some(span));
963 helper.add_import_edge(import_source_id, import_node_id);
964 }
965 }
966 break;
967 }
968 }
969}
970
971fn process_oop_relationships(
977 node: Node,
978 content: &[u8],
979 helper: &mut GraphBuildHelper,
980 node_map: &mut HashMap<String, NodeId>,
981) {
982 let kind = node.kind();
983 if kind == "class_declaration" {
984 process_class_oop(node, content, helper, node_map);
985 } else if kind == "interface_declaration" {
986 process_interface_inheritance(node, content, helper, node_map);
987 }
988
989 let mut cursor = node.walk();
991 for child in node.children(&mut cursor) {
992 process_oop_relationships(child, content, helper, node_map);
993 }
994}
995
996fn process_class_oop(
998 node: Node,
999 content: &[u8],
1000 helper: &mut GraphBuildHelper,
1001 node_map: &mut HashMap<String, NodeId>,
1002) {
1003 let Some(name_node) = node.child_by_field_name("name") else {
1005 return;
1006 };
1007 let Ok(class_name) = name_node.utf8_text(content) else {
1008 return;
1009 };
1010 let class_name = class_name.trim();
1011
1012 let span = span_from_node(node);
1014 let class_id = *node_map
1015 .entry(class_name.to_string())
1016 .or_insert_with(|| helper.add_class(class_name, Some(span)));
1017 helper.mark_definition(class_id);
1019
1020 let mut cursor = node.walk();
1022 for child in node.children(&mut cursor) {
1023 match child.kind() {
1024 "base_clause" => {
1025 process_extends_clause(child, content, helper, node_map, class_id);
1027 }
1028 "class_interface_clause" => {
1029 process_implements_clause(child, content, helper, node_map, class_id);
1031 }
1032 "declaration_list" => {
1033 process_class_body_traits(child, content, helper, node_map, class_id);
1035 }
1036 _ => {}
1037 }
1038 }
1039}
1040
1041fn process_extends_clause(
1043 node: Node,
1044 content: &[u8],
1045 helper: &mut GraphBuildHelper,
1046 node_map: &mut HashMap<String, NodeId>,
1047 class_id: NodeId,
1048) {
1049 let mut cursor = node.walk();
1051 for child in node.children(&mut cursor) {
1052 if child.kind() == "name"
1053 || child.kind() == "qualified_name"
1054 || child.kind() == "namespace_name"
1055 {
1056 if let Ok(parent_name) = child.utf8_text(content) {
1057 let parent_name = parent_name.trim();
1058 if !parent_name.is_empty() {
1059 let span = span_from_node(child);
1060 let parent_id = *node_map
1061 .entry(parent_name.to_string())
1062 .or_insert_with(|| helper.add_class(parent_name, Some(span)));
1063
1064 helper.add_inherits_edge(class_id, parent_id);
1065 }
1066 }
1067 break;
1068 }
1069 }
1070}
1071
1072fn process_implements_clause(
1074 node: Node,
1075 content: &[u8],
1076 helper: &mut GraphBuildHelper,
1077 node_map: &mut HashMap<String, NodeId>,
1078 class_id: NodeId,
1079) {
1080 let mut cursor = node.walk();
1082 for child in node.children(&mut cursor) {
1083 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1084 && let Ok(interface_name) = child.utf8_text(content)
1085 {
1086 let interface_name = interface_name.trim();
1087 if !interface_name.is_empty() {
1088 let span = span_from_node(child);
1089 let interface_id = *node_map
1090 .entry(interface_name.to_string())
1091 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1092
1093 helper.add_implements_edge(class_id, interface_id);
1094 }
1095 }
1096 }
1097}
1098
1099fn process_class_body_traits(
1101 declaration_list: Node,
1102 content: &[u8],
1103 helper: &mut GraphBuildHelper,
1104 node_map: &mut HashMap<String, NodeId>,
1105 class_id: NodeId,
1106) {
1107 let mut cursor = declaration_list.walk();
1108 for child in declaration_list.children(&mut cursor) {
1109 if child.kind() == "use_declaration" {
1110 process_trait_use(child, content, helper, node_map, class_id);
1112 }
1113 }
1114}
1115
1116fn process_trait_use(
1118 node: Node,
1119 content: &[u8],
1120 helper: &mut GraphBuildHelper,
1121 node_map: &mut HashMap<String, NodeId>,
1122 class_id: NodeId,
1123) {
1124 let mut cursor = node.walk();
1126 for child in node.children(&mut cursor) {
1127 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1128 && let Ok(trait_name) = child.utf8_text(content)
1129 {
1130 let trait_name = trait_name.trim();
1131 if !trait_name.is_empty() {
1132 let span = span_from_node(child);
1133 let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
1136 helper.add_node(
1137 trait_name,
1138 Some(span),
1139 sqry_core::graph::unified::node::NodeKind::Trait,
1140 )
1141 });
1142
1143 helper.add_implements_edge(class_id, trait_id);
1146 }
1147 }
1148 }
1149}
1150
1151fn process_interface_inheritance(
1153 node: Node,
1154 content: &[u8],
1155 helper: &mut GraphBuildHelper,
1156 node_map: &mut HashMap<String, NodeId>,
1157) {
1158 let Some(name_node) = node.child_by_field_name("name") else {
1160 return;
1161 };
1162 let Ok(interface_name) = name_node.utf8_text(content) else {
1163 return;
1164 };
1165 let interface_name = interface_name.trim();
1166
1167 let span = span_from_node(node);
1169 let interface_id = *node_map
1170 .entry(interface_name.to_string())
1171 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1172 helper.mark_definition(interface_id);
1174
1175 let mut cursor = node.walk();
1177 for child in node.children(&mut cursor) {
1178 if child.kind() == "base_clause" {
1179 let mut base_cursor = child.walk();
1181 for base_child in child.children(&mut base_cursor) {
1182 if matches!(
1183 base_child.kind(),
1184 "name" | "qualified_name" | "namespace_name"
1185 ) && let Ok(parent_name) = base_child.utf8_text(content)
1186 {
1187 let parent_name = parent_name.trim();
1188 if !parent_name.is_empty() {
1189 let span = span_from_node(base_child);
1190 let parent_id = *node_map
1191 .entry(parent_name.to_string())
1192 .or_insert_with(|| helper.add_interface(parent_name, Some(span)));
1193
1194 helper.add_inherits_edge(interface_id, parent_id);
1196 }
1197 }
1198 }
1199 }
1200 }
1201}
1202
1203fn process_exports(
1222 node: Node,
1223 content: &[u8],
1224 helper: &mut GraphBuildHelper,
1225 node_map: &mut HashMap<String, NodeId>,
1226) {
1227 let file_path = helper.file_path().to_string();
1229 let module_id = helper.add_module(&file_path, None);
1230
1231 if node.kind() != "program" {
1233 return;
1234 }
1235
1236 let mut active_namespace = String::new();
1238
1239 let mut cursor = node.walk();
1241 for child in node.children(&mut cursor) {
1242 process_top_level_for_export(
1243 child,
1244 content,
1245 helper,
1246 node_map,
1247 module_id,
1248 &mut active_namespace,
1249 );
1250 }
1251}
1252
1253fn process_top_level_for_export(
1263 node: Node,
1264 content: &[u8],
1265 helper: &mut GraphBuildHelper,
1266 node_map: &mut HashMap<String, NodeId>,
1267 module_id: NodeId,
1268 active_namespace: &mut String,
1269) {
1270 match node.kind() {
1271 "namespace_definition" => {
1272 let ns_name = node
1274 .child_by_field_name("name")
1275 .and_then(|n| n.utf8_text(content).ok())
1276 .map(|s| s.trim().to_string())
1277 .unwrap_or_default();
1278
1279 let has_body = node
1281 .children(&mut node.walk())
1282 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
1283
1284 if has_body {
1285 active_namespace.clear();
1291
1292 let mut cursor = node.walk();
1294 for child in node.children(&mut cursor) {
1295 if matches!(child.kind(), "compound_statement" | "declaration_list") {
1296 let mut body_cursor = child.walk();
1297 for body_child in child.children(&mut body_cursor) {
1298 export_declaration_if_exportable(
1299 body_child, content, helper, node_map, module_id, &ns_name,
1300 );
1301 }
1302 }
1303 }
1304 } else {
1305 *active_namespace = ns_name;
1308 }
1309 }
1310 "class_declaration"
1312 | "interface_declaration"
1313 | "trait_declaration"
1314 | "enum_declaration"
1315 | "function_definition" => {
1316 export_declaration_if_exportable(
1317 node,
1318 content,
1319 helper,
1320 node_map,
1321 module_id,
1322 active_namespace,
1323 );
1324 }
1325 _ => {
1326 }
1329 }
1330}
1331
1332fn lookup_or_create_node<F>(
1339 node_map: &mut HashMap<String, NodeId>,
1340 qualified_name: &str,
1341 simple_name: &str,
1342 namespace_prefix: &str,
1343 create_fn: F,
1344) -> NodeId
1345where
1346 F: FnOnce() -> NodeId,
1347{
1348 if let Some(&id) = node_map.get(qualified_name) {
1350 return id;
1351 }
1352
1353 if namespace_prefix.is_empty()
1356 && let Some(&id) = node_map.get(simple_name)
1357 {
1358 return id;
1359 }
1360
1361 let id = create_fn();
1363 node_map.insert(qualified_name.to_string(), id);
1364 id
1365}
1366
1367#[allow(clippy::too_many_lines)] fn export_declaration_if_exportable(
1382 node: Node,
1383 content: &[u8],
1384 helper: &mut GraphBuildHelper,
1385 node_map: &mut HashMap<String, NodeId>,
1386 module_id: NodeId,
1387 namespace_prefix: &str,
1388) {
1389 match node.kind() {
1390 "class_declaration" => {
1391 if let Some(name_node) = node.child_by_field_name("name")
1392 && let Ok(class_name) = name_node.utf8_text(content)
1393 {
1394 let simple_name = class_name.trim().to_string();
1395 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1396 let span = span_from_node(node);
1397
1398 let class_id = lookup_or_create_node(
1399 node_map,
1400 &qualified_name,
1401 &simple_name,
1402 namespace_prefix,
1403 || helper.add_class(&qualified_name, Some(span)),
1404 );
1405 helper.mark_definition(class_id);
1407
1408 helper.add_export_edge(module_id, class_id);
1409
1410 export_public_methods_from_class(
1412 node,
1413 content,
1414 helper,
1415 node_map,
1416 module_id,
1417 &qualified_name,
1418 );
1419 }
1420 }
1421 "interface_declaration" => {
1422 if let Some(name_node) = node.child_by_field_name("name")
1423 && let Ok(interface_name) = name_node.utf8_text(content)
1424 {
1425 let simple_name = interface_name.trim().to_string();
1426 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1427 let span = span_from_node(node);
1428
1429 let interface_id = lookup_or_create_node(
1430 node_map,
1431 &qualified_name,
1432 &simple_name,
1433 namespace_prefix,
1434 || helper.add_interface(&qualified_name, Some(span)),
1435 );
1436 helper.mark_definition(interface_id);
1438
1439 helper.add_export_edge(module_id, interface_id);
1440 }
1441 }
1442 "trait_declaration" => {
1443 if let Some(name_node) = node.child_by_field_name("name")
1444 && let Ok(trait_name) = name_node.utf8_text(content)
1445 {
1446 let simple_name = trait_name.trim().to_string();
1447 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1448 let span = span_from_node(node);
1449
1450 let trait_id = lookup_or_create_node(
1451 node_map,
1452 &qualified_name,
1453 &simple_name,
1454 namespace_prefix,
1455 || {
1456 helper.add_node(
1457 &qualified_name,
1458 Some(span),
1459 sqry_core::graph::unified::node::NodeKind::Trait,
1460 )
1461 },
1462 );
1463 helper.mark_definition(trait_id);
1465
1466 helper.add_export_edge(module_id, trait_id);
1467 }
1468 }
1469 "enum_declaration" => {
1470 if let Some(name_node) = node.child_by_field_name("name")
1472 && let Ok(enum_name) = name_node.utf8_text(content)
1473 {
1474 let simple_name = enum_name.trim().to_string();
1475 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1476 let span = span_from_node(node);
1477
1478 let enum_id = lookup_or_create_node(
1479 node_map,
1480 &qualified_name,
1481 &simple_name,
1482 namespace_prefix,
1483 || helper.add_enum(&qualified_name, Some(span)),
1484 );
1485
1486 helper.add_export_edge(module_id, enum_id);
1487 }
1488 }
1489 "function_definition" => {
1490 if let Some(name_node) = node.child_by_field_name("name")
1492 && let Ok(func_name) = name_node.utf8_text(content)
1493 {
1494 let simple_name = func_name.trim().to_string();
1495 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1496 let span = span_from_node(node);
1497
1498 let func_id = lookup_or_create_node(
1499 node_map,
1500 &qualified_name,
1501 &simple_name,
1502 namespace_prefix,
1503 || helper.add_function(&qualified_name, Some(span), false, false),
1504 );
1505 helper.mark_definition(func_id);
1507
1508 helper.add_export_edge(module_id, func_id);
1509 }
1510 }
1511 _ => {
1512 }
1514 }
1515}
1516
1517fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
1519 if namespace_prefix.is_empty() {
1520 name.to_string()
1521 } else {
1522 format!("{namespace_prefix}\\{name}")
1523 }
1524}
1525
1526fn span_from_node(node: Node<'_>) -> Span {
1528 let start = node.start_position();
1529 let end = node.end_position();
1530 Span::new(
1531 sqry_core::graph::node::Position::new(start.row, start.column),
1532 sqry_core::graph::node::Position::new(end.row, end.column),
1533 )
1534}
1535
1536fn count_call_arguments(call_node: Node<'_>) -> u8 {
1537 let args_node = call_node
1538 .child_by_field_name("arguments")
1539 .or_else(|| call_node.child_by_field_name("argument_list"))
1540 .or_else(|| {
1541 let mut cursor = call_node.walk();
1542 call_node
1543 .children(&mut cursor)
1544 .find(|child| child.kind() == "argument_list")
1545 });
1546
1547 let Some(args_node) = args_node else {
1548 return 255;
1549 };
1550 let count = args_node.named_child_count();
1551 if count <= 254 {
1552 u8::try_from(count).unwrap_or(u8::MAX)
1553 } else {
1554 255
1555 }
1556}
1557
1558fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
1563 let mut cursor = node.walk();
1565 for child in node.children(&mut cursor) {
1566 match child.kind() {
1567 "visibility_modifier" => {
1568 if let Ok(vis_text) = child.utf8_text(content) {
1570 return Some(vis_text.trim().to_string());
1571 }
1572 }
1573 "public" | "private" | "protected" => {
1574 if let Ok(vis_text) = child.utf8_text(content) {
1576 return Some(vis_text.trim().to_string());
1577 }
1578 }
1579 _ => {}
1580 }
1581 }
1582
1583 None
1587}
1588
1589fn export_public_methods_from_class(
1595 class_node: Node,
1596 content: &[u8],
1597 helper: &mut GraphBuildHelper,
1598 node_map: &mut HashMap<String, NodeId>,
1599 module_id: NodeId,
1600 class_qualified_name: &str,
1601) {
1602 let mut cursor = class_node.walk();
1604 for child in class_node.children(&mut cursor) {
1605 if child.kind() == "declaration_list" {
1606 let mut body_cursor = child.walk();
1608 for body_child in child.children(&mut body_cursor) {
1609 if body_child.kind() == "method_declaration" {
1610 let visibility = extract_visibility(&body_child, content);
1612
1613 let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
1615
1616 if is_public {
1617 if let Some(name_node) = body_child.child_by_field_name("name")
1619 && let Ok(method_name) = name_node.utf8_text(content)
1620 {
1621 let method_name = method_name.trim();
1622 let qualified_method_name =
1623 format!("{class_qualified_name}::{method_name}");
1624
1625 if let Some(&method_id) = node_map.get(&qualified_method_name) {
1627 helper.add_export_edge(module_id, method_id);
1628 }
1629 }
1630 }
1631 }
1632 }
1633 break;
1634 }
1635 }
1636}
1637
1638fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
1657 let mut found_colon = false;
1659 let mut cursor = node.walk();
1660 for child in node.children(&mut cursor) {
1661 if found_colon && child.is_named() {
1662 return extract_type_from_node(&child, content);
1664 }
1665 if child.kind() == ":" {
1666 found_colon = true;
1667 }
1668 }
1669 None
1670}
1671
1672fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
1686 match type_node.kind() {
1687 "primitive_type" => {
1688 type_node
1690 .utf8_text(content)
1691 .ok()
1692 .map(|s| s.trim().to_string())
1693 }
1694 "optional_type" => {
1695 let mut cursor = type_node.walk();
1698 for child in type_node.children(&mut cursor) {
1699 if child.kind() != "?" && child.is_named() {
1700 return extract_type_from_node(&child, content);
1701 }
1702 }
1703 None
1704 }
1705 "union_type" => {
1706 type_node
1709 .named_child(0)
1710 .and_then(|first_type| extract_type_from_node(&first_type, content))
1711 }
1712 "named_type" | "qualified_name" => {
1713 type_node
1715 .utf8_text(content)
1716 .ok()
1717 .map(|s| s.trim().to_string())
1718 }
1719 "intersection_type" => {
1720 type_node
1723 .named_child(0)
1724 .and_then(|first_type| extract_type_from_node(&first_type, content))
1725 }
1726 _ => {
1727 type_node
1732 .utf8_text(content)
1733 .ok()
1734 .map(|s| {
1735 let trimmed = s.trim();
1736 trimmed
1739 .split(&['|', '&'][..])
1740 .next()
1741 .unwrap_or(trimmed)
1742 .trim()
1743 .trim_start_matches('(')
1744 .trim_end_matches(')')
1745 .trim()
1746 .to_string()
1747 })
1748 .filter(|s| !s.is_empty())
1749 }
1750 }
1751}
1752
1753fn process_phpdoc_annotations(
1776 node: Node,
1777 content: &[u8],
1778 helper: &mut GraphBuildHelper,
1779) -> GraphResult<()> {
1780 let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
1782 process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
1783
1784 process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
1788
1789 Ok(())
1790}
1791
1792fn process_phpdoc_pass_a(
1796 node: Node,
1797 content: &[u8],
1798 helper: &mut GraphBuildHelper,
1799 explicit_field_ids: &mut HashSet<NodeId>,
1800) -> GraphResult<()> {
1801 match node.kind() {
1802 "function_definition" => {
1803 process_function_phpdoc(node, content, helper)?;
1804 }
1805 "method_declaration" => {
1806 process_method_phpdoc(node, content, helper)?;
1809 }
1810 "property_declaration" | "simple_property" => {
1811 let emitted = process_property_declaration(node, content, helper);
1816 explicit_field_ids.extend(emitted);
1817 }
1818 _ => {}
1819 }
1820
1821 let mut cursor = node.walk();
1822 for child in node.children(&mut cursor) {
1823 process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
1824 }
1825
1826 Ok(())
1827}
1828
1829fn process_phpdoc_pass_b(
1835 node: Node,
1836 content: &[u8],
1837 helper: &mut GraphBuildHelper,
1838 explicit_field_ids: &HashSet<NodeId>,
1839) {
1840 if node.kind() == "method_declaration" {
1841 process_constructor_promotion(node, content, helper, explicit_field_ids);
1842 }
1843
1844 let mut cursor = node.walk();
1845 for child in node.children(&mut cursor) {
1846 process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
1847 }
1848}
1849
1850fn process_function_phpdoc(
1852 func_node: Node,
1853 content: &[u8],
1854 helper: &mut GraphBuildHelper,
1855) -> GraphResult<()> {
1856 let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1858 return Ok(());
1859 };
1860
1861 let tags = parse_phpdoc_tags(&phpdoc_text);
1863
1864 let Some(name_node) = func_node.child_by_field_name("name") else {
1866 return Ok(());
1867 };
1868
1869 let function_name = name_node
1870 .utf8_text(content)
1871 .map_err(|_| GraphBuilderError::ParseError {
1872 span: span_from_node(func_node),
1873 reason: "failed to read function name".to_string(),
1874 })?
1875 .trim()
1876 .to_string();
1877
1878 if function_name.is_empty() {
1879 return Ok(());
1880 }
1881
1882 let func_node_id = helper.ensure_callee(
1884 &function_name,
1885 span_from_node(func_node),
1886 CalleeKindHint::Function,
1887 );
1888
1889 let _ast_params = extract_ast_parameters(func_node, content);
1891
1892 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1896 let canonical_type = canonical_type_string(¶m_tag.type_str);
1898 let type_node_id = helper.add_type(&canonical_type, None);
1899 helper.add_typeof_edge_with_context(
1900 func_node_id,
1901 type_node_id,
1902 Some(TypeOfContext::Parameter),
1903 param_idx.try_into().ok(), Some(¶m_tag.name),
1905 );
1906
1907 let type_names = extract_type_names(¶m_tag.type_str);
1909 for type_name in type_names {
1910 let ref_type_id = helper.add_type(&type_name, None);
1911 helper.add_reference_edge(func_node_id, ref_type_id);
1912 }
1913 }
1914
1915 if let Some(return_type) = &tags.returns {
1917 let canonical_type = canonical_type_string(return_type);
1918 let type_node_id = helper.add_type(&canonical_type, None);
1919 helper.add_typeof_edge_with_context(
1920 func_node_id,
1921 type_node_id,
1922 Some(TypeOfContext::Return),
1923 Some(0),
1924 None,
1925 );
1926
1927 let type_names = extract_type_names(return_type);
1929 for type_name in type_names {
1930 let ref_type_id = helper.add_type(&type_name, None);
1931 helper.add_reference_edge(func_node_id, ref_type_id);
1932 }
1933 }
1934
1935 Ok(())
1936}
1937
1938fn process_method_phpdoc(
1940 method_node: Node,
1941 content: &[u8],
1942 helper: &mut GraphBuildHelper,
1943) -> GraphResult<()> {
1944 let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1946 return Ok(());
1947 };
1948
1949 let tags = parse_phpdoc_tags(&phpdoc_text);
1951
1952 let Some(name_node) = method_node.child_by_field_name("name") else {
1954 return Ok(());
1955 };
1956
1957 let method_name = name_node
1958 .utf8_text(content)
1959 .map_err(|_| GraphBuilderError::ParseError {
1960 span: span_from_node(method_node),
1961 reason: "failed to read method name".to_string(),
1962 })?
1963 .trim()
1964 .to_string();
1965
1966 if method_name.is_empty() {
1967 return Ok(());
1968 }
1969
1970 let class_name = get_enclosing_class_name(method_node, content)?;
1972 let Some(class_name) = class_name else {
1973 return Ok(());
1974 };
1975
1976 let qualified_name = format!("{class_name}.{method_name}");
1978
1979 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1982
1983 let _ast_params = extract_ast_parameters(method_node, content);
1985
1986 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1989 let canonical_type = canonical_type_string(¶m_tag.type_str);
1991 let type_node_id = helper.add_type(&canonical_type, None);
1992 helper.add_typeof_edge_with_context(
1993 method_node_id,
1994 type_node_id,
1995 Some(TypeOfContext::Parameter),
1996 param_idx.try_into().ok(),
1997 Some(¶m_tag.name),
1998 );
1999
2000 let type_names = extract_type_names(¶m_tag.type_str);
2002 for type_name in type_names {
2003 let ref_type_id = helper.add_type(&type_name, None);
2004 helper.add_reference_edge(method_node_id, ref_type_id);
2005 }
2006 }
2007
2008 if let Some(return_type) = &tags.returns {
2010 let canonical_type = canonical_type_string(return_type);
2011 let type_node_id = helper.add_type(&canonical_type, None);
2012 helper.add_typeof_edge_with_context(
2013 method_node_id,
2014 type_node_id,
2015 Some(TypeOfContext::Return),
2016 Some(0),
2017 None,
2018 );
2019
2020 let type_names = extract_type_names(return_type);
2022 for type_name in type_names {
2023 let ref_type_id = helper.add_type(&type_name, None);
2024 helper.add_reference_edge(method_node_id, ref_type_id);
2025 }
2026 }
2027
2028 Ok(())
2029}
2030
2031fn process_property_declaration(
2052 prop_node: Node,
2053 content: &[u8],
2054 helper: &mut GraphBuildHelper,
2055) -> Vec<NodeId> {
2056 let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
2060 return Vec::new();
2061 };
2062
2063 let mods = extract_property_modifiers(prop_node, content);
2065
2066 let native_type = prop_node
2069 .child_by_field_name("type")
2070 .and_then(|t| extract_type_from_node(&t, content));
2071
2072 let phpdoc_var_type = if native_type.is_none() {
2074 extract_phpdoc_comment(prop_node, content)
2075 .as_deref()
2076 .and_then(|c| parse_phpdoc_tags(c).var_type)
2077 } else {
2078 None
2079 };
2080
2081 let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
2082
2083 let prop_names = extract_property_element_names(prop_node, content);
2084 if prop_names.is_empty() {
2085 return Vec::new();
2086 }
2087
2088 let span = span_from_node(prop_node);
2089 let mut emitted = Vec::with_capacity(prop_names.len());
2090
2091 for prop_name in prop_names {
2092 let qualified_name = format!("{owner_name}.{prop_name}");
2093 let visibility = mods.visibility.as_deref().unwrap_or("public");
2094
2095 let node_id = if mods.is_readonly {
2096 helper.add_constant_with_name_static_and_visibility(
2097 &prop_name,
2098 &qualified_name,
2099 Some(span),
2100 mods.is_static,
2101 Some(visibility),
2102 )
2103 } else {
2104 helper.add_property_with_name_static_and_visibility(
2105 &prop_name,
2106 &qualified_name,
2107 Some(span),
2108 mods.is_static,
2109 Some(visibility),
2110 )
2111 };
2112
2113 if let Some(type_str) = primary_type.as_deref() {
2114 emit_field_type_edges(helper, node_id, &prop_name, type_str);
2115 }
2116
2117 emitted.push(node_id);
2118 }
2119
2120 emitted
2121}
2122
2123fn process_constructor_promotion(
2143 method_node: Node,
2144 content: &[u8],
2145 helper: &mut GraphBuildHelper,
2146 explicit_field_ids: &HashSet<NodeId>,
2147) {
2148 let Some(name_node) = method_node.child_by_field_name("name") else {
2150 return;
2151 };
2152 let Ok(method_name) = name_node.utf8_text(content) else {
2153 return;
2154 };
2155 if method_name.trim() != "__construct" {
2156 return;
2157 }
2158
2159 let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
2160 return;
2161 };
2162
2163 let Some(params_node) = method_node.child_by_field_name("parameters") else {
2164 return;
2165 };
2166
2167 let mut cursor = params_node.walk();
2168 for param in params_node.children(&mut cursor) {
2169 if param.kind() != "property_promotion_parameter" {
2170 continue;
2171 }
2172
2173 let visibility = param
2175 .child_by_field_name("visibility")
2176 .and_then(|v| v.utf8_text(content).ok())
2177 .map(|s| s.trim().to_string());
2178 let is_readonly = param.child_by_field_name("readonly").is_some()
2179 || direct_child_of_kind(param, "readonly_modifier").is_some();
2180 let is_static = false;
2183 let native_type = param
2184 .child_by_field_name("type")
2185 .and_then(|t| extract_type_from_node(&t, content));
2186
2187 let Some(prop_name) = promoted_param_name(param, content) else {
2188 continue;
2189 };
2190
2191 let qualified_name = format!("{owner_name}.{prop_name}");
2192 let span = span_from_node(param);
2193
2194 if let Some(existing_id) = helper.get_node(&qualified_name) {
2199 if explicit_field_ids.contains(&existing_id) {
2200 continue;
2205 }
2206 if let Some(t) = native_type {
2212 emit_field_type_edges(helper, existing_id, &prop_name, &t);
2213 }
2214 continue;
2215 }
2216
2217 let visibility_ref = visibility.as_deref().unwrap_or("public");
2218 let node_id = if is_readonly {
2219 helper.add_constant_with_name_static_and_visibility(
2220 &prop_name,
2221 &qualified_name,
2222 Some(span),
2223 is_static,
2224 Some(visibility_ref),
2225 )
2226 } else {
2227 helper.add_property_with_name_static_and_visibility(
2228 &prop_name,
2229 &qualified_name,
2230 Some(span),
2231 is_static,
2232 Some(visibility_ref),
2233 )
2234 };
2235
2236 if let Some(type_str) = native_type {
2237 emit_field_type_edges(helper, node_id, &prop_name, &type_str);
2238 }
2239 }
2240}
2241
2242struct PropertyModifiers {
2244 visibility: Option<String>,
2245 is_static: bool,
2246 is_readonly: bool,
2247}
2248
2249fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
2253 let mut visibility: Option<String> = None;
2254 let mut is_static = false;
2255 let mut is_readonly = false;
2256
2257 let mut cursor = prop_node.walk();
2258 for child in prop_node.children(&mut cursor) {
2259 match child.kind() {
2260 "visibility_modifier" => {
2261 if let Ok(text) = child.utf8_text(content) {
2262 visibility = Some(text.trim().to_string());
2263 }
2264 }
2265 "var_modifier" => {
2266 if visibility.is_none() {
2269 visibility = Some("public".to_string());
2270 }
2271 }
2272 "static_modifier" => {
2273 is_static = true;
2274 }
2275 "readonly_modifier" => {
2276 is_readonly = true;
2277 }
2278 _ => {}
2279 }
2280 }
2281
2282 PropertyModifiers {
2283 visibility,
2284 is_static,
2285 is_readonly,
2286 }
2287}
2288
2289fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2293 let mut names = Vec::new();
2294 let mut cursor = prop_node.walk();
2295 for child in prop_node.children(&mut cursor) {
2296 if child.kind() != "property_element" {
2297 continue;
2298 }
2299 if let Some(var_node) = child.child_by_field_name("name")
2300 && let Some(name) = strip_dollar_from_variable(var_node, content)
2301 {
2302 names.push(name);
2303 }
2304 }
2305 names
2306}
2307
2308fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
2311 let name_field = param.child_by_field_name("name")?;
2312 let var_node = if name_field.kind() == "variable_name" {
2314 name_field
2315 } else {
2316 let mut cursor = name_field.walk();
2318 name_field
2319 .children(&mut cursor)
2320 .find(|c| c.kind() == "variable_name")?
2321 };
2322 strip_dollar_from_variable(var_node, content)
2323}
2324
2325fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
2327 if let Some(name_node) = var_node.child_by_field_name("name")
2328 && let Ok(text) = name_node.utf8_text(content)
2329 {
2330 return Some(text.trim().to_string());
2331 }
2332 var_node
2333 .utf8_text(content)
2334 .ok()
2335 .map(|s| s.trim().trim_start_matches('$').to_string())
2336}
2337
2338fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
2340 let mut cursor = node.walk();
2341 node.children(&mut cursor).find(|c| c.kind() == kind)
2342}
2343
2344fn emit_field_type_edges(
2347 helper: &mut GraphBuildHelper,
2348 node_id: NodeId,
2349 prop_name: &str,
2350 type_str: &str,
2351) {
2352 let canonical_type = canonical_type_string(type_str);
2353 let type_node_id = helper.add_type(&canonical_type, None);
2354 helper.add_typeof_edge_with_context(
2355 node_id,
2356 type_node_id,
2357 Some(TypeOfContext::Field),
2358 None,
2359 Some(prop_name),
2360 );
2361
2362 for ref_type_name in extract_type_names(type_str) {
2363 let ref_type_id = helper.add_type(&ref_type_name, None);
2364 helper.add_reference_edge(node_id, ref_type_id);
2365 }
2366}
2367
2368fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
2371 let mut current = node;
2372 while let Some(parent) = current.parent() {
2373 if matches!(
2374 parent.kind(),
2375 "class_declaration" | "trait_declaration" | "interface_declaration"
2376 ) {
2377 return parent
2378 .child_by_field_name("name")
2379 .and_then(|n| n.utf8_text(content).ok())
2380 .map(|s| s.trim().to_string());
2381 }
2382 current = parent;
2383 }
2384 None
2385}
2386
2387fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2389 let mut params = Vec::new();
2390
2391 let Some(params_node) = func_node.child_by_field_name("parameters") else {
2393 return params;
2394 };
2395
2396 let mut index = 0;
2397 let mut cursor = params_node.walk();
2398
2399 for child in params_node.children(&mut cursor) {
2400 if !child.is_named() {
2401 continue;
2402 }
2403
2404 match child.kind() {
2405 "simple_parameter" => {
2406 let mut param_cursor = child.walk();
2408 for param_child in child.children(&mut param_cursor) {
2409 if param_child.kind() == "variable_name"
2410 && let Ok(param_text) = param_child.utf8_text(content)
2411 {
2412 params.push((index, param_text.trim().to_string()));
2413 index += 1;
2414 break;
2415 }
2416 }
2417 }
2418 "variadic_parameter" => {
2419 let mut param_cursor = child.walk();
2421 for param_child in child.children(&mut param_cursor) {
2422 if param_child.kind() == "variable_name"
2423 && let Ok(param_text) = param_child.utf8_text(content)
2424 {
2425 params.push((index, param_text.trim().to_string()));
2426 index += 1;
2427 break;
2428 }
2429 }
2430 }
2431 _ => {}
2432 }
2433 }
2434
2435 params
2436}
2437
2438#[allow(clippy::unnecessary_wraps)]
2440fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2441 let mut current = node;
2442
2443 while let Some(parent) = current.parent() {
2445 if parent.kind() == "class_declaration" {
2446 if let Some(name_node) = parent.child_by_field_name("name")
2448 && let Ok(name_text) = name_node.utf8_text(content)
2449 {
2450 return Ok(Some(name_text.trim().to_string()));
2451 }
2452 return Ok(None);
2453 }
2454 current = parent;
2455 }
2456
2457 Ok(None)
2458}
2459
2460fn process_ffi_member_call(
2468 node: Node,
2469 method_name: &str,
2470 ast_graph: &ASTGraph,
2471 helper: &mut GraphBuildHelper,
2472 node_map: &mut HashMap<String, NodeId>,
2473) {
2474 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2476 return;
2477 };
2478
2479 let source_id = *node_map
2481 .entry(call_context.qualified_name.clone())
2482 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2483
2484 let ffi_name = format!("native::ffi::{method_name}");
2486 let call_span = span_from_node(node);
2487 let target_id = helper.add_module(&ffi_name, Some(call_span));
2488
2489 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2491}
2492
2493fn process_ffi_static_call(
2498 node: Node,
2499 method_name: &str,
2500 ast_graph: &ASTGraph,
2501 helper: &mut GraphBuildHelper,
2502 node_map: &mut HashMap<String, NodeId>,
2503 content: &[u8],
2504) {
2505 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2507 return;
2508 };
2509
2510 let source_id = *node_map
2512 .entry(call_context.qualified_name.clone())
2513 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2514
2515 let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2517 .map_or_else(
2518 || "unknown".to_string(),
2519 |lib| php_ffi_library_simple_name(&lib),
2520 );
2521
2522 let ffi_name = format!("native::{library_name}");
2524 let call_span = span_from_node(node);
2525 let target_id = helper.add_module(&ffi_name, Some(call_span));
2526
2527 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2529}
2530
2531fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2544 if object_node.kind() == "scoped_call_expression"
2546 && let Some(scope_node) = object_node.child_by_field_name("scope")
2547 && let Some(name_node) = object_node.child_by_field_name("name")
2548 && let Ok(scope_text) = scope_node.utf8_text(content)
2549 && let Ok(name_text) = name_node.utf8_text(content)
2550 && is_ffi_static_call(scope_text, name_text)
2551 {
2552 return true;
2553 }
2554
2555 if object_node.kind() == "parenthesized_expression"
2557 && let Some(inner) = object_node.named_child(0)
2558 && inner.kind() == "scoped_call_expression"
2559 && let Some(scope_node) = inner.child_by_field_name("scope")
2560 && let Some(name_node) = inner.child_by_field_name("name")
2561 && let Ok(scope_text) = scope_node.utf8_text(content)
2562 && let Ok(name_text) = name_node.utf8_text(content)
2563 && is_ffi_static_call(scope_text, name_text)
2564 {
2565 return true;
2566 }
2567
2568 let Ok(object_text) = object_node.utf8_text(content) else {
2570 return false;
2571 };
2572
2573 let object_text = object_text.trim();
2574
2575 if object_text == "$ffi" || object_text == "$_ffi" {
2577 return true;
2578 }
2579
2580 if object_text.ends_with("->ffi")
2582 || object_text.ends_with("::$ffi")
2583 || object_text.ends_with("->_ffi")
2584 || object_text.ends_with("::$_ffi")
2585 {
2586 return true;
2587 }
2588
2589 false
2590}
2591
2592fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2596 (scope_text == "FFI" || scope_text == "\\FFI")
2597 && (method_text == "cdef" || method_text == "load")
2598}
2599
2600fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2608 let args = call_node.child_by_field_name("arguments")?;
2609
2610 let mut cursor = args.walk();
2611 let args_vec: Vec<Node> = args
2612 .children(&mut cursor)
2613 .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2614 .collect();
2615
2616 let target_arg_name = if is_cdef { "lib" } else { "filename" };
2619
2620 if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2622 return extract_string_from_argument(named_arg, content);
2623 }
2624
2625 if is_cdef {
2627 args_vec
2629 .get(1)
2630 .and_then(|arg| extract_string_from_argument(*arg, content))
2631 } else {
2632 args_vec
2634 .first()
2635 .and_then(|arg| extract_string_from_argument(*arg, content))
2636 }
2637}
2638
2639fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2646 for arg in args {
2647 if arg.kind() != "argument" {
2648 continue;
2649 }
2650
2651 if arg.named_child_count() < 2 {
2654 continue;
2655 }
2656
2657 if let Some(name_node) = arg.child_by_field_name("name")
2659 && let Ok(name_text) = name_node.utf8_text(content)
2660 && name_text == param_name
2661 {
2662 return Some(*arg);
2663 } else if let Some(name_node) = arg.named_child(0)
2664 && let Ok(name_text) = name_node.utf8_text(content)
2665 && name_text == param_name
2666 {
2667 return Some(*arg);
2669 }
2670 }
2671
2672 None
2673}
2674
2675fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2683 let value_node = unwrap_argument_node(arg_node)?;
2685
2686 if !is_string_literal_node(value_node) {
2688 return None;
2689 }
2690
2691 if is_interpolated_string(value_node) {
2693 return None;
2694 }
2695
2696 extract_php_string_content(value_node, content)
2697}
2698
2699fn unwrap_argument_node(node: Node) -> Option<Node> {
2711 if node.kind() != "argument" {
2712 return Some(node);
2714 }
2715
2716 let name_field_node = node.child_by_field_name("name");
2723 let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2724
2725 for i in 0..node.named_child_count() {
2727 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.named_child(i as u32) {
2729 let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2731 let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2732
2733 if !is_name_field && !is_ref_modifier {
2734 return Some(child);
2736 }
2737 }
2738 }
2739
2740 None
2742}
2743
2744fn is_string_literal_node(node: Node) -> bool {
2751 matches!(
2752 node.kind(),
2753 "string" | "encapsed_string" | "heredoc" | "nowdoc"
2754 )
2755}
2756
2757fn is_interpolated_string(node: Node) -> bool {
2770 if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2771 return false;
2772 }
2773
2774 has_variable_node(node)
2776}
2777
2778fn has_variable_node(node: Node) -> bool {
2792 if matches!(
2794 node.kind(),
2795 "variable_name" | "simple_variable" | "variable" | "complex_variable"
2797 | "dynamic_variable_name"
2799 | "subscript_expression" | "member_access_expression" | "member_call_expression"
2801 | "function_call_expression"
2803 | "scoped_call_expression" | "scoped_property_access_expression"
2805 | "class_constant_access_expression"
2807 | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2809 ) {
2810 return true;
2811 }
2812
2813 for i in 0..node.child_count() {
2815 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.child(i as u32)
2817 && has_variable_node(child)
2818 {
2819 return true;
2820 }
2821 }
2822
2823 false
2824}
2825
2826fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2830 let Ok(text) = string_node.utf8_text(content) else {
2831 return None;
2832 };
2833
2834 let text = text.trim();
2835
2836 if ((text.starts_with('"') && text.ends_with('"'))
2838 || (text.starts_with('\'') && text.ends_with('\'')))
2839 && text.len() >= 2
2840 {
2841 return Some(text[1..text.len() - 1].to_string());
2842 }
2843
2844 Some(text.to_string())
2846}
2847
2848fn php_ffi_library_simple_name(library_path: &str) -> String {
2850 use std::path::Path;
2851
2852 let filename = Path::new(library_path)
2854 .file_name()
2855 .and_then(|f| f.to_str())
2856 .unwrap_or(library_path);
2857
2858 if let Some(so_pos) = filename.find(".so.") {
2860 return filename[..so_pos].to_string();
2861 }
2862
2863 if let Some(dot_pos) = filename.find('.') {
2865 let extension = &filename[dot_pos + 1..];
2866 if extension == "so"
2867 || extension == "dll"
2868 || extension == "dylib"
2869 || extension == "h"
2870 || extension == "hpp"
2871 {
2872 return filename[..dot_pos].to_string();
2873 }
2874 }
2875
2876 filename.to_string()
2877}
2878
2879#[cfg(test)]
2884mod field_emission_tests {
2885 use sqry_core::graph::GraphBuilder;
2903 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2904 use sqry_core::graph::unified::build::test_helpers::{
2905 build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
2906 };
2907 use sqry_core::graph::unified::edge::EdgeKind;
2908 use sqry_core::graph::unified::edge::kind::TypeOfContext;
2909 use sqry_core::graph::unified::node::NodeKind;
2910 use std::path::Path;
2911 use tree_sitter::Parser;
2912
2913 use super::PhpGraphBuilder;
2914
2915 fn parse(source: &str) -> tree_sitter::Tree {
2916 let mut parser = Parser::new();
2917 parser
2918 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
2919 .expect("load PHP grammar");
2920 parser.parse(source, None).expect("parse PHP source")
2921 }
2922
2923 fn build(source: &str) -> StagingGraph {
2924 let tree = parse(source);
2925 let mut staging = StagingGraph::new();
2926 let builder = PhpGraphBuilder::default();
2927 builder
2928 .build_graph(
2929 &tree,
2930 source.as_bytes(),
2931 Path::new("test.php"),
2932 &mut staging,
2933 )
2934 .expect("build graph");
2935 staging
2936 }
2937
2938 fn find_node<'a>(
2940 staging: &'a StagingGraph,
2941 name: &str,
2942 kind: Option<NodeKind>,
2943 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2944 let strings = build_string_lookup(staging);
2945 for op in staging.operations() {
2946 if let StagingOp::AddNode { entry, .. } = op {
2947 if let Some(k) = kind
2948 && entry.kind != k
2949 {
2950 continue;
2951 }
2952 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2953 if let Some(s) = strings.get(&name_idx)
2954 && s == name
2955 {
2956 return Some(entry);
2957 }
2958 }
2959 }
2960 None
2961 }
2962
2963 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
2964 let strings = build_string_lookup(staging);
2965 staging
2966 .operations()
2967 .iter()
2968 .filter(|op| {
2969 if let StagingOp::AddNode { entry, .. } = op {
2970 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2971 strings.get(&name_idx).is_some_and(|s| s == name)
2972 } else {
2973 false
2974 }
2975 })
2976 .count()
2977 }
2978
2979 fn resolve_visibility(
2980 staging: &StagingGraph,
2981 vis: Option<sqry_core::graph::unified::StringId>,
2982 ) -> Option<String> {
2983 let strings = build_string_lookup(staging);
2984 vis.and_then(|sid| strings.get(&sid.index()).cloned())
2985 }
2986
2987 fn typeof_edges_for_node(
2988 staging: &StagingGraph,
2989 source_name: &str,
2990 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
2991 let names = build_node_name_lookup(staging);
2992 let strings = build_string_lookup(staging);
2993 let mut out = Vec::new();
2994 for op in staging.operations() {
2995 if let StagingOp::AddEdge {
2996 source,
2997 target,
2998 kind: EdgeKind::TypeOf { context, name, .. },
2999 ..
3000 } = op
3001 {
3002 let src = names.get(source).cloned().unwrap_or_default();
3003 if src != source_name {
3004 continue;
3005 }
3006 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3007 let target_name = names.get(target).cloned().unwrap_or_default();
3008 out.push((*context, edge_name, target_name));
3009 }
3010 }
3011 out
3012 }
3013
3014 #[test]
3017 fn req_r0001_property_without_phpdoc_emits_property_node() {
3018 let src = "<?php
3019class User {
3020 public string $name;
3021}
3022";
3023 let staging = build(src);
3024 let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
3025 .expect("User.name Property must be emitted without @var");
3026 assert_eq!(entry.kind, NodeKind::Property);
3027 }
3028
3029 #[test]
3030 fn req_r0001_property_with_phpdoc_still_emits_property_node() {
3031 let src = "<?php
3032class Repo {
3033 /** @var string */
3034 public string $label;
3035}
3036";
3037 let staging = build(src);
3038 find_node(&staging, "Repo.label", Some(NodeKind::Property))
3039 .expect("Repo.label Property must be emitted when @var is present");
3040 }
3041
3042 #[test]
3045 fn req_r0002_qualified_name_uses_class_dot_prop() {
3046 let src = "<?php
3047class A { public int $x; }
3048class B { public int $x; }
3049";
3050 let staging = build(src);
3051 find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
3052 find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
3053 assert!(
3054 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3055 "no bare 'x' Property node should leak"
3056 );
3057 }
3058
3059 #[test]
3060 fn req_r0002_visibility_modifiers_round_trip() {
3061 let src = "<?php
3062class V {
3063 public int $a;
3064 private int $b;
3065 protected int $c;
3066 var $d;
3067}
3068";
3069 let staging = build(src);
3070 for (name, expected) in [
3071 ("V.a", "public"),
3072 ("V.b", "private"),
3073 ("V.c", "protected"),
3074 ("V.d", "public"),
3075 ] {
3076 let entry = find_node(&staging, name, Some(NodeKind::Property))
3077 .unwrap_or_else(|| panic!("missing {name}"));
3078 let got = resolve_visibility(&staging, entry.visibility);
3079 assert_eq!(
3080 got.as_deref(),
3081 Some(expected),
3082 "{name} visibility should be {expected}"
3083 );
3084 }
3085 }
3086
3087 #[test]
3088 fn req_r0002_default_visibility_is_public_when_no_modifier() {
3089 let src = "<?php
3091class X { static int $count = 0; }
3092";
3093 let staging = build(src);
3094 let entry =
3095 find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
3096 let vis = resolve_visibility(&staging, entry.visibility);
3097 assert_eq!(
3098 vis.as_deref(),
3099 Some("public"),
3100 "default visibility is public"
3101 );
3102 }
3103
3104 #[test]
3107 fn req_r0003_static_modifier_sets_is_static() {
3108 let src = "<?php
3109class S {
3110 public static int $count = 0;
3111 public int $instance = 0;
3112}
3113";
3114 let staging = build(src);
3115 let s_count =
3116 find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
3117 assert!(s_count.is_static, "S.count should be static");
3118 let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
3119 .expect("S.instance must exist");
3120 assert!(!s_instance.is_static, "S.instance should not be static");
3121 }
3122
3123 #[test]
3126 fn req_r0004_readonly_emits_constant() {
3127 let src = "<?php
3128class R {
3129 public readonly string $id;
3130 public string $name;
3131}
3132";
3133 let staging = build(src);
3134 find_node(&staging, "R.id", Some(NodeKind::Constant))
3135 .expect("R.id must be Constant (readonly)");
3136 find_node(&staging, "R.name", Some(NodeKind::Property))
3137 .expect("R.name must be Property (mutable)");
3138 }
3139
3140 #[test]
3143 fn req_r0005_native_type_takes_precedence_over_phpdoc() {
3144 let src = "<?php
3148class T {
3149 /** @var {int} */
3150 public string $value;
3151}
3152";
3153 let staging = build(src);
3154 let edges = typeof_edges_for_node(&staging, "T.value");
3155 assert!(
3156 !edges.is_empty(),
3157 "T.value should have at least one TypeOf edge"
3158 );
3159 let has_string = edges.iter().any(|(_, _, t)| t == "string");
3160 assert!(
3161 has_string,
3162 "native type 'string' should be the primary TypeOf target, got {edges:?}"
3163 );
3164 let has_int = edges.iter().any(|(_, _, t)| t == "int");
3165 assert!(
3166 !has_int,
3167 "PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
3168 );
3169 }
3170
3171 #[test]
3172 fn req_r0005_phpdoc_fallback_when_no_native_type() {
3173 let src = "<?php
3175class T {
3176 /** @var {SomeUserType} */
3177 public $value;
3178}
3179";
3180 let staging = build(src);
3181 let edges = typeof_edges_for_node(&staging, "T.value");
3182 assert!(
3183 edges.iter().any(|(_, _, t)| t == "SomeUserType"),
3184 "PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
3185 );
3186 }
3187
3188 #[test]
3191 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3192 let src = "<?php
3193class C {
3194 public string $title;
3195}
3196";
3197 let staging = build(src);
3198 let edges = typeof_edges_for_node(&staging, "C.title");
3199 assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
3200 for (ctx, name, _) in &edges {
3201 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3202 assert_eq!(
3203 name.as_deref(),
3204 Some("title"),
3205 "edge name must be the bare property name"
3206 );
3207 }
3208 }
3209
3210 #[test]
3213 fn req_r0007_constructor_promotion_emits_property_on_class() {
3214 let src = "<?php
3215class P {
3216 public function __construct(public int $x, private readonly string $y) {}
3217}
3218";
3219 let staging = build(src);
3220 let x = find_node(&staging, "P.x", Some(NodeKind::Property))
3221 .expect("promoted P.x must be a Property");
3222 assert_eq!(
3223 resolve_visibility(&staging, x.visibility).as_deref(),
3224 Some("public"),
3225 "promoted $x visibility"
3226 );
3227 let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
3228 .expect("promoted readonly P.y must be a Constant");
3229 assert_eq!(
3230 resolve_visibility(&staging, y.visibility).as_deref(),
3231 Some("private"),
3232 "promoted $y visibility"
3233 );
3234 }
3235
3236 #[test]
3239 fn req_r0013_explicit_declaration_wins_over_promotion() {
3240 let src = "<?php
3241class D {
3242 public int $x;
3243 public function __construct(public int $x) {}
3244}
3245";
3246 let staging = build(src);
3247 let n = count_nodes_named(&staging, "D.x");
3248 assert_eq!(
3249 n, 1,
3250 "exactly one D.x node when explicit decl + promotion collide, got {n}"
3251 );
3252 find_node(&staging, "D.x", Some(NodeKind::Property))
3254 .expect("D.x must be Property (explicit declaration wins)");
3255 }
3256
3257 #[test]
3263 fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
3264 let src = "<?php
3265class A {
3266 public function __construct(public string $x) {}
3267 public int $x;
3268}
3269";
3270 let staging = build(src);
3271 let n = count_nodes_named(&staging, "A.x");
3272 assert_eq!(
3273 n, 1,
3274 "exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
3275 );
3276 find_node(&staging, "A.x", Some(NodeKind::Property))
3277 .expect("A.x must be Property (explicit declaration wins)");
3278
3279 let edges = typeof_edges_for_node(&staging, "A.x");
3283 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3284 assert!(
3285 target_types.contains(&"int"),
3286 "explicit `int` TypeOf must be present, got {target_types:?}",
3287 );
3288 assert!(
3289 !target_types.contains(&"string"),
3290 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3291 );
3292 }
3293
3294 #[test]
3298 fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
3299 let src = "<?php
3300class B {
3301 public int $x;
3302 public function __construct(public string $x) {}
3303}
3304";
3305 let staging = build(src);
3306 let n = count_nodes_named(&staging, "B.x");
3307 assert_eq!(
3308 n, 1,
3309 "exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
3310 );
3311 find_node(&staging, "B.x", Some(NodeKind::Property))
3312 .expect("B.x must be Property (explicit declaration wins)");
3313
3314 let edges = typeof_edges_for_node(&staging, "B.x");
3315 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3316 assert!(
3317 target_types.contains(&"int"),
3318 "explicit `int` TypeOf must be present, got {target_types:?}",
3319 );
3320 assert!(
3321 !target_types.contains(&"string"),
3322 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3323 );
3324 }
3325
3326 #[test]
3329 fn req_r0023_span_anchored_on_declaration() {
3330 let src = "<?php
3331class W {
3332
3333 public string $marker;
3334}
3335";
3336 let staging = build(src);
3337 let entry =
3338 find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
3339 assert_eq!(
3347 entry.start_line, 4,
3348 "span start line should match declaration"
3349 );
3350 assert_eq!(entry.end_line, 4, "span end line should match declaration");
3351 assert_eq!(
3352 entry.start_column, 4,
3353 "span start column should match indentation of `public`"
3354 );
3355 assert!(
3356 entry.end_column > entry.start_column,
3357 "span end column must extend past start (got start={}, end={})",
3358 entry.start_column,
3359 entry.end_column,
3360 );
3361 }
3362
3363 #[test]
3366 fn req_r0001_trait_property_emitted() {
3367 let src = "<?php
3368trait Loggable {
3369 protected ?string $logTag;
3370}
3371";
3372 let staging = build(src);
3373 let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
3374 .expect("trait property must be emitted");
3375 let vis = resolve_visibility(&staging, entry.visibility);
3376 assert_eq!(vis.as_deref(), Some("protected"));
3377 }
3378
3379 #[test]
3380 fn no_emission_outside_class_or_trait_or_interface() {
3381 let src = "<?php
3384$x = 1;
3385function f() { $y = 2; }
3386";
3387 let staging = build(src);
3388 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
3389 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
3390 }
3391}
3392
3393pub struct PhpShapeMapping {
3399 cf_by_kind_id: Vec<Option<CfBucket>>,
3400}
3401
3402impl PhpShapeMapping {
3403 fn build() -> Self {
3404 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3405 let count = lang.node_kind_count();
3406 let mut cf_by_kind_id = vec![None; count];
3407 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
3408 let Ok(kind_id) = u16::try_from(id) else {
3409 break;
3410 };
3411 if !lang.node_kind_is_named(kind_id) {
3412 continue;
3413 }
3414 if let Some(name) = lang.node_kind_for_id(kind_id) {
3415 *slot = cf_bucket_for_php_kind(name);
3416 }
3417 }
3418 Self { cf_by_kind_id }
3419 }
3420}
3421
3422impl ShapeMapping for PhpShapeMapping {
3423 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
3424 self.cf_by_kind_id
3425 .get(ts_node_kind_id as usize)
3426 .copied()
3427 .flatten()
3428 }
3429
3430 fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
3431 let mut shape = SignatureShape::default();
3432 if let Some(params) = fn_node.child_by_field_name("parameters") {
3433 let mut cursor = params.walk();
3434 for child in params.named_children(&mut cursor) {
3435 match child.kind() {
3436 "simple_parameter" | "property_promotion_parameter" => {
3437 shape.arity_positional = shape.arity_positional.saturating_add(1);
3438 if child.child_by_field_name("default_value").is_some() {
3439 shape.has_defaults = true;
3440 }
3441 }
3442 "variadic_parameter" => shape.has_varargs = true,
3443 _ => {}
3444 }
3445 }
3446 }
3447 shape.has_return_annotation = fn_node.child_by_field_name("return_type").is_some();
3448 shape
3449 }
3450}
3451
3452fn cf_bucket_for_php_kind(name: &str) -> Option<CfBucket> {
3455 let bucket = match name {
3456 "if_statement"
3457 | "else_if_clause"
3458 | "else_clause"
3459 | "conditional_expression"
3460 | "match_conditional_expression" => CfBucket::Branch,
3461 "while_statement" | "do_statement" | "for_statement" | "foreach_statement" => {
3462 CfBucket::Loop
3463 }
3464 "switch_statement" | "case_statement" | "default_statement" | "match_expression"
3465 | "match_block" => CfBucket::Match,
3466 "try_statement" => CfBucket::Try,
3467 "catch_clause" => CfBucket::Catch,
3468 "finally_clause" => CfBucket::Resource,
3469 "throw_expression" => CfBucket::Throw,
3470 "return_statement" => CfBucket::Return,
3471 "yield_expression" => CfBucket::Yield,
3472 "break_statement" | "continue_statement" => CfBucket::BreakContinue,
3473 "function_call_expression"
3474 | "member_call_expression"
3475 | "scoped_call_expression"
3476 | "nullsafe_member_call_expression"
3477 | "object_creation_expression" => CfBucket::Call,
3478 "assignment_expression" | "augmented_assignment_expression" => CfBucket::Assign,
3479 "anonymous_function" | "arrow_function" => CfBucket::Closure,
3480 _ => return None,
3481 };
3482 Some(bucket)
3483}
3484
3485#[must_use]
3487pub fn php_shape_mapping() -> &'static PhpShapeMapping {
3488 static MAPPING: OnceLock<PhpShapeMapping> = OnceLock::new();
3489 MAPPING.get_or_init(PhpShapeMapping::build)
3490}
3491
3492#[cfg(test)]
3493mod shape_tests {
3494 use super::{cf_bucket_for_php_kind, php_shape_mapping};
3498 use sqry_core::graph::unified::build::shape::{
3499 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3500 };
3501 use tree_sitter::{Node, Parser, Tree};
3502
3503 const SAMPLE: &str = include_str!(concat!(
3504 env!("CARGO_MANIFEST_DIR"),
3505 "/../test-fixtures/shape/dynamic/php.php"
3506 ));
3507
3508 fn parse(src: &str) -> Tree {
3509 let mut parser = Parser::new();
3510 parser
3511 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
3512 .expect("load php grammar");
3513 parser.parse(src, None).expect("parse php")
3514 }
3515
3516 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
3517 let root = tree.root_node();
3518 let mut cursor = root.walk();
3519 for child in root.named_children(&mut cursor) {
3520 if child.kind() == "function_definition" {
3521 return child;
3522 }
3523 }
3524 panic!("no function_definition in php fixture");
3525 }
3526
3527 #[test]
3528 fn mapping_is_non_empty_and_covers_real_kinds() {
3529 assert_eq!(
3530 cf_bucket_for_php_kind("if_statement"),
3531 Some(CfBucket::Branch)
3532 );
3533 assert_eq!(
3534 cf_bucket_for_php_kind("while_statement"),
3535 Some(CfBucket::Loop)
3536 );
3537 assert_eq!(
3538 cf_bucket_for_php_kind("switch_statement"),
3539 Some(CfBucket::Match)
3540 );
3541 assert_eq!(cf_bucket_for_php_kind("try_statement"), Some(CfBucket::Try));
3542 assert_eq!(
3543 cf_bucket_for_php_kind("catch_clause"),
3544 Some(CfBucket::Catch)
3545 );
3546 assert_eq!(
3547 cf_bucket_for_php_kind("finally_clause"),
3548 Some(CfBucket::Resource)
3549 );
3550 assert_eq!(
3551 cf_bucket_for_php_kind("throw_expression"),
3552 Some(CfBucket::Throw)
3553 );
3554 assert_eq!(
3555 cf_bucket_for_php_kind("anonymous_function"),
3556 Some(CfBucket::Closure)
3557 );
3558 assert_eq!(cf_bucket_for_php_kind("nope"), None);
3559
3560 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3561 let id = (0..lang.node_kind_count())
3562 .map(|i| i as u16)
3563 .find(|&i| {
3564 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
3565 })
3566 .expect("grammar exposes named if_statement");
3567 assert_eq!(php_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
3568 }
3569
3570 #[test]
3571 fn descriptor_covers_fixture_control_flow() {
3572 let tree = parse(SAMPLE);
3573 let func = first_function(&tree);
3574 let descriptor = compute_shape_descriptor(
3575 func,
3576 SAMPLE.as_bytes(),
3577 php_shape_mapping(),
3578 &ShapeBudget::default(),
3579 );
3580 let hist = descriptor.cf_histogram;
3581 assert!(hist[CfBucket::Branch.index()] >= 1, "branch");
3582 assert!(hist[CfBucket::Loop.index()] >= 1, "loop");
3583 assert!(hist[CfBucket::Match.index()] >= 1, "switch/case");
3584 assert!(hist[CfBucket::Try.index()] >= 1, "try");
3585 assert!(hist[CfBucket::Catch.index()] >= 1, "catch");
3586 assert!(hist[CfBucket::Resource.index()] >= 1, "finally");
3587 assert!(hist[CfBucket::Throw.index()] >= 1, "throw");
3588 assert!(hist[CfBucket::Return.index()] >= 1, "return");
3589 assert!(hist[CfBucket::Call.index()] >= 1, "call");
3590 assert!(hist[CfBucket::Closure.index()] >= 1, "closure");
3591 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/continue");
3592 }
3593
3594 #[test]
3595 fn signature_shape_reads_arity_and_return() {
3596 let tree = parse(SAMPLE);
3597 let func = first_function(&tree);
3598 let shape = php_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3599 assert_eq!(shape.arity_positional, 2, "value + label");
3601 assert!(shape.has_defaults, "label has a default");
3602 assert!(shape.has_varargs, "...$rest");
3603 assert!(shape.has_return_annotation, ": string");
3604 }
3605}