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};
49use sqry_core::graph::unified::node::NodeKind;
50
51const DEFAULT_MAX_SCOPE_DEPTH: usize = 5;
53
54#[derive(Debug)]
56pub struct PhpGraphBuilder {
57 pub max_scope_depth: usize,
58}
59
60impl Default for PhpGraphBuilder {
61 fn default() -> Self {
62 Self {
63 max_scope_depth: DEFAULT_MAX_SCOPE_DEPTH,
64 }
65 }
66}
67
68impl GraphBuilder for PhpGraphBuilder {
69 fn build_graph(
70 &self,
71 tree: &Tree,
72 content: &[u8],
73 file: &Path,
74 staging: &mut StagingGraph,
75 ) -> GraphResult<()> {
76 let mut helper = GraphBuildHelper::new(staging, file, Language::Php);
77
78 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
80 GraphBuilderError::ParseError {
81 span: Span::default(),
82 reason: e,
83 }
84 })?;
85
86 let mut node_map = HashMap::new();
88
89 for context in ast_graph.contexts() {
91 let qualified_name = &context.qualified_name;
92 let span = context.decl_span;
93
94 let node_id = match &context.kind {
95 ContextKind::Function { is_async } => helper.add_function_with_signature(
96 qualified_name,
97 Some(span),
98 *is_async,
99 false, None, context.return_type.as_deref(),
102 ),
103 ContextKind::Method {
104 is_async,
105 is_static,
106 visibility: _,
107 } => {
108 helper.add_method_with_signature(
112 qualified_name,
113 Some(span),
114 *is_async,
115 *is_static,
116 None, context.return_type.as_deref(),
118 )
119 }
120 ContextKind::Class => helper.add_class(qualified_name, Some(span)),
121 };
122 helper.mark_definition(node_id);
124 node_map.insert(qualified_name.clone(), node_id);
125 }
126
127 let root = tree.root_node();
129 walk_tree_for_edges(root, content, &ast_graph, &mut helper, &mut node_map)?;
130
131 process_oop_relationships(root, content, &mut helper, &mut node_map);
133
134 process_exports(root, content, &mut helper, &mut node_map);
137
138 process_phpdoc_annotations(root, content, &mut helper)?;
140
141 Ok(())
142 }
143
144 fn language(&self) -> Language {
145 Language::Php
146 }
147
148 fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
149 Some(php_shape_mapping())
150 }
151
152 fn detect_cross_language_edges(
153 &self,
154 _snapshot: &GraphSnapshot,
155 ) -> GraphResult<Vec<sqry_core::graph::CodeEdge>> {
156 Ok(vec![])
159 }
160}
161
162#[derive(Debug, Clone)]
167enum ContextKind {
168 Function {
169 is_async: bool,
170 },
171 Method {
172 is_async: bool,
173 is_static: bool,
174 #[allow(dead_code)] visibility: Option<String>,
176 },
177 Class,
178}
179
180#[derive(Debug, Clone)]
181struct CallContext {
182 qualified_name: String,
183 decl_span: Span,
186 kind: ContextKind,
187 class_name: Option<String>,
188 return_type: Option<String>,
189}
190
191struct ASTGraph {
192 contexts: Vec<CallContext>,
193 node_to_context: HashMap<usize, usize>,
194}
195
196impl ASTGraph {
197 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
198 let mut contexts = Vec::new();
199 let mut node_to_context = HashMap::new();
200 let mut scope_stack: Vec<String> = Vec::new();
201 let mut class_stack: Vec<String> = Vec::new();
202
203 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
205 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
206 let file_ops_depth = recursion_limits
207 .effective_file_ops_depth()
208 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
209 let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
210 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
211
212 let mut walk_ctx = WalkContext {
213 contexts: &mut contexts,
214 node_to_context: &mut node_to_context,
215 scope_stack: &mut scope_stack,
216 class_stack: &mut class_stack,
217 max_depth,
218 };
219
220 walk_ast(tree.root_node(), content, &mut walk_ctx, &mut guard)?;
221
222 Ok(Self {
223 contexts,
224 node_to_context,
225 })
226 }
227
228 fn contexts(&self) -> &[CallContext] {
229 &self.contexts
230 }
231
232 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
233 self.node_to_context
234 .get(&node_id)
235 .and_then(|idx| self.contexts.get(*idx))
236 }
237}
238
239#[allow(
240 clippy::too_many_lines,
241 reason = "PHP namespace and scope handling requires a large, unified traversal."
242)]
243struct WalkContext<'a> {
248 contexts: &'a mut Vec<CallContext>,
249 node_to_context: &'a mut HashMap<usize, usize>,
250 scope_stack: &'a mut Vec<String>,
251 class_stack: &'a mut Vec<String>,
252 max_depth: usize,
253}
254
255#[allow(clippy::too_many_lines)]
256fn walk_ast(
257 node: Node,
258 content: &[u8],
259 ctx: &mut WalkContext,
260 guard: &mut sqry_core::query::security::RecursionGuard,
261) -> Result<(), String> {
262 guard
263 .enter()
264 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
265
266 if ctx.scope_stack.len() > ctx.max_depth {
267 guard.exit();
268 return Ok(());
269 }
270
271 match node.kind() {
272 "program" => {
273 let mut active_namespace_parts: Vec<String> = Vec::new();
276
277 let mut cursor = node.walk();
278 for child in node.children(&mut cursor) {
279 if child.kind() == "namespace_definition" {
280 let has_body = child
282 .children(&mut child.walk())
283 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
284
285 let ns_name = child
286 .child_by_field_name("name")
287 .and_then(|n| n.utf8_text(content).ok())
288 .map(|s| s.trim().to_string())
289 .unwrap_or_default();
290
291 if has_body {
292 for _ in 0..active_namespace_parts.len() {
299 ctx.scope_stack.pop();
300 }
301 active_namespace_parts.clear();
302
303 let ns_parts: Vec<String> = if ns_name.is_empty() {
304 Vec::new()
305 } else {
306 ns_name.split('\\').map(ToString::to_string).collect()
307 };
308
309 for part in &ns_parts {
310 ctx.scope_stack.push(part.clone());
311 }
312
313 for ns_child in child.children(&mut child.walk()) {
315 if matches!(ns_child.kind(), "compound_statement" | "declaration_list")
316 {
317 for body_child in ns_child.children(&mut ns_child.walk()) {
318 walk_ast(body_child, content, ctx, guard)?;
319 }
320 }
321 }
322
323 for _ in 0..ns_parts.len() {
324 ctx.scope_stack.pop();
325 }
326 } else {
327 for _ in 0..active_namespace_parts.len() {
330 ctx.scope_stack.pop();
331 }
332
333 active_namespace_parts = if ns_name.is_empty() {
335 Vec::new()
336 } else {
337 ns_name.split('\\').map(ToString::to_string).collect()
338 };
339
340 for part in &active_namespace_parts {
342 ctx.scope_stack.push(part.clone());
343 }
344 }
345 } else {
346 walk_ast(child, content, ctx, guard)?;
348 }
349 }
350
351 for _ in 0..active_namespace_parts.len() {
353 ctx.scope_stack.pop();
354 }
355
356 guard.exit();
357 return Ok(());
358 }
359 "namespace_definition" => {
360 let namespace_name = node
363 .child_by_field_name("name")
364 .and_then(|n| n.utf8_text(content).ok())
365 .map(|s| s.trim().to_string())
366 .unwrap_or_default();
367
368 let namespace_parts: Vec<String> = if namespace_name.is_empty() {
369 Vec::new()
370 } else {
371 namespace_name
372 .split('\\')
373 .map(ToString::to_string)
374 .collect()
375 };
376
377 let parts_count = namespace_parts.len();
378 for part in &namespace_parts {
379 ctx.scope_stack.push(part.clone());
380 }
381
382 let mut cursor = node.walk();
384 for child in node.children(&mut cursor) {
385 if matches!(child.kind(), "compound_statement" | "declaration_list") {
386 let mut body_cursor = child.walk();
387 for body_child in child.children(&mut body_cursor) {
388 walk_ast(body_child, content, ctx, guard)?;
389 }
390 }
391 }
392
393 for _ in 0..parts_count {
395 ctx.scope_stack.pop();
396 }
397 }
398 "class_declaration" => {
399 let name_node = node
400 .child_by_field_name("name")
401 .ok_or_else(|| "class_declaration missing name".to_string())?;
402 let class_name = name_node
403 .utf8_text(content)
404 .map_err(|_| "failed to read class name".to_string())?;
405
406 let qualified_class = if ctx.scope_stack.is_empty() {
408 class_name.to_string()
409 } else {
410 format!("{}\\{}", ctx.scope_stack.join("\\"), class_name)
411 };
412
413 ctx.class_stack.push(qualified_class.clone());
414 ctx.scope_stack.push(class_name.to_string());
415
416 let _context_idx = ctx.contexts.len();
418 ctx.contexts.push(CallContext {
419 qualified_name: qualified_class.clone(),
420 decl_span: Span::from_node(&node),
421 kind: ContextKind::Class,
422 class_name: Some(qualified_class),
423 return_type: None, });
425
426 let mut cursor = node.walk();
428 for child in node.children(&mut cursor) {
429 if child.kind() == "declaration_list" {
430 let mut body_cursor = child.walk();
431 for body_child in child.children(&mut body_cursor) {
432 walk_ast(body_child, content, ctx, guard)?;
433 }
434 }
435 }
436
437 ctx.class_stack.pop();
438 ctx.scope_stack.pop();
439 }
440 "function_definition" | "method_declaration" => {
441 let name_node = node
442 .child_by_field_name("name")
443 .ok_or_else(|| format!("{} missing name", node.kind()).to_string())?;
444 let func_name = name_node
445 .utf8_text(content)
446 .map_err(|_| "failed to read function name".to_string())?;
447
448 let is_async = false; let is_static = node
453 .children(&mut node.walk())
454 .any(|child| child.kind() == "static_modifier");
455
456 let visibility = extract_visibility(&node, content);
458
459 let return_type = extract_return_type(&node, content);
461
462 let is_method = !ctx.class_stack.is_empty();
464 let class_name = ctx.class_stack.last().cloned();
465
466 let qualified_func = if is_method {
470 if let Some(ref class) = class_name {
472 format!("{class}::{func_name}")
473 } else {
474 func_name.to_string()
475 }
476 } else {
477 if ctx.scope_stack.is_empty() {
479 func_name.to_string()
480 } else {
481 format!("{}\\{}", ctx.scope_stack.join("\\"), func_name)
482 }
483 };
484
485 let kind = if is_method {
486 ContextKind::Method {
487 is_async,
488 is_static,
489 visibility: visibility.clone(),
490 }
491 } else {
492 ContextKind::Function { is_async }
493 };
494
495 let context_idx = ctx.contexts.len();
496 ctx.contexts.push(CallContext {
497 qualified_name: qualified_func.clone(),
498 decl_span: Span::from_node(&node),
499 kind,
500 class_name,
501 return_type,
502 });
503
504 if let Some(body) = node.child_by_field_name("body") {
506 associate_descendants(body, context_idx, ctx.node_to_context);
507 }
508
509 ctx.scope_stack.push(func_name.to_string());
510
511 if let Some(body) = node.child_by_field_name("body") {
513 let mut cursor = body.walk();
514 for child in body.children(&mut cursor) {
515 walk_ast(child, content, ctx, guard)?;
516 }
517 }
518
519 ctx.scope_stack.pop();
520 }
521 _ => {
522 let mut cursor = node.walk();
524 for child in node.children(&mut cursor) {
525 walk_ast(child, content, ctx, guard)?;
526 }
527 }
528 }
529
530 guard.exit();
531 Ok(())
532}
533
534fn associate_descendants(
535 node: Node,
536 context_idx: usize,
537 node_to_context: &mut HashMap<usize, usize>,
538) {
539 node_to_context.insert(node.id(), context_idx);
540
541 let mut stack = vec![node];
542 while let Some(current) = stack.pop() {
543 node_to_context.insert(current.id(), context_idx);
544
545 let mut cursor = current.walk();
546 for child in current.children(&mut cursor) {
547 stack.push(child);
548 }
549 }
550}
551
552#[allow(clippy::only_used_in_recursion)]
558fn walk_tree_for_edges(
559 node: Node,
560 content: &[u8],
561 ast_graph: &ASTGraph,
562 helper: &mut GraphBuildHelper,
563 node_map: &mut HashMap<String, NodeId>,
564) -> GraphResult<()> {
565 match node.kind() {
566 "function_call_expression" => {
567 process_function_call(node, content, ast_graph, helper, node_map);
568 }
569 "member_call_expression" | "nullsafe_member_call_expression" => {
570 process_member_call(node, content, ast_graph, helper, node_map);
571 }
572 "scoped_call_expression" => {
573 process_static_call(node, content, ast_graph, helper, node_map);
574 }
575 "namespace_use_declaration" => {
577 process_namespace_use(node, content, helper);
578 }
579 "expression_statement" => {
581 let mut cursor = node.walk();
583 for child in node.children(&mut cursor) {
584 match child.kind() {
585 "require_expression"
586 | "require_once_expression"
587 | "include_expression"
588 | "include_once_expression" => {
589 process_file_include(child, content, helper);
590 }
591 _ => {}
592 }
593 }
594 }
595 _ => {}
596 }
597
598 let mut cursor = node.walk();
600 for child in node.children(&mut cursor) {
601 walk_tree_for_edges(child, content, ast_graph, helper, node_map)?;
602 }
603
604 Ok(())
605}
606
607fn process_function_call(
608 node: Node,
609 content: &[u8],
610 ast_graph: &ASTGraph,
611 helper: &mut GraphBuildHelper,
612 node_map: &mut HashMap<String, NodeId>,
613) {
614 let Some(function_node) = node.child_by_field_name("function") else {
615 return;
616 };
617
618 let Ok(callee_name) = function_node.utf8_text(content) else {
619 return;
620 };
621
622 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
624 return;
625 };
626
627 let source_id = *node_map
629 .entry(call_context.qualified_name.clone())
630 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
631
632 let call_span = span_from_node(node);
634 let target_id = *node_map
635 .entry(callee_name.to_string())
636 .or_insert_with(|| helper.ensure_callee(callee_name, call_span, CalleeKindHint::Function));
637
638 let argument_count = count_call_arguments(node);
639 helper.add_call_edge_full_with_span(
640 source_id,
641 target_id,
642 argument_count,
643 false,
644 vec![call_span],
645 );
646}
647
648fn process_member_call(
649 node: Node,
650 content: &[u8],
651 ast_graph: &ASTGraph,
652 helper: &mut GraphBuildHelper,
653 node_map: &mut HashMap<String, NodeId>,
654) {
655 let Some(method_node) = node.child_by_field_name("name") else {
656 return;
657 };
658
659 let Ok(method_name) = method_node.utf8_text(content) else {
660 return;
661 };
662
663 if let Some(object_node) = node.child_by_field_name("object")
665 && is_php_ffi_call(object_node, content)
666 {
667 process_ffi_member_call(node, method_name, ast_graph, helper, node_map);
668 return;
669 }
670
671 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
673 return;
674 };
675
676 let callee_qualified = if let Some(class_name) = &call_context.class_name {
678 format!("{class_name}::{method_name}")
679 } else {
680 method_name.to_string()
681 };
682
683 let source_id = *node_map
685 .entry(call_context.qualified_name.clone())
686 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
687
688 let call_span = span_from_node(node);
690 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
691 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
692 });
693
694 let argument_count = count_call_arguments(node);
695 helper.add_call_edge_full_with_span(
696 source_id,
697 target_id,
698 argument_count,
699 false,
700 vec![call_span],
701 );
702}
703
704fn process_static_call(
705 node: Node,
706 content: &[u8],
707 ast_graph: &ASTGraph,
708 helper: &mut GraphBuildHelper,
709 node_map: &mut HashMap<String, NodeId>,
710) {
711 let Some(scope_node) = node.child_by_field_name("scope") else {
712 return;
713 };
714 let Some(name_node) = node.child_by_field_name("name") else {
715 return;
716 };
717
718 let Ok(class_name) = scope_node.utf8_text(content) else {
719 return;
720 };
721 let Ok(method_name) = name_node.utf8_text(content) else {
722 return;
723 };
724
725 if is_ffi_static_call(class_name, method_name) {
727 process_ffi_static_call(node, method_name, ast_graph, helper, node_map, content);
728 return;
729 }
730
731 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
733 return;
734 };
735
736 let callee_qualified = format!("{class_name}::{method_name}");
738
739 let source_id = *node_map
741 .entry(call_context.qualified_name.clone())
742 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
743
744 let call_span = span_from_node(node);
746 let target_id = *node_map.entry(callee_qualified.clone()).or_insert_with(|| {
747 helper.ensure_callee(&callee_qualified, call_span, CalleeKindHint::Method)
748 });
749
750 let argument_count = count_call_arguments(node);
751 helper.add_call_edge_full_with_span(
752 source_id,
753 target_id,
754 argument_count,
755 false,
756 vec![call_span],
757 );
758}
759
760fn process_namespace_use(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
773 let file_path = helper.file_path().to_string();
775 let importer_id = helper.add_module(&file_path, None);
776
777 let mut prefix = String::new();
780 let mut cursor = node.walk();
781 for child in node.children(&mut cursor) {
782 if child.kind() == "namespace_name"
783 && let Ok(ns) = child.utf8_text(content)
784 {
785 prefix = ns.trim().to_string();
786 break;
787 }
788 }
789
790 cursor = node.walk();
792 for child in node.children(&mut cursor) {
793 match child.kind() {
794 "namespace_use_clause" => {
795 process_use_clause(child, content, helper, importer_id);
797 }
798 "namespace_use_group" => {
799 process_use_group(child, content, helper, importer_id, &prefix);
802 }
803 _ => {}
804 }
805 }
806}
807
808fn process_use_clause(
820 node: Node,
821 content: &[u8],
822 helper: &mut GraphBuildHelper,
823 import_source_id: NodeId,
824) {
825 process_use_clause_with_prefix(node, content, helper, import_source_id, None);
826}
827
828fn process_use_clause_with_prefix(
830 node: Node,
831 content: &[u8],
832 helper: &mut GraphBuildHelper,
833 import_source_id: NodeId,
834 prefix: Option<&str>,
835) {
836 let mut qualified_name = None;
838 let mut alias = None;
839 let mut found_as = false;
840
841 let mut cursor = node.walk();
842 for child in node.children(&mut cursor) {
843 match child.kind() {
844 "qualified_name" => {
845 if let Ok(name) = child.utf8_text(content) {
847 qualified_name = Some(name.trim().to_string());
848 }
849 }
850 "namespace_name" => {
851 if qualified_name.is_none()
853 && let Ok(name) = child.utf8_text(content)
854 {
855 qualified_name = Some(name.trim().to_string());
856 }
857 }
858 "name" => {
859 if found_as {
861 if let Ok(alias_text) = child.utf8_text(content) {
863 alias = Some(alias_text.trim().to_string());
864 }
865 } else if qualified_name.is_none() {
866 if let Ok(name) = child.utf8_text(content) {
868 qualified_name = Some(name.trim().to_string());
869 }
870 }
871 }
872 "as" => {
873 found_as = true;
875 }
876 _ => {}
877 }
878 }
879
880 if let Some(name) = qualified_name
881 && !name.is_empty()
882 {
883 let full_name = if let Some(pfx) = prefix {
885 format!("{pfx}\\{name}")
886 } else {
887 name
888 };
889
890 let span = span_from_node(node);
892 let import_node_id = helper.add_import(&full_name, Some(span));
893
894 if let Some(alias_str) = alias {
896 helper.add_import_edge_full(import_source_id, import_node_id, Some(&alias_str), false);
897 } else {
898 helper.add_import_edge(import_source_id, import_node_id);
899 }
900 }
901}
902
903fn process_use_group(
922 node: Node,
923 content: &[u8],
924 helper: &mut GraphBuildHelper,
925 import_source_id: NodeId,
926 prefix: &str,
927) {
928 let mut cursor = node.walk();
930 for child in node.children(&mut cursor) {
931 if child.kind() == "namespace_use_clause" {
933 process_use_clause_with_prefix(child, content, helper, import_source_id, Some(prefix));
935 }
936 }
937}
938
939fn process_file_include(node: Node, content: &[u8], helper: &mut GraphBuildHelper) {
941 let file_path = helper.file_path().to_string();
943 let import_source_id = helper.add_module(&file_path, None);
944
945 let mut cursor = node.walk();
948 for child in node.children(&mut cursor) {
949 if child.kind() == "string"
950 || child.kind() == "encapsed_string"
951 || child.kind() == "binary_expression"
952 {
953 if let Ok(path_text) = child.utf8_text(content) {
954 let cleaned_path = path_text
956 .trim()
957 .trim_start_matches(['\'', '"'])
958 .trim_end_matches(['\'', '"'])
959 .to_string();
960
961 if !cleaned_path.is_empty() {
962 let span = span_from_node(node);
963 let import_node_id = helper.add_import(&cleaned_path, Some(span));
964 helper.add_import_edge(import_source_id, import_node_id);
965 }
966 }
967 break;
968 }
969 }
970}
971
972fn process_oop_relationships(
978 node: Node,
979 content: &[u8],
980 helper: &mut GraphBuildHelper,
981 node_map: &mut HashMap<String, NodeId>,
982) {
983 let kind = node.kind();
984 if kind == "class_declaration" {
985 process_class_oop(node, content, helper, node_map);
986 } else if kind == "interface_declaration" {
987 process_interface_inheritance(node, content, helper, node_map);
988 }
989
990 let mut cursor = node.walk();
992 for child in node.children(&mut cursor) {
993 process_oop_relationships(child, content, helper, node_map);
994 }
995}
996
997fn process_class_oop(
999 node: Node,
1000 content: &[u8],
1001 helper: &mut GraphBuildHelper,
1002 node_map: &mut HashMap<String, NodeId>,
1003) {
1004 let Some(name_node) = node.child_by_field_name("name") else {
1006 return;
1007 };
1008 let Ok(class_name) = name_node.utf8_text(content) else {
1009 return;
1010 };
1011 let class_name = class_name.trim();
1012
1013 let span = span_from_node(node);
1015 let class_id = *node_map
1016 .entry(class_name.to_string())
1017 .or_insert_with(|| helper.add_class(class_name, Some(span)));
1018 helper.mark_definition(class_id);
1020
1021 let mut cursor = node.walk();
1023 for child in node.children(&mut cursor) {
1024 match child.kind() {
1025 "base_clause" => {
1026 process_extends_clause(child, content, helper, node_map, class_id);
1028 }
1029 "class_interface_clause" => {
1030 process_implements_clause(child, content, helper, node_map, class_id);
1032 }
1033 "declaration_list" => {
1034 process_class_body_traits(child, content, helper, node_map, class_id);
1036 }
1037 _ => {}
1038 }
1039 }
1040}
1041
1042fn process_extends_clause(
1044 node: Node,
1045 content: &[u8],
1046 helper: &mut GraphBuildHelper,
1047 node_map: &mut HashMap<String, NodeId>,
1048 class_id: NodeId,
1049) {
1050 let mut cursor = node.walk();
1052 for child in node.children(&mut cursor) {
1053 if child.kind() == "name"
1054 || child.kind() == "qualified_name"
1055 || child.kind() == "namespace_name"
1056 {
1057 if let Ok(parent_name) = child.utf8_text(content) {
1058 let parent_name = parent_name.trim();
1059 if !parent_name.is_empty() {
1060 let span = span_from_node(child);
1061 let parent_id = *node_map
1062 .entry(parent_name.to_string())
1063 .or_insert_with(|| {
1065 helper.add_call_site_node(parent_name, span, NodeKind::Class)
1066 });
1067
1068 helper.add_inherits_edge(class_id, parent_id);
1069 }
1070 }
1071 break;
1072 }
1073 }
1074}
1075
1076fn process_implements_clause(
1078 node: Node,
1079 content: &[u8],
1080 helper: &mut GraphBuildHelper,
1081 node_map: &mut HashMap<String, NodeId>,
1082 class_id: NodeId,
1083) {
1084 let mut cursor = node.walk();
1086 for child in node.children(&mut cursor) {
1087 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1088 && let Ok(interface_name) = child.utf8_text(content)
1089 {
1090 let interface_name = interface_name.trim();
1091 if !interface_name.is_empty() {
1092 let span = span_from_node(child);
1093 let interface_id = *node_map
1094 .entry(interface_name.to_string())
1095 .or_insert_with(|| {
1097 helper.add_call_site_node(interface_name, span, NodeKind::Interface)
1098 });
1099
1100 helper.add_implements_edge(class_id, interface_id);
1101 }
1102 }
1103 }
1104}
1105
1106fn process_class_body_traits(
1108 declaration_list: Node,
1109 content: &[u8],
1110 helper: &mut GraphBuildHelper,
1111 node_map: &mut HashMap<String, NodeId>,
1112 class_id: NodeId,
1113) {
1114 let mut cursor = declaration_list.walk();
1115 for child in declaration_list.children(&mut cursor) {
1116 if child.kind() == "use_declaration" {
1117 process_trait_use(child, content, helper, node_map, class_id);
1119 }
1120 }
1121}
1122
1123fn process_trait_use(
1125 node: Node,
1126 content: &[u8],
1127 helper: &mut GraphBuildHelper,
1128 node_map: &mut HashMap<String, NodeId>,
1129 class_id: NodeId,
1130) {
1131 let mut cursor = node.walk();
1133 for child in node.children(&mut cursor) {
1134 if matches!(child.kind(), "name" | "qualified_name" | "namespace_name")
1135 && let Ok(trait_name) = child.utf8_text(content)
1136 {
1137 let trait_name = trait_name.trim();
1138 if !trait_name.is_empty() {
1139 let span = span_from_node(child);
1140 let trait_id = *node_map.entry(trait_name.to_string()).or_insert_with(|| {
1143 helper.add_call_site_node(trait_name, span, NodeKind::Trait)
1146 });
1147
1148 helper.add_implements_edge(class_id, trait_id);
1151 }
1152 }
1153 }
1154}
1155
1156fn process_interface_inheritance(
1158 node: Node,
1159 content: &[u8],
1160 helper: &mut GraphBuildHelper,
1161 node_map: &mut HashMap<String, NodeId>,
1162) {
1163 let Some(name_node) = node.child_by_field_name("name") else {
1165 return;
1166 };
1167 let Ok(interface_name) = name_node.utf8_text(content) else {
1168 return;
1169 };
1170 let interface_name = interface_name.trim();
1171
1172 let span = span_from_node(node);
1174 let interface_id = *node_map
1175 .entry(interface_name.to_string())
1176 .or_insert_with(|| helper.add_interface(interface_name, Some(span)));
1177 helper.mark_definition(interface_id);
1179
1180 let mut cursor = node.walk();
1182 for child in node.children(&mut cursor) {
1183 if child.kind() == "base_clause" {
1184 let mut base_cursor = child.walk();
1186 for base_child in child.children(&mut base_cursor) {
1187 if matches!(
1188 base_child.kind(),
1189 "name" | "qualified_name" | "namespace_name"
1190 ) && let Ok(parent_name) = base_child.utf8_text(content)
1191 {
1192 let parent_name = parent_name.trim();
1193 if !parent_name.is_empty() {
1194 let span = span_from_node(base_child);
1195 let parent_id = *node_map
1196 .entry(parent_name.to_string())
1197 .or_insert_with(|| {
1199 helper.add_call_site_node(parent_name, span, NodeKind::Interface)
1200 });
1201
1202 helper.add_inherits_edge(interface_id, parent_id);
1204 }
1205 }
1206 }
1207 }
1208 }
1209}
1210
1211fn process_exports(
1230 node: Node,
1231 content: &[u8],
1232 helper: &mut GraphBuildHelper,
1233 node_map: &mut HashMap<String, NodeId>,
1234) {
1235 let file_path = helper.file_path().to_string();
1237 let module_id = helper.add_module(&file_path, None);
1238
1239 if node.kind() != "program" {
1241 return;
1242 }
1243
1244 let mut active_namespace = String::new();
1246
1247 let mut cursor = node.walk();
1249 for child in node.children(&mut cursor) {
1250 process_top_level_for_export(
1251 child,
1252 content,
1253 helper,
1254 node_map,
1255 module_id,
1256 &mut active_namespace,
1257 );
1258 }
1259}
1260
1261fn process_top_level_for_export(
1271 node: Node,
1272 content: &[u8],
1273 helper: &mut GraphBuildHelper,
1274 node_map: &mut HashMap<String, NodeId>,
1275 module_id: NodeId,
1276 active_namespace: &mut String,
1277) {
1278 match node.kind() {
1279 "namespace_definition" => {
1280 let ns_name = node
1282 .child_by_field_name("name")
1283 .and_then(|n| n.utf8_text(content).ok())
1284 .map(|s| s.trim().to_string())
1285 .unwrap_or_default();
1286
1287 let has_body = node
1289 .children(&mut node.walk())
1290 .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
1291
1292 if has_body {
1293 active_namespace.clear();
1299
1300 let mut cursor = node.walk();
1302 for child in node.children(&mut cursor) {
1303 if matches!(child.kind(), "compound_statement" | "declaration_list") {
1304 let mut body_cursor = child.walk();
1305 for body_child in child.children(&mut body_cursor) {
1306 export_declaration_if_exportable(
1307 body_child, content, helper, node_map, module_id, &ns_name,
1308 );
1309 }
1310 }
1311 }
1312 } else {
1313 *active_namespace = ns_name;
1316 }
1317 }
1318 "class_declaration"
1320 | "interface_declaration"
1321 | "trait_declaration"
1322 | "enum_declaration"
1323 | "function_definition" => {
1324 export_declaration_if_exportable(
1325 node,
1326 content,
1327 helper,
1328 node_map,
1329 module_id,
1330 active_namespace,
1331 );
1332 }
1333 _ => {
1334 }
1337 }
1338}
1339
1340fn lookup_or_create_node<F>(
1347 node_map: &mut HashMap<String, NodeId>,
1348 qualified_name: &str,
1349 simple_name: &str,
1350 namespace_prefix: &str,
1351 create_fn: F,
1352) -> NodeId
1353where
1354 F: FnOnce() -> NodeId,
1355{
1356 if let Some(&id) = node_map.get(qualified_name) {
1358 return id;
1359 }
1360
1361 if namespace_prefix.is_empty()
1364 && let Some(&id) = node_map.get(simple_name)
1365 {
1366 return id;
1367 }
1368
1369 let id = create_fn();
1371 node_map.insert(qualified_name.to_string(), id);
1372 id
1373}
1374
1375#[allow(clippy::too_many_lines)] fn export_declaration_if_exportable(
1390 node: Node,
1391 content: &[u8],
1392 helper: &mut GraphBuildHelper,
1393 node_map: &mut HashMap<String, NodeId>,
1394 module_id: NodeId,
1395 namespace_prefix: &str,
1396) {
1397 match node.kind() {
1398 "class_declaration" => {
1399 if let Some(name_node) = node.child_by_field_name("name")
1400 && let Ok(class_name) = name_node.utf8_text(content)
1401 {
1402 let simple_name = class_name.trim().to_string();
1403 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1404 let span = span_from_node(node);
1405
1406 let class_id = lookup_or_create_node(
1407 node_map,
1408 &qualified_name,
1409 &simple_name,
1410 namespace_prefix,
1411 || helper.add_class(&qualified_name, Some(span)),
1412 );
1413 helper.mark_definition(class_id);
1415
1416 helper.add_export_edge(module_id, class_id);
1417
1418 export_public_methods_from_class(
1420 node,
1421 content,
1422 helper,
1423 node_map,
1424 module_id,
1425 &qualified_name,
1426 );
1427 }
1428 }
1429 "interface_declaration" => {
1430 if let Some(name_node) = node.child_by_field_name("name")
1431 && let Ok(interface_name) = name_node.utf8_text(content)
1432 {
1433 let simple_name = interface_name.trim().to_string();
1434 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1435 let span = span_from_node(node);
1436
1437 let interface_id = lookup_or_create_node(
1438 node_map,
1439 &qualified_name,
1440 &simple_name,
1441 namespace_prefix,
1442 || helper.add_interface(&qualified_name, Some(span)),
1443 );
1444 helper.mark_definition(interface_id);
1446
1447 helper.add_export_edge(module_id, interface_id);
1448 }
1449 }
1450 "trait_declaration" => {
1451 if let Some(name_node) = node.child_by_field_name("name")
1452 && let Ok(trait_name) = name_node.utf8_text(content)
1453 {
1454 let simple_name = trait_name.trim().to_string();
1455 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1456 let span = span_from_node(node);
1457
1458 let trait_id = lookup_or_create_node(
1459 node_map,
1460 &qualified_name,
1461 &simple_name,
1462 namespace_prefix,
1463 || {
1464 helper.add_node(
1465 &qualified_name,
1466 Some(span),
1467 sqry_core::graph::unified::node::NodeKind::Trait,
1468 )
1469 },
1470 );
1471 helper.mark_definition(trait_id);
1473
1474 helper.add_export_edge(module_id, trait_id);
1475 }
1476 }
1477 "enum_declaration" => {
1478 if let Some(name_node) = node.child_by_field_name("name")
1480 && let Ok(enum_name) = name_node.utf8_text(content)
1481 {
1482 let simple_name = enum_name.trim().to_string();
1483 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1484 let span = span_from_node(node);
1485
1486 let enum_id = lookup_or_create_node(
1487 node_map,
1488 &qualified_name,
1489 &simple_name,
1490 namespace_prefix,
1491 || helper.add_enum(&qualified_name, Some(span)),
1492 );
1493
1494 helper.add_export_edge(module_id, enum_id);
1495 }
1496 }
1497 "function_definition" => {
1498 if let Some(name_node) = node.child_by_field_name("name")
1500 && let Ok(func_name) = name_node.utf8_text(content)
1501 {
1502 let simple_name = func_name.trim().to_string();
1503 let qualified_name = build_qualified_name(namespace_prefix, &simple_name);
1504 let span = span_from_node(node);
1505
1506 let func_id = lookup_or_create_node(
1507 node_map,
1508 &qualified_name,
1509 &simple_name,
1510 namespace_prefix,
1511 || helper.add_function(&qualified_name, Some(span), false, false),
1512 );
1513 helper.mark_definition(func_id);
1515
1516 helper.add_export_edge(module_id, func_id);
1517 }
1518 }
1519 _ => {
1520 }
1522 }
1523}
1524
1525fn build_qualified_name(namespace_prefix: &str, name: &str) -> String {
1527 if namespace_prefix.is_empty() {
1528 name.to_string()
1529 } else {
1530 format!("{namespace_prefix}\\{name}")
1531 }
1532}
1533
1534fn span_from_node(node: Node<'_>) -> Span {
1536 let start = node.start_position();
1537 let end = node.end_position();
1538 Span::new(
1539 sqry_core::graph::node::Position::new(start.row, start.column),
1540 sqry_core::graph::node::Position::new(end.row, end.column),
1541 )
1542}
1543
1544fn count_call_arguments(call_node: Node<'_>) -> u8 {
1545 let args_node = call_node
1546 .child_by_field_name("arguments")
1547 .or_else(|| call_node.child_by_field_name("argument_list"))
1548 .or_else(|| {
1549 let mut cursor = call_node.walk();
1550 call_node
1551 .children(&mut cursor)
1552 .find(|child| child.kind() == "argument_list")
1553 });
1554
1555 let Some(args_node) = args_node else {
1556 return 255;
1557 };
1558 let count = args_node.named_child_count();
1559 if count <= 254 {
1560 u8::try_from(count).unwrap_or(u8::MAX)
1561 } else {
1562 255
1563 }
1564}
1565
1566fn extract_visibility(node: &Node, content: &[u8]) -> Option<String> {
1571 let mut cursor = node.walk();
1573 for child in node.children(&mut cursor) {
1574 match child.kind() {
1575 "visibility_modifier" => {
1576 if let Ok(vis_text) = child.utf8_text(content) {
1578 return Some(vis_text.trim().to_string());
1579 }
1580 }
1581 "public" | "private" | "protected" => {
1582 if let Ok(vis_text) = child.utf8_text(content) {
1584 return Some(vis_text.trim().to_string());
1585 }
1586 }
1587 _ => {}
1588 }
1589 }
1590
1591 None
1595}
1596
1597fn export_public_methods_from_class(
1603 class_node: Node,
1604 content: &[u8],
1605 helper: &mut GraphBuildHelper,
1606 node_map: &mut HashMap<String, NodeId>,
1607 module_id: NodeId,
1608 class_qualified_name: &str,
1609) {
1610 let mut cursor = class_node.walk();
1612 for child in class_node.children(&mut cursor) {
1613 if child.kind() == "declaration_list" {
1614 let mut body_cursor = child.walk();
1616 for body_child in child.children(&mut body_cursor) {
1617 if body_child.kind() == "method_declaration" {
1618 let visibility = extract_visibility(&body_child, content);
1620
1621 let is_public = visibility.as_deref() == Some("public") || visibility.is_none();
1623
1624 if is_public {
1625 if let Some(name_node) = body_child.child_by_field_name("name")
1627 && let Ok(method_name) = name_node.utf8_text(content)
1628 {
1629 let method_name = method_name.trim();
1630 let qualified_method_name =
1631 format!("{class_qualified_name}::{method_name}");
1632
1633 if let Some(&method_id) = node_map.get(&qualified_method_name) {
1635 helper.add_export_edge(module_id, method_id);
1636 }
1637 }
1638 }
1639 }
1640 }
1641 break;
1642 }
1643 }
1644}
1645
1646fn extract_return_type(node: &Node, content: &[u8]) -> Option<String> {
1665 let mut found_colon = false;
1667 let mut cursor = node.walk();
1668 for child in node.children(&mut cursor) {
1669 if found_colon && child.is_named() {
1670 return extract_type_from_node(&child, content);
1672 }
1673 if child.kind() == ":" {
1674 found_colon = true;
1675 }
1676 }
1677 None
1678}
1679
1680fn extract_type_from_node(type_node: &Node, content: &[u8]) -> Option<String> {
1694 match type_node.kind() {
1695 "primitive_type" => {
1696 type_node
1698 .utf8_text(content)
1699 .ok()
1700 .map(|s| s.trim().to_string())
1701 }
1702 "optional_type" => {
1703 let mut cursor = type_node.walk();
1706 for child in type_node.children(&mut cursor) {
1707 if child.kind() != "?" && child.is_named() {
1708 return extract_type_from_node(&child, content);
1709 }
1710 }
1711 None
1712 }
1713 "union_type" => {
1714 type_node
1717 .named_child(0)
1718 .and_then(|first_type| extract_type_from_node(&first_type, content))
1719 }
1720 "named_type" | "qualified_name" => {
1721 type_node
1723 .utf8_text(content)
1724 .ok()
1725 .map(|s| s.trim().to_string())
1726 }
1727 "intersection_type" => {
1728 type_node
1731 .named_child(0)
1732 .and_then(|first_type| extract_type_from_node(&first_type, content))
1733 }
1734 _ => {
1735 type_node
1740 .utf8_text(content)
1741 .ok()
1742 .map(|s| {
1743 let trimmed = s.trim();
1744 trimmed
1747 .split(&['|', '&'][..])
1748 .next()
1749 .unwrap_or(trimmed)
1750 .trim()
1751 .trim_start_matches('(')
1752 .trim_end_matches(')')
1753 .trim()
1754 .to_string()
1755 })
1756 .filter(|s| !s.is_empty())
1757 }
1758 }
1759}
1760
1761fn process_phpdoc_annotations(
1784 node: Node,
1785 content: &[u8],
1786 helper: &mut GraphBuildHelper,
1787) -> GraphResult<()> {
1788 let mut explicit_field_ids: HashSet<NodeId> = HashSet::new();
1790 process_phpdoc_pass_a(node, content, helper, &mut explicit_field_ids)?;
1791
1792 process_phpdoc_pass_b(node, content, helper, &explicit_field_ids);
1796
1797 Ok(())
1798}
1799
1800fn process_phpdoc_pass_a(
1804 node: Node,
1805 content: &[u8],
1806 helper: &mut GraphBuildHelper,
1807 explicit_field_ids: &mut HashSet<NodeId>,
1808) -> GraphResult<()> {
1809 match node.kind() {
1810 "function_definition" => {
1811 process_function_phpdoc(node, content, helper)?;
1812 }
1813 "method_declaration" => {
1814 process_method_phpdoc(node, content, helper)?;
1817 }
1818 "property_declaration" | "simple_property" => {
1819 let emitted = process_property_declaration(node, content, helper);
1824 explicit_field_ids.extend(emitted);
1825 }
1826 _ => {}
1827 }
1828
1829 let mut cursor = node.walk();
1830 for child in node.children(&mut cursor) {
1831 process_phpdoc_pass_a(child, content, helper, explicit_field_ids)?;
1832 }
1833
1834 Ok(())
1835}
1836
1837fn process_phpdoc_pass_b(
1843 node: Node,
1844 content: &[u8],
1845 helper: &mut GraphBuildHelper,
1846 explicit_field_ids: &HashSet<NodeId>,
1847) {
1848 if node.kind() == "method_declaration" {
1849 process_constructor_promotion(node, content, helper, explicit_field_ids);
1850 }
1851
1852 let mut cursor = node.walk();
1853 for child in node.children(&mut cursor) {
1854 process_phpdoc_pass_b(child, content, helper, explicit_field_ids);
1855 }
1856}
1857
1858fn process_function_phpdoc(
1860 func_node: Node,
1861 content: &[u8],
1862 helper: &mut GraphBuildHelper,
1863) -> GraphResult<()> {
1864 let Some(phpdoc_text) = extract_phpdoc_comment(func_node, content) else {
1866 return Ok(());
1867 };
1868
1869 let tags = parse_phpdoc_tags(&phpdoc_text);
1871
1872 let Some(name_node) = func_node.child_by_field_name("name") else {
1874 return Ok(());
1875 };
1876
1877 let function_name = name_node
1878 .utf8_text(content)
1879 .map_err(|_| GraphBuilderError::ParseError {
1880 span: span_from_node(func_node),
1881 reason: "failed to read function name".to_string(),
1882 })?
1883 .trim()
1884 .to_string();
1885
1886 if function_name.is_empty() {
1887 return Ok(());
1888 }
1889
1890 let func_node_id = helper.ensure_callee(
1892 &function_name,
1893 span_from_node(func_node),
1894 CalleeKindHint::Function,
1895 );
1896
1897 let _ast_params = extract_ast_parameters(func_node, content);
1899
1900 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1904 let canonical_type = canonical_type_string(¶m_tag.type_str);
1906 let type_node_id = helper.add_type(&canonical_type, None);
1907 helper.add_typeof_edge_with_context(
1908 func_node_id,
1909 type_node_id,
1910 Some(TypeOfContext::Parameter),
1911 param_idx.try_into().ok(), Some(¶m_tag.name),
1913 );
1914
1915 let type_names = extract_type_names(¶m_tag.type_str);
1917 for type_name in type_names {
1918 let ref_type_id = helper.add_type(&type_name, None);
1919 helper.add_reference_edge(func_node_id, ref_type_id);
1920 }
1921 }
1922
1923 if let Some(return_type) = &tags.returns {
1925 let canonical_type = canonical_type_string(return_type);
1926 let type_node_id = helper.add_type(&canonical_type, None);
1927 helper.add_typeof_edge_with_context(
1928 func_node_id,
1929 type_node_id,
1930 Some(TypeOfContext::Return),
1931 Some(0),
1932 None,
1933 );
1934
1935 let type_names = extract_type_names(return_type);
1937 for type_name in type_names {
1938 let ref_type_id = helper.add_type(&type_name, None);
1939 helper.add_reference_edge(func_node_id, ref_type_id);
1940 }
1941 }
1942
1943 Ok(())
1944}
1945
1946fn process_method_phpdoc(
1948 method_node: Node,
1949 content: &[u8],
1950 helper: &mut GraphBuildHelper,
1951) -> GraphResult<()> {
1952 let Some(phpdoc_text) = extract_phpdoc_comment(method_node, content) else {
1954 return Ok(());
1955 };
1956
1957 let tags = parse_phpdoc_tags(&phpdoc_text);
1959
1960 let Some(name_node) = method_node.child_by_field_name("name") else {
1962 return Ok(());
1963 };
1964
1965 let method_name = name_node
1966 .utf8_text(content)
1967 .map_err(|_| GraphBuilderError::ParseError {
1968 span: span_from_node(method_node),
1969 reason: "failed to read method name".to_string(),
1970 })?
1971 .trim()
1972 .to_string();
1973
1974 if method_name.is_empty() {
1975 return Ok(());
1976 }
1977
1978 let class_name = get_enclosing_class_name(method_node, content)?;
1980 let Some(class_name) = class_name else {
1981 return Ok(());
1982 };
1983
1984 let qualified_name = format!("{class_name}.{method_name}");
1986
1987 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1990
1991 let _ast_params = extract_ast_parameters(method_node, content);
1993
1994 for (param_idx, param_tag) in tags.params.iter().enumerate() {
1997 let canonical_type = canonical_type_string(¶m_tag.type_str);
1999 let type_node_id = helper.add_type(&canonical_type, None);
2000 helper.add_typeof_edge_with_context(
2001 method_node_id,
2002 type_node_id,
2003 Some(TypeOfContext::Parameter),
2004 param_idx.try_into().ok(),
2005 Some(¶m_tag.name),
2006 );
2007
2008 let type_names = extract_type_names(¶m_tag.type_str);
2010 for type_name in type_names {
2011 let ref_type_id = helper.add_type(&type_name, None);
2012 helper.add_reference_edge(method_node_id, ref_type_id);
2013 }
2014 }
2015
2016 if let Some(return_type) = &tags.returns {
2018 let canonical_type = canonical_type_string(return_type);
2019 let type_node_id = helper.add_type(&canonical_type, None);
2020 helper.add_typeof_edge_with_context(
2021 method_node_id,
2022 type_node_id,
2023 Some(TypeOfContext::Return),
2024 Some(0),
2025 None,
2026 );
2027
2028 let type_names = extract_type_names(return_type);
2030 for type_name in type_names {
2031 let ref_type_id = helper.add_type(&type_name, None);
2032 helper.add_reference_edge(method_node_id, ref_type_id);
2033 }
2034 }
2035
2036 Ok(())
2037}
2038
2039fn process_property_declaration(
2060 prop_node: Node,
2061 content: &[u8],
2062 helper: &mut GraphBuildHelper,
2063) -> Vec<NodeId> {
2064 let Some(owner_name) = enclosing_class_or_trait_name(prop_node, content) else {
2068 return Vec::new();
2069 };
2070
2071 let mods = extract_property_modifiers(prop_node, content);
2073
2074 let native_type = prop_node
2077 .child_by_field_name("type")
2078 .and_then(|t| extract_type_from_node(&t, content));
2079
2080 let phpdoc_var_type = if native_type.is_none() {
2082 extract_phpdoc_comment(prop_node, content)
2083 .as_deref()
2084 .and_then(|c| parse_phpdoc_tags(c).var_type)
2085 } else {
2086 None
2087 };
2088
2089 let primary_type = native_type.clone().or_else(|| phpdoc_var_type.clone());
2090
2091 let prop_names = extract_property_element_names(prop_node, content);
2092 if prop_names.is_empty() {
2093 return Vec::new();
2094 }
2095
2096 let span = span_from_node(prop_node);
2097 let mut emitted = Vec::with_capacity(prop_names.len());
2098
2099 for prop_name in prop_names {
2100 let qualified_name = format!("{owner_name}.{prop_name}");
2101 let visibility = mods.visibility.as_deref().unwrap_or("public");
2102
2103 let node_id = if mods.is_readonly {
2104 helper.add_constant_with_name_static_and_visibility(
2105 &prop_name,
2106 &qualified_name,
2107 Some(span),
2108 mods.is_static,
2109 Some(visibility),
2110 )
2111 } else {
2112 helper.add_property_with_name_static_and_visibility(
2113 &prop_name,
2114 &qualified_name,
2115 Some(span),
2116 mods.is_static,
2117 Some(visibility),
2118 )
2119 };
2120
2121 if let Some(type_str) = primary_type.as_deref() {
2122 emit_field_type_edges(helper, node_id, &prop_name, type_str);
2123 }
2124
2125 emitted.push(node_id);
2126 }
2127
2128 emitted
2129}
2130
2131fn process_constructor_promotion(
2151 method_node: Node,
2152 content: &[u8],
2153 helper: &mut GraphBuildHelper,
2154 explicit_field_ids: &HashSet<NodeId>,
2155) {
2156 let Some(name_node) = method_node.child_by_field_name("name") else {
2158 return;
2159 };
2160 let Ok(method_name) = name_node.utf8_text(content) else {
2161 return;
2162 };
2163 if method_name.trim() != "__construct" {
2164 return;
2165 }
2166
2167 let Some(owner_name) = enclosing_class_or_trait_name(method_node, content) else {
2168 return;
2169 };
2170
2171 let Some(params_node) = method_node.child_by_field_name("parameters") else {
2172 return;
2173 };
2174
2175 let mut cursor = params_node.walk();
2176 for param in params_node.children(&mut cursor) {
2177 if param.kind() != "property_promotion_parameter" {
2178 continue;
2179 }
2180
2181 let visibility = param
2183 .child_by_field_name("visibility")
2184 .and_then(|v| v.utf8_text(content).ok())
2185 .map(|s| s.trim().to_string());
2186 let is_readonly = param.child_by_field_name("readonly").is_some()
2187 || direct_child_of_kind(param, "readonly_modifier").is_some();
2188 let is_static = false;
2191 let native_type = param
2192 .child_by_field_name("type")
2193 .and_then(|t| extract_type_from_node(&t, content));
2194
2195 let Some(prop_name) = promoted_param_name(param, content) else {
2196 continue;
2197 };
2198
2199 let qualified_name = format!("{owner_name}.{prop_name}");
2200 let span = span_from_node(param);
2201
2202 if let Some(existing_id) = helper.get_node(&qualified_name) {
2207 if explicit_field_ids.contains(&existing_id) {
2208 continue;
2213 }
2214 if let Some(t) = native_type {
2220 emit_field_type_edges(helper, existing_id, &prop_name, &t);
2221 }
2222 continue;
2223 }
2224
2225 let visibility_ref = visibility.as_deref().unwrap_or("public");
2226 let node_id = if is_readonly {
2227 helper.add_constant_with_name_static_and_visibility(
2228 &prop_name,
2229 &qualified_name,
2230 Some(span),
2231 is_static,
2232 Some(visibility_ref),
2233 )
2234 } else {
2235 helper.add_property_with_name_static_and_visibility(
2236 &prop_name,
2237 &qualified_name,
2238 Some(span),
2239 is_static,
2240 Some(visibility_ref),
2241 )
2242 };
2243
2244 if let Some(type_str) = native_type {
2245 emit_field_type_edges(helper, node_id, &prop_name, &type_str);
2246 }
2247 }
2248}
2249
2250struct PropertyModifiers {
2252 visibility: Option<String>,
2253 is_static: bool,
2254 is_readonly: bool,
2255}
2256
2257fn extract_property_modifiers(prop_node: Node, content: &[u8]) -> PropertyModifiers {
2261 let mut visibility: Option<String> = None;
2262 let mut is_static = false;
2263 let mut is_readonly = false;
2264
2265 let mut cursor = prop_node.walk();
2266 for child in prop_node.children(&mut cursor) {
2267 match child.kind() {
2268 "visibility_modifier" => {
2269 if let Ok(text) = child.utf8_text(content) {
2270 visibility = Some(text.trim().to_string());
2271 }
2272 }
2273 "var_modifier" => {
2274 if visibility.is_none() {
2277 visibility = Some("public".to_string());
2278 }
2279 }
2280 "static_modifier" => {
2281 is_static = true;
2282 }
2283 "readonly_modifier" => {
2284 is_readonly = true;
2285 }
2286 _ => {}
2287 }
2288 }
2289
2290 PropertyModifiers {
2291 visibility,
2292 is_static,
2293 is_readonly,
2294 }
2295}
2296
2297fn extract_property_element_names(prop_node: Node, content: &[u8]) -> Vec<String> {
2301 let mut names = Vec::new();
2302 let mut cursor = prop_node.walk();
2303 for child in prop_node.children(&mut cursor) {
2304 if child.kind() != "property_element" {
2305 continue;
2306 }
2307 if let Some(var_node) = child.child_by_field_name("name")
2308 && let Some(name) = strip_dollar_from_variable(var_node, content)
2309 {
2310 names.push(name);
2311 }
2312 }
2313 names
2314}
2315
2316fn promoted_param_name(param: Node, content: &[u8]) -> Option<String> {
2319 let name_field = param.child_by_field_name("name")?;
2320 let var_node = if name_field.kind() == "variable_name" {
2322 name_field
2323 } else {
2324 let mut cursor = name_field.walk();
2326 name_field
2327 .children(&mut cursor)
2328 .find(|c| c.kind() == "variable_name")?
2329 };
2330 strip_dollar_from_variable(var_node, content)
2331}
2332
2333fn strip_dollar_from_variable(var_node: Node, content: &[u8]) -> Option<String> {
2335 if let Some(name_node) = var_node.child_by_field_name("name")
2336 && let Ok(text) = name_node.utf8_text(content)
2337 {
2338 return Some(text.trim().to_string());
2339 }
2340 var_node
2341 .utf8_text(content)
2342 .ok()
2343 .map(|s| s.trim().trim_start_matches('$').to_string())
2344}
2345
2346fn direct_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
2348 let mut cursor = node.walk();
2349 node.children(&mut cursor).find(|c| c.kind() == kind)
2350}
2351
2352fn emit_field_type_edges(
2355 helper: &mut GraphBuildHelper,
2356 node_id: NodeId,
2357 prop_name: &str,
2358 type_str: &str,
2359) {
2360 let canonical_type = canonical_type_string(type_str);
2361 let type_node_id = helper.add_type(&canonical_type, None);
2362 helper.add_typeof_edge_with_context(
2363 node_id,
2364 type_node_id,
2365 Some(TypeOfContext::Field),
2366 None,
2367 Some(prop_name),
2368 );
2369
2370 for ref_type_name in extract_type_names(type_str) {
2371 let ref_type_id = helper.add_type(&ref_type_name, None);
2372 helper.add_reference_edge(node_id, ref_type_id);
2373 }
2374}
2375
2376fn enclosing_class_or_trait_name(node: Node, content: &[u8]) -> Option<String> {
2379 let mut current = node;
2380 while let Some(parent) = current.parent() {
2381 if matches!(
2382 parent.kind(),
2383 "class_declaration" | "trait_declaration" | "interface_declaration"
2384 ) {
2385 return parent
2386 .child_by_field_name("name")
2387 .and_then(|n| n.utf8_text(content).ok())
2388 .map(|s| s.trim().to_string());
2389 }
2390 current = parent;
2391 }
2392 None
2393}
2394
2395fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2397 let mut params = Vec::new();
2398
2399 let Some(params_node) = func_node.child_by_field_name("parameters") else {
2401 return params;
2402 };
2403
2404 let mut index = 0;
2405 let mut cursor = params_node.walk();
2406
2407 for child in params_node.children(&mut cursor) {
2408 if !child.is_named() {
2409 continue;
2410 }
2411
2412 match child.kind() {
2413 "simple_parameter" => {
2414 let mut param_cursor = child.walk();
2416 for param_child in child.children(&mut param_cursor) {
2417 if param_child.kind() == "variable_name"
2418 && let Ok(param_text) = param_child.utf8_text(content)
2419 {
2420 params.push((index, param_text.trim().to_string()));
2421 index += 1;
2422 break;
2423 }
2424 }
2425 }
2426 "variadic_parameter" => {
2427 let mut param_cursor = child.walk();
2429 for param_child in child.children(&mut param_cursor) {
2430 if param_child.kind() == "variable_name"
2431 && let Ok(param_text) = param_child.utf8_text(content)
2432 {
2433 params.push((index, param_text.trim().to_string()));
2434 index += 1;
2435 break;
2436 }
2437 }
2438 }
2439 _ => {}
2440 }
2441 }
2442
2443 params
2444}
2445
2446#[allow(clippy::unnecessary_wraps)]
2448fn get_enclosing_class_name(node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2449 let mut current = node;
2450
2451 while let Some(parent) = current.parent() {
2453 if parent.kind() == "class_declaration" {
2454 if let Some(name_node) = parent.child_by_field_name("name")
2456 && let Ok(name_text) = name_node.utf8_text(content)
2457 {
2458 return Ok(Some(name_text.trim().to_string()));
2459 }
2460 return Ok(None);
2461 }
2462 current = parent;
2463 }
2464
2465 Ok(None)
2466}
2467
2468fn process_ffi_member_call(
2476 node: Node,
2477 method_name: &str,
2478 ast_graph: &ASTGraph,
2479 helper: &mut GraphBuildHelper,
2480 node_map: &mut HashMap<String, NodeId>,
2481) {
2482 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2484 return;
2485 };
2486
2487 let source_id = *node_map
2489 .entry(call_context.qualified_name.clone())
2490 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2491
2492 let ffi_name = format!("native::ffi::{method_name}");
2494 let call_span = span_from_node(node);
2495 let target_id = helper.add_call_site_node(&ffi_name, call_span, NodeKind::Module);
2496
2497 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2499}
2500
2501fn process_ffi_static_call(
2506 node: Node,
2507 method_name: &str,
2508 ast_graph: &ASTGraph,
2509 helper: &mut GraphBuildHelper,
2510 node_map: &mut HashMap<String, NodeId>,
2511 content: &[u8],
2512) {
2513 let Some(call_context) = ast_graph.get_callable_context(node.id()) else {
2515 return;
2516 };
2517
2518 let source_id = *node_map
2520 .entry(call_context.qualified_name.clone())
2521 .or_insert_with(|| helper.add_function(&call_context.qualified_name, None, false, false));
2522
2523 let library_name = extract_php_ffi_library_name(node, content, method_name == "cdef")
2525 .map_or_else(
2526 || "unknown".to_string(),
2527 |lib| php_ffi_library_simple_name(&lib),
2528 );
2529
2530 let ffi_name = format!("native::{library_name}");
2532 let call_span = span_from_node(node);
2533 let target_id = helper.add_call_site_node(&ffi_name, call_span, NodeKind::Module);
2534
2535 helper.add_ffi_edge(source_id, target_id, FfiConvention::C);
2537}
2538
2539fn is_php_ffi_call(object_node: Node, content: &[u8]) -> bool {
2552 if object_node.kind() == "scoped_call_expression"
2554 && let Some(scope_node) = object_node.child_by_field_name("scope")
2555 && let Some(name_node) = object_node.child_by_field_name("name")
2556 && let Ok(scope_text) = scope_node.utf8_text(content)
2557 && let Ok(name_text) = name_node.utf8_text(content)
2558 && is_ffi_static_call(scope_text, name_text)
2559 {
2560 return true;
2561 }
2562
2563 if object_node.kind() == "parenthesized_expression"
2565 && let Some(inner) = object_node.named_child(0)
2566 && inner.kind() == "scoped_call_expression"
2567 && let Some(scope_node) = inner.child_by_field_name("scope")
2568 && let Some(name_node) = inner.child_by_field_name("name")
2569 && let Ok(scope_text) = scope_node.utf8_text(content)
2570 && let Ok(name_text) = name_node.utf8_text(content)
2571 && is_ffi_static_call(scope_text, name_text)
2572 {
2573 return true;
2574 }
2575
2576 let Ok(object_text) = object_node.utf8_text(content) else {
2578 return false;
2579 };
2580
2581 let object_text = object_text.trim();
2582
2583 if object_text == "$ffi" || object_text == "$_ffi" {
2585 return true;
2586 }
2587
2588 if object_text.ends_with("->ffi")
2590 || object_text.ends_with("::$ffi")
2591 || object_text.ends_with("->_ffi")
2592 || object_text.ends_with("::$_ffi")
2593 {
2594 return true;
2595 }
2596
2597 false
2598}
2599
2600fn is_ffi_static_call(scope_text: &str, method_text: &str) -> bool {
2604 (scope_text == "FFI" || scope_text == "\\FFI")
2605 && (method_text == "cdef" || method_text == "load")
2606}
2607
2608fn extract_php_ffi_library_name(call_node: Node, content: &[u8], is_cdef: bool) -> Option<String> {
2616 let args = call_node.child_by_field_name("arguments")?;
2617
2618 let mut cursor = args.walk();
2619 let args_vec: Vec<Node> = args
2620 .children(&mut cursor)
2621 .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2622 .collect();
2623
2624 let target_arg_name = if is_cdef { "lib" } else { "filename" };
2627
2628 if let Some(named_arg) = find_named_argument(&args_vec, target_arg_name, content) {
2630 return extract_string_from_argument(named_arg, content);
2631 }
2632
2633 if is_cdef {
2635 args_vec
2637 .get(1)
2638 .and_then(|arg| extract_string_from_argument(*arg, content))
2639 } else {
2640 args_vec
2642 .first()
2643 .and_then(|arg| extract_string_from_argument(*arg, content))
2644 }
2645}
2646
2647fn find_named_argument<'a>(args: &'a [Node], param_name: &str, content: &[u8]) -> Option<Node<'a>> {
2654 for arg in args {
2655 if arg.kind() != "argument" {
2656 continue;
2657 }
2658
2659 if arg.named_child_count() < 2 {
2662 continue;
2663 }
2664
2665 if let Some(name_node) = arg.child_by_field_name("name")
2667 && let Ok(name_text) = name_node.utf8_text(content)
2668 && name_text == param_name
2669 {
2670 return Some(*arg);
2671 } else if let Some(name_node) = arg.named_child(0)
2672 && let Ok(name_text) = name_node.utf8_text(content)
2673 && name_text == param_name
2674 {
2675 return Some(*arg);
2677 }
2678 }
2679
2680 None
2681}
2682
2683fn extract_string_from_argument(arg_node: Node, content: &[u8]) -> Option<String> {
2691 let value_node = unwrap_argument_node(arg_node)?;
2693
2694 if !is_string_literal_node(value_node) {
2696 return None;
2697 }
2698
2699 if is_interpolated_string(value_node) {
2701 return None;
2702 }
2703
2704 extract_php_string_content(value_node, content)
2705}
2706
2707fn unwrap_argument_node(node: Node) -> Option<Node> {
2719 if node.kind() != "argument" {
2720 return Some(node);
2722 }
2723
2724 let name_field_node = node.child_by_field_name("name");
2731 let ref_modifier_field_node = node.child_by_field_name("reference_modifier");
2732
2733 for i in 0..node.named_child_count() {
2735 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.named_child(i as u32) {
2737 let is_name_field = name_field_node.is_some_and(|n| n.id() == child.id());
2739 let is_ref_modifier = ref_modifier_field_node.is_some_and(|n| n.id() == child.id());
2740
2741 if !is_name_field && !is_ref_modifier {
2742 return Some(child);
2744 }
2745 }
2746 }
2747
2748 None
2750}
2751
2752fn is_string_literal_node(node: Node) -> bool {
2759 matches!(
2760 node.kind(),
2761 "string" | "encapsed_string" | "heredoc" | "nowdoc"
2762 )
2763}
2764
2765fn is_interpolated_string(node: Node) -> bool {
2778 if !matches!(node.kind(), "encapsed_string" | "heredoc") {
2779 return false;
2780 }
2781
2782 has_variable_node(node)
2784}
2785
2786fn has_variable_node(node: Node) -> bool {
2800 if matches!(
2802 node.kind(),
2803 "variable_name" | "simple_variable" | "variable" | "complex_variable"
2805 | "dynamic_variable_name"
2807 | "subscript_expression" | "member_access_expression" | "member_call_expression"
2809 | "function_call_expression"
2811 | "scoped_call_expression" | "scoped_property_access_expression"
2813 | "class_constant_access_expression"
2815 | "nullsafe_member_access_expression" | "nullsafe_member_call_expression"
2817 ) {
2818 return true;
2819 }
2820
2821 for i in 0..node.child_count() {
2823 #[allow(clippy::cast_possible_truncation)] if let Some(child) = node.child(i as u32)
2825 && has_variable_node(child)
2826 {
2827 return true;
2828 }
2829 }
2830
2831 false
2832}
2833
2834fn extract_php_string_content(string_node: Node, content: &[u8]) -> Option<String> {
2838 let Ok(text) = string_node.utf8_text(content) else {
2839 return None;
2840 };
2841
2842 let text = text.trim();
2843
2844 if ((text.starts_with('"') && text.ends_with('"'))
2846 || (text.starts_with('\'') && text.ends_with('\'')))
2847 && text.len() >= 2
2848 {
2849 return Some(text[1..text.len() - 1].to_string());
2850 }
2851
2852 Some(text.to_string())
2854}
2855
2856fn php_ffi_library_simple_name(library_path: &str) -> String {
2858 use std::path::Path;
2859
2860 let filename = Path::new(library_path)
2862 .file_name()
2863 .and_then(|f| f.to_str())
2864 .unwrap_or(library_path);
2865
2866 if let Some(so_pos) = filename.find(".so.") {
2868 return filename[..so_pos].to_string();
2869 }
2870
2871 if let Some(dot_pos) = filename.find('.') {
2873 let extension = &filename[dot_pos + 1..];
2874 if extension == "so"
2875 || extension == "dll"
2876 || extension == "dylib"
2877 || extension == "h"
2878 || extension == "hpp"
2879 {
2880 return filename[..dot_pos].to_string();
2881 }
2882 }
2883
2884 filename.to_string()
2885}
2886
2887#[cfg(test)]
2892mod field_emission_tests {
2893 use sqry_core::graph::GraphBuilder;
2911 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2912 use sqry_core::graph::unified::build::test_helpers::{
2913 build_node_name_lookup, build_string_lookup, count_nodes_by_kind,
2914 };
2915 use sqry_core::graph::unified::edge::EdgeKind;
2916 use sqry_core::graph::unified::edge::kind::TypeOfContext;
2917 use sqry_core::graph::unified::node::NodeKind;
2918 use std::path::Path;
2919 use tree_sitter::Parser;
2920
2921 use super::PhpGraphBuilder;
2922
2923 fn parse(source: &str) -> tree_sitter::Tree {
2924 let mut parser = Parser::new();
2925 parser
2926 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
2927 .expect("load PHP grammar");
2928 parser.parse(source, None).expect("parse PHP source")
2929 }
2930
2931 fn build(source: &str) -> StagingGraph {
2932 let tree = parse(source);
2933 let mut staging = StagingGraph::new();
2934 let builder = PhpGraphBuilder::default();
2935 builder
2936 .build_graph(
2937 &tree,
2938 source.as_bytes(),
2939 Path::new("test.php"),
2940 &mut staging,
2941 )
2942 .expect("build graph");
2943 staging
2944 }
2945
2946 fn find_node<'a>(
2948 staging: &'a StagingGraph,
2949 name: &str,
2950 kind: Option<NodeKind>,
2951 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2952 let strings = build_string_lookup(staging);
2953 for op in staging.operations() {
2954 if let StagingOp::AddNode { entry, .. } = op {
2955 if let Some(k) = kind
2956 && entry.kind != k
2957 {
2958 continue;
2959 }
2960 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2961 if let Some(s) = strings.get(&name_idx)
2962 && s == name
2963 {
2964 return Some(entry);
2965 }
2966 }
2967 }
2968 None
2969 }
2970
2971 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
2972 let strings = build_string_lookup(staging);
2973 staging
2974 .operations()
2975 .iter()
2976 .filter(|op| {
2977 if let StagingOp::AddNode { entry, .. } = op {
2978 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
2979 strings.get(&name_idx).is_some_and(|s| s == name)
2980 } else {
2981 false
2982 }
2983 })
2984 .count()
2985 }
2986
2987 fn resolve_visibility(
2988 staging: &StagingGraph,
2989 vis: Option<sqry_core::graph::unified::StringId>,
2990 ) -> Option<String> {
2991 let strings = build_string_lookup(staging);
2992 vis.and_then(|sid| strings.get(&sid.index()).cloned())
2993 }
2994
2995 fn typeof_edges_for_node(
2996 staging: &StagingGraph,
2997 source_name: &str,
2998 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
2999 let names = build_node_name_lookup(staging);
3000 let strings = build_string_lookup(staging);
3001 let mut out = Vec::new();
3002 for op in staging.operations() {
3003 if let StagingOp::AddEdge {
3004 source,
3005 target,
3006 kind: EdgeKind::TypeOf { context, name, .. },
3007 ..
3008 } = op
3009 {
3010 let src = names.get(source).cloned().unwrap_or_default();
3011 if src != source_name {
3012 continue;
3013 }
3014 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3015 let target_name = names.get(target).cloned().unwrap_or_default();
3016 out.push((*context, edge_name, target_name));
3017 }
3018 }
3019 out
3020 }
3021
3022 #[test]
3025 fn req_r0001_property_without_phpdoc_emits_property_node() {
3026 let src = "<?php
3027class User {
3028 public string $name;
3029}
3030";
3031 let staging = build(src);
3032 let entry = find_node(&staging, "User.name", Some(NodeKind::Property))
3033 .expect("User.name Property must be emitted without @var");
3034 assert_eq!(entry.kind, NodeKind::Property);
3035 }
3036
3037 #[test]
3038 fn req_r0001_property_with_phpdoc_still_emits_property_node() {
3039 let src = "<?php
3040class Repo {
3041 /** @var string */
3042 public string $label;
3043}
3044";
3045 let staging = build(src);
3046 find_node(&staging, "Repo.label", Some(NodeKind::Property))
3047 .expect("Repo.label Property must be emitted when @var is present");
3048 }
3049
3050 #[test]
3053 fn req_r0002_qualified_name_uses_class_dot_prop() {
3054 let src = "<?php
3055class A { public int $x; }
3056class B { public int $x; }
3057";
3058 let staging = build(src);
3059 find_node(&staging, "A.x", Some(NodeKind::Property)).expect("A.x must exist");
3060 find_node(&staging, "B.x", Some(NodeKind::Property)).expect("B.x must exist");
3061 assert!(
3062 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3063 "no bare 'x' Property node should leak"
3064 );
3065 }
3066
3067 #[test]
3068 fn req_r0002_visibility_modifiers_round_trip() {
3069 let src = "<?php
3070class V {
3071 public int $a;
3072 private int $b;
3073 protected int $c;
3074 var $d;
3075}
3076";
3077 let staging = build(src);
3078 for (name, expected) in [
3079 ("V.a", "public"),
3080 ("V.b", "private"),
3081 ("V.c", "protected"),
3082 ("V.d", "public"),
3083 ] {
3084 let entry = find_node(&staging, name, Some(NodeKind::Property))
3085 .unwrap_or_else(|| panic!("missing {name}"));
3086 let got = resolve_visibility(&staging, entry.visibility);
3087 assert_eq!(
3088 got.as_deref(),
3089 Some(expected),
3090 "{name} visibility should be {expected}"
3091 );
3092 }
3093 }
3094
3095 #[test]
3096 fn req_r0002_default_visibility_is_public_when_no_modifier() {
3097 let src = "<?php
3099class X { static int $count = 0; }
3100";
3101 let staging = build(src);
3102 let entry =
3103 find_node(&staging, "X.count", Some(NodeKind::Property)).expect("X.count must exist");
3104 let vis = resolve_visibility(&staging, entry.visibility);
3105 assert_eq!(
3106 vis.as_deref(),
3107 Some("public"),
3108 "default visibility is public"
3109 );
3110 }
3111
3112 #[test]
3115 fn req_r0003_static_modifier_sets_is_static() {
3116 let src = "<?php
3117class S {
3118 public static int $count = 0;
3119 public int $instance = 0;
3120}
3121";
3122 let staging = build(src);
3123 let s_count =
3124 find_node(&staging, "S.count", Some(NodeKind::Property)).expect("S.count must exist");
3125 assert!(s_count.is_static, "S.count should be static");
3126 let s_instance = find_node(&staging, "S.instance", Some(NodeKind::Property))
3127 .expect("S.instance must exist");
3128 assert!(!s_instance.is_static, "S.instance should not be static");
3129 }
3130
3131 #[test]
3134 fn req_r0004_readonly_emits_constant() {
3135 let src = "<?php
3136class R {
3137 public readonly string $id;
3138 public string $name;
3139}
3140";
3141 let staging = build(src);
3142 find_node(&staging, "R.id", Some(NodeKind::Constant))
3143 .expect("R.id must be Constant (readonly)");
3144 find_node(&staging, "R.name", Some(NodeKind::Property))
3145 .expect("R.name must be Property (mutable)");
3146 }
3147
3148 #[test]
3151 fn req_r0005_native_type_takes_precedence_over_phpdoc() {
3152 let src = "<?php
3156class T {
3157 /** @var {int} */
3158 public string $value;
3159}
3160";
3161 let staging = build(src);
3162 let edges = typeof_edges_for_node(&staging, "T.value");
3163 assert!(
3164 !edges.is_empty(),
3165 "T.value should have at least one TypeOf edge"
3166 );
3167 let has_string = edges.iter().any(|(_, _, t)| t == "string");
3168 assert!(
3169 has_string,
3170 "native type 'string' should be the primary TypeOf target, got {edges:?}"
3171 );
3172 let has_int = edges.iter().any(|(_, _, t)| t == "int");
3173 assert!(
3174 !has_int,
3175 "PHPDoc @var must not appear as TypeOf when native type wins, got {edges:?}"
3176 );
3177 }
3178
3179 #[test]
3180 fn req_r0005_phpdoc_fallback_when_no_native_type() {
3181 let src = "<?php
3183class T {
3184 /** @var {SomeUserType} */
3185 public $value;
3186}
3187";
3188 let staging = build(src);
3189 let edges = typeof_edges_for_node(&staging, "T.value");
3190 assert!(
3191 edges.iter().any(|(_, _, t)| t == "SomeUserType"),
3192 "PHPDoc @var should provide TypeOf when no native type, got {edges:?}"
3193 );
3194 }
3195
3196 #[test]
3199 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3200 let src = "<?php
3201class C {
3202 public string $title;
3203}
3204";
3205 let staging = build(src);
3206 let edges = typeof_edges_for_node(&staging, "C.title");
3207 assert!(!edges.is_empty(), "C.title should have a TypeOf edge");
3208 for (ctx, name, _) in &edges {
3209 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3210 assert_eq!(
3211 name.as_deref(),
3212 Some("title"),
3213 "edge name must be the bare property name"
3214 );
3215 }
3216 }
3217
3218 #[test]
3221 fn req_r0007_constructor_promotion_emits_property_on_class() {
3222 let src = "<?php
3223class P {
3224 public function __construct(public int $x, private readonly string $y) {}
3225}
3226";
3227 let staging = build(src);
3228 let x = find_node(&staging, "P.x", Some(NodeKind::Property))
3229 .expect("promoted P.x must be a Property");
3230 assert_eq!(
3231 resolve_visibility(&staging, x.visibility).as_deref(),
3232 Some("public"),
3233 "promoted $x visibility"
3234 );
3235 let y = find_node(&staging, "P.y", Some(NodeKind::Constant))
3236 .expect("promoted readonly P.y must be a Constant");
3237 assert_eq!(
3238 resolve_visibility(&staging, y.visibility).as_deref(),
3239 Some("private"),
3240 "promoted $y visibility"
3241 );
3242 }
3243
3244 #[test]
3247 fn req_r0013_explicit_declaration_wins_over_promotion() {
3248 let src = "<?php
3249class D {
3250 public int $x;
3251 public function __construct(public int $x) {}
3252}
3253";
3254 let staging = build(src);
3255 let n = count_nodes_named(&staging, "D.x");
3256 assert_eq!(
3257 n, 1,
3258 "exactly one D.x node when explicit decl + promotion collide, got {n}"
3259 );
3260 find_node(&staging, "D.x", Some(NodeKind::Property))
3262 .expect("D.x must be Property (explicit declaration wins)");
3263 }
3264
3265 #[test]
3271 fn req_r0013_explicit_wins_when_ctor_appears_before_property_decl() {
3272 let src = "<?php
3273class A {
3274 public function __construct(public string $x) {}
3275 public int $x;
3276}
3277";
3278 let staging = build(src);
3279 let n = count_nodes_named(&staging, "A.x");
3280 assert_eq!(
3281 n, 1,
3282 "exactly one A.x node regardless of ctor-vs-decl source order, got {n}"
3283 );
3284 find_node(&staging, "A.x", Some(NodeKind::Property))
3285 .expect("A.x must be Property (explicit declaration wins)");
3286
3287 let edges = typeof_edges_for_node(&staging, "A.x");
3291 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3292 assert!(
3293 target_types.contains(&"int"),
3294 "explicit `int` TypeOf must be present, got {target_types:?}",
3295 );
3296 assert!(
3297 !target_types.contains(&"string"),
3298 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3299 );
3300 }
3301
3302 #[test]
3306 fn req_r0013_explicit_wins_when_property_decl_appears_before_ctor() {
3307 let src = "<?php
3308class B {
3309 public int $x;
3310 public function __construct(public string $x) {}
3311}
3312";
3313 let staging = build(src);
3314 let n = count_nodes_named(&staging, "B.x");
3315 assert_eq!(
3316 n, 1,
3317 "exactly one B.x node regardless of decl-vs-ctor source order, got {n}"
3318 );
3319 find_node(&staging, "B.x", Some(NodeKind::Property))
3320 .expect("B.x must be Property (explicit declaration wins)");
3321
3322 let edges = typeof_edges_for_node(&staging, "B.x");
3323 let target_types: Vec<&str> = edges.iter().map(|(_, _, target)| target.as_str()).collect();
3324 assert!(
3325 target_types.contains(&"int"),
3326 "explicit `int` TypeOf must be present, got {target_types:?}",
3327 );
3328 assert!(
3329 !target_types.contains(&"string"),
3330 "promoted `string` TypeOf must NOT be emitted; explicit type wins (got {target_types:?})",
3331 );
3332 }
3333
3334 #[test]
3337 fn req_r0023_span_anchored_on_declaration() {
3338 let src = "<?php
3339class W {
3340
3341 public string $marker;
3342}
3343";
3344 let staging = build(src);
3345 let entry =
3346 find_node(&staging, "W.marker", Some(NodeKind::Property)).expect("W.marker must exist");
3347 assert_eq!(
3355 entry.start_line, 4,
3356 "span start line should match declaration"
3357 );
3358 assert_eq!(entry.end_line, 4, "span end line should match declaration");
3359 assert_eq!(
3360 entry.start_column, 4,
3361 "span start column should match indentation of `public`"
3362 );
3363 assert!(
3364 entry.end_column > entry.start_column,
3365 "span end column must extend past start (got start={}, end={})",
3366 entry.start_column,
3367 entry.end_column,
3368 );
3369 }
3370
3371 #[test]
3374 fn req_r0001_trait_property_emitted() {
3375 let src = "<?php
3376trait Loggable {
3377 protected ?string $logTag;
3378}
3379";
3380 let staging = build(src);
3381 let entry = find_node(&staging, "Loggable.logTag", Some(NodeKind::Property))
3382 .expect("trait property must be emitted");
3383 let vis = resolve_visibility(&staging, entry.visibility);
3384 assert_eq!(vis.as_deref(), Some("protected"));
3385 }
3386
3387 #[test]
3388 fn no_emission_outside_class_or_trait_or_interface() {
3389 let src = "<?php
3392$x = 1;
3393function f() { $y = 2; }
3394";
3395 let staging = build(src);
3396 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Property), 0);
3397 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Constant), 0);
3398 }
3399}
3400
3401pub struct PhpShapeMapping {
3407 cf_by_kind_id: Vec<Option<CfBucket>>,
3408}
3409
3410impl PhpShapeMapping {
3411 fn build() -> Self {
3412 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3413 let count = lang.node_kind_count();
3414 let mut cf_by_kind_id = vec![None; count];
3415 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
3416 let Ok(kind_id) = u16::try_from(id) else {
3417 break;
3418 };
3419 if !lang.node_kind_is_named(kind_id) {
3420 continue;
3421 }
3422 if let Some(name) = lang.node_kind_for_id(kind_id) {
3423 *slot = cf_bucket_for_php_kind(name);
3424 }
3425 }
3426 Self { cf_by_kind_id }
3427 }
3428}
3429
3430impl ShapeMapping for PhpShapeMapping {
3431 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
3432 self.cf_by_kind_id
3433 .get(ts_node_kind_id as usize)
3434 .copied()
3435 .flatten()
3436 }
3437
3438 fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
3439 let mut shape = SignatureShape::default();
3440 if let Some(params) = fn_node.child_by_field_name("parameters") {
3441 let mut cursor = params.walk();
3442 for child in params.named_children(&mut cursor) {
3443 match child.kind() {
3444 "simple_parameter" | "property_promotion_parameter" => {
3445 shape.arity_positional = shape.arity_positional.saturating_add(1);
3446 if child.child_by_field_name("default_value").is_some() {
3447 shape.has_defaults = true;
3448 }
3449 }
3450 "variadic_parameter" => shape.has_varargs = true,
3451 _ => {}
3452 }
3453 }
3454 }
3455 shape.has_return_annotation = fn_node.child_by_field_name("return_type").is_some();
3456 shape
3457 }
3458}
3459
3460fn cf_bucket_for_php_kind(name: &str) -> Option<CfBucket> {
3463 let bucket = match name {
3464 "if_statement"
3465 | "else_if_clause"
3466 | "else_clause"
3467 | "conditional_expression"
3468 | "match_conditional_expression" => CfBucket::Branch,
3469 "while_statement" | "do_statement" | "for_statement" | "foreach_statement" => {
3470 CfBucket::Loop
3471 }
3472 "switch_statement" | "case_statement" | "default_statement" | "match_expression"
3473 | "match_block" => CfBucket::Match,
3474 "try_statement" => CfBucket::Try,
3475 "catch_clause" => CfBucket::Catch,
3476 "finally_clause" => CfBucket::Resource,
3477 "throw_expression" => CfBucket::Throw,
3478 "return_statement" => CfBucket::Return,
3479 "yield_expression" => CfBucket::Yield,
3480 "break_statement" | "continue_statement" => CfBucket::BreakContinue,
3481 "function_call_expression"
3482 | "member_call_expression"
3483 | "scoped_call_expression"
3484 | "nullsafe_member_call_expression"
3485 | "object_creation_expression" => CfBucket::Call,
3486 "assignment_expression" | "augmented_assignment_expression" => CfBucket::Assign,
3487 "anonymous_function" | "arrow_function" => CfBucket::Closure,
3488 _ => return None,
3489 };
3490 Some(bucket)
3491}
3492
3493#[must_use]
3495pub fn php_shape_mapping() -> &'static PhpShapeMapping {
3496 static MAPPING: OnceLock<PhpShapeMapping> = OnceLock::new();
3497 MAPPING.get_or_init(PhpShapeMapping::build)
3498}
3499
3500#[cfg(test)]
3501mod shape_tests {
3502 use super::{cf_bucket_for_php_kind, php_shape_mapping};
3506 use sqry_core::graph::unified::build::shape::{
3507 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3508 };
3509 use tree_sitter::{Node, Parser, Tree};
3510
3511 const SAMPLE: &str = include_str!(concat!(
3512 env!("CARGO_MANIFEST_DIR"),
3513 "/../test-fixtures/shape/dynamic/php.php"
3514 ));
3515
3516 fn parse(src: &str) -> Tree {
3517 let mut parser = Parser::new();
3518 parser
3519 .set_language(&tree_sitter_php::LANGUAGE_PHP.into())
3520 .expect("load php grammar");
3521 parser.parse(src, None).expect("parse php")
3522 }
3523
3524 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
3525 let root = tree.root_node();
3526 let mut cursor = root.walk();
3527 for child in root.named_children(&mut cursor) {
3528 if child.kind() == "function_definition" {
3529 return child;
3530 }
3531 }
3532 panic!("no function_definition in php fixture");
3533 }
3534
3535 #[test]
3536 fn mapping_is_non_empty_and_covers_real_kinds() {
3537 assert_eq!(
3538 cf_bucket_for_php_kind("if_statement"),
3539 Some(CfBucket::Branch)
3540 );
3541 assert_eq!(
3542 cf_bucket_for_php_kind("while_statement"),
3543 Some(CfBucket::Loop)
3544 );
3545 assert_eq!(
3546 cf_bucket_for_php_kind("switch_statement"),
3547 Some(CfBucket::Match)
3548 );
3549 assert_eq!(cf_bucket_for_php_kind("try_statement"), Some(CfBucket::Try));
3550 assert_eq!(
3551 cf_bucket_for_php_kind("catch_clause"),
3552 Some(CfBucket::Catch)
3553 );
3554 assert_eq!(
3555 cf_bucket_for_php_kind("finally_clause"),
3556 Some(CfBucket::Resource)
3557 );
3558 assert_eq!(
3559 cf_bucket_for_php_kind("throw_expression"),
3560 Some(CfBucket::Throw)
3561 );
3562 assert_eq!(
3563 cf_bucket_for_php_kind("anonymous_function"),
3564 Some(CfBucket::Closure)
3565 );
3566 assert_eq!(cf_bucket_for_php_kind("nope"), None);
3567
3568 let lang: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into();
3569 let id = (0..lang.node_kind_count())
3570 .map(|i| i as u16)
3571 .find(|&i| {
3572 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
3573 })
3574 .expect("grammar exposes named if_statement");
3575 assert_eq!(php_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
3576 }
3577
3578 #[test]
3579 fn descriptor_covers_fixture_control_flow() {
3580 let tree = parse(SAMPLE);
3581 let func = first_function(&tree);
3582 let descriptor = compute_shape_descriptor(
3583 func,
3584 SAMPLE.as_bytes(),
3585 php_shape_mapping(),
3586 &ShapeBudget::default(),
3587 );
3588 let hist = descriptor.cf_histogram;
3589 assert!(hist[CfBucket::Branch.index()] >= 1, "branch");
3590 assert!(hist[CfBucket::Loop.index()] >= 1, "loop");
3591 assert!(hist[CfBucket::Match.index()] >= 1, "switch/case");
3592 assert!(hist[CfBucket::Try.index()] >= 1, "try");
3593 assert!(hist[CfBucket::Catch.index()] >= 1, "catch");
3594 assert!(hist[CfBucket::Resource.index()] >= 1, "finally");
3595 assert!(hist[CfBucket::Throw.index()] >= 1, "throw");
3596 assert!(hist[CfBucket::Return.index()] >= 1, "return");
3597 assert!(hist[CfBucket::Call.index()] >= 1, "call");
3598 assert!(hist[CfBucket::Closure.index()] >= 1, "closure");
3599 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/continue");
3600 }
3601
3602 #[test]
3603 fn signature_shape_reads_arity_and_return() {
3604 let tree = parse(SAMPLE);
3605 let func = first_function(&tree);
3606 let shape = php_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3607 assert_eq!(shape.arity_positional, 2, "value + label");
3609 assert!(shape.has_defaults, "label has a default");
3610 assert!(shape.has_varargs, "...$rest");
3611 assert!(shape.has_return_annotation, ": string");
3612 }
3613}