1use std::{
2 collections::{HashMap, HashSet},
3 path::Path,
4};
5
6use sqry_core::graph::unified::build::helper::CalleeKindHint;
7use sqry_core::graph::unified::edge::FfiConvention;
8use sqry_core::graph::unified::edge::kind::TypeOfContext;
9use sqry_core::graph::unified::{GraphBuildHelper, StagingGraph};
10use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
11use tree_sitter::{Node, Point, Tree};
12
13use super::type_extractor::{canonical_type_string, extract_type_names};
14use super::yard_parser::{extract_yard_comment, parse_yard_tags};
15
16const DEFAULT_SCOPE_DEPTH: usize = 4;
17
18const FILE_MODULE_NAME: &str = "<file_module>";
21
22type CallEdgeData = (String, String, usize, Span, bool);
23
24#[derive(Debug, Clone, Copy)]
30pub struct RubyGraphBuilder {
31 max_scope_depth: usize,
32}
33
34impl Default for RubyGraphBuilder {
35 fn default() -> Self {
36 Self {
37 max_scope_depth: DEFAULT_SCOPE_DEPTH,
38 }
39 }
40}
41
42impl RubyGraphBuilder {
43 #[must_use]
45 pub fn new(max_scope_depth: usize) -> Self {
46 Self { max_scope_depth }
47 }
48}
49
50impl GraphBuilder for RubyGraphBuilder {
51 fn build_graph(
52 &self,
53 tree: &Tree,
54 content: &[u8],
55 file: &Path,
56 staging: &mut StagingGraph,
57 ) -> GraphResult<()> {
58 let mut helper = GraphBuildHelper::new(staging, file, Language::Ruby);
60
61 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
63 GraphBuilderError::ParseError {
64 span: Span::default(),
65 reason: e,
66 }
67 })?;
68
69 walk_tree_for_graph(
71 tree.root_node(),
72 content,
73 &ast_graph,
74 &mut helper,
75 &ast_graph.ffi_enabled_scopes,
76 )?;
77
78 apply_controller_dsl_hooks(&ast_graph, &mut helper);
79
80 process_yard_annotations(tree.root_node(), content, &ast_graph, &mut helper)?;
82
83 Ok(())
84 }
85
86 fn language(&self) -> Language {
87 Language::Ruby
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum Visibility {
93 Public,
94 Protected,
95 Private,
96}
97
98impl Visibility {
99 #[allow(dead_code)] fn as_str(self) -> &'static str {
101 match self {
102 Visibility::Public => "public",
103 Visibility::Protected => "protected",
104 Visibility::Private => "private",
105 }
106 }
107
108 fn from_keyword(keyword: &str) -> Option<Self> {
109 match keyword {
110 "public" => Some(Visibility::Public),
111 "protected" => Some(Visibility::Protected),
112 "private" => Some(Visibility::Private),
113 _ => None,
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
119enum RubyContextKind {
120 Method,
121 SingletonMethod,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125enum ControllerDslKind {
126 Before,
127 After,
128 Around,
129}
130
131#[allow(dead_code)] #[derive(Debug, Clone)]
133struct ControllerDslHook {
134 container: String,
135 kind: ControllerDslKind,
136 callbacks: Vec<String>,
137 only: Option<Vec<String>>, except: Option<Vec<String>>, }
140
141#[derive(Debug, Clone)]
142struct RubyContext {
143 qualified_name: String,
144 container: Option<String>,
145 kind: RubyContextKind,
146 visibility: Visibility,
147 start_position: Point,
148 end_position: Point,
149}
150
151impl RubyContext {
152 #[allow(dead_code)] fn is_method(&self) -> bool {
154 matches!(
155 self.kind,
156 RubyContextKind::Method | RubyContextKind::SingletonMethod
157 )
158 }
159
160 fn is_singleton(&self) -> bool {
161 matches!(self.kind, RubyContextKind::SingletonMethod)
162 }
163
164 fn qualified_name(&self) -> &str {
165 &self.qualified_name
166 }
167
168 fn container(&self) -> Option<&str> {
169 self.container.as_deref()
170 }
171
172 fn visibility(&self) -> Visibility {
173 self.visibility
174 }
175}
176
177struct ASTGraph {
178 contexts: Vec<RubyContext>,
179 node_to_context: HashMap<usize, usize>,
180 attr_visibility: HashMap<usize, Visibility>,
181 ffi_enabled_scopes: HashSet<Vec<String>>,
183 #[allow(dead_code)] controller_dsl_hooks: Vec<ControllerDslHook>,
185}
186
187impl ASTGraph {
188 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
189 let mut builder = ContextBuilder::new(content, max_depth)?;
190 builder.walk(tree.root_node())?;
191 Ok(Self {
192 contexts: builder.contexts,
193 node_to_context: builder.node_to_context,
194 attr_visibility: builder.attr_visibility,
195 ffi_enabled_scopes: builder.ffi_enabled_scopes,
196 controller_dsl_hooks: builder.controller_dsl_hooks,
197 })
198 }
199
200 #[allow(dead_code)] fn contexts(&self) -> &[RubyContext] {
202 &self.contexts
203 }
204
205 fn context_for_node(&self, node: &Node<'_>) -> Option<&RubyContext> {
206 self.node_to_context
207 .get(&node.id())
208 .and_then(|idx| self.contexts.get(*idx))
209 }
210
211 fn attr_visibility_for_node(&self, node: &Node<'_>) -> Visibility {
212 self.attr_visibility
213 .get(&node.id())
214 .copied()
215 .unwrap_or(Visibility::Public)
216 }
217}
218
219fn walk_tree_for_graph(
221 node: Node,
222 content: &[u8],
223 ast_graph: &ASTGraph,
224 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
225 ffi_enabled_scopes: &HashSet<Vec<String>>,
226) -> GraphResult<()> {
227 let mut current_namespace: Vec<String> = Vec::new();
229
230 walk_tree_for_graph_impl(
231 node,
232 content,
233 ast_graph,
234 helper,
235 ffi_enabled_scopes,
236 &mut current_namespace,
237 )
238}
239
240fn apply_controller_dsl_hooks(ast_graph: &ASTGraph, helper: &mut GraphBuildHelper) {
241 if ast_graph.controller_dsl_hooks.is_empty() {
242 return;
243 }
244
245 let mut actions_by_container: HashMap<String, Vec<String>> = HashMap::new();
246 for context in &ast_graph.contexts {
247 if !matches!(context.kind, RubyContextKind::Method) {
248 continue;
249 }
250 let Some(container) = context.container() else {
251 continue;
252 };
253 let Some(action_name) = context.qualified_name.rsplit('#').next() else {
254 continue;
255 };
256 actions_by_container
257 .entry(container.to_string())
258 .or_default()
259 .push(action_name.to_string());
260 }
261
262 let mut emitted: HashSet<(String, String)> = HashSet::new();
263 for hook in &ast_graph.controller_dsl_hooks {
264 let Some(actions) = actions_by_container.get(&hook.container) else {
265 continue;
266 };
267
268 for action in actions {
269 let included = if let Some(only) = &hook.only {
270 only.iter().any(|name| name == action)
271 } else if let Some(except) = &hook.except {
272 !except.iter().any(|name| name == action)
273 } else {
274 true
275 };
276
277 if !included {
278 continue;
279 }
280
281 for callback in &hook.callbacks {
282 if callback.trim().is_empty() {
283 continue;
284 }
285
286 let action_qname = format!("{}#{}", hook.container, action);
287 let callback_qname = format!("{}#{}", hook.container, callback);
288 if !emitted.insert((action_qname.clone(), callback_qname.clone())) {
289 continue;
290 }
291
292 let action_id = helper.ensure_method(&action_qname, None, false, false);
293 let callback_id = helper.ensure_method(&callback_qname, None, false, false);
294 helper.add_call_edge_full_with_span(action_id, callback_id, 255, false, vec![]);
295 }
296 }
297 }
298}
299
300#[allow(
302 clippy::too_many_lines,
303 reason = "Ruby graph extraction handles DSLs and FFI patterns in one traversal."
304)]
305fn walk_tree_for_graph_impl(
306 node: Node,
307 content: &[u8],
308 ast_graph: &ASTGraph,
309 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
310 ffi_enabled_scopes: &HashSet<Vec<String>>,
311 current_namespace: &mut Vec<String>,
312) -> GraphResult<()> {
313 match node.kind() {
314 "class" => {
315 if let Some(name_node) = node.child_by_field_name("name")
317 && let Ok(class_name) = name_node.utf8_text(content)
318 {
319 let span = span_from_points(node.start_position(), node.end_position());
320 let qualified_name = class_name.to_string();
321 let class_id = helper.add_class(&qualified_name, Some(span));
322
323 let module_id = helper.add_module(FILE_MODULE_NAME, None);
326 helper.add_export_edge(module_id, class_id);
327
328 if let Some(superclass_node) = node.child_by_field_name("superclass")
330 && let Ok(superclass_name) = superclass_node.utf8_text(content)
331 {
332 let superclass_name = superclass_name.trim();
333 if !superclass_name.is_empty() {
334 let parent_id = helper.add_class(superclass_name, None);
336 helper.add_inherits_edge(class_id, parent_id);
337 }
338 }
339
340 current_namespace.push(class_name.trim().to_string());
342
343 let mut cursor = node.walk();
345 for child in node.children(&mut cursor) {
346 walk_tree_for_graph_impl(
347 child,
348 content,
349 ast_graph,
350 helper,
351 ffi_enabled_scopes,
352 current_namespace,
353 )?;
354 }
355
356 current_namespace.pop();
357 return Ok(());
358 }
359 }
360 "module" => {
361 if let Some(name_node) = node.child_by_field_name("name")
363 && let Ok(module_name) = name_node.utf8_text(content)
364 {
365 let span = span_from_points(node.start_position(), node.end_position());
366 let qualified_name = module_name.to_string();
367 let mod_id = helper.add_module(&qualified_name, Some(span));
368
369 let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
372 helper.add_export_edge(file_module_id, mod_id);
373
374 current_namespace.push(module_name.trim().to_string());
376
377 let mut cursor = node.walk();
379 for child in node.children(&mut cursor) {
380 walk_tree_for_graph_impl(
381 child,
382 content,
383 ast_graph,
384 helper,
385 ffi_enabled_scopes,
386 current_namespace,
387 )?;
388 }
389
390 current_namespace.pop();
391 return Ok(());
392 }
393 }
394 "method" | "singleton_method" => {
395 if let Some(context) = ast_graph.context_for_node(&node) {
397 let span = span_from_points(context.start_position, context.end_position);
398
399 let is_async = detect_async_method(node, content);
401
402 let params = node
404 .child_by_field_name("parameters")
405 .and_then(|params_node| extract_method_parameters(params_node, content));
406
407 let return_type = extract_return_type(node, content);
409
410 let signature = match (params.as_ref(), return_type.as_ref()) {
412 (Some(p), Some(r)) => Some(format!("{p} -> {r}")),
413 (Some(p), None) => Some(p.clone()),
414 (None, Some(r)) => Some(format!("-> {r}")),
415 (None, None) => None,
416 };
417
418 let visibility = context.visibility().as_str();
420
421 let method_id = helper.add_method_with_signature(
423 context.qualified_name(),
424 Some(span),
425 is_async,
426 context.is_singleton(),
427 Some(visibility),
428 signature.as_deref(),
429 );
430
431 if context.visibility() == Visibility::Public {
434 let module_id = helper.add_module(FILE_MODULE_NAME, None);
435 helper.add_export_edge(module_id, method_id);
436 }
437 }
438 }
439 "assignment" => {
440 if let Some(left_node) = node.child_by_field_name("left")
442 && left_node.kind() == "constant"
443 && let Ok(const_name) = left_node.utf8_text(content)
444 {
445 let qualified_name = if current_namespace.is_empty() {
447 const_name.to_string()
448 } else {
449 format!("{}::{}", current_namespace.join("::"), const_name)
450 };
451
452 let span = span_from_points(node.start_position(), node.end_position());
453 let const_id = helper.add_constant(&qualified_name, Some(span));
454
455 let module_id = helper.add_module(FILE_MODULE_NAME, None);
457 helper.add_export_edge(module_id, const_id);
458 }
459 }
460 "call" | "command" | "command_call" | "identifier" | "super" => {
461 if is_include_or_extend_statement(node, content) {
463 handle_include_extend(node, content, helper, current_namespace);
464 }
465 else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
469 } else if is_require_statement(node, content) {
471 if let Some((from_qname, to_qname)) =
473 build_import_for_staging(node, content, helper.file_path())
474 {
475 let from_id = helper.add_import(&from_qname, None);
477 let to_id = helper.add_import(
478 &to_qname,
479 Some(span_from_points(node.start_position(), node.end_position())),
480 );
481
482 helper.add_import_edge(from_id, to_id);
484 }
485 } else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
486 build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
488 } else {
489 if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
491 build_call_for_staging(ast_graph, node, content)
492 {
493 let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
495 let target_id =
496 helper.ensure_callee(&target_qname, span, CalleeKindHint::Function);
497
498 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
500 helper.add_call_edge_full_with_span(
501 source_id,
502 target_id,
503 argument_count,
504 false,
505 vec![span],
506 );
507 }
508 }
509 }
510 _ => {}
511 }
512
513 let mut cursor = node.walk();
515 for child in node.children(&mut cursor) {
516 walk_tree_for_graph_impl(
517 child,
518 content,
519 ast_graph,
520 helper,
521 ffi_enabled_scopes,
522 current_namespace,
523 )?;
524 }
525
526 Ok(())
527}
528
529fn is_ffi_attach_function(
540 node: Node,
541 content: &[u8],
542 ffi_enabled_scopes: &HashSet<Vec<String>>,
543 current_namespace: &[String],
544) -> bool {
545 let method_name = match node.kind() {
547 "command" => node
548 .child_by_field_name("name")
549 .and_then(|n| n.utf8_text(content).ok()),
550 "call" | "command_call" => node
551 .child_by_field_name("method")
552 .and_then(|n| n.utf8_text(content).ok()),
553 _ => None,
554 };
555
556 let Some(method_name) = method_name else {
557 return false;
558 };
559 let method_name = method_name.trim();
560 if !matches!(
561 method_name,
562 "attach_function" | "attach_variable" | "ffi_lib" | "callback"
563 ) {
564 return false;
565 }
566
567 let receiver = match node.kind() {
568 "call" | "command_call" | "method_call" => node
569 .child_by_field_name("receiver")
570 .and_then(|n| n.utf8_text(content).ok()),
571 _ => None,
572 };
573 if let Some(receiver) = receiver {
574 let trimmed = receiver.trim();
575 if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
576 return true;
577 }
578 }
579
580 ffi_enabled_scopes.contains(current_namespace)
581}
582
583fn build_ffi_edge_for_attach_function(
590 node: Node,
591 content: &[u8],
592 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
593 current_namespace: &[String],
594) {
595 let arguments = node.child_by_field_name("arguments");
597
598 let func_name = if let Some(args) = arguments {
600 extract_first_symbol_from_arguments(args, content)
601 } else {
602 let mut cursor = node.walk();
604 let mut found_name = false;
605 let mut result = None;
606 for child in node.children(&mut cursor) {
607 if !child.is_named() {
608 continue;
609 }
610 if !found_name {
612 found_name = true;
613 continue;
614 }
615 if matches!(child.kind(), "symbol" | "simple_symbol")
617 && let Ok(text) = child.utf8_text(content)
618 {
619 result = Some(text.trim().trim_start_matches(':').to_string());
620 break;
621 }
622 }
623 result
624 };
625
626 let Some(func_name) = func_name else {
627 return;
628 };
629
630 let caller_name = if current_namespace.is_empty() {
632 "<module>".to_string()
633 } else {
634 current_namespace.join("::")
635 };
636
637 let caller_id = helper.add_module(&caller_name, None);
639
640 let ffi_func_name = format!("ffi::{func_name}");
642 let span = span_from_points(node.start_position(), node.end_position());
643 let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
644
645 helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
647}
648
649fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
651 let mut cursor = arguments.walk();
652 for child in arguments.children(&mut cursor) {
653 if matches!(child.kind(), "symbol" | "simple_symbol")
654 && let Ok(text) = child.utf8_text(content)
655 {
656 return Some(text.trim().trim_start_matches(':').to_string());
657 }
658 if child.kind() == "bare_symbol"
660 && let Ok(text) = child.utf8_text(content)
661 {
662 return Some(text.trim().to_string());
663 }
664 }
665 None
666}
667
668fn build_call_for_staging(
670 ast_graph: &ASTGraph,
671 call_node: Node<'_>,
672 content: &[u8],
673) -> GraphResult<Option<CallEdgeData>> {
674 let Some(call_context) = ast_graph.context_for_node(&call_node) else {
675 return Ok(None);
676 };
677
678 let Some(method_call) = extract_method_call(call_node, content)? else {
679 return Ok(None);
680 };
681
682 if is_visibility_command(&method_call) {
683 return Ok(None);
684 }
685
686 let source_qualified = call_context.qualified_name().to_string();
687 let target_name = resolve_callee(&method_call, call_context);
688
689 if target_name.is_empty() {
690 return Ok(None);
691 }
692
693 let span = span_from_node(call_node);
694 let argument_count = count_arguments(method_call.arguments, content);
695 let is_singleton = call_context.is_singleton();
696
697 Ok(Some((
698 source_qualified,
699 target_name,
700 argument_count,
701 span,
702 is_singleton,
703 )))
704}
705
706fn build_import_for_staging(
708 require_node: Node<'_>,
709 content: &[u8],
710 file_path: &str,
711) -> Option<(String, String)> {
712 let method_name = match require_node.kind() {
714 "command" => require_node
715 .child_by_field_name("name")
716 .and_then(|n| n.utf8_text(content).ok())
717 .map(|s| s.trim().to_string()),
718 "call" | "method_call" => require_node
719 .child_by_field_name("method")
720 .and_then(|n| n.utf8_text(content).ok())
721 .map(|s| s.trim().to_string()),
722 _ => None,
723 };
724
725 let method_name = method_name?;
726
727 if !matches!(method_name.as_str(), "require" | "require_relative") {
729 return None;
730 }
731
732 let arguments = require_node.child_by_field_name("arguments");
734 let module_name = if let Some(args) = arguments {
735 extract_require_module_name(args, content)
736 } else {
737 let mut cursor = require_node.walk();
739 let mut found_name = false;
740 let mut result = None;
741 for child in require_node.children(&mut cursor) {
742 if !child.is_named() {
743 continue;
744 }
745 if !found_name {
746 found_name = true;
747 continue;
748 }
749 result = extract_string_content(child, content);
751 break;
752 }
753 result
754 };
755
756 let module_name = module_name?;
757
758 if module_name.is_empty() {
759 return None;
760 }
761
762 let is_relative = method_name == "require_relative";
764 let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
765
766 Some(("<module>".to_string(), resolved_path))
768}
769
770fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
771 node.kind() == "identifier"
772 && node
773 .parent()
774 .is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
775}
776
777fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
787 let body_node = method_node.child_by_field_name("body");
789 if body_node.is_none() {
790 return false;
791 }
792 let body_node = body_node.unwrap();
793
794 if let Ok(body_text) = body_node.utf8_text(content) {
796 let body_lower = body_text.to_lowercase();
797
798 if body_lower.contains("fiber.")
800 || body_lower.contains("fiber.new")
801 || body_lower.contains("fiber.yield")
802 || body_lower.contains("fiber.resume")
803 || body_lower.contains("thread.new")
804 || body_lower.contains("thread.start")
805 || body_lower.contains("async do")
806 || body_lower.contains("async {")
807 || body_lower.contains("async.reactor")
808 || body_lower.contains("concurrent::")
809 {
810 return true;
811 }
812 }
813
814 false
815}
816
817fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
819 let method_name = match node.kind() {
820 "command" => node
821 .child_by_field_name("name")
822 .and_then(|n| n.utf8_text(content).ok()),
823 "call" | "method_call" => node
824 .child_by_field_name("method")
825 .and_then(|n| n.utf8_text(content).ok()),
826 _ => None,
827 };
828
829 method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
830}
831
832fn handle_include_extend(
840 node: Node<'_>,
841 content: &[u8],
842 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
843 current_namespace: &[String],
844) {
845 let module_name = if let Some(args) = node.child_by_field_name("arguments") {
847 extract_first_constant_from_arguments(args, content)
848 } else if node.kind() == "command" {
849 let mut cursor = node.walk();
851 let mut found_method = false;
852 let mut result = None;
853 for child in node.children(&mut cursor) {
854 if !child.is_named() {
855 continue;
856 }
857 if !found_method {
859 found_method = true;
860 continue;
861 }
862 if child.kind() == "constant"
864 && let Ok(text) = child.utf8_text(content)
865 {
866 result = Some(text.trim().to_string());
867 break;
868 }
869 }
870 result
871 } else {
872 None
873 };
874
875 let Some(module_name) = module_name else {
876 return;
877 };
878
879 let class_name = if current_namespace.is_empty() {
881 return; } else {
883 current_namespace.join("::")
884 };
885
886 let class_id = helper.add_class(&class_name, None);
888 let module_id = helper.add_module(&module_name, None);
889
890 helper.add_implements_edge(class_id, module_id);
892}
893
894fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
896 let mut cursor = args_node.walk();
897 for child in args_node.children(&mut cursor) {
898 if !child.is_named() {
899 continue;
900 }
901 if child.kind() == "constant"
903 && let Ok(text) = child.utf8_text(content)
904 {
905 return Some(text.trim().to_string());
906 }
907 }
908 None
909}
910
911fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
913 let method_name = match node.kind() {
914 "command" => node
915 .child_by_field_name("name")
916 .and_then(|n| n.utf8_text(content).ok()),
917 "call" | "method_call" => node
918 .child_by_field_name("method")
919 .and_then(|n| n.utf8_text(content).ok()),
920 _ => None,
921 };
922
923 method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
924}
925
926struct ContextBuilder<'a> {
927 contexts: Vec<RubyContext>,
928 node_to_context: HashMap<usize, usize>,
929 attr_visibility: HashMap<usize, Visibility>,
930 namespace: Vec<String>,
931 visibility_stack: Vec<Visibility>,
932 ffi_enabled_scopes: HashSet<Vec<String>>,
933 controller_dsl_hooks: Vec<ControllerDslHook>,
934 max_depth: usize,
935 content: &'a [u8],
936 guard: sqry_core::query::security::RecursionGuard,
937}
938
939impl<'a> ContextBuilder<'a> {
940 fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
941 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
942 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
943 let file_ops_depth = recursion_limits
944 .effective_file_ops_depth()
945 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
946 let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
947 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
948
949 Ok(Self {
950 contexts: Vec::new(),
951 node_to_context: HashMap::new(),
952 attr_visibility: HashMap::new(),
953 namespace: Vec::new(),
954 visibility_stack: vec![Visibility::Public],
955 ffi_enabled_scopes: HashSet::new(),
956 controller_dsl_hooks: Vec::new(),
957 max_depth,
958 content,
959 guard,
960 })
961 }
962
963 fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
967 self.guard
968 .enter()
969 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
970
971 match node.kind() {
972 "class" => self.visit_class(node)?,
973 "module" => self.visit_module(node)?,
974 "singleton_class" => self.visit_singleton_class(node)?,
975 "method" => self.visit_method(node)?,
976 "singleton_method" => self.visit_singleton_method(node)?,
977 "command" | "command_call" | "call" => {
978 self.detect_ffi_extend(node)?;
979 self.detect_controller_dsl(node)?;
980 self.record_attr_visibility(node);
981 self.adjust_visibility(node)?;
982 self.walk_children(node)?;
983 }
984 "identifier" => {
985 self.adjust_visibility_from_identifier(node)?;
988 self.walk_children(node)?;
989 }
990 _ => self.walk_children(node)?,
991 }
992
993 self.guard.exit();
994 Ok(())
995 }
996
997 fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
998 let name_node = node
999 .child_by_field_name("name")
1000 .ok_or_else(|| "class node missing name".to_string())?;
1001 let class_name = self.node_text(name_node)?;
1002
1003 if self.namespace.len() > self.max_depth {
1004 return Ok(());
1005 }
1006
1007 self.namespace.push(class_name);
1008 self.visibility_stack.push(Visibility::Public);
1009
1010 self.walk_children(node)?;
1011
1012 self.visibility_stack.pop();
1013 self.namespace.pop();
1014 Ok(())
1015 }
1016
1017 fn visit_module(&mut self, node: Node<'a>) -> Result<(), String> {
1018 let name_node = node
1019 .child_by_field_name("name")
1020 .ok_or_else(|| "module node missing name".to_string())?;
1021 let module_name = self.node_text(name_node)?;
1022
1023 if self.namespace.len() > self.max_depth {
1024 return Ok(());
1025 }
1026
1027 self.namespace.push(module_name);
1028 self.visibility_stack.push(Visibility::Public);
1029
1030 self.walk_children(node)?;
1031
1032 self.visibility_stack.pop();
1033 self.namespace.pop();
1034 Ok(())
1035 }
1036
1037 fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
1038 let name_node = node
1039 .child_by_field_name("name")
1040 .ok_or_else(|| "method node missing name".to_string())?;
1041 let method_name = self.node_text(name_node)?;
1042
1043 let (qualified_name, container) =
1044 method_qualified_name(&self.namespace, &method_name, false);
1045
1046 let visibility = inline_visibility_for_method(node, self.content)
1047 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1048
1049 let context = RubyContext {
1050 qualified_name,
1051 container,
1052 kind: RubyContextKind::Method,
1053 visibility,
1054 start_position: node.start_position(),
1055 end_position: node.end_position(),
1056 };
1057
1058 let idx = self.contexts.len();
1059 self.contexts.push(context);
1060 associate_descendants(node, idx, &mut self.node_to_context);
1061
1062 self.walk_children(node)?;
1063 Ok(())
1064 }
1065
1066 fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
1067 let value_node = node
1069 .child_by_field_name("value")
1070 .ok_or_else(|| "singleton_class missing value".to_string())?;
1071 let object_text = self.node_text(value_node)?;
1072
1073 let scope_name = if object_text == "self" {
1075 if let Some(current_class) = self.namespace.last() {
1077 format!("<<{current_class}>>")
1078 } else {
1079 "<<main>>".to_string()
1080 }
1081 } else {
1082 format!("<<{object_text}>>")
1084 };
1085
1086 if self.namespace.len() > self.max_depth {
1087 return Ok(());
1088 }
1089
1090 self.namespace.push(scope_name);
1092 self.visibility_stack.push(Visibility::Public);
1093
1094 self.visit_singleton_class_body(node)?;
1096
1097 self.visibility_stack.pop();
1099 self.namespace.pop();
1100 Ok(())
1101 }
1102
1103 fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
1104 let mut cursor = node.walk();
1105 for child in node.children(&mut cursor) {
1106 if !child.is_named() {
1107 continue;
1108 }
1109
1110 if child.kind() == "method" {
1112 self.visit_method_as_singleton(child)?;
1113 } else {
1114 self.walk(child)?;
1115 }
1116 }
1117 Ok(())
1118 }
1119
1120 fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
1121 let name_node = node
1122 .child_by_field_name("name")
1123 .ok_or_else(|| "method node missing name".to_string())?;
1124 let method_name = self.node_text(name_node)?;
1125
1126 let actual_namespace: Vec<String> = self
1128 .namespace
1129 .iter()
1130 .map(|s| {
1131 if s.starts_with("<<") && s.ends_with(">>") {
1132 s[2..s.len() - 2].to_string()
1134 } else {
1135 s.clone()
1136 }
1137 })
1138 .collect();
1139
1140 let (qualified_name, container) =
1141 method_qualified_name(&actual_namespace, &method_name, true);
1142
1143 let visibility = inline_visibility_for_method(node, self.content)
1144 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1145
1146 let context = RubyContext {
1147 qualified_name,
1148 container,
1149 kind: RubyContextKind::SingletonMethod,
1150 visibility,
1151 start_position: node.start_position(),
1152 end_position: node.end_position(),
1153 };
1154
1155 let idx = self.contexts.len();
1156 self.contexts.push(context);
1157 associate_descendants(node, idx, &mut self.node_to_context);
1158
1159 self.walk_children(node)?;
1160 Ok(())
1161 }
1162
1163 fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
1164 let name_node = node
1165 .child_by_field_name("name")
1166 .ok_or_else(|| "singleton_method missing name".to_string())?;
1167 let method_name = self.node_text(name_node)?;
1168
1169 let object_node = node
1170 .child_by_field_name("object")
1171 .ok_or_else(|| "singleton_method missing object".to_string())?;
1172 let object_text = self.node_text(object_node)?;
1173
1174 let (qualified_name, container) =
1175 singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
1176
1177 let visibility = inline_visibility_for_method(node, self.content)
1178 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1179
1180 let context = RubyContext {
1181 qualified_name,
1182 container,
1183 kind: RubyContextKind::SingletonMethod,
1184 visibility,
1185 start_position: node.start_position(),
1186 end_position: node.end_position(),
1187 };
1188
1189 let idx = self.contexts.len();
1190 self.contexts.push(context);
1191 associate_descendants(node, idx, &mut self.node_to_context);
1192
1193 self.walk_children(node)?;
1194 Ok(())
1195 }
1196
1197 fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
1198 let name_node = node.child_by_field_name("name");
1199 let Some(name_node) = name_node else {
1200 return Ok(());
1201 };
1202
1203 let keyword = self.node_text(name_node)?;
1204 if keyword.trim() != "extend" {
1205 return Ok(());
1206 }
1207
1208 let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
1209 node_text_raw(arguments, self.content).unwrap_or_default()
1210 } else {
1211 let mut cursor = node.walk();
1212 let mut found_name = false;
1213 let mut result = String::new();
1214 for child in node.children(&mut cursor) {
1215 if !child.is_named() {
1216 continue;
1217 }
1218 if !found_name {
1219 found_name = true;
1220 continue;
1221 }
1222 if let Some(text) = node_text_raw(child, self.content) {
1223 result = text;
1224 break;
1225 }
1226 }
1227 result
1228 };
1229
1230 if arg_text.contains("FFI::Library") {
1231 self.ffi_enabled_scopes.insert(self.namespace.clone());
1233 }
1234
1235 Ok(())
1236 }
1237
1238 fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
1239 let name_node = node
1240 .child_by_field_name("name")
1241 .or_else(|| node.child_by_field_name("method"));
1242 let Some(name_node) = name_node else {
1243 return Ok(());
1244 };
1245 let dsl = self.node_text(name_node)?;
1246
1247 let kind = match dsl.as_str() {
1248 "before_action" => Some(ControllerDslKind::Before),
1249 "after_action" => Some(ControllerDslKind::After),
1250 "around_action" => Some(ControllerDslKind::Around),
1251 _ => None,
1252 };
1253 let Some(kind) = kind else {
1254 return Ok(());
1255 };
1256
1257 if self.namespace.is_empty() {
1258 return Ok(());
1259 }
1260 let container = self.namespace.join("::");
1261
1262 let mut callbacks: Vec<String> = Vec::new();
1263 let mut only: Option<Vec<String>> = None;
1264 let mut except: Option<Vec<String>> = None;
1265
1266 if let Some(arguments) = node.child_by_field_name("arguments") {
1267 let mut cursor = arguments.walk();
1268 for child in arguments.children(&mut cursor) {
1269 if !child.is_named() {
1270 continue;
1271 }
1272 let kind = child.kind();
1273 match kind {
1274 "symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
1275 let mut v = extract_symbols_from_node(child, self.content);
1276 callbacks.append(&mut v);
1277 }
1278 "pair" => {
1279 let key = child.child_by_field_name("key");
1281 let val = child.child_by_field_name("value");
1282 if key.is_none() || val.is_none() {
1283 continue;
1284 }
1285 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1286 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1287 if key_text.contains("only") && !symbols.is_empty() {
1288 only = Some(symbols);
1289 } else if key_text.contains("except") && !symbols.is_empty() {
1290 except = Some(symbols);
1291 }
1292 }
1293 "hash" => {
1294 let mut hcur = child.walk();
1296 for pair in child.children(&mut hcur) {
1297 if !pair.is_named() {
1298 continue;
1299 }
1300 if pair.kind() != "pair" {
1301 continue;
1302 }
1303 let key = pair.child_by_field_name("key");
1304 let val = pair.child_by_field_name("value");
1305 if key.is_none() || val.is_none() {
1306 continue;
1307 }
1308 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1309 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1310 if key_text.contains("only") && !symbols.is_empty() {
1311 only = Some(symbols);
1312 } else if key_text.contains("except") && !symbols.is_empty() {
1313 except = Some(symbols);
1314 }
1315 }
1316 }
1317 _ => {}
1318 }
1319 }
1320 } else {
1321 if let Some(raw) = node_text_raw(node, self.content) {
1323 let (cbs, o, e) = parse_controller_dsl_args(&raw);
1324 callbacks = cbs;
1325 only = o;
1326 except = e;
1327 }
1328 }
1329
1330 if callbacks.is_empty() {
1331 return Ok(());
1332 }
1333
1334 self.controller_dsl_hooks.push(ControllerDslHook {
1335 container,
1336 kind,
1337 callbacks,
1338 only,
1339 except,
1340 });
1341 Ok(())
1342 }
1343
1344 fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
1345 let name_node = node.child_by_field_name("name");
1346 let Some(name_node) = name_node else {
1347 return Ok(());
1348 };
1349
1350 let keyword = self.node_text(name_node)?;
1351 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1352 return Ok(());
1353 };
1354
1355 if !has_call_arguments(node)
1357 && let Some(last) = self.visibility_stack.last_mut()
1358 {
1359 *last = new_visibility;
1360 }
1361 Ok(())
1362 }
1363
1364 fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
1366 let keyword = self.node_text(node)?;
1367 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1368 return Ok(());
1369 };
1370
1371 if let Some(last) = self.visibility_stack.last_mut() {
1373 *last = new_visibility;
1374 }
1375
1376 Ok(())
1377 }
1378
1379 fn record_attr_visibility(&mut self, node: Node<'a>) {
1380 if !is_attr_call(node, self.content) {
1381 return;
1382 }
1383
1384 let visibility = self
1385 .visibility_stack
1386 .last()
1387 .copied()
1388 .unwrap_or(Visibility::Public);
1389 self.attr_visibility.insert(node.id(), visibility);
1390 }
1391
1392 fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
1393 let mut cursor = node.walk();
1394 for child in node.children(&mut cursor) {
1395 if child.is_named() {
1396 self.walk(child)?;
1397 }
1398 }
1399 Ok(())
1400 }
1401
1402 fn node_text(&self, node: Node<'a>) -> Result<String, String> {
1403 node.utf8_text(self.content)
1404 .map(|s| s.trim().to_string())
1405 .map_err(|err| err.to_string())
1406 }
1407}
1408
1409#[derive(Clone)]
1410struct MethodCall<'a> {
1411 name: String,
1412 receiver: Option<String>,
1413 arguments: Option<Node<'a>>,
1414 node: Node<'a>,
1415}
1416
1417fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
1418 let method_name = match node.kind() {
1419 "call" | "command_call" | "method_call" => {
1420 let method_node = node
1421 .child_by_field_name("method")
1422 .ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
1423 node_text(method_node, content)?
1424 }
1425 "command" => {
1426 let name_node = node
1427 .child_by_field_name("name")
1428 .ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
1429 node_text(name_node, content)?
1430 }
1431 "super" => "super".to_string(),
1432 "identifier" => {
1433 if !should_treat_identifier_as_call(node) {
1434 return Ok(None);
1435 }
1436 node_text(node, content)?
1437 }
1438 _ => return Ok(None),
1439 };
1440
1441 let receiver = match node.kind() {
1442 "call" | "command_call" | "method_call" => node
1443 .child_by_field_name("receiver")
1444 .and_then(|r| node_text(r, content).ok()),
1445 _ => None,
1446 };
1447
1448 let arguments = node.child_by_field_name("arguments");
1449
1450 Ok(Some(MethodCall {
1451 name: method_name,
1452 receiver,
1453 arguments,
1454 node,
1455 }))
1456}
1457
1458fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
1459 if let Some(parent) = node.parent() {
1460 let kind = parent.kind();
1461 if matches!(
1462 kind,
1463 "call"
1464 | "command"
1465 | "command_call"
1466 | "method_call"
1467 | "method"
1468 | "singleton_method"
1469 | "alias"
1470 | "symbol"
1471 ) {
1472 return false;
1473 }
1474
1475 if kind.contains("assignment")
1476 || matches!(
1477 kind,
1478 "parameters"
1479 | "method_parameters"
1480 | "block_parameters"
1481 | "lambda_parameters"
1482 | "constant_path"
1483 | "module"
1484 | "class"
1485 | "hash"
1486 | "pair"
1487 | "array"
1488 | "argument_list"
1489 )
1490 {
1491 return false;
1492 }
1493 }
1494
1495 true
1496}
1497
1498fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
1512 let name = method_call.name.trim();
1513 if name.is_empty() {
1514 return String::new();
1515 }
1516
1517 if name == "super" {
1519 return format!("super::{}", context.qualified_name());
1523 }
1524
1525 if let Some(receiver) = method_call.receiver.as_deref() {
1526 let receiver = receiver.trim();
1527 if receiver == "self" {
1528 if let Some(container) = context.container() {
1529 return format!("{container}.{name}");
1530 }
1531 return format!("self.{name}");
1532 }
1533
1534 if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
1535 let cleaned = receiver.trim_start_matches("::");
1536 if let Some(class_name) = cleaned.strip_suffix(".new") {
1538 return format!("{class_name}#{name}");
1539 }
1540 return format!("{cleaned}.{name}");
1541 }
1542
1543 return name.to_string();
1545 }
1546
1547 if context.is_singleton() {
1548 if let Some(container) = context.container() {
1549 return format!("{container}.{name}");
1550 }
1551 return name.to_string();
1552 }
1553
1554 if let Some(container) = context.container() {
1555 return format!("{container}#{name}");
1556 }
1557
1558 name.to_string()
1559}
1560
1561fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
1573 let Some(arguments) = arguments else {
1574 return 0;
1575 };
1576
1577 let mut count = 0;
1578 let mut cursor = arguments.walk();
1579 for child in arguments.children(&mut cursor) {
1580 if child.is_named()
1581 && !is_literal_delimiter(child.kind())
1582 && node_text(child, content)
1583 .map(|s| !s.trim().is_empty())
1584 .unwrap_or(false)
1585 {
1586 count += 1;
1587 }
1588 }
1589 count
1590}
1591
1592fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
1603 let mut stack = vec![node];
1604 while let Some(current) = stack.pop() {
1605 map.insert(current.id(), idx);
1606 let mut cursor = current.walk();
1607 for child in current.children(&mut cursor) {
1608 stack.push(child);
1609 }
1610 }
1611}
1612
1613fn method_qualified_name(
1627 namespace: &[String],
1628 method_name: &str,
1629 singleton: bool,
1630) -> (String, Option<String>) {
1631 if namespace.is_empty() {
1632 return (method_name.to_string(), None);
1633 }
1634
1635 let container = namespace.join("::");
1636 let qualified = if singleton {
1637 format!("{container}.{method_name}")
1638 } else {
1639 format!("{container}#{method_name}")
1640 };
1641 (qualified, Some(container))
1642}
1643
1644fn singleton_qualified_name(
1657 current_namespace: &[String],
1658 object_text: &str,
1659 method_name: &str,
1660) -> (String, Option<String>) {
1661 if object_text == "self" {
1662 if current_namespace.is_empty() {
1663 (method_name.to_string(), None)
1664 } else {
1665 let container = current_namespace.join("::");
1666 (format!("{container}.{method_name}"), Some(container))
1667 }
1668 } else {
1669 let parts = split_constant_path(object_text);
1670 if parts.is_empty() {
1671 (method_name.to_string(), None)
1672 } else {
1673 let container = parts.join("::");
1674 (format!("{container}.{method_name}"), Some(container))
1675 }
1676 }
1677}
1678
1679fn split_constant_path(path: &str) -> Vec<String> {
1693 path.trim()
1694 .trim_start_matches("::")
1695 .split("::")
1696 .filter_map(|seg| {
1697 let trimmed = seg.trim();
1698 if trimmed.is_empty() {
1699 None
1700 } else {
1701 Some(trimmed.to_string())
1702 }
1703 })
1704 .collect()
1705}
1706
1707fn is_constant(text: &str) -> bool {
1717 text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1718}
1719
1720fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
1731 matches!(
1732 method_call.name.as_str(),
1733 "public" | "private" | "protected"
1734 ) && method_call.receiver.is_none()
1735 && !has_call_arguments(method_call.node)
1736}
1737
1738fn has_call_arguments(node: Node<'_>) -> bool {
1748 if let Some(arguments) = node.child_by_field_name("arguments") {
1749 let mut cursor = arguments.walk();
1750 for child in arguments.children(&mut cursor) {
1751 if child.is_named() {
1752 return true;
1753 }
1754 }
1755 }
1756 false
1757}
1758
1759fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
1760 let parent = node.parent()?;
1761 let visibility_node = match parent.kind() {
1762 "call" | "command" | "command_call" => parent,
1763 "argument_list" => parent.parent()?,
1764 _ => return None,
1765 };
1766
1767 if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
1768 return None;
1769 }
1770
1771 let keyword_node = visibility_node
1772 .child_by_field_name("name")
1773 .or_else(|| visibility_node.child_by_field_name("method"))?;
1774 let keyword = node_text_raw(keyword_node, content)?;
1775 Visibility::from_keyword(keyword.trim())
1776}
1777
1778fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
1789 node.utf8_text(content)
1790 .map(|s| s.trim().to_string())
1791 .map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
1792}
1793
1794fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
1796 node.utf8_text(content)
1797 .ok()
1798 .map(std::string::ToString::to_string)
1799}
1800
1801fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
1810 GraphBuilderError::ParseError {
1811 span: span_from_node(node),
1812 reason: reason.to_string(),
1813 }
1814}
1815
1816#[allow(clippy::match_same_arms)]
1833fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
1834 let mut params = Vec::new();
1835 let mut cursor = params_node.walk();
1836
1837 for child in params_node.named_children(&mut cursor) {
1838 match child.kind() {
1839 "identifier" | "optional_parameter" => {
1842 if let Ok(text) = child.utf8_text(content) {
1843 params.push(text.to_string());
1844 }
1845 }
1846 "splat_parameter" => {
1848 if let Some(name_node) = child.child_by_field_name("name") {
1849 if let Ok(name) = name_node.utf8_text(content) {
1850 params.push(format!("*{name}"));
1851 }
1852 } else if let Ok(text) = child.utf8_text(content) {
1853 params.push(text.to_string());
1855 }
1856 }
1857 "hash_splat_parameter" => {
1859 if let Some(name_node) = child.child_by_field_name("name") {
1860 if let Ok(name) = name_node.utf8_text(content) {
1861 params.push(format!("**{name}"));
1862 }
1863 } else if let Ok(text) = child.utf8_text(content) {
1864 params.push(text.to_string());
1866 }
1867 }
1868 "block_parameter" => {
1870 if let Some(name_node) = child.child_by_field_name("name") {
1871 if let Ok(name) = name_node.utf8_text(content) {
1872 params.push(format!("&{name}"));
1873 }
1874 } else if let Ok(text) = child.utf8_text(content) {
1875 params.push(text.to_string());
1877 }
1878 }
1879 "keyword_parameter" => {
1881 if let Ok(text) = child.utf8_text(content) {
1882 params.push(text.to_string());
1883 }
1884 }
1885 "destructured_parameter" => {
1887 if let Ok(text) = child.utf8_text(content) {
1888 params.push(text.to_string());
1889 }
1890 }
1891 "forward_parameter" => {
1893 params.push("...".to_string());
1894 }
1895 "hash_splat_nil" => {
1897 params.push("**nil".to_string());
1898 }
1899 _ => {
1900 }
1902 }
1903 }
1904
1905 if params.is_empty() {
1906 None
1907 } else {
1908 Some(params.join(", "))
1909 }
1910}
1911
1912fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1926 if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
1928 return Some(return_type);
1929 }
1930
1931 if let Some(return_type) = extract_rbs_return_type(method_node, content) {
1933 return Some(return_type);
1934 }
1935
1936 if let Some(return_type) = extract_yard_return_type(method_node, content) {
1938 return Some(return_type);
1939 }
1940
1941 None
1942}
1943
1944fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1955 let mut sibling = method_node.prev_sibling()?;
1957
1958 while sibling.kind() == "comment" {
1960 sibling = sibling.prev_sibling()?;
1961 }
1962
1963 if sibling.kind() == "call"
1965 && let Some(method_name) = sibling.child_by_field_name("method")
1966 && let Ok(name_text) = method_name.utf8_text(content)
1967 && name_text == "sig"
1968 {
1969 if let Some(block_node) = sibling.child_by_field_name("block") {
1971 return extract_returns_from_sig_block(block_node, content);
1972 }
1973 }
1974
1975 None
1976}
1977
1978fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
1980 let mut cursor = block_node.walk();
1981
1982 for child in block_node.named_children(&mut cursor) {
1983 if child.kind() == "call"
1984 && let Some(method_name) = child.child_by_field_name("method")
1985 && let Ok(name_text) = method_name.utf8_text(content)
1986 && name_text == "returns"
1987 {
1988 if let Some(args) = child.child_by_field_name("arguments") {
1990 let mut args_cursor = args.walk();
1991 for arg in args.named_children(&mut args_cursor) {
1992 if arg.kind() != ","
1993 && let Ok(type_text) = arg.utf8_text(content)
1994 {
1995 return Some(type_text.to_string());
1996 }
1997 }
1998 }
1999 }
2000 if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
2002 return Some(nested_type);
2003 }
2004 }
2005
2006 None
2007}
2008
2009fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2020 let mut cursor = method_node.walk();
2022 for child in method_node.children(&mut cursor) {
2023 if child.kind() == "comment"
2024 && let Ok(comment_text) = child.utf8_text(content)
2025 {
2026 if comment_text.trim_start().starts_with("#:") {
2029 if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
2031 let return_part = &comment_text[arrow_pos + 2..];
2032 let return_type = return_part.trim().to_string();
2033 if !return_type.is_empty() {
2034 return Some(return_type);
2035 }
2036 }
2037 }
2038 }
2039 }
2040
2041 None
2042}
2043
2044fn find_top_level_arrow(text: &str) -> Option<usize> {
2048 let chars: Vec<char> = text.chars().collect();
2049 let mut depth: i32 = 0;
2050 let mut i = 0;
2051
2052 while i < chars.len() {
2053 match chars[i] {
2054 '(' | '[' | '{' => depth += 1,
2055 ')' | ']' | '}' => depth = depth.saturating_sub(1),
2056 '-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
2057 return Some(i);
2058 }
2059 _ => {}
2060 }
2061 i += 1;
2062 }
2063
2064 None
2065}
2066
2067fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2078 let mut sibling_opt = method_node.prev_sibling();
2080 let method_start_row = method_node.start_position().row;
2081
2082 let mut comments = Vec::new();
2084 let mut expected_row = method_start_row;
2085
2086 while let Some(sibling) = sibling_opt {
2087 if sibling.kind() == "comment" {
2088 let comment_end_row = sibling.end_position().row;
2089
2090 if comment_end_row + 1 >= expected_row {
2093 if let Ok(comment_text) = sibling.utf8_text(content) {
2094 comments.push(comment_text);
2095 }
2096 expected_row = sibling.start_position().row;
2097 sibling_opt = sibling.prev_sibling();
2098 } else {
2099 break;
2101 }
2102 } else {
2103 break;
2104 }
2105 }
2106
2107 for comment in comments.iter().rev() {
2109 if let Some(return_pos) = comment.find("@return") {
2110 let after_return = &comment[return_pos + 7..];
2111 if let Some(start_bracket) = after_return.find('[')
2113 && let Some(end_bracket) = after_return.find(']')
2114 && end_bracket > start_bracket
2115 {
2116 let return_type = &after_return[start_bracket + 1..end_bracket];
2117 return Some(return_type.trim().to_string());
2118 }
2119 }
2120 }
2121
2122 None
2123}
2124
2125fn span_from_node(node: Node<'_>) -> Span {
2133 span_from_points(node.start_position(), node.end_position())
2134}
2135
2136fn span_from_points(start: Point, end: Point) -> Span {
2145 Span::new(
2146 Position::new(start.row, start.column),
2147 Position::new(end.row, end.column),
2148 )
2149}
2150
2151fn is_literal_delimiter(kind: &str) -> bool {
2161 matches!(kind, "," | "(" | ")" | "[" | "]")
2162}
2163
2164fn parse_controller_dsl_args(
2167 text: &str,
2168) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
2169 let mut head = text;
2171 let mut tail = "";
2172 if let Some(idx) = text.find("only:") {
2173 head = &text[..idx];
2174 tail = &text[idx..];
2175 } else if let Some(idx) = text.find("except:") {
2176 head = &text[..idx];
2177 tail = &text[idx..];
2178 }
2179 let callbacks = extract_symbol_list_from_args(head);
2180 let only = extract_kw_symbol_list(tail, "only:");
2181 let except = extract_kw_symbol_list(tail, "except:");
2182 (callbacks, only, except)
2183}
2184
2185fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
2186 let mut out = Vec::new();
2187 let bytes = text.as_bytes();
2188 let mut i = 0;
2189 while i < bytes.len() {
2190 if bytes[i] == b':' {
2191 let start = i + 1;
2192 let mut j = start;
2193 while j < bytes.len() {
2194 let c = bytes[j] as char;
2195 if c.is_ascii_alphanumeric() || c == '_' {
2196 j += 1;
2197 } else {
2198 break;
2199 }
2200 }
2201 if j > start {
2202 out.push(text[start..j].to_string());
2203 i = j;
2204 continue;
2205 }
2206 }
2207 i += 1;
2208 }
2209 out
2210}
2211
2212fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
2213 let pos = text.find(kw)?;
2214 let mut after = &text[pos + kw.len()..];
2215 after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
2217 if after.starts_with('[')
2218 && let Some(end) = after.find(']')
2219 {
2220 return Some(extract_symbol_list_from_args(&after[..=end]));
2221 }
2222 if let Some(colon) = after.find(':') {
2224 let mut j = colon + 1;
2225 while j < after.len() {
2226 let ch = after.as_bytes()[j] as char;
2227 if ch.is_ascii_alphanumeric() || ch == '_' {
2228 j += 1;
2229 } else {
2230 break;
2231 }
2232 }
2233 if j > colon + 1 {
2234 return Some(vec![after[colon + 1..j].to_string()]);
2235 }
2236 }
2237 None
2238}
2239
2240fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
2241 let mut out = Vec::new();
2242 match node.kind() {
2243 "symbol" | "simple_symbol" => {
2244 if let Ok(t) = node_text(node, content) {
2245 out.push(t.trim_start_matches(':').to_string());
2246 }
2247 }
2248 "array" => {
2249 let mut c = node.walk();
2250 for ch in node.children(&mut c) {
2251 if matches!(ch.kind(), "symbol" | "simple_symbol")
2252 && let Ok(t) = node_text(ch, content)
2253 {
2254 out.push(t.trim_start_matches(':').to_string());
2255 }
2256 }
2257 }
2258 _ => {
2259 if let Some(txt) = node_text_raw(node, content) {
2261 out = extract_symbol_list_from_args(&txt);
2262 }
2263 }
2264 }
2265 out
2266}
2267
2268fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
2270 let mut cursor = arguments.walk();
2271 for child in arguments.children(&mut cursor) {
2272 if !child.is_named() {
2273 continue;
2274 }
2275 if let Some(s) = extract_string_content(child, content) {
2276 return Some(s);
2277 }
2278 }
2279 None
2280}
2281
2282fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
2284 let text = node.utf8_text(content).ok()?;
2285 let trimmed = text.trim();
2286
2287 if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
2289 || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
2290 && trimmed.len() >= 2
2291 {
2292 return Some(trimmed[1..trimmed.len() - 1].to_string());
2293 }
2294
2295 if matches!(node.kind(), "string" | "chained_string") {
2297 let mut cursor = node.walk();
2298 for child in node.children(&mut cursor) {
2299 if child.kind() == "string_content"
2300 && let Ok(s) = child.utf8_text(content)
2301 {
2302 return Some(s.to_string());
2303 }
2304 }
2305 }
2306
2307 None
2308}
2309
2310pub(crate) fn resolve_ruby_require(
2321 module_name: &str,
2322 is_relative: bool,
2323 source_file: &str,
2324) -> String {
2325 if is_relative {
2326 let source_path = std::path::Path::new(source_file);
2330 let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
2331
2332 let relative_path = std::path::Path::new(module_name);
2334 let resolved = source_dir.join(relative_path);
2335
2336 let normalized = normalize_path(&resolved);
2338
2339 let path_str = normalized.to_string_lossy();
2342 let separators: &[char] = &['/', '\\'];
2343 path_str
2344 .split(separators)
2345 .filter(|s| !s.is_empty())
2346 .collect::<Vec<_>>()
2347 .join("::")
2348 } else {
2349 module_name.replace('/', "::")
2352 }
2353}
2354
2355fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
2357 let mut components = Vec::new();
2358
2359 for component in path.components() {
2360 match component {
2361 std::path::Component::CurDir => {
2362 }
2364 std::path::Component::ParentDir => {
2365 if components
2367 .last()
2368 .is_some_and(|c| *c != std::path::Component::ParentDir)
2369 {
2370 components.pop();
2371 } else {
2372 components.push(component);
2373 }
2374 }
2375 _ => {
2376 components.push(component);
2377 }
2378 }
2379 }
2380
2381 components.iter().collect()
2382}
2383
2384fn process_yard_annotations(
2391 node: Node,
2392 content: &[u8],
2393 ast_graph: &ASTGraph,
2394 helper: &mut GraphBuildHelper,
2395) -> GraphResult<()> {
2396 match node.kind() {
2397 "method" => {
2398 process_method_yard(node, content, helper)?;
2399 }
2400 "singleton_method" => {
2401 process_singleton_method_yard(node, content, helper)?;
2402 }
2403 "call" | "command" | "command_call" => {
2404 if is_attr_call(node, content) {
2406 process_attr_yard(node, content, ast_graph, helper)?;
2407 }
2408 }
2409 "assignment" => {
2410 if is_instance_variable_assignment(node, content) {
2412 process_assignment_yard(node, content, helper)?;
2413 }
2414 }
2415 _ => {}
2416 }
2417
2418 let mut cursor = node.walk();
2420 for child in node.children(&mut cursor) {
2421 process_yard_annotations(child, content, ast_graph, helper)?;
2422 }
2423
2424 Ok(())
2425}
2426
2427fn process_method_yard(
2429 method_node: Node,
2430 content: &[u8],
2431 helper: &mut GraphBuildHelper,
2432) -> GraphResult<()> {
2433 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2435 return Ok(());
2436 };
2437
2438 let tags = parse_yard_tags(&yard_text);
2440
2441 let Some(name_node) = method_node.child_by_field_name("name") else {
2443 return Ok(());
2444 };
2445
2446 let method_name = name_node
2447 .utf8_text(content)
2448 .map_err(|_| GraphBuilderError::ParseError {
2449 span: span_from_node(method_node),
2450 reason: "failed to read method name".to_string(),
2451 })?
2452 .trim()
2453 .to_string();
2454
2455 if method_name.is_empty() {
2456 return Ok(());
2457 }
2458
2459 let class_name = get_enclosing_class_name(method_node, content);
2461
2462 let qualified_name = if let Some(class_name) = class_name {
2464 format!("{class_name}#{method_name}")
2465 } else {
2466 method_name.clone()
2467 };
2468
2469 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
2471
2472 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2474 let canonical_type = canonical_type_string(¶m_tag.type_str);
2476 let type_node_id = helper.add_type(&canonical_type, None);
2477 helper.add_typeof_edge_with_context(
2478 method_node_id,
2479 type_node_id,
2480 Some(TypeOfContext::Parameter),
2481 param_idx.try_into().ok(),
2482 Some(¶m_tag.name),
2483 );
2484
2485 let type_names = extract_type_names(¶m_tag.type_str);
2487 for type_name in type_names {
2488 let ref_type_id = helper.add_type(&type_name, None);
2489 helper.add_reference_edge(method_node_id, ref_type_id);
2490 }
2491 }
2492
2493 if let Some(return_type) = &tags.returns {
2495 let canonical_type = canonical_type_string(return_type);
2496 let type_node_id = helper.add_type(&canonical_type, None);
2497 helper.add_typeof_edge_with_context(
2498 method_node_id,
2499 type_node_id,
2500 Some(TypeOfContext::Return),
2501 Some(0),
2502 None,
2503 );
2504
2505 let type_names = extract_type_names(return_type);
2507 for type_name in type_names {
2508 let ref_type_id = helper.add_type(&type_name, None);
2509 helper.add_reference_edge(method_node_id, ref_type_id);
2510 }
2511 }
2512
2513 Ok(())
2514}
2515
2516fn process_singleton_method_yard(
2518 method_node: Node,
2519 content: &[u8],
2520 helper: &mut GraphBuildHelper,
2521) -> GraphResult<()> {
2522 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2524 return Ok(());
2525 };
2526
2527 let tags = parse_yard_tags(&yard_text);
2529
2530 let Some(name_node) = method_node.child_by_field_name("name") else {
2532 return Ok(());
2533 };
2534
2535 let method_name = name_node
2536 .utf8_text(content)
2537 .map_err(|_| GraphBuilderError::ParseError {
2538 span: span_from_node(method_node),
2539 reason: "failed to read method name".to_string(),
2540 })?
2541 .trim()
2542 .to_string();
2543
2544 if method_name.is_empty() {
2545 return Ok(());
2546 }
2547
2548 let class_name = get_enclosing_class_name(method_node, content);
2550
2551 let qualified_name = if let Some(class_name) = class_name {
2553 format!("{class_name}.{method_name}")
2554 } else {
2555 method_name.clone()
2556 };
2557
2558 let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
2560
2561 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2563 let canonical_type = canonical_type_string(¶m_tag.type_str);
2565 let type_node_id = helper.add_type(&canonical_type, None);
2566 helper.add_typeof_edge_with_context(
2567 method_node_id,
2568 type_node_id,
2569 Some(TypeOfContext::Parameter),
2570 param_idx.try_into().ok(),
2571 Some(¶m_tag.name),
2572 );
2573
2574 let type_names = extract_type_names(¶m_tag.type_str);
2576 for type_name in type_names {
2577 let ref_type_id = helper.add_type(&type_name, None);
2578 helper.add_reference_edge(method_node_id, ref_type_id);
2579 }
2580 }
2581
2582 if let Some(return_type) = &tags.returns {
2584 let canonical_type = canonical_type_string(return_type);
2585 let type_node_id = helper.add_type(&canonical_type, None);
2586 helper.add_typeof_edge_with_context(
2587 method_node_id,
2588 type_node_id,
2589 Some(TypeOfContext::Return),
2590 Some(0),
2591 None,
2592 );
2593
2594 let type_names = extract_type_names(return_type);
2596 for type_name in type_names {
2597 let ref_type_id = helper.add_type(&type_name, None);
2598 helper.add_reference_edge(method_node_id, ref_type_id);
2599 }
2600 }
2601
2602 Ok(())
2603}
2604
2605#[allow(clippy::unnecessary_wraps)]
2625fn process_attr_yard(
2626 attr_node: Node,
2627 content: &[u8],
2628 ast_graph: &ASTGraph,
2629 helper: &mut GraphBuildHelper,
2630) -> GraphResult<()> {
2631 let Some(method_name) = attr_method_name(attr_node, content) else {
2636 return Ok(());
2637 };
2638 let is_reader = method_name == "attr_reader";
2639
2640 let attr_names = extract_attr_names(attr_node, content);
2643 if attr_names.is_empty() {
2644 return Ok(());
2645 }
2646
2647 let class_name = get_enclosing_class_name(attr_node, content);
2649
2650 let yard_return = extract_yard_comment(attr_node, content)
2653 .map(|yard_text| parse_yard_tags(&yard_text))
2654 .and_then(|tags| tags.returns);
2655
2656 let span = span_from_node(attr_node);
2659 let visibility = ast_graph.attr_visibility_for_node(&attr_node).as_str();
2660
2661 for attr_name in attr_names {
2662 let qualified_name = if let Some(ref class) = class_name {
2666 format!("{class}#{attr_name}")
2667 } else {
2668 attr_name.clone()
2669 };
2670
2671 let attr_node_id = if is_reader {
2675 helper.add_constant_with_static_and_visibility(
2676 &qualified_name,
2677 Some(span),
2678 false,
2679 Some(visibility),
2680 )
2681 } else {
2682 helper.add_property_with_static_and_visibility(
2683 &qualified_name,
2684 Some(span),
2685 false,
2686 Some(visibility),
2687 )
2688 };
2689
2690 if let Some(var_type) = &yard_return {
2694 let canonical_type = canonical_type_string(var_type);
2695 let type_node_id = helper.add_type(&canonical_type, None);
2696 helper.add_typeof_edge_with_context(
2697 attr_node_id,
2698 type_node_id,
2699 Some(TypeOfContext::Field),
2700 None,
2701 Some(&attr_name),
2702 );
2703
2704 for type_name in extract_type_names(var_type) {
2705 let ref_type_id = helper.add_type(&type_name, None);
2706 helper.add_reference_edge(attr_node_id, ref_type_id);
2707 }
2708 }
2709 }
2710
2711 Ok(())
2712}
2713
2714fn attr_method_name(node: Node, content: &[u8]) -> Option<String> {
2718 let raw = match node.kind() {
2719 "command" => node
2720 .child_by_field_name("name")
2721 .and_then(|n| n.utf8_text(content).ok()),
2722 "call" | "command_call" => node
2723 .child_by_field_name("method")
2724 .and_then(|n| n.utf8_text(content).ok()),
2725 _ => None,
2726 }?;
2727 Some(raw.trim().to_string())
2728}
2729
2730fn process_assignment_yard(
2732 assignment_node: Node,
2733 content: &[u8],
2734 helper: &mut GraphBuildHelper,
2735) -> GraphResult<()> {
2736 let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
2738 return Ok(());
2739 };
2740
2741 let tags = parse_yard_tags(&yard_text);
2743
2744 let Some(var_type) = &tags.type_annotation else {
2746 return Ok(());
2747 };
2748
2749 let Some(left_node) = assignment_node.child_by_field_name("left") else {
2751 return Ok(());
2752 };
2753
2754 if left_node.kind() != "instance_variable" {
2755 return Ok(());
2756 }
2757
2758 let var_name = left_node
2759 .utf8_text(content)
2760 .map_err(|_| GraphBuilderError::ParseError {
2761 span: span_from_node(assignment_node),
2762 reason: "failed to read variable name".to_string(),
2763 })?
2764 .trim()
2765 .to_string();
2766
2767 if var_name.is_empty() {
2768 return Ok(());
2769 }
2770
2771 let class_name = get_enclosing_class_name(assignment_node, content);
2773
2774 let qualified_name = if let Some(class) = class_name {
2776 format!("{class}#{var_name}")
2777 } else {
2778 var_name.clone()
2779 };
2780
2781 let var_node_id = helper.add_variable(&qualified_name, None);
2783
2784 let canonical_type = canonical_type_string(var_type);
2786 let type_node_id = helper.add_type(&canonical_type, None);
2787 helper.add_typeof_edge_with_context(
2788 var_node_id,
2789 type_node_id,
2790 Some(TypeOfContext::Variable),
2791 None,
2792 Some(&var_name),
2793 );
2794
2795 let type_names = extract_type_names(var_type);
2797 for type_name in type_names {
2798 let ref_type_id = helper.add_type(&type_name, None);
2799 helper.add_reference_edge(var_node_id, ref_type_id);
2800 }
2801
2802 Ok(())
2803}
2804
2805fn is_attr_call(node: Node, content: &[u8]) -> bool {
2807 let method_name = match node.kind() {
2808 "command" => node
2809 .child_by_field_name("name")
2810 .and_then(|n| n.utf8_text(content).ok()),
2811 "call" | "command_call" => node
2812 .child_by_field_name("method")
2813 .and_then(|n| n.utf8_text(content).ok()),
2814 _ => None,
2815 };
2816
2817 method_name
2818 .is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
2819}
2820
2821fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
2823 if let Some(left_node) = node.child_by_field_name("left") {
2824 left_node.kind() == "instance_variable"
2825 } else {
2826 false
2827 }
2828}
2829
2830fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
2833 let mut names = Vec::new();
2834
2835 let arguments = attr_node.child_by_field_name("arguments");
2837
2838 if let Some(args) = arguments {
2839 let mut cursor = args.walk();
2841 for child in args.children(&mut cursor) {
2842 if matches!(child.kind(), "symbol" | "simple_symbol")
2843 && let Ok(text) = child.utf8_text(content)
2844 {
2845 let cleaned = text.trim().trim_start_matches(':');
2846 if !cleaned.is_empty() {
2847 names.push(cleaned.to_string());
2848 }
2849 } else if child.kind() == "string"
2850 && let Ok(text) = child.utf8_text(content)
2851 {
2852 let cleaned = text
2854 .trim()
2855 .trim_start_matches(['\'', '"'])
2856 .trim_end_matches(['\'', '"']);
2857 if !cleaned.is_empty() {
2858 names.push(cleaned.to_string());
2859 }
2860 }
2861 }
2862 } else if matches!(attr_node.kind(), "command" | "command_call") {
2863 let mut cursor = attr_node.walk();
2865 let mut found_method = false;
2866 for child in attr_node.children(&mut cursor) {
2867 if !child.is_named() {
2868 continue;
2869 }
2870 if !found_method {
2872 found_method = true;
2873 continue;
2874 }
2875 if matches!(child.kind(), "symbol" | "simple_symbol")
2877 && let Ok(text) = child.utf8_text(content)
2878 {
2879 let cleaned = text.trim().trim_start_matches(':');
2880 if !cleaned.is_empty() {
2881 names.push(cleaned.to_string());
2882 }
2883 } else if child.kind() == "string"
2884 && let Ok(text) = child.utf8_text(content)
2885 {
2886 let cleaned = text
2888 .trim()
2889 .trim_start_matches(['\'', '"'])
2890 .trim_end_matches(['\'', '"']);
2891 if !cleaned.is_empty() {
2892 names.push(cleaned.to_string());
2893 }
2894 }
2895 }
2896 }
2897
2898 names
2899}
2900
2901fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
2906 let mut current = node;
2907 let mut namespace_parts = Vec::new();
2908
2909 while let Some(parent) = current.parent() {
2911 if matches!(parent.kind(), "class" | "module") {
2912 if let Some(name_node) = parent.child_by_field_name("name")
2914 && let Ok(name_text) = name_node.utf8_text(content)
2915 {
2916 let trimmed = name_text.trim();
2917 if trimmed.starts_with("::") {
2919 namespace_parts.clear();
2921 namespace_parts.push(trimmed.trim_start_matches("::").to_string());
2922 break;
2923 }
2924 namespace_parts.insert(0, trimmed.to_string());
2926 }
2927 }
2928 current = parent;
2929 }
2930
2931 if namespace_parts.is_empty() {
2933 None
2934 } else {
2935 Some(namespace_parts.join("::"))
2936 }
2937}
2938
2939#[cfg(test)]
2940mod field_emission_tests {
2941 use sqry_core::graph::GraphBuilder;
2959 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
2960 use sqry_core::graph::unified::build::test_helpers::{
2961 build_node_name_lookup, build_string_lookup,
2962 };
2963 use sqry_core::graph::unified::edge::EdgeKind;
2964 use sqry_core::graph::unified::edge::kind::TypeOfContext;
2965 use sqry_core::graph::unified::node::NodeKind;
2966 use std::path::Path;
2967 use tree_sitter::Parser;
2968
2969 use super::RubyGraphBuilder;
2970
2971 fn parse(source: &str) -> tree_sitter::Tree {
2972 let mut parser = Parser::new();
2973 parser
2974 .set_language(&tree_sitter_ruby::LANGUAGE.into())
2975 .expect("load Ruby grammar");
2976 parser.parse(source, None).expect("parse Ruby source")
2977 }
2978
2979 fn build(source: &str) -> StagingGraph {
2980 let tree = parse(source);
2981 let mut staging = StagingGraph::new();
2982 let builder = RubyGraphBuilder::default();
2983 builder
2984 .build_graph(&tree, source.as_bytes(), Path::new("test.rb"), &mut staging)
2985 .expect("build graph");
2986 staging
2987 }
2988
2989 fn find_node<'a>(
2993 staging: &'a StagingGraph,
2994 name: &str,
2995 kind: Option<NodeKind>,
2996 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
2997 let strings = build_string_lookup(staging);
2998 for op in staging.operations() {
2999 if let StagingOp::AddNode { entry, .. } = op {
3000 if let Some(k) = kind
3001 && entry.kind != k
3002 {
3003 continue;
3004 }
3005 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3006 if let Some(s) = strings.get(&name_idx)
3007 && s == name
3008 {
3009 return Some(entry);
3010 }
3011 }
3012 }
3013 None
3014 }
3015
3016 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
3017 let strings = build_string_lookup(staging);
3018 staging
3019 .operations()
3020 .iter()
3021 .filter(|op| {
3022 if let StagingOp::AddNode { entry, .. } = op {
3023 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3024 strings.get(&name_idx).is_some_and(|s| s == name)
3025 } else {
3026 false
3027 }
3028 })
3029 .count()
3030 }
3031
3032 fn visibility(
3033 staging: &StagingGraph,
3034 entry: &sqry_core::graph::unified::storage::NodeEntry,
3035 ) -> Option<String> {
3036 let strings = build_string_lookup(staging);
3037 entry
3038 .visibility
3039 .and_then(|visibility_id| strings.get(&visibility_id.index()).cloned())
3040 }
3041
3042 fn typeof_edges_for_node(
3043 staging: &StagingGraph,
3044 source_name: &str,
3045 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
3046 let names = build_node_name_lookup(staging);
3047 let strings = build_string_lookup(staging);
3048 let mut out = Vec::new();
3049 for op in staging.operations() {
3050 if let StagingOp::AddEdge {
3051 source,
3052 target,
3053 kind: EdgeKind::TypeOf { context, name, .. },
3054 ..
3055 } = op
3056 {
3057 let src = names.get(source).cloned().unwrap_or_default();
3058 if src != source_name {
3059 continue;
3060 }
3061 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3062 let target_name = names.get(target).cloned().unwrap_or_default();
3063 out.push((*context, edge_name, target_name));
3064 }
3065 }
3066 out
3067 }
3068
3069 #[test]
3072 fn req_r0001_attr_accessor_without_yard_emits_property_node() {
3073 let src = "class Foo\n attr_accessor :x\nend\n";
3074 let staging = build(src);
3075 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3076 .expect("Foo#x Property must be emitted without YARD");
3077 }
3078
3079 #[test]
3080 fn req_r0001_attr_reader_without_yard_emits_constant_node() {
3081 let src = "class Foo\n attr_reader :y\nend\n";
3082 let staging = build(src);
3083 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3084 .expect("Foo#y Constant must be emitted without YARD");
3085 }
3086
3087 #[test]
3088 fn req_r0001_attr_writer_without_yard_emits_property_node() {
3089 let src = "class Foo\n attr_writer :z\nend\n";
3090 let staging = build(src);
3091 find_node(&staging, "Foo::z", Some(NodeKind::Property))
3092 .expect("Foo#z Property must be emitted without YARD");
3093 }
3094
3095 #[test]
3096 fn req_r0001_attr_with_yard_still_emits() {
3097 let src = "class Foo\n # @return [String]\n attr_reader :y\nend\n";
3098 let staging = build(src);
3099 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3100 .expect("Foo#y Constant must be emitted when YARD is present too");
3101 }
3102
3103 #[test]
3106 fn req_r0023_attr_reader_branches_to_constant() {
3107 let src = "class Bar\n attr_reader :name\nend\n";
3108 let staging = build(src);
3109 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Constant))
3110 .expect("attr_reader must produce Constant");
3111 assert_eq!(entry.kind, NodeKind::Constant);
3112 assert!(
3113 find_node(&staging, "Bar::name", Some(NodeKind::Property)).is_none(),
3114 "attr_reader must NOT also produce a Property"
3115 );
3116 }
3117
3118 #[test]
3119 fn req_r0023_attr_writer_branches_to_property() {
3120 let src = "class Bar\n attr_writer :name\nend\n";
3121 let staging = build(src);
3122 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3123 .expect("attr_writer must produce Property");
3124 assert_eq!(entry.kind, NodeKind::Property);
3125 assert!(
3126 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3127 "attr_writer must NOT also produce a Constant"
3128 );
3129 }
3130
3131 #[test]
3132 fn req_r0023_attr_accessor_branches_to_property() {
3133 let src = "class Bar\n attr_accessor :name\nend\n";
3134 let staging = build(src);
3135 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3136 .expect("attr_accessor must produce Property");
3137 assert_eq!(entry.kind, NodeKind::Property);
3138 assert!(
3139 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3140 "attr_accessor must NOT also produce a Constant"
3141 );
3142 }
3143
3144 #[test]
3145 fn req_r0023_attr_accessor_emits_one_per_argument() {
3146 let src = "class Multi\n attr_accessor :a, :b, :c\nend\n";
3147 let staging = build(src);
3148 find_node(&staging, "Multi::a", Some(NodeKind::Property))
3149 .expect("Multi#a Property must exist");
3150 find_node(&staging, "Multi::b", Some(NodeKind::Property))
3151 .expect("Multi#b Property must exist");
3152 find_node(&staging, "Multi::c", Some(NodeKind::Property))
3153 .expect("Multi#c Property must exist");
3154 assert_eq!(count_nodes_named(&staging, "Multi::a"), 1);
3156 assert_eq!(count_nodes_named(&staging, "Multi::b"), 1);
3157 assert_eq!(count_nodes_named(&staging, "Multi::c"), 1);
3158 }
3159
3160 #[test]
3163 fn req_r0017_qualified_name_uses_ruby_hash_idiom() {
3164 let src = "class Foo\n attr_accessor :x\nend\n";
3171 let staging = build(src);
3172 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3174 .expect("canonical Foo::x must exist");
3175 assert!(
3177 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3178 "bare 'x' must not be the qualified name (would collide across classes)"
3179 );
3180 }
3181
3182 #[test]
3185 fn req_r0006_yard_type_tag_drives_typeof_field_edge() {
3186 let src = "class User\n # @return [String]\n attr_reader :name\nend\n";
3187 let staging = build(src);
3188 let edges = typeof_edges_for_node(&staging, "User::name");
3189 assert!(
3190 !edges.is_empty(),
3191 "User#name should have a TypeOf edge from YARD @return"
3192 );
3193 let has_string = edges.iter().any(|(_, _, t)| t == "String");
3194 assert!(
3195 has_string,
3196 "YARD @return [String] should produce a TypeOf target 'String', got {edges:?}"
3197 );
3198 }
3199
3200 #[test]
3201 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3202 let src = "class C\n # @return [String]\n attr_accessor :title\nend\n";
3203 let staging = build(src);
3204 let edges = typeof_edges_for_node(&staging, "C::title");
3205 assert!(!edges.is_empty(), "C#title should have a TypeOf edge");
3206 for (ctx, name, _) in &edges {
3207 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3208 assert_eq!(
3209 name.as_deref(),
3210 Some("title"),
3211 "edge name must be the bare attr name"
3212 );
3213 }
3214 }
3215
3216 #[test]
3217 fn req_r0006_no_yard_means_no_typeof_edge_but_node_emitted() {
3218 let src = "class C\n attr_accessor :untyped\nend\n";
3220 let staging = build(src);
3221 find_node(&staging, "C::untyped", Some(NodeKind::Property))
3222 .expect("Property must emit even without YARD type tag");
3223 let edges = typeof_edges_for_node(&staging, "C::untyped");
3224 assert!(
3225 edges.is_empty(),
3226 "no YARD => no TypeOf{{Field}} enrichment edge, got {edges:?}"
3227 );
3228 }
3229
3230 #[test]
3233 fn req_r0023_attr_node_visibility_defaults_to_public() {
3234 let src = "class V\n attr_accessor :x\nend\n";
3235 let staging = build(src);
3236 let entry =
3237 find_node(&staging, "V::x", Some(NodeKind::Property)).expect("V#x Property must exist");
3238 assert_eq!(
3239 visibility(&staging, entry).as_deref(),
3240 Some("public"),
3241 "Ruby attr_* nodes default to public visibility"
3242 );
3243 }
3244
3245 #[test]
3246 fn req_r0023_attr_node_visibility_tracks_private_and_protected_scope() {
3247 let src = "class V\n private\n attr_accessor :hidden\n protected\n attr_reader :guarded\nend\n";
3248 let staging = build(src);
3249 let hidden = find_node(&staging, "V::hidden", Some(NodeKind::Property))
3250 .expect("V#hidden Property must exist");
3251 let guarded = find_node(&staging, "V::guarded", Some(NodeKind::Constant))
3252 .expect("V#guarded Constant must exist");
3253 assert_eq!(
3254 visibility(&staging, hidden).as_deref(),
3255 Some("private"),
3256 "Ruby attr_* nodes must inherit private visibility scope"
3257 );
3258 assert_eq!(
3259 visibility(&staging, guarded).as_deref(),
3260 Some("protected"),
3261 "Ruby attr_reader nodes must inherit protected visibility scope"
3262 );
3263 }
3264
3265 #[test]
3266 fn req_r0023_attr_node_is_not_static() {
3267 let src = "class S\n attr_reader :y\nend\n";
3268 let staging = build(src);
3269 let entry =
3270 find_node(&staging, "S::y", Some(NodeKind::Constant)).expect("S#y Constant must exist");
3271 assert!(
3272 !entry.is_static,
3273 "attr_* nodes must have is_static=false (always instance per design §4.5)"
3274 );
3275 }
3276
3277 #[test]
3280 fn req_r0017_same_attr_name_across_classes_distinct_nodes() {
3281 let src = "class A\n attr_accessor :x\nend\nclass B\n attr_accessor :x\nend\n";
3282 let staging = build(src);
3283 find_node(&staging, "A::x", Some(NodeKind::Property)).expect("A#x Property must exist");
3284 find_node(&staging, "B::x", Some(NodeKind::Property)).expect("B#x Property must exist");
3285 assert!(
3286 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3287 "bare 'x' must not exist; qualified names disambiguate cross-class"
3288 );
3289 }
3290
3291 #[test]
3294 fn req_r0017_nested_module_class_qualifies_attr() {
3295 let src = "module M\n class Inner\n attr_accessor :n\n end\nend\n";
3296 let staging = build(src);
3297 find_node(&staging, "M::Inner::n", Some(NodeKind::Property))
3298 .expect("M::Inner#n Property must exist with full namespace");
3299 }
3300
3301 #[test]
3304 fn req_r0001_attr_reader_string_argument_emits_constant() {
3305 let src = "class User\n attr_reader \"username\"\nend\n";
3306 let staging = build(src);
3307 find_node(&staging, "User::username", Some(NodeKind::Constant))
3308 .expect("attr_reader with string arg must emit Constant");
3309 }
3310
3311 #[test]
3312 fn req_r0001_attr_accessor_command_call_form_emits_property() {
3313 let src = "class Service\n self.attr_accessor :logger\nend\n";
3314 let staging = build(src);
3315 find_node(&staging, "Service::logger", Some(NodeKind::Property))
3316 .expect("self.attr_accessor command_call must emit Property");
3317 }
3318}