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 node_map.insert(qualified_name.clone(), node_id);
122 }
123
124 let root = tree.root_node();
126 walk_tree_for_edges(root, content, &ast_graph, &mut helper, &mut node_map)?;
127
128 process_oop_relationships(root, content, &mut helper, &mut node_map);
130
131 process_exports(root, content, &mut helper, &mut node_map);
134
135 process_phpdoc_annotations(root, content, &mut helper)?;
137
138 Ok(())
139 }
140
141 fn language(&self) -> Language {
142 Language::Php
143 }
144
145 fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
146 Some(php_shape_mapping())
147 }
148
149 fn detect_cross_language_edges(
150 &self,
151 _snapshot: &GraphSnapshot,
152 ) -> GraphResult<Vec<sqry_core::graph::CodeEdge>> {
153 Ok(vec![])
156 }
157}
158
159#[derive(Debug, Clone)]
164enum ContextKind {
165 Function {
166 is_async: bool,
167 },
168 Method {
169 is_async: bool,
170 is_static: bool,
171 #[allow(dead_code)] visibility: Option<String>,
173 },
174 Class,
175}
176
177#[derive(Debug, Clone)]
178struct CallContext {
179 qualified_name: String,
180 span: (usize, usize),
181 kind: ContextKind,
182 class_name: Option<String>,
183 return_type: Option<String>,
184}
185
186struct ASTGraph {
187 contexts: Vec<CallContext>,
188 node_to_context: HashMap<usize, usize>,
189}
190
191impl ASTGraph {
192 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
193 let mut contexts = Vec::new();
194 let mut node_to_context = HashMap::new();
195 let mut scope_stack: Vec<String> = Vec::new();
196 let mut class_stack: Vec<String> = Vec::new();
197
198 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
200 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
201 let file_ops_depth = recursion_limits
202 .effective_file_ops_depth()
203 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
204 let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
205 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
206
207 let mut walk_ctx = WalkContext {
208 contexts: &mut contexts,
209 node_to_context: &mut node_to_context,
210 scope_stack: &mut scope_stack,
211 class_stack: &mut class_stack,
212 max_depth,
213 };
214
215 walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
216
217 Ok(Self {
218 contexts,
219 node_to_context,
220 })
221 }
222
223 fn contexts(&self) -> &[CallContext] {
224 &self.contexts
225 }
226
227 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
228 self.node_to_context
229 .get(&node_id)
230 .and_then(|idx| self.contexts.get(*idx))
231 }
232}
233
234#[allow(
235 clippy::too_many_lines,
236 reason = "PHP namespace and scope handling requires a large, unified traversal."
237)]
238struct WalkContext<'a> {
243 contexts: &'a mut Vec<CallContext>,
244 node_to_context: &'a mut HashMap<usize, usize>,
245 scope_stack: &'a mut Vec<String>,
246 class_stack: &'a mut Vec<String>,
247 max_depth: usize,
248}
249
250#[allow(clippy::too_many_lines)]
251fn walk_ast(
252 node: Node,
253 content: &[u8],
254 ctx: &mut WalkContext,
255 guard: &mut sqry_core::query::security::RecursionGuard,
256) -> Result<(), String> {
257 guard
258 .enter()
259 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
260
261 if ctx.scope_stack.len() > ctx.max_depth {
262 guard.exit();
263 return Ok(());
264 }
265
266 match node.kind() {
267 "program" => {
268 let mut active_namespace_parts: Vec<String> = Vec::new();
271
272 let mut cursor = node.walk();
273 for child in node.children(&mut cursor) {
274 if child.kind() == "namespace_definition" {
275 let has_body = child
277 .children(&mut child.walk())
278 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
279
280 let ns_name = child
281 .child_by_field_name("name")
282 .and_then(|n| n.utf8_text(content).ok())
283 .map(|s| s.trim().to_string())
284 .unwrap_or_default();
285
286 if has_body {
287 for _ in 0..active_namespace_parts.len() {
294 ctx.scope_stack.pop();
295 }
296 active_namespace_parts.clear();
297
298 let ns_parts: Vec<String> = if ns_name.is_empty() {
299 Vec::new()
300 } else {
301 ns_name.split('\\').map(ToString::to_string).collect()
302 };
303
304 for part in &ns_parts {
305 ctx.scope_stack.push(part.clone());
306 }
307
308 for ns_child in child.children(&mut child.walk()) {
310 if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
311 {
312 for body_child in ns_child.children(&mut ns_child.walk()) {
313 walk_ast(body_child, content, ctx, guard)?;
314 }
315 }
316 }
317
318 for _ in 0..ns_parts.len() {
319 ctx.scope_stack.pop();
320 }
321 } else {
322 for _ in 0..active_namespace_parts.len() {
325 ctx.scope_stack.pop();
326 }
327
328 active_namespace_parts = if ns_name.is_empty() {
330 Vec::new()
331 } else {
332 ns_name.split('\\').map(ToString::to_string).collect()
333 };
334
335 for part in &active_namespace_parts {
337 ctx.scope_stack.push(part.clone());
338 }
339 }
340 } else {
341 walk_ast(child, content, ctx, guard)?;
343 }
344 }
345
346 for _ in 0..active_namespace_parts.len() {
348 ctx.scope_stack.pop();
349 }
350
351 guard.exit();
352 return Ok(());
353 }
354 "namespace_definition" => {
355 let namespace_name = node
358 .child_by_field_name("name")
359 .and_then(|n| n.utf8_text(content).ok())
360 .map(|s| s.trim().to_string())
361 .unwrap_or_default();
362
363 let namespace_parts: Vec<String> = if namespace_name.is_empty() {
364 Vec::new()
365 } else {
366 namespace_name
367 .split('\\')
368 .map(ToString::to_string)
369 .collect()
370 };
371
372 let parts_count = namespace_parts.len();
373 for part in &namespace_parts {
374 ctx.scope_stack.push(part.clone());
375 }
376
377 let mut cursor = node.walk();
379 for child in node.children(&mut cursor) {
380 if matches!(child.kind(), "compound_statement" | "declaration_list") {
381 let mut body_cursor = child.walk();
382 for body_child in child.children(&mut body_cursor) {
383 walk_ast(body_child, content, ctx, guard)?;
384 }
385 }
386 }
387
388 for _ in 0..parts_count {
390 ctx.scope_stack.pop();
391 }
392 }
393 "class_declaration" => {
394 let name_node = node
395 .child_by_field_name("name")
396 .ok_or_else(|| "class_declaration missing name".to_string())?;
397 let class_name = name_node
398 .utf8_text(content)
399 .map_err(|_| "failed to read class name".to_string())?;
400
401 let qualified_class = if ctx.scope_stack.is_empty() {
403 class_name.to_string()
404 } else {
405 format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
406 };
407
408 ctx.class_stack.push(qualified_class.clone());
409 ctx.scope_stack.push(class_name.to_string());
410
411 let _context_idx = ctx.contexts.len();
413 ctx.contexts.push(CallContext {
414 qualified_name: qualified_class.clone(),
415 span: (node.start_byte(), node.end_byte()),
416 kind: ContextKind::Class,
417 class_name: Some(qualified_class),
418 return_type: None, });
420
421 let mut cursor = node.walk();
423 for child in node.children(&mut cursor) {
424 if child.kind() == "declaration_list" {
425 let mut body_cursor = child.walk();
426 for body_child in child.children(&mut body_cursor) {
427 walk_ast(body_child, content, ctx, guard)?;
428 }
429 }
430 }
431
432 ctx.class_stack.pop();
433 ctx.scope_stack.pop();
434 }
435 "function_definition" | "method_declaration" => {
436 let name_node = node
437 .child_by_field_name("name")
438 .ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
439 let func_name = name_node
440 .utf8_text(content)
441 .map_err(|_| "failed to read function name".to_string())?;
442
443 let is_async = false; let is_static = node
448 .children(&mut node.walk())
449 .any(|child| child.kind() == "static_modifier");
450
451 let visibility = extract_visibility(&node, content);
453
454 let return_type = extract_return_type(&node, content);
456
457 let is_method = !ctx.class_stack.is_empty();
459 let class_name = ctx.class_stack.last().cloned();
460
461 let qualified_func = if is_method {
465 if let Some(ref class) = class_name {
467 format!("{class}::{func_name}")
468 } else {
469 func_name.to_string()
470 }
471 } else {
472 if ctx.scope_stack.is_empty() {
474 func_name.to_string()
475 } else {
476 format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
477 }
478 };
479
480 let kind = if is_method {
481 ContextKind::Method {
482 is_async,
483 is_static,
484 visibility: visibility.clone(),
485 }
486 } else {
487 ContextKind::Function { is_async }
488 };
489
490 let context_idx = ctx.contexts.len();
491 ctx.contexts.push(CallContext {
492 qualified_name: qualified_func.clone(),
493 span: (node.start_byte(), node.end_byte()),
494 kind,
495 class_name,
496 return_type,
497 });
498
499 if let Some(body) = node.child_by_field_name("body") {
501 associate_descendants(body, context_idx, ctx.node_to_context);
502 }
503
504 ctx.scope_stack.push(func_name.to_string());
505
506 if let Some(body) = node.child_by_field_name("body") {
508 let mut cursor = body.walk();
509 for child in body.children(&mut cursor) {
510 walk_ast(child, content, ctx, guard)?;
511 }
512 }
513
514 ctx.scope_stack.pop();
515 }
516 _ => {
517 let mut cursor = node.walk();
519 for child in node.children(&mut cursor) {
520 walk_ast(child, content, ctx, guard)?;
521 }
522 }
523 }
524
525 guard.exit();
526 Ok(())
527}
528
529fn associate_descendants(
530 node: Node,
531 context_idx: usize,
532 node_to_context: &mut HashMap<usize, usize>,
533) {
534 node_to_context.insert(node.id(), context_idx);
535
536 let mut stack = vec![node];
537 while let Some(current) = stack.pop() {
538 node_to_context.insert(current.id(), context_idx);
539
540 let mut cursor = current.walk();
541 for child in current.children(&mut cursor) {
542 stack.push(child);
543 }
544 }
545}
546
547#[allow(clippy::only_used_in_recursion)]
553fn walk_tree_for_edges(
554 node: Node,
555 content: &[u8],
556 ast_graph: &ASTGraph,
557 helper: &mut GraphBuildHelper,
558 node_map: &mut HashMap<String, NodeId>,
559) -> GraphResult<()> {
560 match node.kind() {
561 "function_call_expression" => {
562 process_function_call(node, content, ast_graph, helper, node_map);
563 }
564 "member_call_expression" | "nullsafe_member_call_expression" => {
565 process_member_call(node, content, ast_graph, helper, node_map);
566 }
567 "scoped_call_expression" => {
568 process_static_call(node, content, ast_graph, helper, node_map);
569 }
570 "namespace_use_declaration" => {
572 process_namespace_use(node, content, helper);
573 }
574 "expression_statement" => {
576 let mut cursor = node.walk();
578 for child in node.children(&mut cursor) {
579 match child.kind() {
580 "require_expression"
581 | "require_once_expression"
582 | "include_expression"
583 | "include_once_expression" => {
584 process_file_include(child, content, helper);
585 }
586 _ => {}
587 }
588 }
589 }
590 _ => {}
591 }
592
593 let mut cursor = node.walk();
595 for child in node.children(&mut cursor) {
596 walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
597 }
598
599 Ok(())
600}
601
602fn process_function_call(
603 node: Node,
604 content: &[u8],
605 ast_graph: &ASTGraph,
606 helper: &mut GraphBuildHelper,
607 node_map: &mut HashMap<String, NodeId>,
608) {
609 let Some(function_node) = node.child_by_field_name("function") else {
610 return;
611 };
612
613 let Ok(callee_name) = function_node.utf8_text(content) else {
614 return;
615 };
616
617 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
619 return;
620 };
621
622 let source_id = *node_map
624 .entry(call_context.qualified_name.clone())
625 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
626
627 let call_span = span_from_node(node);
629 let target_id = *node_map
630 .entry(callee_name.to_string())
631 .or_insert_with(|| helper.ensure_callee(callee_name, call_span, CalleeKindHint::Function));
632
633 let argument_count = count_call_arguments(node);
634 helper.add_call_edge_full_with_span(
635 source_id,
636 target_id,
637 argument_count,
638 false,
639 vec![call_span],
640 );
641}
642
643fn process_member_call(
644 node: Node,
645 content: &[u8],
646 ast_graph: &ASTGraph,
647 helper: &mut GraphBuildHelper,
648 node_map: &mut HashMap<String, NodeId>,
649) {
650 let Some(method_node) = node.child_by_field_name("name") else {
651 return;
652 };
653
654 let Ok(method_name) = method_node.utf8_text(content) else {
655 return;
656 };
657
658 if let Some(object_node) = node.child_by_field_name("object")
660 && is_php_ffi_call(object_node, content)
661 {
662 process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
663 return;
664 }
665
666 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
668 return;
669 };
670
671 let callee_qualified = if let Some(class_name) = &call_context.class_name {
673 format!("{class_name}::{method_name}")
674 } else {
675 method_name.to_string()
676 };
677
678 let source_id = *node_map
680 .entry(call_context.qualified_name.clone())
681 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
682
683 let call_span = span_from_node(node);
685 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
686 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
687 });
688
689 let argument_count = count_call_arguments(node);
690 helper.add_call_edge_full_with_span(
691 source_id,
692 target_id,
693 argument_count,
694 false,
695 vec![call_span],
696 );
697}
698
699fn process_static_call(
700 node: Node,
701 content: &[u8],
702 ast_graph: &ASTGraph,
703 helper: &mut GraphBuildHelper,
704 node_map: &mut HashMap<String, NodeId>,
705) {
706 let Some(scope_node) = node.child_by_field_name("scope") else {
707 return;
708 };
709 let Some(name_node) = node.child_by_field_name("name") else {
710 return;
711 };
712
713 let Ok(class_name) = scope_node.utf8_text(content) else {
714 return;
715 };
716 let Ok(method_name) = name_node.utf8_text(content) else {
717 return;
718 };
719
720 if is_ffi_static_call(class_name, method_name) {
722 process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
723 return;
724 }
725
726 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
728 return;
729 };
730
731 let callee_qualified = format!("{class_name}::{method_name}");
733
734 let source_id = *node_map
736 .entry(call_context.qualified_name.clone())
737 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
738
739 let call_span = span_from_node(node);
741 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
742 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
743 });
744
745 let argument_count = count_call_arguments(node);
746 helper.add_call_edge_full_with_span(
747 source_id,
748 target_id,
749 argument_count,
750 false,
751 vec![call_span],
752 );
753}
754
755fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
768 let file_path = helper.file_path().to_string();
770 let importer_id = helper.add_module(&file_path, None);
771
772 let mut prefix = String::new();
775 let mut cursor = node.walk();
776 for child in node.children(&mut cursor) {
777 if child.kind() == "namespace_name"
778 && let Ok(ns) = child.utf8_text(content)
779 {
780 prefix = ns.trim().to_string();
781 break;
782 }
783 }
784
785 cursor = node.walk();
787 for child in node.children(&mut cursor) {
788 match child.kind() {
789 "namespace_use_clause" => {
790 process_use_clause(child, content, helper, importer_id);
792 }
793 "namespace_use_group" => {
794 process_use_group(child, content, helper, importer_id, &prefix);
797 }
798 _ => {}
799 }
800 }
801}
802
803fn process_use_clause(
815 node: Node,
816 content: &[u8],
817 helper: &mut GraphBuildHelper,
818 import_source_id: NodeId,
819) {
820 process_use_clause_with_prefix(node, content, helper, import_source_id, None);
821}
822
823fn process_use_clause_with_prefix(
825 node: Node,
826 content: &[u8],
827 helper: &mut GraphBuildHelper,
828 import_source_id: NodeId,
829 prefix: Option<&str>,
830) {
831 let mut qualified_name = None;
833 let mut alias = None;
834 let mut found_as = false;
835
836 let mut cursor = node.walk();
837 for child in node.children(&mut cursor) {
838 match child.kind() {
839 "qualified_name" => {
840 if let Ok(name) = child.utf8_text(content) {
842 qualified_name = Some(name.trim().to_string());
843 }
844 }
845 "namespace_name" => {
846 if qualified_name.is_none()
848 && let Ok(name) = child.utf8_text(content)
849 {
850 qualified_name = Some(name.trim().to_string());
851 }
852 }
853 "name" => {
854 if found_as {
856 if let Ok(alias_text) = child.utf8_text(content) {
858 alias = Some(alias_text.trim().to_string());
859 }
860 } else if qualified_name.is_none() {
861 if let Ok(name) = child.utf8_text(content) {
863 qualified_name = Some(name.trim().to_string());
864 }
865 }
866 }
867 "as" => {
868 found_as = true;
870 }
871 _ => {}
872 }
873 }
874
875 if let Some(name) = qualified_name
876 && !name.is_empty()
877 {
878 let full_name = if let Some(pfx) = prefix {
880 format!("{pfx}\\{name}")
881 } else {
882 name
883 };
884
885 let span = span_from_node(node);
887 let import_node_id = helper.add_import(&full_name, Some(span));
888
889 if let Some(alias_str) = alias {
891 helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
892 } else {
893 helper.add_import_edge(import_source_id, import_node_id);
894 }
895 }
896}
897
898fn process_use_group(
917 node: Node,
918 content: &[u8],
919 helper: &mut GraphBuildHelper,
920 import_source_id: NodeId,
921 prefix: &str,
922) {
923 let mut cursor = node.walk();
925 for child in node.children(&mut cursor) {
926 if child.kind() == "namespace_use_clause" {
928 process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
930 }
931 }
932}
933
934fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
936 let file_path = helper.file_path().to_string();
938 let import_source_id = helper.add_module(&file_path, None);
939
940 let mut cursor = node.walk();
943 for child in node.children(&mut cursor) {
944 if child.kind() == "string"
945 || child.kind() == "encapsed_string"
946 || child.kind() == "binary_expression"
947 {
948 if let Ok(path_text) = child.utf8_text(content) {
949 let cleaned_path = path_text
951 .trim()
952 .trim_start_matches(['\'', '"'])
953 .trim_end_matches(['\'', '"'])
954 .to_string();
955
956 if !cleaned_path.is_empty() {
957 let span = span_from_node(node);
958 let import_node_id = helper.add_import(&cleaned_path, Some(span));
959 helper.add_import_edge(import_source_id, import_node_id);
960 }
961 }
962 break;
963 }
964 }
965}
966
967fn process_oop_relationships(
973 node: Node,
974 content: &[u8],
975 helper: &mut GraphBuildHelper,
976 node_map: &mut HashMap<String, NodeId>,
977) {
978 let kind = node.kind();
979 if kind == "class_declaration" {
980 process_class_oop(node, content, helper, node_map);
981 } else if kind == "interface_declaration" {
982 process_interface_inheritance(node, content, helper, node_map);
983 }
984
985 let mut cursor = node.walk();
987 for child in node.children(&mut cursor) {
988 process_oop_relationships(child, content, helper, node_map);
989 }
990}
991
992fn process_class_oop(
994 node: Node,
995 content: &[u8],
996 helper: &mut GraphBuildHelper,
997 node_map: &mut HashMap<String, NodeId>,
998) {
999 let Some(name_node) = node.child_by_field_name("name") else {
1001 return;
1002 };
1003 let Ok(class_name) = name_node.utf8_text(content) else {
1004 return;
1005 };
1006 let class_name = class_name.trim();
1007
1008 let span = span_from_node(node);
1010 let class_id = *node_map
1011 .entry(class_name.to_string())
1012 .or_insert_with(|| helper.add_class(class_name, Some(span)));
1013
1014 let mut cursor = node.walk();
1016 for child in node.children(&mut cursor) {
1017 match child.kind() {
1018 "base_clause" => {
1019 process_extends_clause(child, content, helper, node_map, class_id);
1021 }
1022 "class_interface_clause" => {
1023 process_implements_clause(child, content, helper, node_map, class_id);
1025 }
1026 "declaration_list" => {
1027 process_class_body_traits(child, content, helper, node_map, class_id);
1029 }
1030 _ => {}
1031 }
1032 }
1033}
1034
1035fn process_extends_clause(
1037 node: Node,
1038 content: &[u8],
1039 helper: &mut GraphBuildHelper,
1040 node_map: &mut HashMap<String, NodeId>,
1041 class_id: NodeId,
1042) {
1043 let mut cursor = node.walk();
1045 for child in node.children(&mut cursor) {
1046 if child.kind() == "name"
1047 || child.kind() == "qualified_name"
1048 || child.kind() == "namespace_name"
1049 {
1050 if let Ok(parent_name) = child.utf8_text(content) {
1051 let parent_name = parent_name.trim();
1052 if !parent_name.is_empty() {
1053 let span = span_from_node(child);
1054 let parent_id = *node_map
1055 .entry(parent_name.to_string())
1056 .or_insert_with(|| helper.add_class(parent_name, Some(span)));
1057
1058 helper.add_inherits_edge(class_id, parent_id);
1059 }
1060 }
1061 break;
1062 }
1063 }
1064}
1065
1066fn process_implements_clause(
1068 node: Node,
1069 content: &[u8],
1070 helper: &mut GraphBuildHelper,
1071 node_map: &mut HashMap<String, NodeId>,
1072 class_id: NodeId,
1073) {
1074 let mut cursor = node.walk();
1076 for child in node.children(&mut cursor) {
1077 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1078 && let Ok(interface_name) = child.utf8_text(content)
1079 {
1080 let interface_name = interface_name.trim();
1081 if !interface_name.is_empty() {
1082 let span = span_from_node(child);
1083 let interface_id = *node_map
1084 .entry(interface_name.to_string())
1085 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1086
1087 helper.add_implements_edge(class_id, interface_id);
1088 }
1089 }
1090 }
1091}
1092
1093fn process_class_body_traits(
1095 declaration_list: Node,
1096 content: &[u8],
1097 helper: &mut GraphBuildHelper,
1098 node_map: &mut HashMap<String, NodeId>,
1099 class_id: NodeId,
1100) {
1101 let mut cursor = declaration_list.walk();
1102 for child in declaration_list.children(&mut cursor) {
1103 if child.kind() == "use_declaration" {
1104 process_trait_use(child, content, helper, node_map, class_id);
1106 }
1107 }
1108}
1109
1110fn process_trait_use(
1112 node: Node,
1113 content: &[u8],
1114 helper: &mut GraphBuildHelper,
1115 node_map: &mut HashMap<String, NodeId>,
1116 class_id: NodeId,
1117) {
1118 let mut cursor = node.walk();
1120 for child in node.children(&mut cursor) {
1121 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1122 && let Ok(trait_name) = child.utf8_text(content)
1123 {
1124 let trait_name = trait_name.trim();
1125 if !trait_name.is_empty() {
1126 let span = span_from_node(child);
1127 let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
1130 helper.add_node(
1131 trait_name,
1132 Some(span),
1133 sqry_core::graph::unified::node::NodeKind::Trait,
1134 )
1135 });
1136
1137 helper.add_implements_edge(class_id, trait_id);
1140 }
1141 }
1142 }
1143}
1144
1145fn process_interface_inheritance(
1147 node: Node,
1148 content: &[u8],
1149 helper: &mut GraphBuildHelper,
1150 node_map: &mut HashMap<String, NodeId>,
1151) {
1152 let Some(name_node) = node.child_by_field_name("name") else {
1154 return;
1155 };
1156 let Ok(interface_name) = name_node.utf8_text(content) else {
1157 return;
1158 };
1159 let interface_name = interface_name.trim();
1160
1161 let span = span_from_node(node);
1163 let interface_id = *node_map
1164 .entry(interface_name.to_string())
1165 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1166
1167 let mut cursor = node.walk();
1169 for child in node.children(&mut cursor) {
1170 if child.kind() == "base_clause" {
1171 let mut base_cursor = child.walk();
1173 for base_child in child.children(&mut base_cursor) {
1174 if matches!(
1175 base_child.kind(),
1176 "name" | "qualified_name" | "namespace_name"
1177 ) && let Ok(parent_name) = base_child.utf8_text(content)
1178 {
1179 let parent_name = parent_name.trim();
1180 if !parent_name.is_empty() {
1181 let span = span_from_node(base_child);
1182 let parent_id = *node_map
1183 .entry(parent_name.to_string())
1184 .or_insert_with(|| helper.add_interface(parent_name, Some(span)));
1185
1186 helper.add_inherits_edge(interface_id, parent_id);
1188 }
1189 }
1190 }
1191 }
1192 }
1193}
1194
1195fn process_exports(
1214 node: Node,
1215 content: &[u8],
1216 helper: &mut GraphBuildHelper,
1217 node_map: &mut HashMap<String, NodeId>,
1218) {
1219 let file_path = helper.file_path().to_string();
1221 let module_id = helper.add_module(&file_path, None);
1222
1223 if node.kind() != "program" {
1225 return;
1226 }
1227
1228 let mut active_namespace = String::new();
1230
1231 let mut cursor = node.walk();
1233 for child in node.children(&mut cursor) {
1234 process_top_level_for_export(
1235 child,
1236 content,
1237 helper,
1238 node_map,
1239 module_id,
1240 &mut active_namespace,
1241 );
1242 }
1243}
1244
1245fn process_top_level_for_export(
1255 node: Node,
1256 content: &[u8],
1257 helper: &mut GraphBuildHelper,
1258 node_map: &mut HashMap<String, NodeId>,
1259 module_id: NodeId,
1260 active_namespace: &mut String,
1261) {
1262 match node.kind() {
1263 "namespace_definition" => {
1264 let ns_name = node
1266 .child_by_field_name("name")
1267 .and_then(|n| n.utf8_text(content).ok())
1268 .map(|s| s.trim().to_string())
1269 .unwrap_or_default();
1270
1271 let has_body = node
1273 .children(&mut node.walk())
1274 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
1275
1276 if has_body {
1277 active_namespace.clear();
1283
1284 let mut cursor = node.walk();
1286 for child in node.children(&mut cursor) {
1287 if matches!(child.kind(), "compound_statement" | "declaration_list") {
1288 let mut body_cursor = child.walk();
1289 for body_child in child.children(&mut body_cursor) {
1290 export_declaration_if_exportable(
1291 body_child, content, helper, node_map, module_id, &ns_name,
1292 );
1293 }
1294 }
1295 }
1296 } else {
1297 *active_namespace = ns_name;
1300 }
1301 }
1302 "class_declaration"
1304 | "interface_declaration"
1305 | "trait_declaration"
1306 | "enum_declaration"
1307 | "function_definition" => {
1308 export_declaration_if_exportable(
1309 node,
1310 content,
1311 helper,
1312 node_map,
1313 module_id,
1314 active_namespace,
1315 );
1316 }
1317 _ => {
1318 }
1321 }
1322}
1323
1324fn lookup_or_create_node<F>(
1331 node_map: &mut HashMap<String, NodeId>,
1332 qualified_name: &str,
1333 simple_name: &str,
1334 namespace_prefix: &str,
1335 create_fn: F,
1336) -> NodeId
1337where
1338 F: FnOnce() -> NodeId,
1339{
1340 if let Some(&id) = node_map.get(qualified_name) {
1342 return id;
1343 }
1344
1345 if namespace_prefix.is_empty()
1348 && let Some(&id) = node_map.get(simple_name)
1349 {
1350 return id;
1351 }
1352
1353 let id = create_fn();
1355 node_map.insert(qualified_name.to_string(), id);
1356 id
1357}
1358
1359#[allow(clippy::too_many_lines)] fn export_declaration_if_exportable(
1374 node: Node,
1375 content: &[u8],
1376 helper: &mut GraphBuildHelper,
1377 node_map: &mut HashMap<String, NodeId>,
1378 module_id: NodeId,
1379 namespace_prefix: &str,
1380) {
1381 match node.kind() {
1382 "class_declaration" => {
1383 if let Some(name_node) = node.child_by_field_name("name")
1384 && let Ok(class_name) = name_node.utf8_text(content)
1385 {
1386 let simple_name = class_name.trim().to_string();
1387 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1388 let span = span_from_node(node);
1389
1390 let class_id = lookup_or_create_node(
1391 node_map,
1392 &qualified_name,
1393 &simple_name,
1394 namespace_prefix,
1395 || helper.add_class(&qualified_name, Some(span)),
1396 );
1397
1398 helper.add_export_edge(module_id, class_id);
1399
1400 export_public_methods_from_class(
1402 node,
1403 content,
1404 helper,
1405 node_map,
1406 module_id,
1407 &qualified_name,
1408 );
1409 }
1410 }
1411 "interface_declaration" => {
1412 if let Some(name_node) = node.child_by_field_name("name")
1413 && let Ok(interface_name) = name_node.utf8_text(content)
1414 {
1415 let simple_name = interface_name.trim().to_string();
1416 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1417 let span = span_from_node(node);
1418
1419 let interface_id = lookup_or_create_node(
1420 node_map,
1421 &qualified_name,
1422 &simple_name,
1423 namespace_prefix,
1424 || helper.add_interface(&qualified_name, Some(span)),
1425 );
1426
1427 helper.add_export_edge(module_id, interface_id);
1428 }
1429 }
1430 "trait_declaration" => {
1431 if let Some(name_node) = node.child_by_field_name("name")
1432 && let Ok(trait_name) = name_node.utf8_text(content)
1433 {
1434 let simple_name = trait_name.trim().to_string();
1435 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1436 let span = span_from_node(node);
1437
1438 let trait_id = lookup_or_create_node(
1439 node_map,
1440 &qualified_name,
1441 &simple_name,
1442 namespace_prefix,
1443 || {
1444 helper.add_node(
1445 &qualified_name,
1446 Some(span),
1447 sqry_core::graph::unified::node::NodeKind::Trait,
1448 )
1449 },
1450 );
1451
1452 helper.add_export_edge(module_id, trait_id);
1453 }
1454 }
1455 "enum_declaration" => {
1456 if let Some(name_node) = node.child_by_field_name("name")
1458 && let Ok(enum_name) = name_node.utf8_text(content)
1459 {
1460 let simple_name = enum_name.trim().to_string();
1461 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1462 let span = span_from_node(node);
1463
1464 let enum_id = lookup_or_create_node(
1465 node_map,
1466 &qualified_name,
1467 &simple_name,
1468 namespace_prefix,
1469 || helper.add_enum(&qualified_name, Some(span)),
1470 );
1471
1472 helper.add_export_edge(module_id, enum_id);
1473 }
1474 }
1475 "function_definition" => {
1476 if let Some(name_node) = node.child_by_field_name("name")
1478 && let Ok(func_name) = name_node.utf8_text(content)
1479 {
1480 let simple_name = func_name.trim().to_string();
1481 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1482 let span = span_from_node(node);
1483
1484 let func_id = lookup_or_create_node(
1485 node_map,
1486 &qualified_name,
1487 &simple_name,
1488 namespace_prefix,
1489 || helper.add_function(&qualified_name, Some(span), false, false),
1490 );
1491
1492 helper.add_export_edge(module_id, func_id);
1493 }
1494 }
1495 _ => {
1496 }
1498 }
1499}
1500
1501fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
1503 if namespace_prefix.is_empty() {
1504 name.to_string()
1505 } else {
1506 format!("{namespace_prefix}\\{name}")
1507 }
1508}
1509
1510fn span_from_node(node: Node<'_>) -> Span {
1512 let start = node.start_position();
1513 let end = node.end_position();
1514 Span::new(
1515 sqry_core::graph::node::Position::new(start.row, start.column),
1516 sqry_core::graph::node::Position::new(end.row, end.column),
1517 )
1518}
1519
1520fn count_call_arguments(call_node: Node<'_>) -> u8 {
1521 let args_node = call_node
1522 .child_by_field_name("arguments")
1523 .or_else(|| call_node.child_by_field_name("argument_list"))
1524 .or_else(|| {
1525 let mut cursor = call_node.walk();
1526 call_node
1527 .children(&mut cursor)
1528 .find(|child| child.kind() == "argument_list")
1529 });
1530
1531 let Some(args_node) = args_node else {
1532 return 255;
1533 };
1534 let count = args_node.named_child_count();
1535 if count <= 254 {
1536 u8::try_from(count).unwrap_or(u8::MAX)
1537 } else {
1538 255
1539 }
1540}
1541
1542fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
1547 let mut cursor = node.walk();
1549 for child in node.children(&mut cursor) {
1550 match child.kind() {
1551 "visibility_modifier" => {
1552 if let Ok(vis_text) = child.utf8_text(content) {
1554 return Some(vis_text.trim().to_string());
1555 }
1556 }
1557 "public" | "private" | "protected" => {
1558 if let Ok(vis_text) = child.utf8_text(content) {
1560 return Some(vis_text.trim().to_string());
1561 }
1562 }
1563 _ => {}
1564 }
1565 }
1566
1567 None
1571}
1572
1573fn export_public_methods_from_class(
1579 class_node: Node,
1580 content: &[u8],
1581 helper: &mut GraphBuildHelper,
1582 node_map: &mut HashMap<String, NodeId>,
1583 module_id: NodeId,
1584 class_qualified_name: &str,
1585) {
1586 let mut cursor = class_node.walk();
1588 for child in class_node.children(&mut cursor) {
1589 if child.kind() == "declaration_list" {
1590 let mut body_cursor = child.walk();
1592 for body_child in child.children(&mut body_cursor) {
1593 if body_child.kind() == "method_declaration" {
1594 let visibility = extract_visibility(&body_child, content);
1596
1597 let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
1599
1600 if is_public {
1601 if let Some(name_node) = body_child.child_by_field_name("name")
1603 && let Ok(method_name) = name_node.utf8_text(content)
1604 {
1605 let method_name = method_name.trim();
1606 let qualified_method_name =
1607 format!("{class_qualified_name}::{method_name}");
1608
1609 if let Some(&method_id) = node_map.get(&qualified_method_name) {
1611 helper.add_export_edge(module_id, method_id);
1612 }
1613 }
1614 }
1615 }
1616 }
1617 break;
1618 }
1619 }
1620}
1621
1622fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
1641 let mut found_colon = false;
1643 let mut cursor = node.walk();
1644 for child in node.children(&mut cursor) {
1645 if found_colon && child.is_named() {
1646 return extract_type_from_node(&child, content);
1648 }
1649 if child.kind() == ":" {
1650 found_colon = true;
1651 }
1652 }
1653 None
1654}
1655
1656fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
1670 match type_node.kind() {
1671 "primitive_type" => {
1672 type_node
1674 .utf8_text(content)
1675 .ok()
1676 .map(|s| s.trim().to_string())
1677 }
1678 "optional_type" => {
1679 let mut cursor = type_node.walk();
1682 for child in type_node.children(&mut cursor) {
1683 if child.kind() != "?" && child.is_named() {
1684 return extract_type_from_node(&child, content);
1685 }
1686 }
1687 None
1688 }
1689 "union_type" => {
1690 type_node
1693 .named_child(0)
1694 .and_then(|first_type| extract_type_from_node(&first_type, content))
1695 }
1696 "named_type" | "qualified_name" => {
1697 type_node
1699 .utf8_text(content)
1700 .ok()
1701 .map(|s| s.trim().to_string())
1702 }
1703 "intersection_type" => {
1704 type_node
1707 .named_child(0)
1708 .and_then(|first_type| extract_type_from_node(&first_type, content))
1709 }
1710 _ => {
1711 type_node
1716 .utf8_text(content)
1717 .ok()
1718 .map(|s| {
1719 let trimmed = s.trim();
1720 trimmed
1723 .split(&['|', '&'][..])
1724 .next()
1725 .unwrap_or(trimmed)
1726 .trim()
1727 .trim_start_matches('(')
1728 .trim_end_matches(')')
1729 .trim()
1730 .to_string()
1731 })
1732 .filter(|s| !s.is_empty())
1733 }
1734 }
1735}
1736
1737fn process_phpdoc_annotations(
1760 node: Node,
1761 content: &[u8],
1762 helper: &mut GraphBuildHelper,
1763) -> GraphResult<()> {
1764 let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
1766 process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
1767
1768 process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
1772
1773 Ok(())
1774}
1775
1776fn process_phpdoc_pass_a(
1780 node: Node,
1781 content: &[u8],
1782 helper: &mut GraphBuildHelper,
1783 explicit_field_ids: &mut HashSet<NodeId>,
1784) -> GraphResult<()> {
1785 match node.kind() {
1786 "function_definition" => {
1787 process_function_phpdoc(node, content, helper)?;
1788 }
1789 "method_declaration" => {
1790 process_method_phpdoc(node, content, helper)?;
1793 }
1794 "property_declaration" | "simple_property" => {
1795 let emitted = process_property_declaration(node, content, helper);
1800 explicit_field_ids.extend(emitted);
1801 }
1802 _ => {}
1803 }
1804
1805 let mut cursor = node.walk();
1806 for child in node.children(&mut cursor) {
1807 process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
1808 }
1809
1810 Ok(())
1811}
1812
1813fn process_phpdoc_pass_b(
1819 node: Node,
1820 content: &[u8],
1821 helper: &mut GraphBuildHelper,
1822 explicit_field_ids: &HashSet<NodeId>,
1823) {
1824 if node.kind() == "method_declaration" {
1825 process_constructor_promotion(node, content, helper, explicit_field_ids);
1826 }
1827
1828 let mut cursor = node.walk();
1829 for child in node.children(&mut cursor) {
1830 process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
1831 }
1832}
1833
1834fn process_function_phpdoc(
1836 func_node: Node,
1837 content: &[u8],
1838 helper: &mut GraphBuildHelper,
1839) -> GraphResult<()> {
1840 let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1842 return Ok(());
1843 };
1844
1845 let tags = parse_phpdoc_tags(&phpdoc_text);
1847
1848 let Some(name_node) = func_node.child_by_field_name("name") else {
1850 return Ok(());
1851 };
1852
1853 let function_name = name_node
1854 .utf8_text(content)
1855 .map_err(|_| GraphBuilderError::ParseError {
1856 span: span_from_node(func_node),
1857 reason: "failed to read function name".to_string(),
1858 })?
1859 .trim()
1860 .to_string();
1861
1862 if function_name.is_empty() {
1863 return Ok(());
1864 }
1865
1866 let func_node_id = helper.ensure_callee(
1868 &function_name,
1869 span_from_node(func_node),
1870 CalleeKindHint::Function,
1871 );
1872
1873 let _ast_params = extract_ast_parameters(func_node, content);
1875
1876 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1880 let canonical_type = canonical_type_string(¶m_tag.type_str);
1882 let type_node_id = helper.add_type(&canonical_type, None);
1883 helper.add_typeof_edge_with_context(
1884 func_node_id,
1885 type_node_id,
1886 Some(TypeOfContext::Parameter),
1887 param_idx.try_into().ok(), Some(¶m_tag.name),
1889 );
1890
1891 let type_names = extract_type_names(¶m_tag.type_str);
1893 for type_name in type_names {
1894 let ref_type_id = helper.add_type(&type_name, None);
1895 helper.add_reference_edge(func_node_id, ref_type_id);
1896 }
1897 }
1898
1899 if let Some(return_type) = &tags.returns {
1901 let canonical_type = canonical_type_string(return_type);
1902 let type_node_id = helper.add_type(&canonical_type, None);
1903 helper.add_typeof_edge_with_context(
1904 func_node_id,
1905 type_node_id,
1906 Some(TypeOfContext::Return),
1907 Some(0),
1908 None,
1909 );
1910
1911 let type_names = extract_type_names(return_type);
1913 for type_name in type_names {
1914 let ref_type_id = helper.add_type(&type_name, None);
1915 helper.add_reference_edge(func_node_id, ref_type_id);
1916 }
1917 }
1918
1919 Ok(())
1920}
1921
1922fn process_method_phpdoc(
1924 method_node: Node,
1925 content: &[u8],
1926 helper: &mut GraphBuildHelper,
1927) -> GraphResult<()> {
1928 let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1930 return Ok(());
1931 };
1932
1933 let tags = parse_phpdoc_tags(&phpdoc_text);
1935
1936 let Some(name_node) = method_node.child_by_field_name("name") else {
1938 return Ok(());
1939 };
1940
1941 let method_name = name_node
1942 .utf8_text(content)
1943 .map_err(|_| GraphBuilderError::ParseError {
1944 span: span_from_node(method_node),
1945 reason: "failed to read method name".to_string(),
1946 })?
1947 .trim()
1948 .to_string();
1949
1950 if method_name.is_empty() {
1951 return Ok(());
1952 }
1953
1954 let class_name = get_enclosing_class_name(method_node, content)?;
1956 let Some(class_name) = class_name else {
1957 return Ok(());
1958 };
1959
1960 let qualified_name = format!("{class_name}.{method_name}");
1962
1963 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1966
1967 let _ast_params = extract_ast_parameters(method_node, content);
1969
1970 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1973 let canonical_type = canonical_type_string(¶m_tag.type_str);
1975 let type_node_id = helper.add_type(&canonical_type, None);
1976 helper.add_typeof_edge_with_context(
1977 method_node_id,
1978 type_node_id,
1979 Some(TypeOfContext::Parameter),
1980 param_idx.try_into().ok(),
1981 Some(¶m_tag.name),
1982 );
1983
1984 let type_names = extract_type_names(¶m_tag.type_str);
1986 for type_name in type_names {
1987 let ref_type_id = helper.add_type(&type_name, None);
1988 helper.add_reference_edge(method_node_id, ref_type_id);
1989 }
1990 }
1991
1992 if let Some(return_type) = &tags.returns {
1994 let canonical_type = canonical_type_string(return_type);
1995 let type_node_id = helper.add_type(&canonical_type, None);
1996 helper.add_typeof_edge_with_context(
1997 method_node_id,
1998 type_node_id,
1999 Some(TypeOfContext::Return),
2000 Some(0),
2001 None,
2002 );
2003
2004 let type_names = extract_type_names(return_type);
2006 for type_name in type_names {
2007 let ref_type_id = helper.add_type(&type_name, None);
2008 helper.add_reference_edge(method_node_id, ref_type_id);
2009 }
2010 }
2011
2012 Ok(())
2013}
2014
2015fn process_property_declaration(
2036 prop_node: Node,
2037 content: &[u8],
2038 helper: &mut GraphBuildHelper,
2039) -> Vec<NodeId> {
2040 let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
2044 return Vec::new();
2045 };
2046
2047 let mods = extract_property_modifiers(prop_node, content);
2049
2050 let native_type = prop_node
2053 .child_by_field_name("type")
2054 .and_then(|t| extract_type_from_node(&t, content));
2055
2056 let phpdoc_var_type = if native_type.is_none() {
2058 extract_phpdoc_comment(prop_node, content)
2059 .as_deref()
2060 .and_then(|c| parse_phpdoc_tags(c).var_type)
2061 } else {
2062 None
2063 };
2064
2065 let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
2066
2067 let prop_names = extract_property_element_names(prop_node, content);
2068 if prop_names.is_empty() {
2069 return Vec::new();
2070 }
2071
2072 let span = span_from_node(prop_node);
2073 let mut emitted = Vec::with_capacity(prop_names.len());
2074
2075 for prop_name in prop_names {
2076 let qualified_name = format!("{owner_name}.{prop_name}");
2077 let visibility = mods.visibility.as_deref().unwrap_or("public");
2078
2079 let node_id = if mods.is_readonly {
2080 helper.add_constant_with_name_static_and_visibility(
2081 &prop_name,
2082 &qualified_name,
2083 Some(span),
2084 mods.is_static,
2085 Some(visibility),
2086 )
2087 } else {
2088 helper.add_property_with_name_static_and_visibility(
2089 &prop_name,
2090 &qualified_name,
2091 Some(span),
2092 mods.is_static,
2093 Some(visibility),
2094 )
2095 };
2096
2097 if let Some(type_str) = primary_type.as_deref() {
2098 emit_field_type_edges(helper, node_id, &prop_name, type_str);
2099 }
2100
2101 emitted.push(node_id);
2102 }
2103
2104 emitted
2105}
2106
2107fn process_constructor_promotion(
2127 method_node: Node,
2128 content: &[u8],
2129 helper: &mut GraphBuildHelper,
2130 explicit_field_ids: &HashSet<NodeId>,
2131) {
2132 let Some(name_node) = method_node.child_by_field_name("name") else {
2134 return;
2135 };
2136 let Ok(method_name) = name_node.utf8_text(content) else {
2137 return;
2138 };
2139 if method_name.trim() != "__construct" {
2140 return;
2141 }
2142
2143 let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
2144 return;
2145 };
2146
2147 let Some(params_node) = method_node.child_by_field_name("parameters") else {
2148 return;
2149 };
2150
2151 let mut cursor = params_node.walk();
2152 for param in params_node.children(&mut cursor) {
2153 if param.kind() != "property_promotion_parameter" {
2154 continue;
2155 }
2156
2157 let visibility = param
2159 .child_by_field_name("visibility")
2160 .and_then(|v| v.utf8_text(content).ok())
2161 .map(|s| s.trim().to_string());
2162 let is_readonly = param.child_by_field_name("readonly").is_some()
2163 || direct_child_of_kind(param, "readonly_modifier").is_some();
2164 let is_static = false;
2167 let native_type = param
2168 .child_by_field_name("type")
2169 .and_then(|t| extract_type_from_node(&t, content));
2170
2171 let Some(prop_name) = promoted_param_name(param, content) else {
2172 continue;
2173 };
2174
2175 let qualified_name = format!("{owner_name}.{prop_name}");
2176 let span = span_from_node(param);
2177
2178 if let Some(existing_id) = helper.get_node(&qualified_name) {
2183 if explicit_field_ids.contains(&existing_id) {
2184 continue;
2189 }
2190 if let Some(t) = native_type {
2196 emit_field_type_edges(helper, existing_id, &prop_name, &t);
2197 }
2198 continue;
2199 }
2200
2201 let visibility_ref = visibility.as_deref().unwrap_or("public");
2202 let node_id = if is_readonly {
2203 helper.add_constant_with_name_static_and_visibility(
2204 &prop_name,
2205 &qualified_name,
2206 Some(span),
2207 is_static,
2208 Some(visibility_ref),
2209 )
2210 } else {
2211 helper.add_property_with_name_static_and_visibility(
2212 &prop_name,
2213 &qualified_name,
2214 Some(span),
2215 is_static,
2216 Some(visibility_ref),
2217 )
2218 };
2219
2220 if let Some(type_str) = native_type {
2221 emit_field_type_edges(helper, node_id, &prop_name, &type_str);
2222 }
2223 }
2224}
2225
2226struct PropertyModifiers {
2228 visibility: Option<String>,
2229 is_static: bool,
2230 is_readonly: bool,
2231}
2232
2233fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
2237 let mut visibility: Option<String> = None;
2238 let mut is_static = false;
2239 let mut is_readonly = false;
2240
2241 let mut cursor = prop_node.walk();
2242 for child in prop_node.children(&mut cursor) {
2243 match child.kind() {
2244 "visibility_modifier" => {
2245 if let Ok(text) = child.utf8_text(content) {
2246 visibility = Some(text.trim().to_string());
2247 }
2248 }
2249 "var_modifier" => {
2250 if visibility.is_none() {
2253 visibility = Some("public".to_string());
2254 }
2255 }
2256 "static_modifier" => {
2257 is_static = true;
2258 }
2259 "readonly_modifier" => {
2260 is_readonly = true;
2261 }
2262 _ => {}
2263 }
2264 }
2265
2266 PropertyModifiers {
2267 visibility,
2268 is_static,
2269 is_readonly,
2270 }
2271}
2272
2273fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2277 let mut names = Vec::new();
2278 let mut cursor = prop_node.walk();
2279 for child in prop_node.children(&mut cursor) {
2280 if child.kind() != "property_element" {
2281 continue;
2282 }
2283 if let Some(var_node) = child.child_by_field_name("name")
2284 && let Some(name) = strip_dollar_from_variable(var_node, content)
2285 {
2286 names.push(name);
2287 }
2288 }
2289 names
2290}
2291
2292fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
2295 let name_field = param.child_by_field_name("name")?;
2296 let var_node = if name_field.kind() == "variable_name" {
2298 name_field
2299 } else {
2300 let mut cursor = name_field.walk();
2302 name_field
2303 .children(&mut cursor)
2304 .find(|c| c.kind() == "variable_name")?
2305 };
2306 strip_dollar_from_variable(var_node, content)
2307}
2308
2309fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
2311 if let Some(name_node) = var_node.child_by_field_name("name")
2312 && let Ok(text) = name_node.utf8_text(content)
2313 {
2314 return Some(text.trim().to_string());
2315 }
2316 var_node
2317 .utf8_text(content)
2318 .ok()
2319 .map(|s| s.trim().trim_start_matches('$').to_string())
2320}
2321
2322fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
2324 let mut cursor = node.walk();
2325 node.children(&mut cursor).find(|c| c.kind() == kind)
2326}
2327
2328fn emit_field_type_edges(
2331 helper: &mut GraphBuildHelper,
2332 node_id: NodeId,
2333 prop_name: &str,
2334 type_str: &str,
2335) {
2336 let canonical_type = canonical_type_string(type_str);
2337 let type_node_id = helper.add_type(&canonical_type, None);
2338 helper.add_typeof_edge_with_context(
2339 node_id,
2340 type_node_id,
2341 Some(TypeOfContext::Field),
2342 None,
2343 Some(prop_name),
2344 );
2345
2346 for ref_type_name in extract_type_names(type_str) {
2347 let ref_type_id = helper.add_type(&ref_type_name, None);
2348 helper.add_reference_edge(node_id, ref_type_id);
2349 }
2350}
2351
2352fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
2355 let mut current = node;
2356 while let Some(parent) = current.parent() {
2357 if matches!(
2358 parent.kind(),
2359 "class_declaration" | "trait_declaration" | "interface_declaration"
2360 ) {
2361 return parent
2362 .child_by_field_name("name")
2363 .and_then(|n| n.utf8_text(content).ok())
2364 .map(|s| s.trim().to_string());
2365 }
2366 current = parent;
2367 }
2368 None
2369}
2370
2371fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2373 let mut params = Vec::new();
2374
2375 let Some(params_node) = func_node.child_by_field_name("parameters") else {
2377 return params;
2378 };
2379
2380 let mut index = 0;
2381 let mut cursor = params_node.walk();
2382
2383 for child in params_node.children(&mut cursor) {
2384 if !child.is_named() {
2385 continue;
2386 }
2387
2388 match child.kind() {
2389 "simple_parameter" => {
2390 let mut param_cursor = child.walk();
2392 for param_child in child.children(&mut param_cursor) {
2393 if param_child.kind() == "variable_name"
2394 && let Ok(param_text) = param_child.utf8_text(content)
2395 {
2396 params.push((index, param_text.trim().to_string()));
2397 index += 1;
2398 break;
2399 }
2400 }
2401 }
2402 "variadic_parameter" => {
2403 let mut param_cursor = child.walk();
2405 for param_child in child.children(&mut param_cursor) {
2406 if param_child.kind() == "variable_name"
2407 && let Ok(param_text) = param_child.utf8_text(content)
2408 {
2409 params.push((index, param_text.trim().to_string()));
2410 index += 1;
2411 break;
2412 }
2413 }
2414 }
2415 _ => {}
2416 }
2417 }
2418
2419 params
2420}
2421
2422#[allow(clippy::unnecessary_wraps)]
2424fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2425 let mut current = node;
2426
2427 while let Some(parent) = current.parent() {
2429 if parent.kind() == "class_declaration" {
2430 if let Some(name_node) = parent.child_by_field_name("name")
2432 && let Ok(name_text) = name_node.utf8_text(content)
2433 {
2434 return Ok(Some(name_text.trim().to_string()));
2435 }
2436 return Ok(None);
2437 }
2438 current = parent;
2439 }
2440
2441 Ok(None)
2442}
2443
2444fn process_ffi_member_call(
2452 node: Node,
2453 method_name: &str,
2454 ast_graph: &ASTGraph,
2455 helper: &mut GraphBuildHelper,
2456 node_map: &mut HashMap<String, NodeId>,
2457) {
2458 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2460 return;
2461 };
2462
2463 let source_id = *node_map
2465 .entry(call_context.qualified_name.clone())
2466 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2467
2468 let ffi_name = format!("native::ffi::{method_name}");
2470 let call_span = span_from_node(node);
2471 let target_id = helper.add_module(&ffi_name, Some(call_span));
2472
2473 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2475}
2476
2477fn process_ffi_static_call(
2482 node: Node,
2483 method_name: &str,
2484 ast_graph: &ASTGraph,
2485 helper: &mut GraphBuildHelper,
2486 node_map: &mut HashMap<String, NodeId>,
2487 content: &[u8],
2488) {
2489 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2491 return;
2492 };
2493
2494 let source_id = *node_map
2496 .entry(call_context.qualified_name.clone())
2497 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2498
2499 let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2501 .map_or_else(
2502 || "unknown".to_string(),
2503 |lib| php_ffi_library_simple_name(&lib),
2504 );
2505
2506 let ffi_name = format!("native::{library_name}");
2508 let call_span = span_from_node(node);
2509 let target_id = helper.add_module(&ffi_name, Some(call_span));
2510
2511 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2513}
2514
2515fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2528 if object_node.kind() == "scoped_call_expression"
2530 && let Some(scope_node) = object_node.child_by_field_name("scope")
2531 && let Some(name_node) = object_node.child_by_field_name("name")
2532 && let Ok(scope_text) = scope_node.utf8_text(content)
2533 && let Ok(name_text) = name_node.utf8_text(content)
2534 && is_ffi_static_call(scope_text, name_text)
2535 {
2536 return true;
2537 }
2538
2539 if object_node.kind() == "parenthesized_expression"
2541 && let Some(inner) = object_node.named_child(0)
2542 && inner.kind() == "scoped_call_expression"
2543 && let Some(scope_node) = inner.child_by_field_name("scope")
2544 && let Some(name_node) = inner.child_by_field_name("name")
2545 && let Ok(scope_text) = scope_node.utf8_text(content)
2546 && let Ok(name_text) = name_node.utf8_text(content)
2547 && is_ffi_static_call(scope_text, name_text)
2548 {
2549 return true;
2550 }
2551
2552 let Ok(object_text) = object_node.utf8_text(content) else {
2554 return false;
2555 };
2556
2557 let object_text = object_text.trim();
2558
2559 if object_text == "$ffi" || object_text == "$_ffi" {
2561 return true;
2562 }
2563
2564 if object_text.ends_with("->ffi")
2566 || object_text.ends_with("::$ffi")
2567 || object_text.ends_with("->_ffi")
2568 || object_text.ends_with("::$_ffi")
2569 {
2570 return true;
2571 }
2572
2573 false
2574}
2575
2576fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2580 (scope_text == "FFI" || scope_text == "\\FFI")
2581 && (method_text == "cdef" || method_text == "load")
2582}
2583
2584fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2592 let args = call_node.child_by_field_name("arguments")?;
2593
2594 let mut cursor = args.walk();
2595 let args_vec: Vec<Node> = args
2596 .children(&mut cursor)
2597 .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2598 .collect();
2599
2600 let target_arg_name = if is_cdef { "lib" } else { "filename" };
2603
2604 if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2606 return extract_string_from_argument(named_arg, content);
2607 }
2608
2609 if is_cdef {
2611 args_vec
2613 .get(1)
2614 .and_then(|arg| extract_string_from_argument(*arg, content))
2615 } else {
2616 args_vec
2618 .first()
2619 .and_then(|arg| extract_string_from_argument(*arg, content))
2620 }
2621}
2622
2623fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2630 for arg in args {
2631 if arg.kind() != "argument" {
2632 continue;
2633 }
2634
2635 if arg.named_child_count() < 2 {
2638 continue;
2639 }
2640
2641 if let Some(name_node) = arg.child_by_field_name("name")
2643 && let Ok(name_text) = name_node.utf8_text(content)
2644 && name_text == param_name
2645 {
2646 return Some(*arg);
2647 } else if let Some(name_node) = arg.named_child(0)
2648 && let Ok(name_text) = name_node.utf8_text(content)
2649 && name_text == param_name
2650 {
2651 return Some(*arg);
2653 }
2654 }
2655
2656 None
2657}
2658
2659fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2667 let value_node = unwrap_argument_node(arg_node)?;
2669
2670 if !is_string_literal_node(value_node) {
2672 return None;
2673 }
2674
2675 if is_interpolated_string(value_node) {
2677 return None;
2678 }
2679
2680 extract_php_string_content(value_node, content)
2681}
2682
2683fn unwrap_argument_node(node: Node) -> Option<Node> {
2695 if node.kind() != "argument" {
2696 return Some(node);
2698 }
2699
2700 let name_field_node = node.child_by_field_name("name");
2707 let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2708
2709 for i in 0..node.named_child_count() {
2711 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.named_child(i as u32) {
2713 let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2715 let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2716
2717 if !is_name_field && !is_ref_modifier {
2718 return Some(child);
2720 }
2721 }
2722 }
2723
2724 None
2726}
2727
2728fn is_string_literal_node(node: Node) -> bool {
2735 matches!(
2736 node.kind(),
2737 "string" | "encapsed_string" | "heredoc" | "nowdoc"
2738 )
2739}
2740
2741fn is_interpolated_string(node: Node) -> bool {
2754 if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2755 return false;
2756 }
2757
2758 has_variable_node(node)
2760}
2761
2762fn has_variable_node(node: Node) -> bool {
2776 if matches!(
2778 node.kind(),
2779 "variable_name" | "simple_variable" | "variable" | "complex_variable"
2781 | "dynamic_variable_name"
2783 | "subscript_expression" | "member_access_expression" | "member_call_expression"
2785 | "function_call_expression"
2787 | "scoped_call_expression" | "scoped_property_access_expression"
2789 | "class_constant_access_expression"
2791 | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2793 ) {
2794 return true;
2795 }
2796
2797 for i in 0..node.child_count() {
2799 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.child(i as u32)
2801 && has_variable_node(child)
2802 {
2803 return true;
2804 }
2805 }
2806
2807 false
2808}
2809
2810fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2814 let Ok(text) = string_node.utf8_text(content) else {
2815 return None;
2816 };
2817
2818 let text = text.trim();
2819
2820 if ((text.starts_with('"') && text.ends_with('"'))
2822 || (text.starts_with('\'') && text.ends_with('\'')))
2823 && text.len() >= 2
2824 {
2825 return Some(text[1..text.len() - 1].to_string());
2826 }
2827
2828 Some(text.to_string())
2830}
2831
2832fn php_ffi_library_simple_name(library_path: &str) -> String {
2834 use std::path::Path;
2835
2836 let filename = Path::new(library_path)
2838 .file_name()
2839 .and_then(|f| f.to_str())
2840 .unwrap_or(library_path);
2841
2842 if let Some(so_pos) = filename.find(".so.") {
2844 return filename[..so_pos].to_string();
2845 }
2846
2847 if let Some(dot_pos) = filename.find('.') {
2849 let extension = &filename[dot_pos + 1..];
2850 if extension == "so"
2851 || extension == "dll"
2852 || extension == "dylib"
2853 || extension == "h"
2854 || extension == "hpp"
2855 {
2856 return filename[..dot_pos].to_string();
2857 }
2858 }
2859
2860 filename.to_string()
2861}
2862
2863#[cfg(test)]
2868mod field_emission_tests {
2869 use sqry_core::graph::GraphBuilder;
2887 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2888 use sqry_core::graph::unified::build::test_helpers::{
2889 build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
2890 };
2891 use sqry_core::graph::unified::edge::EdgeKind;
2892 use sqry_core::graph::unified::edge::kind::TypeOfContext;
2893 use sqry_core::graph::unified::node::NodeKind;
2894 use std::path::Path;
2895 use tree_sitter::Parser;
2896
2897 use super::PhpGraphBuilder;
2898
2899 fn parse(source: &str) -> tree_sitter::Tree {
2900 let mut parser = Parser::new();
2901 parser
2902 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
2903 .expect("load PHP grammar");
2904 parser.parse(source, None).expect("parse PHP source")
2905 }
2906
2907 fn build(source: &str) -> StagingGraph {
2908 let tree = parse(source);
2909 let mut staging = StagingGraph::new();
2910 let builder = PhpGraphBuilder::default();
2911 builder
2912 .build_graph(
2913 &tree,
2914 source.as_bytes(),
2915 Path::new("test.php"),
2916 &mut staging,
2917 )
2918 .expect("build graph");
2919 staging
2920 }
2921
2922 fn find_node<'a>(
2924 staging: &'a StagingGraph,
2925 name: &str,
2926 kind: Option<NodeKind>,
2927 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2928 let strings = build_string_lookup(staging);
2929 for op in staging.operations() {
2930 if let StagingOp::AddNode { entry, .. } = op {
2931 if let Some(k) = kind
2932 && entry.kind != k
2933 {
2934 continue;
2935 }
2936 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2937 if let Some(s) = strings.get(&name_idx)
2938 && s == name
2939 {
2940 return Some(entry);
2941 }
2942 }
2943 }
2944 None
2945 }
2946
2947 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
2948 let strings = build_string_lookup(staging);
2949 staging
2950 .operations()
2951 .iter()
2952 .filter(|op| {
2953 if let StagingOp::AddNode { entry, .. } = op {
2954 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2955 strings.get(&name_idx).is_some_and(|s| s == name)
2956 } else {
2957 false
2958 }
2959 })
2960 .count()
2961 }
2962
2963 fn resolve_visibility(
2964 staging: &StagingGraph,
2965 vis: Option<sqry_core::graph::unified::StringId>,
2966 ) -> Option<String> {
2967 let strings = build_string_lookup(staging);
2968 vis.and_then(|sid| strings.get(&sid.index()).cloned())
2969 }
2970
2971 fn typeof_edges_for_node(
2972 staging: &StagingGraph,
2973 source_name: &str,
2974 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
2975 let names = build_node_name_lookup(staging);
2976 let strings = build_string_lookup(staging);
2977 let mut out = Vec::new();
2978 for op in staging.operations() {
2979 if let StagingOp::AddEdge {
2980 source,
2981 target,
2982 kind: EdgeKind::TypeOf { context, name, .. },
2983 ..
2984 } = op
2985 {
2986 let src = names.get(source).cloned().unwrap_or_default();
2987 if src != source_name {
2988 continue;
2989 }
2990 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
2991 let target_name = names.get(target).cloned().unwrap_or_default();
2992 out.push((*context, edge_name, target_name));
2993 }
2994 }
2995 out
2996 }
2997
2998 #[test]
3001 fn req_r0001_property_without_phpdoc_emits_property_node() {
3002 let src = "<?php
3003class User {
3004 public string $name;
3005}
3006";
3007 let staging = build(src);
3008 let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
3009 .expect("User.name Property must be emitted without @var");
3010 assert_eq!(entry.kind, NodeKind::Property);
3011 }
3012
3013 #[test]
3014 fn req_r0001_property_with_phpdoc_still_emits_property_node() {
3015 let src = "<?php
3016class Repo {
3017 /** @var string */
3018 public string $label;
3019}
3020";
3021 let staging = build(src);
3022 find_node(&staging, "Repo.label", Some(NodeKind::Property))
3023 .expect("Repo.label Property must be emitted when @var is present");
3024 }
3025
3026 #[test]
3029 fn req_r0002_qualified_name_uses_class_dot_prop() {
3030 let src = "<?php
3031class A { public int $x; }
3032class B { public int $x; }
3033";
3034 let staging = build(src);
3035 find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
3036 find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
3037 assert!(
3038 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3039 "no bare 'x' Property node should leak"
3040 );
3041 }
3042
3043 #[test]
3044 fn req_r0002_visibility_modifiers_round_trip() {
3045 let src = "<?php
3046class V {
3047 public int $a;
3048 private int $b;
3049 protected int $c;
3050 var $d;
3051}
3052";
3053 let staging = build(src);
3054 for (name, expected) in [
3055 ("V.a", "public"),
3056 ("V.b", "private"),
3057 ("V.c", "protected"),
3058 ("V.d", "public"),
3059 ] {
3060 let entry = find_node(&staging, name, Some(NodeKind::Property))
3061 .unwrap_or_else(|| panic!("missing {name}"));
3062 let got = resolve_visibility(&staging, entry.visibility);
3063 assert_eq!(
3064 got.as_deref(),
3065 Some(expected),
3066 "{name} visibility should be {expected}"
3067 );
3068 }
3069 }
3070
3071 #[test]
3072 fn req_r0002_default_visibility_is_public_when_no_modifier() {
3073 let src = "<?php
3075class X { static int $count = 0; }
3076";
3077 let staging = build(src);
3078 let entry =
3079 find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
3080 let vis = resolve_visibility(&staging, entry.visibility);
3081 assert_eq!(
3082 vis.as_deref(),
3083 Some("public"),
3084 "default visibility is public"
3085 );
3086 }
3087
3088 #[test]
3091 fn req_r0003_static_modifier_sets_is_static() {
3092 let src = "<?php
3093class S {
3094 public static int $count = 0;
3095 public int $instance = 0;
3096}
3097";
3098 let staging = build(src);
3099 let s_count =
3100 find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
3101 assert!(s_count.is_static, "S.count should be static");
3102 let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
3103 .expect("S.instance must exist");
3104 assert!(!s_instance.is_static, "S.instance should not be static");
3105 }
3106
3107 #[test]
3110 fn req_r0004_readonly_emits_constant() {
3111 let src = "<?php
3112class R {
3113 public readonly string $id;
3114 public string $name;
3115}
3116";
3117 let staging = build(src);
3118 find_node(&staging, "R.id", Some(NodeKind::Constant))
3119 .expect("R.id must be Constant (readonly)");
3120 find_node(&staging, "R.name", Some(NodeKind::Property))
3121 .expect("R.name must be Property (mutable)");
3122 }
3123
3124 #[test]
3127 fn req_r0005_native_type_takes_precedence_over_phpdoc() {
3128 let src = "<?php
3132class T {
3133 /** @var {int} */
3134 public string $value;
3135}
3136";
3137 let staging = build(src);
3138 let edges = typeof_edges_for_node(&staging, "T.value");
3139 assert!(
3140 !edges.is_empty(),
3141 "T.value should have at least one TypeOf edge"
3142 );
3143 let has_string = edges.iter().any(|(_, _, t)| t == "string");
3144 assert!(
3145 has_string,
3146 "native type 'string' should be the primary TypeOf target, got {edges:?}"
3147 );
3148 let has_int = edges.iter().any(|(_, _, t)| t == "int");
3149 assert!(
3150 !has_int,
3151 "PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
3152 );
3153 }
3154
3155 #[test]
3156 fn req_r0005_phpdoc_fallback_when_no_native_type() {
3157 let src = "<?php
3159class T {
3160 /** @var {SomeUserType} */
3161 public $value;
3162}
3163";
3164 let staging = build(src);
3165 let edges = typeof_edges_for_node(&staging, "T.value");
3166 assert!(
3167 edges.iter().any(|(_, _, t)| t == "SomeUserType"),
3168 "PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
3169 );
3170 }
3171
3172 #[test]
3175 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3176 let src = "<?php
3177class C {
3178 public string $title;
3179}
3180";
3181 let staging = build(src);
3182 let edges = typeof_edges_for_node(&staging, "C.title");
3183 assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
3184 for (ctx, name, _) in &edges {
3185 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3186 assert_eq!(
3187 name.as_deref(),
3188 Some("title"),
3189 "edge name must be the bare property name"
3190 );
3191 }
3192 }
3193
3194 #[test]
3197 fn req_r0007_constructor_promotion_emits_property_on_class() {
3198 let src = "<?php
3199class P {
3200 public function __construct(public int $x, private readonly string $y) {}
3201}
3202";
3203 let staging = build(src);
3204 let x = find_node(&staging, "P.x", Some(NodeKind::Property))
3205 .expect("promoted P.x must be a Property");
3206 assert_eq!(
3207 resolve_visibility(&staging, x.visibility).as_deref(),
3208 Some("public"),
3209 "promoted $x visibility"
3210 );
3211 let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
3212 .expect("promoted readonly P.y must be a Constant");
3213 assert_eq!(
3214 resolve_visibility(&staging, y.visibility).as_deref(),
3215 Some("private"),
3216 "promoted $y visibility"
3217 );
3218 }
3219
3220 #[test]
3223 fn req_r0013_explicit_declaration_wins_over_promotion() {
3224 let src = "<?php
3225class D {
3226 public int $x;
3227 public function __construct(public int $x) {}
3228}
3229";
3230 let staging = build(src);
3231 let n = count_nodes_named(&staging, "D.x");
3232 assert_eq!(
3233 n, 1,
3234 "exactly one D.x node when explicit decl + promotion collide, got {n}"
3235 );
3236 find_node(&staging, "D.x", Some(NodeKind::Property))
3238 .expect("D.x must be Property (explicit declaration wins)");
3239 }
3240
3241 #[test]
3247 fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
3248 let src = "<?php
3249class A {
3250 public function __construct(public string $x) {}
3251 public int $x;
3252}
3253";
3254 let staging = build(src);
3255 let n = count_nodes_named(&staging, "A.x");
3256 assert_eq!(
3257 n, 1,
3258 "exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
3259 );
3260 find_node(&staging, "A.x", Some(NodeKind::Property))
3261 .expect("A.x must be Property (explicit declaration wins)");
3262
3263 let edges = typeof_edges_for_node(&staging, "A.x");
3267 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3268 assert!(
3269 target_types.contains(&"int"),
3270 "explicit `int` TypeOf must be present, got {target_types:?}",
3271 );
3272 assert!(
3273 !target_types.contains(&"string"),
3274 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3275 );
3276 }
3277
3278 #[test]
3282 fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
3283 let src = "<?php
3284class B {
3285 public int $x;
3286 public function __construct(public string $x) {}
3287}
3288";
3289 let staging = build(src);
3290 let n = count_nodes_named(&staging, "B.x");
3291 assert_eq!(
3292 n, 1,
3293 "exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
3294 );
3295 find_node(&staging, "B.x", Some(NodeKind::Property))
3296 .expect("B.x must be Property (explicit declaration wins)");
3297
3298 let edges = typeof_edges_for_node(&staging, "B.x");
3299 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3300 assert!(
3301 target_types.contains(&"int"),
3302 "explicit `int` TypeOf must be present, got {target_types:?}",
3303 );
3304 assert!(
3305 !target_types.contains(&"string"),
3306 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3307 );
3308 }
3309
3310 #[test]
3313 fn req_r0023_span_anchored_on_declaration() {
3314 let src = "<?php
3315class W {
3316
3317 public string $marker;
3318}
3319";
3320 let staging = build(src);
3321 let entry =
3322 find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
3323 assert_eq!(
3331 entry.start_line, 4,
3332 "span start line should match declaration"
3333 );
3334 assert_eq!(entry.end_line, 4, "span end line should match declaration");
3335 assert_eq!(
3336 entry.start_column, 4,
3337 "span start column should match indentation of `public`"
3338 );
3339 assert!(
3340 entry.end_column > entry.start_column,
3341 "span end column must extend past start (got start={}, end={})",
3342 entry.start_column,
3343 entry.end_column,
3344 );
3345 }
3346
3347 #[test]
3350 fn req_r0001_trait_property_emitted() {
3351 let src = "<?php
3352trait Loggable {
3353 protected ?string $logTag;
3354}
3355";
3356 let staging = build(src);
3357 let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
3358 .expect("trait property must be emitted");
3359 let vis = resolve_visibility(&staging, entry.visibility);
3360 assert_eq!(vis.as_deref(), Some("protected"));
3361 }
3362
3363 #[test]
3364 fn no_emission_outside_class_or_trait_or_interface() {
3365 let src = "<?php
3368$x = 1;
3369function f() { $y = 2; }
3370";
3371 let staging = build(src);
3372 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
3373 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
3374 }
3375}
3376
3377pub struct PhpShapeMapping {
3383 cf_by_kind_id: Vec<Option<CfBucket>>,
3384}
3385
3386impl PhpShapeMapping {
3387 fn build() -> Self {
3388 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3389 let count = lang.node_kind_count();
3390 let mut cf_by_kind_id = vec![None; count];
3391 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
3392 let Ok(kind_id) = u16::try_from(id) else {
3393 break;
3394 };
3395 if !lang.node_kind_is_named(kind_id) {
3396 continue;
3397 }
3398 if let Some(name) = lang.node_kind_for_id(kind_id) {
3399 *slot = cf_bucket_for_php_kind(name);
3400 }
3401 }
3402 Self { cf_by_kind_id }
3403 }
3404}
3405
3406impl ShapeMapping for PhpShapeMapping {
3407 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
3408 self.cf_by_kind_id
3409 .get(ts_node_kind_id as usize)
3410 .copied()
3411 .flatten()
3412 }
3413
3414 fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
3415 let mut shape = SignatureShape::default();
3416 if let Some(params) = fn_node.child_by_field_name("parameters") {
3417 let mut cursor = params.walk();
3418 for child in params.named_children(&mut cursor) {
3419 match child.kind() {
3420 "simple_parameter" | "property_promotion_parameter" => {
3421 shape.arity_positional = shape.arity_positional.saturating_add(1);
3422 if child.child_by_field_name("default_value").is_some() {
3423 shape.has_defaults = true;
3424 }
3425 }
3426 "variadic_parameter" => shape.has_varargs = true,
3427 _ => {}
3428 }
3429 }
3430 }
3431 shape.has_return_annotation = fn_node.child_by_field_name("return_type").is_some();
3432 shape
3433 }
3434}
3435
3436fn cf_bucket_for_php_kind(name: &str) -> Option<CfBucket> {
3439 let bucket = match name {
3440 "if_statement"
3441 | "else_if_clause"
3442 | "else_clause"
3443 | "conditional_expression"
3444 | "match_conditional_expression" => CfBucket::Branch,
3445 "while_statement" | "do_statement" | "for_statement" | "foreach_statement" => {
3446 CfBucket::Loop
3447 }
3448 "switch_statement" | "case_statement" | "default_statement" | "match_expression"
3449 | "match_block" => CfBucket::Match,
3450 "try_statement" => CfBucket::Try,
3451 "catch_clause" => CfBucket::Catch,
3452 "finally_clause" => CfBucket::Resource,
3453 "throw_expression" => CfBucket::Throw,
3454 "return_statement" => CfBucket::Return,
3455 "yield_expression" => CfBucket::Yield,
3456 "break_statement" | "continue_statement" => CfBucket::BreakContinue,
3457 "function_call_expression"
3458 | "member_call_expression"
3459 | "scoped_call_expression"
3460 | "nullsafe_member_call_expression"
3461 | "object_creation_expression" => CfBucket::Call,
3462 "assignment_expression" | "augmented_assignment_expression" => CfBucket::Assign,
3463 "anonymous_function" | "arrow_function" => CfBucket::Closure,
3464 _ => return None,
3465 };
3466 Some(bucket)
3467}
3468
3469#[must_use]
3471pub fn php_shape_mapping() -> &'static PhpShapeMapping {
3472 static MAPPING: OnceLock<PhpShapeMapping> = OnceLock::new();
3473 MAPPING.get_or_init(PhpShapeMapping::build)
3474}
3475
3476#[cfg(test)]
3477mod shape_tests {
3478 use super::{cf_bucket_for_php_kind, php_shape_mapping};
3482 use sqry_core::graph::unified::build::shape::{
3483 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3484 };
3485 use tree_sitter::{Node, Parser, Tree};
3486
3487 const SAMPLE: &str = include_str!(concat!(
3488 env!("CARGO_MANIFEST_DIR"),
3489 "/../test-fixtures/shape/dynamic/php.php"
3490 ));
3491
3492 fn parse(src: &str) -> Tree {
3493 let mut parser = Parser::new();
3494 parser
3495 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
3496 .expect("load php grammar");
3497 parser.parse(src, None).expect("parse php")
3498 }
3499
3500 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
3501 let root = tree.root_node();
3502 let mut cursor = root.walk();
3503 for child in root.named_children(&mut cursor) {
3504 if child.kind() == "function_definition" {
3505 return child;
3506 }
3507 }
3508 panic!("no function_definition in php fixture");
3509 }
3510
3511 #[test]
3512 fn mapping_is_non_empty_and_covers_real_kinds() {
3513 assert_eq!(
3514 cf_bucket_for_php_kind("if_statement"),
3515 Some(CfBucket::Branch)
3516 );
3517 assert_eq!(
3518 cf_bucket_for_php_kind("while_statement"),
3519 Some(CfBucket::Loop)
3520 );
3521 assert_eq!(
3522 cf_bucket_for_php_kind("switch_statement"),
3523 Some(CfBucket::Match)
3524 );
3525 assert_eq!(cf_bucket_for_php_kind("try_statement"), Some(CfBucket::Try));
3526 assert_eq!(
3527 cf_bucket_for_php_kind("catch_clause"),
3528 Some(CfBucket::Catch)
3529 );
3530 assert_eq!(
3531 cf_bucket_for_php_kind("finally_clause"),
3532 Some(CfBucket::Resource)
3533 );
3534 assert_eq!(
3535 cf_bucket_for_php_kind("throw_expression"),
3536 Some(CfBucket::Throw)
3537 );
3538 assert_eq!(
3539 cf_bucket_for_php_kind("anonymous_function"),
3540 Some(CfBucket::Closure)
3541 );
3542 assert_eq!(cf_bucket_for_php_kind("nope"), None);
3543
3544 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3545 let id = (0..lang.node_kind_count())
3546 .map(|i| i as u16)
3547 .find(|&i| {
3548 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
3549 })
3550 .expect("grammar exposes named if_statement");
3551 assert_eq!(php_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
3552 }
3553
3554 #[test]
3555 fn descriptor_covers_fixture_control_flow() {
3556 let tree = parse(SAMPLE);
3557 let func = first_function(&tree);
3558 let descriptor = compute_shape_descriptor(
3559 func,
3560 SAMPLE.as_bytes(),
3561 php_shape_mapping(),
3562 &ShapeBudget::default(),
3563 );
3564 let hist = descriptor.cf_histogram;
3565 assert!(hist[CfBucket::Branch.index()] >= 1, "branch");
3566 assert!(hist[CfBucket::Loop.index()] >= 1, "loop");
3567 assert!(hist[CfBucket::Match.index()] >= 1, "switch/case");
3568 assert!(hist[CfBucket::Try.index()] >= 1, "try");
3569 assert!(hist[CfBucket::Catch.index()] >= 1, "catch");
3570 assert!(hist[CfBucket::Resource.index()] >= 1, "finally");
3571 assert!(hist[CfBucket::Throw.index()] >= 1, "throw");
3572 assert!(hist[CfBucket::Return.index()] >= 1, "return");
3573 assert!(hist[CfBucket::Call.index()] >= 1, "call");
3574 assert!(hist[CfBucket::Closure.index()] >= 1, "closure");
3575 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/continue");
3576 }
3577
3578 #[test]
3579 fn signature_shape_reads_arity_and_return() {
3580 let tree = parse(SAMPLE);
3581 let func = first_function(&tree);
3582 let shape = php_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3583 assert_eq!(shape.arity_positional, 2, "value + label");
3585 assert!(shape.has_defaults, "label has a default");
3586 assert!(shape.has_varargs, "...$rest");
3587 assert!(shape.has_return_annotation, ": string");
3588 }
3589}