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 = Span::from_bytes(context.span.0, context.span.1);
92
93 let node_id = match &context.kind {
94 ContextKind::Function { is_async } => helper.add_function_with_signature(
95 qualified_name,
96 Some(span),
97 *is_async,
98 false, 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 span: (usize, usize),
183 kind: ContextKind,
184 class_name: Option<String>,
185 return_type: Option<String>,
186}
187
188struct ASTGraph {
189 contexts: Vec<CallContext>,
190 node_to_context: HashMap<usize, usize>,
191}
192
193impl ASTGraph {
194 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
195 let mut contexts = Vec::new();
196 let mut node_to_context = HashMap::new();
197 let mut scope_stack: Vec<String> = Vec::new();
198 let mut class_stack: Vec<String> = Vec::new();
199
200 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
202 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
203 let file_ops_depth = recursion_limits
204 .effective_file_ops_depth()
205 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
206 let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
207 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
208
209 let mut walk_ctx = WalkContext {
210 contexts: &mut contexts,
211 node_to_context: &mut node_to_context,
212 scope_stack: &mut scope_stack,
213 class_stack: &mut class_stack,
214 max_depth,
215 };
216
217 walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
218
219 Ok(Self {
220 contexts,
221 node_to_context,
222 })
223 }
224
225 fn contexts(&self) -> &[CallContext] {
226 &self.contexts
227 }
228
229 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
230 self.node_to_context
231 .get(&node_id)
232 .and_then(|idx| self.contexts.get(*idx))
233 }
234}
235
236#[allow(
237 clippy::too_many_lines,
238 reason = "PHP namespace and scope handling requires a large, unified traversal."
239)]
240struct WalkContext<'a> {
245 contexts: &'a mut Vec<CallContext>,
246 node_to_context: &'a mut HashMap<usize, usize>,
247 scope_stack: &'a mut Vec<String>,
248 class_stack: &'a mut Vec<String>,
249 max_depth: usize,
250}
251
252#[allow(clippy::too_many_lines)]
253fn walk_ast(
254 node: Node,
255 content: &[u8],
256 ctx: &mut WalkContext,
257 guard: &mut sqry_core::query::security::RecursionGuard,
258) -> Result<(), String> {
259 guard
260 .enter()
261 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
262
263 if ctx.scope_stack.len() > ctx.max_depth {
264 guard.exit();
265 return Ok(());
266 }
267
268 match node.kind() {
269 "program" => {
270 let mut active_namespace_parts: Vec<String> = Vec::new();
273
274 let mut cursor = node.walk();
275 for child in node.children(&mut cursor) {
276 if child.kind() == "namespace_definition" {
277 let has_body = child
279 .children(&mut child.walk())
280 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
281
282 let ns_name = child
283 .child_by_field_name("name")
284 .and_then(|n| n.utf8_text(content).ok())
285 .map(|s| s.trim().to_string())
286 .unwrap_or_default();
287
288 if has_body {
289 for _ in 0..active_namespace_parts.len() {
296 ctx.scope_stack.pop();
297 }
298 active_namespace_parts.clear();
299
300 let ns_parts: Vec<String> = if ns_name.is_empty() {
301 Vec::new()
302 } else {
303 ns_name.split('\\').map(ToString::to_string).collect()
304 };
305
306 for part in &ns_parts {
307 ctx.scope_stack.push(part.clone());
308 }
309
310 for ns_child in child.children(&mut child.walk()) {
312 if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
313 {
314 for body_child in ns_child.children(&mut ns_child.walk()) {
315 walk_ast(body_child, content, ctx, guard)?;
316 }
317 }
318 }
319
320 for _ in 0..ns_parts.len() {
321 ctx.scope_stack.pop();
322 }
323 } else {
324 for _ in 0..active_namespace_parts.len() {
327 ctx.scope_stack.pop();
328 }
329
330 active_namespace_parts = if ns_name.is_empty() {
332 Vec::new()
333 } else {
334 ns_name.split('\\').map(ToString::to_string).collect()
335 };
336
337 for part in &active_namespace_parts {
339 ctx.scope_stack.push(part.clone());
340 }
341 }
342 } else {
343 walk_ast(child, content, ctx, guard)?;
345 }
346 }
347
348 for _ in 0..active_namespace_parts.len() {
350 ctx.scope_stack.pop();
351 }
352
353 guard.exit();
354 return Ok(());
355 }
356 "namespace_definition" => {
357 let namespace_name = node
360 .child_by_field_name("name")
361 .and_then(|n| n.utf8_text(content).ok())
362 .map(|s| s.trim().to_string())
363 .unwrap_or_default();
364
365 let namespace_parts: Vec<String> = if namespace_name.is_empty() {
366 Vec::new()
367 } else {
368 namespace_name
369 .split('\\')
370 .map(ToString::to_string)
371 .collect()
372 };
373
374 let parts_count = namespace_parts.len();
375 for part in &namespace_parts {
376 ctx.scope_stack.push(part.clone());
377 }
378
379 let mut cursor = node.walk();
381 for child in node.children(&mut cursor) {
382 if matches!(child.kind(), "compound_statement" | "declaration_list") {
383 let mut body_cursor = child.walk();
384 for body_child in child.children(&mut body_cursor) {
385 walk_ast(body_child, content, ctx, guard)?;
386 }
387 }
388 }
389
390 for _ in 0..parts_count {
392 ctx.scope_stack.pop();
393 }
394 }
395 "class_declaration" => {
396 let name_node = node
397 .child_by_field_name("name")
398 .ok_or_else(|| "class_declaration missing name".to_string())?;
399 let class_name = name_node
400 .utf8_text(content)
401 .map_err(|_| "failed to read class name".to_string())?;
402
403 let qualified_class = if ctx.scope_stack.is_empty() {
405 class_name.to_string()
406 } else {
407 format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
408 };
409
410 ctx.class_stack.push(qualified_class.clone());
411 ctx.scope_stack.push(class_name.to_string());
412
413 let _context_idx = ctx.contexts.len();
415 ctx.contexts.push(CallContext {
416 qualified_name: qualified_class.clone(),
417 span: (node.start_byte(), node.end_byte()),
418 kind: ContextKind::Class,
419 class_name: Some(qualified_class),
420 return_type: None, });
422
423 let mut cursor = node.walk();
425 for child in node.children(&mut cursor) {
426 if child.kind() == "declaration_list" {
427 let mut body_cursor = child.walk();
428 for body_child in child.children(&mut body_cursor) {
429 walk_ast(body_child, content, ctx, guard)?;
430 }
431 }
432 }
433
434 ctx.class_stack.pop();
435 ctx.scope_stack.pop();
436 }
437 "function_definition" | "method_declaration" => {
438 let name_node = node
439 .child_by_field_name("name")
440 .ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
441 let func_name = name_node
442 .utf8_text(content)
443 .map_err(|_| "failed to read function name".to_string())?;
444
445 let is_async = false; let is_static = node
450 .children(&mut node.walk())
451 .any(|child| child.kind() == "static_modifier");
452
453 let visibility = extract_visibility(&node, content);
455
456 let return_type = extract_return_type(&node, content);
458
459 let is_method = !ctx.class_stack.is_empty();
461 let class_name = ctx.class_stack.last().cloned();
462
463 let qualified_func = if is_method {
467 if let Some(ref class) = class_name {
469 format!("{class}::{func_name}")
470 } else {
471 func_name.to_string()
472 }
473 } else {
474 if ctx.scope_stack.is_empty() {
476 func_name.to_string()
477 } else {
478 format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
479 }
480 };
481
482 let kind = if is_method {
483 ContextKind::Method {
484 is_async,
485 is_static,
486 visibility: visibility.clone(),
487 }
488 } else {
489 ContextKind::Function { is_async }
490 };
491
492 let context_idx = ctx.contexts.len();
493 ctx.contexts.push(CallContext {
494 qualified_name: qualified_func.clone(),
495 span: (node.start_byte(), node.end_byte()),
496 kind,
497 class_name,
498 return_type,
499 });
500
501 if let Some(body) = node.child_by_field_name("body") {
503 associate_descendants(body, context_idx, ctx.node_to_context);
504 }
505
506 ctx.scope_stack.push(func_name.to_string());
507
508 if let Some(body) = node.child_by_field_name("body") {
510 let mut cursor = body.walk();
511 for child in body.children(&mut cursor) {
512 walk_ast(child, content, ctx, guard)?;
513 }
514 }
515
516 ctx.scope_stack.pop();
517 }
518 _ => {
519 let mut cursor = node.walk();
521 for child in node.children(&mut cursor) {
522 walk_ast(child, content, ctx, guard)?;
523 }
524 }
525 }
526
527 guard.exit();
528 Ok(())
529}
530
531fn associate_descendants(
532 node: Node,
533 context_idx: usize,
534 node_to_context: &mut HashMap<usize, usize>,
535) {
536 node_to_context.insert(node.id(), context_idx);
537
538 let mut stack = vec![node];
539 while let Some(current) = stack.pop() {
540 node_to_context.insert(current.id(), context_idx);
541
542 let mut cursor = current.walk();
543 for child in current.children(&mut cursor) {
544 stack.push(child);
545 }
546 }
547}
548
549#[allow(clippy::only_used_in_recursion)]
555fn walk_tree_for_edges(
556 node: Node,
557 content: &[u8],
558 ast_graph: &ASTGraph,
559 helper: &mut GraphBuildHelper,
560 node_map: &mut HashMap<String, NodeId>,
561) -> GraphResult<()> {
562 match node.kind() {
563 "function_call_expression" => {
564 process_function_call(node, content, ast_graph, helper, node_map);
565 }
566 "member_call_expression" | "nullsafe_member_call_expression" => {
567 process_member_call(node, content, ast_graph, helper, node_map);
568 }
569 "scoped_call_expression" => {
570 process_static_call(node, content, ast_graph, helper, node_map);
571 }
572 "namespace_use_declaration" => {
574 process_namespace_use(node, content, helper);
575 }
576 "expression_statement" => {
578 let mut cursor = node.walk();
580 for child in node.children(&mut cursor) {
581 match child.kind() {
582 "require_expression"
583 | "require_once_expression"
584 | "include_expression"
585 | "include_once_expression" => {
586 process_file_include(child, content, helper);
587 }
588 _ => {}
589 }
590 }
591 }
592 _ => {}
593 }
594
595 let mut cursor = node.walk();
597 for child in node.children(&mut cursor) {
598 walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
599 }
600
601 Ok(())
602}
603
604fn process_function_call(
605 node: Node,
606 content: &[u8],
607 ast_graph: &ASTGraph,
608 helper: &mut GraphBuildHelper,
609 node_map: &mut HashMap<String, NodeId>,
610) {
611 let Some(function_node) = node.child_by_field_name("function") else {
612 return;
613 };
614
615 let Ok(callee_name) = function_node.utf8_text(content) else {
616 return;
617 };
618
619 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
621 return;
622 };
623
624 let source_id = *node_map
626 .entry(call_context.qualified_name.clone())
627 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
628
629 let call_span = span_from_node(node);
631 let target_id = *node_map
632 .entry(callee_name.to_string())
633 .or_insert_with(|| helper.ensure_callee(callee_name, call_span, CalleeKindHint::Function));
634
635 let argument_count = count_call_arguments(node);
636 helper.add_call_edge_full_with_span(
637 source_id,
638 target_id,
639 argument_count,
640 false,
641 vec![call_span],
642 );
643}
644
645fn process_member_call(
646 node: Node,
647 content: &[u8],
648 ast_graph: &ASTGraph,
649 helper: &mut GraphBuildHelper,
650 node_map: &mut HashMap<String, NodeId>,
651) {
652 let Some(method_node) = node.child_by_field_name("name") else {
653 return;
654 };
655
656 let Ok(method_name) = method_node.utf8_text(content) else {
657 return;
658 };
659
660 if let Some(object_node) = node.child_by_field_name("object")
662 && is_php_ffi_call(object_node, content)
663 {
664 process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
665 return;
666 }
667
668 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
670 return;
671 };
672
673 let callee_qualified = if let Some(class_name) = &call_context.class_name {
675 format!("{class_name}::{method_name}")
676 } else {
677 method_name.to_string()
678 };
679
680 let source_id = *node_map
682 .entry(call_context.qualified_name.clone())
683 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
684
685 let call_span = span_from_node(node);
687 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
688 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
689 });
690
691 let argument_count = count_call_arguments(node);
692 helper.add_call_edge_full_with_span(
693 source_id,
694 target_id,
695 argument_count,
696 false,
697 vec![call_span],
698 );
699}
700
701fn process_static_call(
702 node: Node,
703 content: &[u8],
704 ast_graph: &ASTGraph,
705 helper: &mut GraphBuildHelper,
706 node_map: &mut HashMap<String, NodeId>,
707) {
708 let Some(scope_node) = node.child_by_field_name("scope") else {
709 return;
710 };
711 let Some(name_node) = node.child_by_field_name("name") else {
712 return;
713 };
714
715 let Ok(class_name) = scope_node.utf8_text(content) else {
716 return;
717 };
718 let Ok(method_name) = name_node.utf8_text(content) else {
719 return;
720 };
721
722 if is_ffi_static_call(class_name, method_name) {
724 process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
725 return;
726 }
727
728 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
730 return;
731 };
732
733 let callee_qualified = format!("{class_name}::{method_name}");
735
736 let source_id = *node_map
738 .entry(call_context.qualified_name.clone())
739 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
740
741 let call_span = span_from_node(node);
743 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
744 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
745 });
746
747 let argument_count = count_call_arguments(node);
748 helper.add_call_edge_full_with_span(
749 source_id,
750 target_id,
751 argument_count,
752 false,
753 vec![call_span],
754 );
755}
756
757fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
770 let file_path = helper.file_path().to_string();
772 let importer_id = helper.add_module(&file_path, None);
773
774 let mut prefix = String::new();
777 let mut cursor = node.walk();
778 for child in node.children(&mut cursor) {
779 if child.kind() == "namespace_name"
780 && let Ok(ns) = child.utf8_text(content)
781 {
782 prefix = ns.trim().to_string();
783 break;
784 }
785 }
786
787 cursor = node.walk();
789 for child in node.children(&mut cursor) {
790 match child.kind() {
791 "namespace_use_clause" => {
792 process_use_clause(child, content, helper, importer_id);
794 }
795 "namespace_use_group" => {
796 process_use_group(child, content, helper, importer_id, &prefix);
799 }
800 _ => {}
801 }
802 }
803}
804
805fn process_use_clause(
817 node: Node,
818 content: &[u8],
819 helper: &mut GraphBuildHelper,
820 import_source_id: NodeId,
821) {
822 process_use_clause_with_prefix(node, content, helper, import_source_id, None);
823}
824
825fn process_use_clause_with_prefix(
827 node: Node,
828 content: &[u8],
829 helper: &mut GraphBuildHelper,
830 import_source_id: NodeId,
831 prefix: Option<&str>,
832) {
833 let mut qualified_name = None;
835 let mut alias = None;
836 let mut found_as = false;
837
838 let mut cursor = node.walk();
839 for child in node.children(&mut cursor) {
840 match child.kind() {
841 "qualified_name" => {
842 if let Ok(name) = child.utf8_text(content) {
844 qualified_name = Some(name.trim().to_string());
845 }
846 }
847 "namespace_name" => {
848 if qualified_name.is_none()
850 && let Ok(name) = child.utf8_text(content)
851 {
852 qualified_name = Some(name.trim().to_string());
853 }
854 }
855 "name" => {
856 if found_as {
858 if let Ok(alias_text) = child.utf8_text(content) {
860 alias = Some(alias_text.trim().to_string());
861 }
862 } else if qualified_name.is_none() {
863 if let Ok(name) = child.utf8_text(content) {
865 qualified_name = Some(name.trim().to_string());
866 }
867 }
868 }
869 "as" => {
870 found_as = true;
872 }
873 _ => {}
874 }
875 }
876
877 if let Some(name) = qualified_name
878 && !name.is_empty()
879 {
880 let full_name = if let Some(pfx) = prefix {
882 format!("{pfx}\\{name}")
883 } else {
884 name
885 };
886
887 let span = span_from_node(node);
889 let import_node_id = helper.add_import(&full_name, Some(span));
890
891 if let Some(alias_str) = alias {
893 helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
894 } else {
895 helper.add_import_edge(import_source_id, import_node_id);
896 }
897 }
898}
899
900fn process_use_group(
919 node: Node,
920 content: &[u8],
921 helper: &mut GraphBuildHelper,
922 import_source_id: NodeId,
923 prefix: &str,
924) {
925 let mut cursor = node.walk();
927 for child in node.children(&mut cursor) {
928 if child.kind() == "namespace_use_clause" {
930 process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
932 }
933 }
934}
935
936fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
938 let file_path = helper.file_path().to_string();
940 let import_source_id = helper.add_module(&file_path, None);
941
942 let mut cursor = node.walk();
945 for child in node.children(&mut cursor) {
946 if child.kind() == "string"
947 || child.kind() == "encapsed_string"
948 || child.kind() == "binary_expression"
949 {
950 if let Ok(path_text) = child.utf8_text(content) {
951 let cleaned_path = path_text
953 .trim()
954 .trim_start_matches(['\'', '"'])
955 .trim_end_matches(['\'', '"'])
956 .to_string();
957
958 if !cleaned_path.is_empty() {
959 let span = span_from_node(node);
960 let import_node_id = helper.add_import(&cleaned_path, Some(span));
961 helper.add_import_edge(import_source_id, import_node_id);
962 }
963 }
964 break;
965 }
966 }
967}
968
969fn process_oop_relationships(
975 node: Node,
976 content: &[u8],
977 helper: &mut GraphBuildHelper,
978 node_map: &mut HashMap<String, NodeId>,
979) {
980 let kind = node.kind();
981 if kind == "class_declaration" {
982 process_class_oop(node, content, helper, node_map);
983 } else if kind == "interface_declaration" {
984 process_interface_inheritance(node, content, helper, node_map);
985 }
986
987 let mut cursor = node.walk();
989 for child in node.children(&mut cursor) {
990 process_oop_relationships(child, content, helper, node_map);
991 }
992}
993
994fn process_class_oop(
996 node: Node,
997 content: &[u8],
998 helper: &mut GraphBuildHelper,
999 node_map: &mut HashMap<String, NodeId>,
1000) {
1001 let Some(name_node) = node.child_by_field_name("name") else {
1003 return;
1004 };
1005 let Ok(class_name) = name_node.utf8_text(content) else {
1006 return;
1007 };
1008 let class_name = class_name.trim();
1009
1010 let span = span_from_node(node);
1012 let class_id = *node_map
1013 .entry(class_name.to_string())
1014 .or_insert_with(|| helper.add_class(class_name, Some(span)));
1015 helper.mark_definition(class_id);
1017
1018 let mut cursor = node.walk();
1020 for child in node.children(&mut cursor) {
1021 match child.kind() {
1022 "base_clause" => {
1023 process_extends_clause(child, content, helper, node_map, class_id);
1025 }
1026 "class_interface_clause" => {
1027 process_implements_clause(child, content, helper, node_map, class_id);
1029 }
1030 "declaration_list" => {
1031 process_class_body_traits(child, content, helper, node_map, class_id);
1033 }
1034 _ => {}
1035 }
1036 }
1037}
1038
1039fn process_extends_clause(
1041 node: Node,
1042 content: &[u8],
1043 helper: &mut GraphBuildHelper,
1044 node_map: &mut HashMap<String, NodeId>,
1045 class_id: NodeId,
1046) {
1047 let mut cursor = node.walk();
1049 for child in node.children(&mut cursor) {
1050 if child.kind() == "name"
1051 || child.kind() == "qualified_name"
1052 || child.kind() == "namespace_name"
1053 {
1054 if let Ok(parent_name) = child.utf8_text(content) {
1055 let parent_name = parent_name.trim();
1056 if !parent_name.is_empty() {
1057 let span = span_from_node(child);
1058 let parent_id = *node_map
1059 .entry(parent_name.to_string())
1060 .or_insert_with(|| helper.add_class(parent_name, Some(span)));
1061
1062 helper.add_inherits_edge(class_id, parent_id);
1063 }
1064 }
1065 break;
1066 }
1067 }
1068}
1069
1070fn process_implements_clause(
1072 node: Node,
1073 content: &[u8],
1074 helper: &mut GraphBuildHelper,
1075 node_map: &mut HashMap<String, NodeId>,
1076 class_id: NodeId,
1077) {
1078 let mut cursor = node.walk();
1080 for child in node.children(&mut cursor) {
1081 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1082 && let Ok(interface_name) = child.utf8_text(content)
1083 {
1084 let interface_name = interface_name.trim();
1085 if !interface_name.is_empty() {
1086 let span = span_from_node(child);
1087 let interface_id = *node_map
1088 .entry(interface_name.to_string())
1089 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1090
1091 helper.add_implements_edge(class_id, interface_id);
1092 }
1093 }
1094 }
1095}
1096
1097fn process_class_body_traits(
1099 declaration_list: Node,
1100 content: &[u8],
1101 helper: &mut GraphBuildHelper,
1102 node_map: &mut HashMap<String, NodeId>,
1103 class_id: NodeId,
1104) {
1105 let mut cursor = declaration_list.walk();
1106 for child in declaration_list.children(&mut cursor) {
1107 if child.kind() == "use_declaration" {
1108 process_trait_use(child, content, helper, node_map, class_id);
1110 }
1111 }
1112}
1113
1114fn process_trait_use(
1116 node: Node,
1117 content: &[u8],
1118 helper: &mut GraphBuildHelper,
1119 node_map: &mut HashMap<String, NodeId>,
1120 class_id: NodeId,
1121) {
1122 let mut cursor = node.walk();
1124 for child in node.children(&mut cursor) {
1125 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1126 && let Ok(trait_name) = child.utf8_text(content)
1127 {
1128 let trait_name = trait_name.trim();
1129 if !trait_name.is_empty() {
1130 let span = span_from_node(child);
1131 let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
1134 helper.add_node(
1135 trait_name,
1136 Some(span),
1137 sqry_core::graph::unified::node::NodeKind::Trait,
1138 )
1139 });
1140
1141 helper.add_implements_edge(class_id, trait_id);
1144 }
1145 }
1146 }
1147}
1148
1149fn process_interface_inheritance(
1151 node: Node,
1152 content: &[u8],
1153 helper: &mut GraphBuildHelper,
1154 node_map: &mut HashMap<String, NodeId>,
1155) {
1156 let Some(name_node) = node.child_by_field_name("name") else {
1158 return;
1159 };
1160 let Ok(interface_name) = name_node.utf8_text(content) else {
1161 return;
1162 };
1163 let interface_name = interface_name.trim();
1164
1165 let span = span_from_node(node);
1167 let interface_id = *node_map
1168 .entry(interface_name.to_string())
1169 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1170 helper.mark_definition(interface_id);
1172
1173 let mut cursor = node.walk();
1175 for child in node.children(&mut cursor) {
1176 if child.kind() == "base_clause" {
1177 let mut base_cursor = child.walk();
1179 for base_child in child.children(&mut base_cursor) {
1180 if matches!(
1181 base_child.kind(),
1182 "name" | "qualified_name" | "namespace_name"
1183 ) && let Ok(parent_name) = base_child.utf8_text(content)
1184 {
1185 let parent_name = parent_name.trim();
1186 if !parent_name.is_empty() {
1187 let span = span_from_node(base_child);
1188 let parent_id = *node_map
1189 .entry(parent_name.to_string())
1190 .or_insert_with(|| helper.add_interface(parent_name, Some(span)));
1191
1192 helper.add_inherits_edge(interface_id, parent_id);
1194 }
1195 }
1196 }
1197 }
1198 }
1199}
1200
1201fn process_exports(
1220 node: Node,
1221 content: &[u8],
1222 helper: &mut GraphBuildHelper,
1223 node_map: &mut HashMap<String, NodeId>,
1224) {
1225 let file_path = helper.file_path().to_string();
1227 let module_id = helper.add_module(&file_path, None);
1228
1229 if node.kind() != "program" {
1231 return;
1232 }
1233
1234 let mut active_namespace = String::new();
1236
1237 let mut cursor = node.walk();
1239 for child in node.children(&mut cursor) {
1240 process_top_level_for_export(
1241 child,
1242 content,
1243 helper,
1244 node_map,
1245 module_id,
1246 &mut active_namespace,
1247 );
1248 }
1249}
1250
1251fn process_top_level_for_export(
1261 node: Node,
1262 content: &[u8],
1263 helper: &mut GraphBuildHelper,
1264 node_map: &mut HashMap<String, NodeId>,
1265 module_id: NodeId,
1266 active_namespace: &mut String,
1267) {
1268 match node.kind() {
1269 "namespace_definition" => {
1270 let ns_name = node
1272 .child_by_field_name("name")
1273 .and_then(|n| n.utf8_text(content).ok())
1274 .map(|s| s.trim().to_string())
1275 .unwrap_or_default();
1276
1277 let has_body = node
1279 .children(&mut node.walk())
1280 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
1281
1282 if has_body {
1283 active_namespace.clear();
1289
1290 let mut cursor = node.walk();
1292 for child in node.children(&mut cursor) {
1293 if matches!(child.kind(), "compound_statement" | "declaration_list") {
1294 let mut body_cursor = child.walk();
1295 for body_child in child.children(&mut body_cursor) {
1296 export_declaration_if_exportable(
1297 body_child, content, helper, node_map, module_id, &ns_name,
1298 );
1299 }
1300 }
1301 }
1302 } else {
1303 *active_namespace = ns_name;
1306 }
1307 }
1308 "class_declaration"
1310 | "interface_declaration"
1311 | "trait_declaration"
1312 | "enum_declaration"
1313 | "function_definition" => {
1314 export_declaration_if_exportable(
1315 node,
1316 content,
1317 helper,
1318 node_map,
1319 module_id,
1320 active_namespace,
1321 );
1322 }
1323 _ => {
1324 }
1327 }
1328}
1329
1330fn lookup_or_create_node<F>(
1337 node_map: &mut HashMap<String, NodeId>,
1338 qualified_name: &str,
1339 simple_name: &str,
1340 namespace_prefix: &str,
1341 create_fn: F,
1342) -> NodeId
1343where
1344 F: FnOnce() -> NodeId,
1345{
1346 if let Some(&id) = node_map.get(qualified_name) {
1348 return id;
1349 }
1350
1351 if namespace_prefix.is_empty()
1354 && let Some(&id) = node_map.get(simple_name)
1355 {
1356 return id;
1357 }
1358
1359 let id = create_fn();
1361 node_map.insert(qualified_name.to_string(), id);
1362 id
1363}
1364
1365#[allow(clippy::too_many_lines)] fn export_declaration_if_exportable(
1380 node: Node,
1381 content: &[u8],
1382 helper: &mut GraphBuildHelper,
1383 node_map: &mut HashMap<String, NodeId>,
1384 module_id: NodeId,
1385 namespace_prefix: &str,
1386) {
1387 match node.kind() {
1388 "class_declaration" => {
1389 if let Some(name_node) = node.child_by_field_name("name")
1390 && let Ok(class_name) = name_node.utf8_text(content)
1391 {
1392 let simple_name = class_name.trim().to_string();
1393 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1394 let span = span_from_node(node);
1395
1396 let class_id = lookup_or_create_node(
1397 node_map,
1398 &qualified_name,
1399 &simple_name,
1400 namespace_prefix,
1401 || helper.add_class(&qualified_name, Some(span)),
1402 );
1403 helper.mark_definition(class_id);
1405
1406 helper.add_export_edge(module_id, class_id);
1407
1408 export_public_methods_from_class(
1410 node,
1411 content,
1412 helper,
1413 node_map,
1414 module_id,
1415 &qualified_name,
1416 );
1417 }
1418 }
1419 "interface_declaration" => {
1420 if let Some(name_node) = node.child_by_field_name("name")
1421 && let Ok(interface_name) = name_node.utf8_text(content)
1422 {
1423 let simple_name = interface_name.trim().to_string();
1424 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1425 let span = span_from_node(node);
1426
1427 let interface_id = lookup_or_create_node(
1428 node_map,
1429 &qualified_name,
1430 &simple_name,
1431 namespace_prefix,
1432 || helper.add_interface(&qualified_name, Some(span)),
1433 );
1434 helper.mark_definition(interface_id);
1436
1437 helper.add_export_edge(module_id, interface_id);
1438 }
1439 }
1440 "trait_declaration" => {
1441 if let Some(name_node) = node.child_by_field_name("name")
1442 && let Ok(trait_name) = name_node.utf8_text(content)
1443 {
1444 let simple_name = trait_name.trim().to_string();
1445 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1446 let span = span_from_node(node);
1447
1448 let trait_id = lookup_or_create_node(
1449 node_map,
1450 &qualified_name,
1451 &simple_name,
1452 namespace_prefix,
1453 || {
1454 helper.add_node(
1455 &qualified_name,
1456 Some(span),
1457 sqry_core::graph::unified::node::NodeKind::Trait,
1458 )
1459 },
1460 );
1461 helper.mark_definition(trait_id);
1463
1464 helper.add_export_edge(module_id, trait_id);
1465 }
1466 }
1467 "enum_declaration" => {
1468 if let Some(name_node) = node.child_by_field_name("name")
1470 && let Ok(enum_name) = name_node.utf8_text(content)
1471 {
1472 let simple_name = enum_name.trim().to_string();
1473 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1474 let span = span_from_node(node);
1475
1476 let enum_id = lookup_or_create_node(
1477 node_map,
1478 &qualified_name,
1479 &simple_name,
1480 namespace_prefix,
1481 || helper.add_enum(&qualified_name, Some(span)),
1482 );
1483
1484 helper.add_export_edge(module_id, enum_id);
1485 }
1486 }
1487 "function_definition" => {
1488 if let Some(name_node) = node.child_by_field_name("name")
1490 && let Ok(func_name) = name_node.utf8_text(content)
1491 {
1492 let simple_name = func_name.trim().to_string();
1493 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1494 let span = span_from_node(node);
1495
1496 let func_id = lookup_or_create_node(
1497 node_map,
1498 &qualified_name,
1499 &simple_name,
1500 namespace_prefix,
1501 || helper.add_function(&qualified_name, Some(span), false, false),
1502 );
1503 helper.mark_definition(func_id);
1505
1506 helper.add_export_edge(module_id, func_id);
1507 }
1508 }
1509 _ => {
1510 }
1512 }
1513}
1514
1515fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
1517 if namespace_prefix.is_empty() {
1518 name.to_string()
1519 } else {
1520 format!("{namespace_prefix}\\{name}")
1521 }
1522}
1523
1524fn span_from_node(node: Node<'_>) -> Span {
1526 let start = node.start_position();
1527 let end = node.end_position();
1528 Span::new(
1529 sqry_core::graph::node::Position::new(start.row, start.column),
1530 sqry_core::graph::node::Position::new(end.row, end.column),
1531 )
1532}
1533
1534fn count_call_arguments(call_node: Node<'_>) -> u8 {
1535 let args_node = call_node
1536 .child_by_field_name("arguments")
1537 .or_else(|| call_node.child_by_field_name("argument_list"))
1538 .or_else(|| {
1539 let mut cursor = call_node.walk();
1540 call_node
1541 .children(&mut cursor)
1542 .find(|child| child.kind() == "argument_list")
1543 });
1544
1545 let Some(args_node) = args_node else {
1546 return 255;
1547 };
1548 let count = args_node.named_child_count();
1549 if count <= 254 {
1550 u8::try_from(count).unwrap_or(u8::MAX)
1551 } else {
1552 255
1553 }
1554}
1555
1556fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
1561 let mut cursor = node.walk();
1563 for child in node.children(&mut cursor) {
1564 match child.kind() {
1565 "visibility_modifier" => {
1566 if let Ok(vis_text) = child.utf8_text(content) {
1568 return Some(vis_text.trim().to_string());
1569 }
1570 }
1571 "public" | "private" | "protected" => {
1572 if let Ok(vis_text) = child.utf8_text(content) {
1574 return Some(vis_text.trim().to_string());
1575 }
1576 }
1577 _ => {}
1578 }
1579 }
1580
1581 None
1585}
1586
1587fn export_public_methods_from_class(
1593 class_node: Node,
1594 content: &[u8],
1595 helper: &mut GraphBuildHelper,
1596 node_map: &mut HashMap<String, NodeId>,
1597 module_id: NodeId,
1598 class_qualified_name: &str,
1599) {
1600 let mut cursor = class_node.walk();
1602 for child in class_node.children(&mut cursor) {
1603 if child.kind() == "declaration_list" {
1604 let mut body_cursor = child.walk();
1606 for body_child in child.children(&mut body_cursor) {
1607 if body_child.kind() == "method_declaration" {
1608 let visibility = extract_visibility(&body_child, content);
1610
1611 let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
1613
1614 if is_public {
1615 if let Some(name_node) = body_child.child_by_field_name("name")
1617 && let Ok(method_name) = name_node.utf8_text(content)
1618 {
1619 let method_name = method_name.trim();
1620 let qualified_method_name =
1621 format!("{class_qualified_name}::{method_name}");
1622
1623 if let Some(&method_id) = node_map.get(&qualified_method_name) {
1625 helper.add_export_edge(module_id, method_id);
1626 }
1627 }
1628 }
1629 }
1630 }
1631 break;
1632 }
1633 }
1634}
1635
1636fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
1655 let mut found_colon = false;
1657 let mut cursor = node.walk();
1658 for child in node.children(&mut cursor) {
1659 if found_colon && child.is_named() {
1660 return extract_type_from_node(&child, content);
1662 }
1663 if child.kind() == ":" {
1664 found_colon = true;
1665 }
1666 }
1667 None
1668}
1669
1670fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
1684 match type_node.kind() {
1685 "primitive_type" => {
1686 type_node
1688 .utf8_text(content)
1689 .ok()
1690 .map(|s| s.trim().to_string())
1691 }
1692 "optional_type" => {
1693 let mut cursor = type_node.walk();
1696 for child in type_node.children(&mut cursor) {
1697 if child.kind() != "?" && child.is_named() {
1698 return extract_type_from_node(&child, content);
1699 }
1700 }
1701 None
1702 }
1703 "union_type" => {
1704 type_node
1707 .named_child(0)
1708 .and_then(|first_type| extract_type_from_node(&first_type, content))
1709 }
1710 "named_type" | "qualified_name" => {
1711 type_node
1713 .utf8_text(content)
1714 .ok()
1715 .map(|s| s.trim().to_string())
1716 }
1717 "intersection_type" => {
1718 type_node
1721 .named_child(0)
1722 .and_then(|first_type| extract_type_from_node(&first_type, content))
1723 }
1724 _ => {
1725 type_node
1730 .utf8_text(content)
1731 .ok()
1732 .map(|s| {
1733 let trimmed = s.trim();
1734 trimmed
1737 .split(&['|', '&'][..])
1738 .next()
1739 .unwrap_or(trimmed)
1740 .trim()
1741 .trim_start_matches('(')
1742 .trim_end_matches(')')
1743 .trim()
1744 .to_string()
1745 })
1746 .filter(|s| !s.is_empty())
1747 }
1748 }
1749}
1750
1751fn process_phpdoc_annotations(
1774 node: Node,
1775 content: &[u8],
1776 helper: &mut GraphBuildHelper,
1777) -> GraphResult<()> {
1778 let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
1780 process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
1781
1782 process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
1786
1787 Ok(())
1788}
1789
1790fn process_phpdoc_pass_a(
1794 node: Node,
1795 content: &[u8],
1796 helper: &mut GraphBuildHelper,
1797 explicit_field_ids: &mut HashSet<NodeId>,
1798) -> GraphResult<()> {
1799 match node.kind() {
1800 "function_definition" => {
1801 process_function_phpdoc(node, content, helper)?;
1802 }
1803 "method_declaration" => {
1804 process_method_phpdoc(node, content, helper)?;
1807 }
1808 "property_declaration" | "simple_property" => {
1809 let emitted = process_property_declaration(node, content, helper);
1814 explicit_field_ids.extend(emitted);
1815 }
1816 _ => {}
1817 }
1818
1819 let mut cursor = node.walk();
1820 for child in node.children(&mut cursor) {
1821 process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
1822 }
1823
1824 Ok(())
1825}
1826
1827fn process_phpdoc_pass_b(
1833 node: Node,
1834 content: &[u8],
1835 helper: &mut GraphBuildHelper,
1836 explicit_field_ids: &HashSet<NodeId>,
1837) {
1838 if node.kind() == "method_declaration" {
1839 process_constructor_promotion(node, content, helper, explicit_field_ids);
1840 }
1841
1842 let mut cursor = node.walk();
1843 for child in node.children(&mut cursor) {
1844 process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
1845 }
1846}
1847
1848fn process_function_phpdoc(
1850 func_node: Node,
1851 content: &[u8],
1852 helper: &mut GraphBuildHelper,
1853) -> GraphResult<()> {
1854 let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1856 return Ok(());
1857 };
1858
1859 let tags = parse_phpdoc_tags(&phpdoc_text);
1861
1862 let Some(name_node) = func_node.child_by_field_name("name") else {
1864 return Ok(());
1865 };
1866
1867 let function_name = name_node
1868 .utf8_text(content)
1869 .map_err(|_| GraphBuilderError::ParseError {
1870 span: span_from_node(func_node),
1871 reason: "failed to read function name".to_string(),
1872 })?
1873 .trim()
1874 .to_string();
1875
1876 if function_name.is_empty() {
1877 return Ok(());
1878 }
1879
1880 let func_node_id = helper.ensure_callee(
1882 &function_name,
1883 span_from_node(func_node),
1884 CalleeKindHint::Function,
1885 );
1886
1887 let _ast_params = extract_ast_parameters(func_node, content);
1889
1890 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1894 let canonical_type = canonical_type_string(¶m_tag.type_str);
1896 let type_node_id = helper.add_type(&canonical_type, None);
1897 helper.add_typeof_edge_with_context(
1898 func_node_id,
1899 type_node_id,
1900 Some(TypeOfContext::Parameter),
1901 param_idx.try_into().ok(), Some(¶m_tag.name),
1903 );
1904
1905 let type_names = extract_type_names(¶m_tag.type_str);
1907 for type_name in type_names {
1908 let ref_type_id = helper.add_type(&type_name, None);
1909 helper.add_reference_edge(func_node_id, ref_type_id);
1910 }
1911 }
1912
1913 if let Some(return_type) = &tags.returns {
1915 let canonical_type = canonical_type_string(return_type);
1916 let type_node_id = helper.add_type(&canonical_type, None);
1917 helper.add_typeof_edge_with_context(
1918 func_node_id,
1919 type_node_id,
1920 Some(TypeOfContext::Return),
1921 Some(0),
1922 None,
1923 );
1924
1925 let type_names = extract_type_names(return_type);
1927 for type_name in type_names {
1928 let ref_type_id = helper.add_type(&type_name, None);
1929 helper.add_reference_edge(func_node_id, ref_type_id);
1930 }
1931 }
1932
1933 Ok(())
1934}
1935
1936fn process_method_phpdoc(
1938 method_node: Node,
1939 content: &[u8],
1940 helper: &mut GraphBuildHelper,
1941) -> GraphResult<()> {
1942 let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1944 return Ok(());
1945 };
1946
1947 let tags = parse_phpdoc_tags(&phpdoc_text);
1949
1950 let Some(name_node) = method_node.child_by_field_name("name") else {
1952 return Ok(());
1953 };
1954
1955 let method_name = name_node
1956 .utf8_text(content)
1957 .map_err(|_| GraphBuilderError::ParseError {
1958 span: span_from_node(method_node),
1959 reason: "failed to read method name".to_string(),
1960 })?
1961 .trim()
1962 .to_string();
1963
1964 if method_name.is_empty() {
1965 return Ok(());
1966 }
1967
1968 let class_name = get_enclosing_class_name(method_node, content)?;
1970 let Some(class_name) = class_name else {
1971 return Ok(());
1972 };
1973
1974 let qualified_name = format!("{class_name}.{method_name}");
1976
1977 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1980
1981 let _ast_params = extract_ast_parameters(method_node, content);
1983
1984 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1987 let canonical_type = canonical_type_string(¶m_tag.type_str);
1989 let type_node_id = helper.add_type(&canonical_type, None);
1990 helper.add_typeof_edge_with_context(
1991 method_node_id,
1992 type_node_id,
1993 Some(TypeOfContext::Parameter),
1994 param_idx.try_into().ok(),
1995 Some(¶m_tag.name),
1996 );
1997
1998 let type_names = extract_type_names(¶m_tag.type_str);
2000 for type_name in type_names {
2001 let ref_type_id = helper.add_type(&type_name, None);
2002 helper.add_reference_edge(method_node_id, ref_type_id);
2003 }
2004 }
2005
2006 if let Some(return_type) = &tags.returns {
2008 let canonical_type = canonical_type_string(return_type);
2009 let type_node_id = helper.add_type(&canonical_type, None);
2010 helper.add_typeof_edge_with_context(
2011 method_node_id,
2012 type_node_id,
2013 Some(TypeOfContext::Return),
2014 Some(0),
2015 None,
2016 );
2017
2018 let type_names = extract_type_names(return_type);
2020 for type_name in type_names {
2021 let ref_type_id = helper.add_type(&type_name, None);
2022 helper.add_reference_edge(method_node_id, ref_type_id);
2023 }
2024 }
2025
2026 Ok(())
2027}
2028
2029fn process_property_declaration(
2050 prop_node: Node,
2051 content: &[u8],
2052 helper: &mut GraphBuildHelper,
2053) -> Vec<NodeId> {
2054 let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
2058 return Vec::new();
2059 };
2060
2061 let mods = extract_property_modifiers(prop_node, content);
2063
2064 let native_type = prop_node
2067 .child_by_field_name("type")
2068 .and_then(|t| extract_type_from_node(&t, content));
2069
2070 let phpdoc_var_type = if native_type.is_none() {
2072 extract_phpdoc_comment(prop_node, content)
2073 .as_deref()
2074 .and_then(|c| parse_phpdoc_tags(c).var_type)
2075 } else {
2076 None
2077 };
2078
2079 let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
2080
2081 let prop_names = extract_property_element_names(prop_node, content);
2082 if prop_names.is_empty() {
2083 return Vec::new();
2084 }
2085
2086 let span = span_from_node(prop_node);
2087 let mut emitted = Vec::with_capacity(prop_names.len());
2088
2089 for prop_name in prop_names {
2090 let qualified_name = format!("{owner_name}.{prop_name}");
2091 let visibility = mods.visibility.as_deref().unwrap_or("public");
2092
2093 let node_id = if mods.is_readonly {
2094 helper.add_constant_with_name_static_and_visibility(
2095 &prop_name,
2096 &qualified_name,
2097 Some(span),
2098 mods.is_static,
2099 Some(visibility),
2100 )
2101 } else {
2102 helper.add_property_with_name_static_and_visibility(
2103 &prop_name,
2104 &qualified_name,
2105 Some(span),
2106 mods.is_static,
2107 Some(visibility),
2108 )
2109 };
2110
2111 if let Some(type_str) = primary_type.as_deref() {
2112 emit_field_type_edges(helper, node_id, &prop_name, type_str);
2113 }
2114
2115 emitted.push(node_id);
2116 }
2117
2118 emitted
2119}
2120
2121fn process_constructor_promotion(
2141 method_node: Node,
2142 content: &[u8],
2143 helper: &mut GraphBuildHelper,
2144 explicit_field_ids: &HashSet<NodeId>,
2145) {
2146 let Some(name_node) = method_node.child_by_field_name("name") else {
2148 return;
2149 };
2150 let Ok(method_name) = name_node.utf8_text(content) else {
2151 return;
2152 };
2153 if method_name.trim() != "__construct" {
2154 return;
2155 }
2156
2157 let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
2158 return;
2159 };
2160
2161 let Some(params_node) = method_node.child_by_field_name("parameters") else {
2162 return;
2163 };
2164
2165 let mut cursor = params_node.walk();
2166 for param in params_node.children(&mut cursor) {
2167 if param.kind() != "property_promotion_parameter" {
2168 continue;
2169 }
2170
2171 let visibility = param
2173 .child_by_field_name("visibility")
2174 .and_then(|v| v.utf8_text(content).ok())
2175 .map(|s| s.trim().to_string());
2176 let is_readonly = param.child_by_field_name("readonly").is_some()
2177 || direct_child_of_kind(param, "readonly_modifier").is_some();
2178 let is_static = false;
2181 let native_type = param
2182 .child_by_field_name("type")
2183 .and_then(|t| extract_type_from_node(&t, content));
2184
2185 let Some(prop_name) = promoted_param_name(param, content) else {
2186 continue;
2187 };
2188
2189 let qualified_name = format!("{owner_name}.{prop_name}");
2190 let span = span_from_node(param);
2191
2192 if let Some(existing_id) = helper.get_node(&qualified_name) {
2197 if explicit_field_ids.contains(&existing_id) {
2198 continue;
2203 }
2204 if let Some(t) = native_type {
2210 emit_field_type_edges(helper, existing_id, &prop_name, &t);
2211 }
2212 continue;
2213 }
2214
2215 let visibility_ref = visibility.as_deref().unwrap_or("public");
2216 let node_id = if is_readonly {
2217 helper.add_constant_with_name_static_and_visibility(
2218 &prop_name,
2219 &qualified_name,
2220 Some(span),
2221 is_static,
2222 Some(visibility_ref),
2223 )
2224 } else {
2225 helper.add_property_with_name_static_and_visibility(
2226 &prop_name,
2227 &qualified_name,
2228 Some(span),
2229 is_static,
2230 Some(visibility_ref),
2231 )
2232 };
2233
2234 if let Some(type_str) = native_type {
2235 emit_field_type_edges(helper, node_id, &prop_name, &type_str);
2236 }
2237 }
2238}
2239
2240struct PropertyModifiers {
2242 visibility: Option<String>,
2243 is_static: bool,
2244 is_readonly: bool,
2245}
2246
2247fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
2251 let mut visibility: Option<String> = None;
2252 let mut is_static = false;
2253 let mut is_readonly = false;
2254
2255 let mut cursor = prop_node.walk();
2256 for child in prop_node.children(&mut cursor) {
2257 match child.kind() {
2258 "visibility_modifier" => {
2259 if let Ok(text) = child.utf8_text(content) {
2260 visibility = Some(text.trim().to_string());
2261 }
2262 }
2263 "var_modifier" => {
2264 if visibility.is_none() {
2267 visibility = Some("public".to_string());
2268 }
2269 }
2270 "static_modifier" => {
2271 is_static = true;
2272 }
2273 "readonly_modifier" => {
2274 is_readonly = true;
2275 }
2276 _ => {}
2277 }
2278 }
2279
2280 PropertyModifiers {
2281 visibility,
2282 is_static,
2283 is_readonly,
2284 }
2285}
2286
2287fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2291 let mut names = Vec::new();
2292 let mut cursor = prop_node.walk();
2293 for child in prop_node.children(&mut cursor) {
2294 if child.kind() != "property_element" {
2295 continue;
2296 }
2297 if let Some(var_node) = child.child_by_field_name("name")
2298 && let Some(name) = strip_dollar_from_variable(var_node, content)
2299 {
2300 names.push(name);
2301 }
2302 }
2303 names
2304}
2305
2306fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
2309 let name_field = param.child_by_field_name("name")?;
2310 let var_node = if name_field.kind() == "variable_name" {
2312 name_field
2313 } else {
2314 let mut cursor = name_field.walk();
2316 name_field
2317 .children(&mut cursor)
2318 .find(|c| c.kind() == "variable_name")?
2319 };
2320 strip_dollar_from_variable(var_node, content)
2321}
2322
2323fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
2325 if let Some(name_node) = var_node.child_by_field_name("name")
2326 && let Ok(text) = name_node.utf8_text(content)
2327 {
2328 return Some(text.trim().to_string());
2329 }
2330 var_node
2331 .utf8_text(content)
2332 .ok()
2333 .map(|s| s.trim().trim_start_matches('$').to_string())
2334}
2335
2336fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
2338 let mut cursor = node.walk();
2339 node.children(&mut cursor).find(|c| c.kind() == kind)
2340}
2341
2342fn emit_field_type_edges(
2345 helper: &mut GraphBuildHelper,
2346 node_id: NodeId,
2347 prop_name: &str,
2348 type_str: &str,
2349) {
2350 let canonical_type = canonical_type_string(type_str);
2351 let type_node_id = helper.add_type(&canonical_type, None);
2352 helper.add_typeof_edge_with_context(
2353 node_id,
2354 type_node_id,
2355 Some(TypeOfContext::Field),
2356 None,
2357 Some(prop_name),
2358 );
2359
2360 for ref_type_name in extract_type_names(type_str) {
2361 let ref_type_id = helper.add_type(&ref_type_name, None);
2362 helper.add_reference_edge(node_id, ref_type_id);
2363 }
2364}
2365
2366fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
2369 let mut current = node;
2370 while let Some(parent) = current.parent() {
2371 if matches!(
2372 parent.kind(),
2373 "class_declaration" | "trait_declaration" | "interface_declaration"
2374 ) {
2375 return parent
2376 .child_by_field_name("name")
2377 .and_then(|n| n.utf8_text(content).ok())
2378 .map(|s| s.trim().to_string());
2379 }
2380 current = parent;
2381 }
2382 None
2383}
2384
2385fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2387 let mut params = Vec::new();
2388
2389 let Some(params_node) = func_node.child_by_field_name("parameters") else {
2391 return params;
2392 };
2393
2394 let mut index = 0;
2395 let mut cursor = params_node.walk();
2396
2397 for child in params_node.children(&mut cursor) {
2398 if !child.is_named() {
2399 continue;
2400 }
2401
2402 match child.kind() {
2403 "simple_parameter" => {
2404 let mut param_cursor = child.walk();
2406 for param_child in child.children(&mut param_cursor) {
2407 if param_child.kind() == "variable_name"
2408 && let Ok(param_text) = param_child.utf8_text(content)
2409 {
2410 params.push((index, param_text.trim().to_string()));
2411 index += 1;
2412 break;
2413 }
2414 }
2415 }
2416 "variadic_parameter" => {
2417 let mut param_cursor = child.walk();
2419 for param_child in child.children(&mut param_cursor) {
2420 if param_child.kind() == "variable_name"
2421 && let Ok(param_text) = param_child.utf8_text(content)
2422 {
2423 params.push((index, param_text.trim().to_string()));
2424 index += 1;
2425 break;
2426 }
2427 }
2428 }
2429 _ => {}
2430 }
2431 }
2432
2433 params
2434}
2435
2436#[allow(clippy::unnecessary_wraps)]
2438fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2439 let mut current = node;
2440
2441 while let Some(parent) = current.parent() {
2443 if parent.kind() == "class_declaration" {
2444 if let Some(name_node) = parent.child_by_field_name("name")
2446 && let Ok(name_text) = name_node.utf8_text(content)
2447 {
2448 return Ok(Some(name_text.trim().to_string()));
2449 }
2450 return Ok(None);
2451 }
2452 current = parent;
2453 }
2454
2455 Ok(None)
2456}
2457
2458fn process_ffi_member_call(
2466 node: Node,
2467 method_name: &str,
2468 ast_graph: &ASTGraph,
2469 helper: &mut GraphBuildHelper,
2470 node_map: &mut HashMap<String, NodeId>,
2471) {
2472 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2474 return;
2475 };
2476
2477 let source_id = *node_map
2479 .entry(call_context.qualified_name.clone())
2480 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2481
2482 let ffi_name = format!("native::ffi::{method_name}");
2484 let call_span = span_from_node(node);
2485 let target_id = helper.add_module(&ffi_name, Some(call_span));
2486
2487 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2489}
2490
2491fn process_ffi_static_call(
2496 node: Node,
2497 method_name: &str,
2498 ast_graph: &ASTGraph,
2499 helper: &mut GraphBuildHelper,
2500 node_map: &mut HashMap<String, NodeId>,
2501 content: &[u8],
2502) {
2503 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2505 return;
2506 };
2507
2508 let source_id = *node_map
2510 .entry(call_context.qualified_name.clone())
2511 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2512
2513 let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2515 .map_or_else(
2516 || "unknown".to_string(),
2517 |lib| php_ffi_library_simple_name(&lib),
2518 );
2519
2520 let ffi_name = format!("native::{library_name}");
2522 let call_span = span_from_node(node);
2523 let target_id = helper.add_module(&ffi_name, Some(call_span));
2524
2525 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2527}
2528
2529fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2542 if object_node.kind() == "scoped_call_expression"
2544 && let Some(scope_node) = object_node.child_by_field_name("scope")
2545 && let Some(name_node) = object_node.child_by_field_name("name")
2546 && let Ok(scope_text) = scope_node.utf8_text(content)
2547 && let Ok(name_text) = name_node.utf8_text(content)
2548 && is_ffi_static_call(scope_text, name_text)
2549 {
2550 return true;
2551 }
2552
2553 if object_node.kind() == "parenthesized_expression"
2555 && let Some(inner) = object_node.named_child(0)
2556 && inner.kind() == "scoped_call_expression"
2557 && let Some(scope_node) = inner.child_by_field_name("scope")
2558 && let Some(name_node) = inner.child_by_field_name("name")
2559 && let Ok(scope_text) = scope_node.utf8_text(content)
2560 && let Ok(name_text) = name_node.utf8_text(content)
2561 && is_ffi_static_call(scope_text, name_text)
2562 {
2563 return true;
2564 }
2565
2566 let Ok(object_text) = object_node.utf8_text(content) else {
2568 return false;
2569 };
2570
2571 let object_text = object_text.trim();
2572
2573 if object_text == "$ffi" || object_text == "$_ffi" {
2575 return true;
2576 }
2577
2578 if object_text.ends_with("->ffi")
2580 || object_text.ends_with("::$ffi")
2581 || object_text.ends_with("->_ffi")
2582 || object_text.ends_with("::$_ffi")
2583 {
2584 return true;
2585 }
2586
2587 false
2588}
2589
2590fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2594 (scope_text == "FFI" || scope_text == "\\FFI")
2595 && (method_text == "cdef" || method_text == "load")
2596}
2597
2598fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2606 let args = call_node.child_by_field_name("arguments")?;
2607
2608 let mut cursor = args.walk();
2609 let args_vec: Vec<Node> = args
2610 .children(&mut cursor)
2611 .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2612 .collect();
2613
2614 let target_arg_name = if is_cdef { "lib" } else { "filename" };
2617
2618 if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2620 return extract_string_from_argument(named_arg, content);
2621 }
2622
2623 if is_cdef {
2625 args_vec
2627 .get(1)
2628 .and_then(|arg| extract_string_from_argument(*arg, content))
2629 } else {
2630 args_vec
2632 .first()
2633 .and_then(|arg| extract_string_from_argument(*arg, content))
2634 }
2635}
2636
2637fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2644 for arg in args {
2645 if arg.kind() != "argument" {
2646 continue;
2647 }
2648
2649 if arg.named_child_count() < 2 {
2652 continue;
2653 }
2654
2655 if let Some(name_node) = arg.child_by_field_name("name")
2657 && let Ok(name_text) = name_node.utf8_text(content)
2658 && name_text == param_name
2659 {
2660 return Some(*arg);
2661 } else if let Some(name_node) = arg.named_child(0)
2662 && let Ok(name_text) = name_node.utf8_text(content)
2663 && name_text == param_name
2664 {
2665 return Some(*arg);
2667 }
2668 }
2669
2670 None
2671}
2672
2673fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2681 let value_node = unwrap_argument_node(arg_node)?;
2683
2684 if !is_string_literal_node(value_node) {
2686 return None;
2687 }
2688
2689 if is_interpolated_string(value_node) {
2691 return None;
2692 }
2693
2694 extract_php_string_content(value_node, content)
2695}
2696
2697fn unwrap_argument_node(node: Node) -> Option<Node> {
2709 if node.kind() != "argument" {
2710 return Some(node);
2712 }
2713
2714 let name_field_node = node.child_by_field_name("name");
2721 let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2722
2723 for i in 0..node.named_child_count() {
2725 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.named_child(i as u32) {
2727 let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2729 let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2730
2731 if !is_name_field && !is_ref_modifier {
2732 return Some(child);
2734 }
2735 }
2736 }
2737
2738 None
2740}
2741
2742fn is_string_literal_node(node: Node) -> bool {
2749 matches!(
2750 node.kind(),
2751 "string" | "encapsed_string" | "heredoc" | "nowdoc"
2752 )
2753}
2754
2755fn is_interpolated_string(node: Node) -> bool {
2768 if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2769 return false;
2770 }
2771
2772 has_variable_node(node)
2774}
2775
2776fn has_variable_node(node: Node) -> bool {
2790 if matches!(
2792 node.kind(),
2793 "variable_name" | "simple_variable" | "variable" | "complex_variable"
2795 | "dynamic_variable_name"
2797 | "subscript_expression" | "member_access_expression" | "member_call_expression"
2799 | "function_call_expression"
2801 | "scoped_call_expression" | "scoped_property_access_expression"
2803 | "class_constant_access_expression"
2805 | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2807 ) {
2808 return true;
2809 }
2810
2811 for i in 0..node.child_count() {
2813 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.child(i as u32)
2815 && has_variable_node(child)
2816 {
2817 return true;
2818 }
2819 }
2820
2821 false
2822}
2823
2824fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2828 let Ok(text) = string_node.utf8_text(content) else {
2829 return None;
2830 };
2831
2832 let text = text.trim();
2833
2834 if ((text.starts_with('"') && text.ends_with('"'))
2836 || (text.starts_with('\'') && text.ends_with('\'')))
2837 && text.len() >= 2
2838 {
2839 return Some(text[1..text.len() - 1].to_string());
2840 }
2841
2842 Some(text.to_string())
2844}
2845
2846fn php_ffi_library_simple_name(library_path: &str) -> String {
2848 use std::path::Path;
2849
2850 let filename = Path::new(library_path)
2852 .file_name()
2853 .and_then(|f| f.to_str())
2854 .unwrap_or(library_path);
2855
2856 if let Some(so_pos) = filename.find(".so.") {
2858 return filename[..so_pos].to_string();
2859 }
2860
2861 if let Some(dot_pos) = filename.find('.') {
2863 let extension = &filename[dot_pos + 1..];
2864 if extension == "so"
2865 || extension == "dll"
2866 || extension == "dylib"
2867 || extension == "h"
2868 || extension == "hpp"
2869 {
2870 return filename[..dot_pos].to_string();
2871 }
2872 }
2873
2874 filename.to_string()
2875}
2876
2877#[cfg(test)]
2882mod field_emission_tests {
2883 use sqry_core::graph::GraphBuilder;
2901 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2902 use sqry_core::graph::unified::build::test_helpers::{
2903 build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
2904 };
2905 use sqry_core::graph::unified::edge::EdgeKind;
2906 use sqry_core::graph::unified::edge::kind::TypeOfContext;
2907 use sqry_core::graph::unified::node::NodeKind;
2908 use std::path::Path;
2909 use tree_sitter::Parser;
2910
2911 use super::PhpGraphBuilder;
2912
2913 fn parse(source: &str) -> tree_sitter::Tree {
2914 let mut parser = Parser::new();
2915 parser
2916 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
2917 .expect("load PHP grammar");
2918 parser.parse(source, None).expect("parse PHP source")
2919 }
2920
2921 fn build(source: &str) -> StagingGraph {
2922 let tree = parse(source);
2923 let mut staging = StagingGraph::new();
2924 let builder = PhpGraphBuilder::default();
2925 builder
2926 .build_graph(
2927 &tree,
2928 source.as_bytes(),
2929 Path::new("test.php"),
2930 &mut staging,
2931 )
2932 .expect("build graph");
2933 staging
2934 }
2935
2936 fn find_node<'a>(
2938 staging: &'a StagingGraph,
2939 name: &str,
2940 kind: Option<NodeKind>,
2941 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2942 let strings = build_string_lookup(staging);
2943 for op in staging.operations() {
2944 if let StagingOp::AddNode { entry, .. } = op {
2945 if let Some(k) = kind
2946 && entry.kind != k
2947 {
2948 continue;
2949 }
2950 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2951 if let Some(s) = strings.get(&name_idx)
2952 && s == name
2953 {
2954 return Some(entry);
2955 }
2956 }
2957 }
2958 None
2959 }
2960
2961 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
2962 let strings = build_string_lookup(staging);
2963 staging
2964 .operations()
2965 .iter()
2966 .filter(|op| {
2967 if let StagingOp::AddNode { entry, .. } = op {
2968 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2969 strings.get(&name_idx).is_some_and(|s| s == name)
2970 } else {
2971 false
2972 }
2973 })
2974 .count()
2975 }
2976
2977 fn resolve_visibility(
2978 staging: &StagingGraph,
2979 vis: Option<sqry_core::graph::unified::StringId>,
2980 ) -> Option<String> {
2981 let strings = build_string_lookup(staging);
2982 vis.and_then(|sid| strings.get(&sid.index()).cloned())
2983 }
2984
2985 fn typeof_edges_for_node(
2986 staging: &StagingGraph,
2987 source_name: &str,
2988 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
2989 let names = build_node_name_lookup(staging);
2990 let strings = build_string_lookup(staging);
2991 let mut out = Vec::new();
2992 for op in staging.operations() {
2993 if let StagingOp::AddEdge {
2994 source,
2995 target,
2996 kind: EdgeKind::TypeOf { context, name, .. },
2997 ..
2998 } = op
2999 {
3000 let src = names.get(source).cloned().unwrap_or_default();
3001 if src != source_name {
3002 continue;
3003 }
3004 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3005 let target_name = names.get(target).cloned().unwrap_or_default();
3006 out.push((*context, edge_name, target_name));
3007 }
3008 }
3009 out
3010 }
3011
3012 #[test]
3015 fn req_r0001_property_without_phpdoc_emits_property_node() {
3016 let src = "<?php
3017class User {
3018 public string $name;
3019}
3020";
3021 let staging = build(src);
3022 let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
3023 .expect("User.name Property must be emitted without @var");
3024 assert_eq!(entry.kind, NodeKind::Property);
3025 }
3026
3027 #[test]
3028 fn req_r0001_property_with_phpdoc_still_emits_property_node() {
3029 let src = "<?php
3030class Repo {
3031 /** @var string */
3032 public string $label;
3033}
3034";
3035 let staging = build(src);
3036 find_node(&staging, "Repo.label", Some(NodeKind::Property))
3037 .expect("Repo.label Property must be emitted when @var is present");
3038 }
3039
3040 #[test]
3043 fn req_r0002_qualified_name_uses_class_dot_prop() {
3044 let src = "<?php
3045class A { public int $x; }
3046class B { public int $x; }
3047";
3048 let staging = build(src);
3049 find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
3050 find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
3051 assert!(
3052 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3053 "no bare 'x' Property node should leak"
3054 );
3055 }
3056
3057 #[test]
3058 fn req_r0002_visibility_modifiers_round_trip() {
3059 let src = "<?php
3060class V {
3061 public int $a;
3062 private int $b;
3063 protected int $c;
3064 var $d;
3065}
3066";
3067 let staging = build(src);
3068 for (name, expected) in [
3069 ("V.a", "public"),
3070 ("V.b", "private"),
3071 ("V.c", "protected"),
3072 ("V.d", "public"),
3073 ] {
3074 let entry = find_node(&staging, name, Some(NodeKind::Property))
3075 .unwrap_or_else(|| panic!("missing {name}"));
3076 let got = resolve_visibility(&staging, entry.visibility);
3077 assert_eq!(
3078 got.as_deref(),
3079 Some(expected),
3080 "{name} visibility should be {expected}"
3081 );
3082 }
3083 }
3084
3085 #[test]
3086 fn req_r0002_default_visibility_is_public_when_no_modifier() {
3087 let src = "<?php
3089class X { static int $count = 0; }
3090";
3091 let staging = build(src);
3092 let entry =
3093 find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
3094 let vis = resolve_visibility(&staging, entry.visibility);
3095 assert_eq!(
3096 vis.as_deref(),
3097 Some("public"),
3098 "default visibility is public"
3099 );
3100 }
3101
3102 #[test]
3105 fn req_r0003_static_modifier_sets_is_static() {
3106 let src = "<?php
3107class S {
3108 public static int $count = 0;
3109 public int $instance = 0;
3110}
3111";
3112 let staging = build(src);
3113 let s_count =
3114 find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
3115 assert!(s_count.is_static, "S.count should be static");
3116 let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
3117 .expect("S.instance must exist");
3118 assert!(!s_instance.is_static, "S.instance should not be static");
3119 }
3120
3121 #[test]
3124 fn req_r0004_readonly_emits_constant() {
3125 let src = "<?php
3126class R {
3127 public readonly string $id;
3128 public string $name;
3129}
3130";
3131 let staging = build(src);
3132 find_node(&staging, "R.id", Some(NodeKind::Constant))
3133 .expect("R.id must be Constant (readonly)");
3134 find_node(&staging, "R.name", Some(NodeKind::Property))
3135 .expect("R.name must be Property (mutable)");
3136 }
3137
3138 #[test]
3141 fn req_r0005_native_type_takes_precedence_over_phpdoc() {
3142 let src = "<?php
3146class T {
3147 /** @var {int} */
3148 public string $value;
3149}
3150";
3151 let staging = build(src);
3152 let edges = typeof_edges_for_node(&staging, "T.value");
3153 assert!(
3154 !edges.is_empty(),
3155 "T.value should have at least one TypeOf edge"
3156 );
3157 let has_string = edges.iter().any(|(_, _, t)| t == "string");
3158 assert!(
3159 has_string,
3160 "native type 'string' should be the primary TypeOf target, got {edges:?}"
3161 );
3162 let has_int = edges.iter().any(|(_, _, t)| t == "int");
3163 assert!(
3164 !has_int,
3165 "PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
3166 );
3167 }
3168
3169 #[test]
3170 fn req_r0005_phpdoc_fallback_when_no_native_type() {
3171 let src = "<?php
3173class T {
3174 /** @var {SomeUserType} */
3175 public $value;
3176}
3177";
3178 let staging = build(src);
3179 let edges = typeof_edges_for_node(&staging, "T.value");
3180 assert!(
3181 edges.iter().any(|(_, _, t)| t == "SomeUserType"),
3182 "PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
3183 );
3184 }
3185
3186 #[test]
3189 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3190 let src = "<?php
3191class C {
3192 public string $title;
3193}
3194";
3195 let staging = build(src);
3196 let edges = typeof_edges_for_node(&staging, "C.title");
3197 assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
3198 for (ctx, name, _) in &edges {
3199 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3200 assert_eq!(
3201 name.as_deref(),
3202 Some("title"),
3203 "edge name must be the bare property name"
3204 );
3205 }
3206 }
3207
3208 #[test]
3211 fn req_r0007_constructor_promotion_emits_property_on_class() {
3212 let src = "<?php
3213class P {
3214 public function __construct(public int $x, private readonly string $y) {}
3215}
3216";
3217 let staging = build(src);
3218 let x = find_node(&staging, "P.x", Some(NodeKind::Property))
3219 .expect("promoted P.x must be a Property");
3220 assert_eq!(
3221 resolve_visibility(&staging, x.visibility).as_deref(),
3222 Some("public"),
3223 "promoted $x visibility"
3224 );
3225 let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
3226 .expect("promoted readonly P.y must be a Constant");
3227 assert_eq!(
3228 resolve_visibility(&staging, y.visibility).as_deref(),
3229 Some("private"),
3230 "promoted $y visibility"
3231 );
3232 }
3233
3234 #[test]
3237 fn req_r0013_explicit_declaration_wins_over_promotion() {
3238 let src = "<?php
3239class D {
3240 public int $x;
3241 public function __construct(public int $x) {}
3242}
3243";
3244 let staging = build(src);
3245 let n = count_nodes_named(&staging, "D.x");
3246 assert_eq!(
3247 n, 1,
3248 "exactly one D.x node when explicit decl + promotion collide, got {n}"
3249 );
3250 find_node(&staging, "D.x", Some(NodeKind::Property))
3252 .expect("D.x must be Property (explicit declaration wins)");
3253 }
3254
3255 #[test]
3261 fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
3262 let src = "<?php
3263class A {
3264 public function __construct(public string $x) {}
3265 public int $x;
3266}
3267";
3268 let staging = build(src);
3269 let n = count_nodes_named(&staging, "A.x");
3270 assert_eq!(
3271 n, 1,
3272 "exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
3273 );
3274 find_node(&staging, "A.x", Some(NodeKind::Property))
3275 .expect("A.x must be Property (explicit declaration wins)");
3276
3277 let edges = typeof_edges_for_node(&staging, "A.x");
3281 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3282 assert!(
3283 target_types.contains(&"int"),
3284 "explicit `int` TypeOf must be present, got {target_types:?}",
3285 );
3286 assert!(
3287 !target_types.contains(&"string"),
3288 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3289 );
3290 }
3291
3292 #[test]
3296 fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
3297 let src = "<?php
3298class B {
3299 public int $x;
3300 public function __construct(public string $x) {}
3301}
3302";
3303 let staging = build(src);
3304 let n = count_nodes_named(&staging, "B.x");
3305 assert_eq!(
3306 n, 1,
3307 "exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
3308 );
3309 find_node(&staging, "B.x", Some(NodeKind::Property))
3310 .expect("B.x must be Property (explicit declaration wins)");
3311
3312 let edges = typeof_edges_for_node(&staging, "B.x");
3313 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3314 assert!(
3315 target_types.contains(&"int"),
3316 "explicit `int` TypeOf must be present, got {target_types:?}",
3317 );
3318 assert!(
3319 !target_types.contains(&"string"),
3320 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3321 );
3322 }
3323
3324 #[test]
3327 fn req_r0023_span_anchored_on_declaration() {
3328 let src = "<?php
3329class W {
3330
3331 public string $marker;
3332}
3333";
3334 let staging = build(src);
3335 let entry =
3336 find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
3337 assert_eq!(
3345 entry.start_line, 4,
3346 "span start line should match declaration"
3347 );
3348 assert_eq!(entry.end_line, 4, "span end line should match declaration");
3349 assert_eq!(
3350 entry.start_column, 4,
3351 "span start column should match indentation of `public`"
3352 );
3353 assert!(
3354 entry.end_column > entry.start_column,
3355 "span end column must extend past start (got start={}, end={})",
3356 entry.start_column,
3357 entry.end_column,
3358 );
3359 }
3360
3361 #[test]
3364 fn req_r0001_trait_property_emitted() {
3365 let src = "<?php
3366trait Loggable {
3367 protected ?string $logTag;
3368}
3369";
3370 let staging = build(src);
3371 let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
3372 .expect("trait property must be emitted");
3373 let vis = resolve_visibility(&staging, entry.visibility);
3374 assert_eq!(vis.as_deref(), Some("protected"));
3375 }
3376
3377 #[test]
3378 fn no_emission_outside_class_or_trait_or_interface() {
3379 let src = "<?php
3382$x = 1;
3383function f() { $y = 2; }
3384";
3385 let staging = build(src);
3386 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
3387 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
3388 }
3389}
3390
3391pub struct PhpShapeMapping {
3397 cf_by_kind_id: Vec<Option<CfBucket>>,
3398}
3399
3400impl PhpShapeMapping {
3401 fn build() -> Self {
3402 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3403 let count = lang.node_kind_count();
3404 let mut cf_by_kind_id = vec![None; count];
3405 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
3406 let Ok(kind_id) = u16::try_from(id) else {
3407 break;
3408 };
3409 if !lang.node_kind_is_named(kind_id) {
3410 continue;
3411 }
3412 if let Some(name) = lang.node_kind_for_id(kind_id) {
3413 *slot = cf_bucket_for_php_kind(name);
3414 }
3415 }
3416 Self { cf_by_kind_id }
3417 }
3418}
3419
3420impl ShapeMapping for PhpShapeMapping {
3421 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
3422 self.cf_by_kind_id
3423 .get(ts_node_kind_id as usize)
3424 .copied()
3425 .flatten()
3426 }
3427
3428 fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
3429 let mut shape = SignatureShape::default();
3430 if let Some(params) = fn_node.child_by_field_name("parameters") {
3431 let mut cursor = params.walk();
3432 for child in params.named_children(&mut cursor) {
3433 match child.kind() {
3434 "simple_parameter" | "property_promotion_parameter" => {
3435 shape.arity_positional = shape.arity_positional.saturating_add(1);
3436 if child.child_by_field_name("default_value").is_some() {
3437 shape.has_defaults = true;
3438 }
3439 }
3440 "variadic_parameter" => shape.has_varargs = true,
3441 _ => {}
3442 }
3443 }
3444 }
3445 shape.has_return_annotation = fn_node.child_by_field_name("return_type").is_some();
3446 shape
3447 }
3448}
3449
3450fn cf_bucket_for_php_kind(name: &str) -> Option<CfBucket> {
3453 let bucket = match name {
3454 "if_statement"
3455 | "else_if_clause"
3456 | "else_clause"
3457 | "conditional_expression"
3458 | "match_conditional_expression" => CfBucket::Branch,
3459 "while_statement" | "do_statement" | "for_statement" | "foreach_statement" => {
3460 CfBucket::Loop
3461 }
3462 "switch_statement" | "case_statement" | "default_statement" | "match_expression"
3463 | "match_block" => CfBucket::Match,
3464 "try_statement" => CfBucket::Try,
3465 "catch_clause" => CfBucket::Catch,
3466 "finally_clause" => CfBucket::Resource,
3467 "throw_expression" => CfBucket::Throw,
3468 "return_statement" => CfBucket::Return,
3469 "yield_expression" => CfBucket::Yield,
3470 "break_statement" | "continue_statement" => CfBucket::BreakContinue,
3471 "function_call_expression"
3472 | "member_call_expression"
3473 | "scoped_call_expression"
3474 | "nullsafe_member_call_expression"
3475 | "object_creation_expression" => CfBucket::Call,
3476 "assignment_expression" | "augmented_assignment_expression" => CfBucket::Assign,
3477 "anonymous_function" | "arrow_function" => CfBucket::Closure,
3478 _ => return None,
3479 };
3480 Some(bucket)
3481}
3482
3483#[must_use]
3485pub fn php_shape_mapping() -> &'static PhpShapeMapping {
3486 static MAPPING: OnceLock<PhpShapeMapping> = OnceLock::new();
3487 MAPPING.get_or_init(PhpShapeMapping::build)
3488}
3489
3490#[cfg(test)]
3491mod shape_tests {
3492 use super::{cf_bucket_for_php_kind, php_shape_mapping};
3496 use sqry_core::graph::unified::build::shape::{
3497 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3498 };
3499 use tree_sitter::{Node, Parser, Tree};
3500
3501 const SAMPLE: &str = include_str!(concat!(
3502 env!("CARGO_MANIFEST_DIR"),
3503 "/../test-fixtures/shape/dynamic/php.php"
3504 ));
3505
3506 fn parse(src: &str) -> Tree {
3507 let mut parser = Parser::new();
3508 parser
3509 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
3510 .expect("load php grammar");
3511 parser.parse(src, None).expect("parse php")
3512 }
3513
3514 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
3515 let root = tree.root_node();
3516 let mut cursor = root.walk();
3517 for child in root.named_children(&mut cursor) {
3518 if child.kind() == "function_definition" {
3519 return child;
3520 }
3521 }
3522 panic!("no function_definition in php fixture");
3523 }
3524
3525 #[test]
3526 fn mapping_is_non_empty_and_covers_real_kinds() {
3527 assert_eq!(
3528 cf_bucket_for_php_kind("if_statement"),
3529 Some(CfBucket::Branch)
3530 );
3531 assert_eq!(
3532 cf_bucket_for_php_kind("while_statement"),
3533 Some(CfBucket::Loop)
3534 );
3535 assert_eq!(
3536 cf_bucket_for_php_kind("switch_statement"),
3537 Some(CfBucket::Match)
3538 );
3539 assert_eq!(cf_bucket_for_php_kind("try_statement"), Some(CfBucket::Try));
3540 assert_eq!(
3541 cf_bucket_for_php_kind("catch_clause"),
3542 Some(CfBucket::Catch)
3543 );
3544 assert_eq!(
3545 cf_bucket_for_php_kind("finally_clause"),
3546 Some(CfBucket::Resource)
3547 );
3548 assert_eq!(
3549 cf_bucket_for_php_kind("throw_expression"),
3550 Some(CfBucket::Throw)
3551 );
3552 assert_eq!(
3553 cf_bucket_for_php_kind("anonymous_function"),
3554 Some(CfBucket::Closure)
3555 );
3556 assert_eq!(cf_bucket_for_php_kind("nope"), None);
3557
3558 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3559 let id = (0..lang.node_kind_count())
3560 .map(|i| i as u16)
3561 .find(|&i| {
3562 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
3563 })
3564 .expect("grammar exposes named if_statement");
3565 assert_eq!(php_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
3566 }
3567
3568 #[test]
3569 fn descriptor_covers_fixture_control_flow() {
3570 let tree = parse(SAMPLE);
3571 let func = first_function(&tree);
3572 let descriptor = compute_shape_descriptor(
3573 func,
3574 SAMPLE.as_bytes(),
3575 php_shape_mapping(),
3576 &ShapeBudget::default(),
3577 );
3578 let hist = descriptor.cf_histogram;
3579 assert!(hist[CfBucket::Branch.index()] >= 1, "branch");
3580 assert!(hist[CfBucket::Loop.index()] >= 1, "loop");
3581 assert!(hist[CfBucket::Match.index()] >= 1, "switch/case");
3582 assert!(hist[CfBucket::Try.index()] >= 1, "try");
3583 assert!(hist[CfBucket::Catch.index()] >= 1, "catch");
3584 assert!(hist[CfBucket::Resource.index()] >= 1, "finally");
3585 assert!(hist[CfBucket::Throw.index()] >= 1, "throw");
3586 assert!(hist[CfBucket::Return.index()] >= 1, "return");
3587 assert!(hist[CfBucket::Call.index()] >= 1, "call");
3588 assert!(hist[CfBucket::Closure.index()] >= 1, "closure");
3589 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/continue");
3590 }
3591
3592 #[test]
3593 fn signature_shape_reads_arity_and_return() {
3594 let tree = parse(SAMPLE);
3595 let func = first_function(&tree);
3596 let shape = php_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3597 assert_eq!(shape.arity_positional, 2, "value + label");
3599 assert!(shape.has_defaults, "label has a default");
3600 assert!(shape.has_varargs, "...$rest");
3601 assert!(shape.has_return_annotation, ": string");
3602 }
3603}