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, &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 ffi_enabled_scopes: HashSet<Vec<String>>,
182 #[allow(dead_code)] controller_dsl_hooks: Vec<ControllerDslHook>,
184}
185
186impl ASTGraph {
187 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
188 let mut builder = ContextBuilder::new(content, max_depth)?;
189 builder.walk(tree.root_node())?;
190 Ok(Self {
191 contexts: builder.contexts,
192 node_to_context: builder.node_to_context,
193 ffi_enabled_scopes: builder.ffi_enabled_scopes,
194 controller_dsl_hooks: builder.controller_dsl_hooks,
195 })
196 }
197
198 #[allow(dead_code)] fn contexts(&self) -> &[RubyContext] {
200 &self.contexts
201 }
202
203 fn context_for_node(&self, node: &Node<'_>) -> Option<&RubyContext> {
204 self.node_to_context
205 .get(&node.id())
206 .and_then(|idx| self.contexts.get(*idx))
207 }
208}
209
210fn walk_tree_for_graph(
212 node: Node,
213 content: &[u8],
214 ast_graph: &ASTGraph,
215 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
216 ffi_enabled_scopes: &HashSet<Vec<String>>,
217) -> GraphResult<()> {
218 let mut current_namespace: Vec<String> = Vec::new();
220
221 walk_tree_for_graph_impl(
222 node,
223 content,
224 ast_graph,
225 helper,
226 ffi_enabled_scopes,
227 &mut current_namespace,
228 )
229}
230
231fn apply_controller_dsl_hooks(ast_graph: &ASTGraph, helper: &mut GraphBuildHelper) {
232 if ast_graph.controller_dsl_hooks.is_empty() {
233 return;
234 }
235
236 let mut actions_by_container: HashMap<String, Vec<String>> = HashMap::new();
237 for context in &ast_graph.contexts {
238 if !matches!(context.kind, RubyContextKind::Method) {
239 continue;
240 }
241 let Some(container) = context.container() else {
242 continue;
243 };
244 let Some(action_name) = context.qualified_name.rsplit('#').next() else {
245 continue;
246 };
247 actions_by_container
248 .entry(container.to_string())
249 .or_default()
250 .push(action_name.to_string());
251 }
252
253 let mut emitted: HashSet<(String, String)> = HashSet::new();
254 for hook in &ast_graph.controller_dsl_hooks {
255 let Some(actions) = actions_by_container.get(&hook.container) else {
256 continue;
257 };
258
259 for action in actions {
260 let included = if let Some(only) = &hook.only {
261 only.iter().any(|name| name == action)
262 } else if let Some(except) = &hook.except {
263 !except.iter().any(|name| name == action)
264 } else {
265 true
266 };
267
268 if !included {
269 continue;
270 }
271
272 for callback in &hook.callbacks {
273 if callback.trim().is_empty() {
274 continue;
275 }
276
277 let action_qname = format!("{}#{}", hook.container, action);
278 let callback_qname = format!("{}#{}", hook.container, callback);
279 if !emitted.insert((action_qname.clone(), callback_qname.clone())) {
280 continue;
281 }
282
283 let action_id = helper.ensure_method(&action_qname, None, false, false);
284 let callback_id = helper.ensure_method(&callback_qname, None, false, false);
285 helper.add_call_edge_full_with_span(action_id, callback_id, 255, false, vec![]);
286 }
287 }
288 }
289}
290
291#[allow(
293 clippy::too_many_lines,
294 reason = "Ruby graph extraction handles DSLs and FFI patterns in one traversal."
295)]
296fn walk_tree_for_graph_impl(
297 node: Node,
298 content: &[u8],
299 ast_graph: &ASTGraph,
300 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
301 ffi_enabled_scopes: &HashSet<Vec<String>>,
302 current_namespace: &mut Vec<String>,
303) -> GraphResult<()> {
304 match node.kind() {
305 "class" => {
306 if let Some(name_node) = node.child_by_field_name("name")
308 && let Ok(class_name) = name_node.utf8_text(content)
309 {
310 let span = span_from_points(node.start_position(), node.end_position());
311 let qualified_name = class_name.to_string();
312 let class_id = helper.add_class(&qualified_name, Some(span));
313
314 let module_id = helper.add_module(FILE_MODULE_NAME, None);
317 helper.add_export_edge(module_id, class_id);
318
319 if let Some(superclass_node) = node.child_by_field_name("superclass")
321 && let Ok(superclass_name) = superclass_node.utf8_text(content)
322 {
323 let superclass_name = superclass_name.trim();
324 if !superclass_name.is_empty() {
325 let parent_id = helper.add_class(superclass_name, None);
327 helper.add_inherits_edge(class_id, parent_id);
328 }
329 }
330
331 current_namespace.push(class_name.trim().to_string());
333
334 let mut cursor = node.walk();
336 for child in node.children(&mut cursor) {
337 walk_tree_for_graph_impl(
338 child,
339 content,
340 ast_graph,
341 helper,
342 ffi_enabled_scopes,
343 current_namespace,
344 )?;
345 }
346
347 current_namespace.pop();
348 return Ok(());
349 }
350 }
351 "module" => {
352 if let Some(name_node) = node.child_by_field_name("name")
354 && let Ok(module_name) = name_node.utf8_text(content)
355 {
356 let span = span_from_points(node.start_position(), node.end_position());
357 let qualified_name = module_name.to_string();
358 let mod_id = helper.add_module(&qualified_name, Some(span));
359
360 let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
363 helper.add_export_edge(file_module_id, mod_id);
364
365 current_namespace.push(module_name.trim().to_string());
367
368 let mut cursor = node.walk();
370 for child in node.children(&mut cursor) {
371 walk_tree_for_graph_impl(
372 child,
373 content,
374 ast_graph,
375 helper,
376 ffi_enabled_scopes,
377 current_namespace,
378 )?;
379 }
380
381 current_namespace.pop();
382 return Ok(());
383 }
384 }
385 "method" | "singleton_method" => {
386 if let Some(context) = ast_graph.context_for_node(&node) {
388 let span = span_from_points(context.start_position, context.end_position);
389
390 let is_async = detect_async_method(node, content);
392
393 let params = node
395 .child_by_field_name("parameters")
396 .and_then(|params_node| extract_method_parameters(params_node, content));
397
398 let return_type = extract_return_type(node, content);
400
401 let signature = match (params.as_ref(), return_type.as_ref()) {
403 (Some(p), Some(r)) => Some(format!("{p} -> {r}")),
404 (Some(p), None) => Some(p.clone()),
405 (None, Some(r)) => Some(format!("-> {r}")),
406 (None, None) => None,
407 };
408
409 let visibility = context.visibility().as_str();
411
412 let method_id = helper.add_method_with_signature(
414 context.qualified_name(),
415 Some(span),
416 is_async,
417 context.is_singleton(),
418 Some(visibility),
419 signature.as_deref(),
420 );
421
422 if context.visibility() == Visibility::Public {
425 let module_id = helper.add_module(FILE_MODULE_NAME, None);
426 helper.add_export_edge(module_id, method_id);
427 }
428 }
429 }
430 "assignment" => {
431 if let Some(left_node) = node.child_by_field_name("left")
433 && left_node.kind() == "constant"
434 && let Ok(const_name) = left_node.utf8_text(content)
435 {
436 let qualified_name = if current_namespace.is_empty() {
438 const_name.to_string()
439 } else {
440 format!("{}::{}", current_namespace.join("::"), const_name)
441 };
442
443 let span = span_from_points(node.start_position(), node.end_position());
444 let const_id = helper.add_constant(&qualified_name, Some(span));
445
446 let module_id = helper.add_module(FILE_MODULE_NAME, None);
448 helper.add_export_edge(module_id, const_id);
449 }
450 }
451 "call" | "command" | "command_call" | "identifier" | "super" => {
452 if is_include_or_extend_statement(node, content) {
454 handle_include_extend(node, content, helper, current_namespace);
455 }
456 else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
460 } else if is_require_statement(node, content) {
462 if let Some((from_qname, to_qname)) =
464 build_import_for_staging(node, content, helper.file_path())
465 {
466 let from_id = helper.add_import(&from_qname, None);
468 let to_id = helper.add_import(
469 &to_qname,
470 Some(span_from_points(node.start_position(), node.end_position())),
471 );
472
473 helper.add_import_edge(from_id, to_id);
475 }
476 } else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
477 build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
479 } else {
480 if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
482 build_call_for_staging(ast_graph, node, content)
483 {
484 let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
486 let target_id =
487 helper.ensure_callee(&target_qname, span, CalleeKindHint::Function);
488
489 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
491 helper.add_call_edge_full_with_span(
492 source_id,
493 target_id,
494 argument_count,
495 false,
496 vec![span],
497 );
498 }
499 }
500 }
501 _ => {}
502 }
503
504 let mut cursor = node.walk();
506 for child in node.children(&mut cursor) {
507 walk_tree_for_graph_impl(
508 child,
509 content,
510 ast_graph,
511 helper,
512 ffi_enabled_scopes,
513 current_namespace,
514 )?;
515 }
516
517 Ok(())
518}
519
520fn is_ffi_attach_function(
531 node: Node,
532 content: &[u8],
533 ffi_enabled_scopes: &HashSet<Vec<String>>,
534 current_namespace: &[String],
535) -> bool {
536 let method_name = match node.kind() {
538 "command" => node
539 .child_by_field_name("name")
540 .and_then(|n| n.utf8_text(content).ok()),
541 "call" | "command_call" => node
542 .child_by_field_name("method")
543 .and_then(|n| n.utf8_text(content).ok()),
544 _ => None,
545 };
546
547 let Some(method_name) = method_name else {
548 return false;
549 };
550 let method_name = method_name.trim();
551 if !matches!(
552 method_name,
553 "attach_function" | "attach_variable" | "ffi_lib" | "callback"
554 ) {
555 return false;
556 }
557
558 let receiver = match node.kind() {
559 "call" | "command_call" | "method_call" => node
560 .child_by_field_name("receiver")
561 .and_then(|n| n.utf8_text(content).ok()),
562 _ => None,
563 };
564 if let Some(receiver) = receiver {
565 let trimmed = receiver.trim();
566 if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
567 return true;
568 }
569 }
570
571 ffi_enabled_scopes.contains(current_namespace)
572}
573
574fn build_ffi_edge_for_attach_function(
581 node: Node,
582 content: &[u8],
583 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
584 current_namespace: &[String],
585) {
586 let arguments = node.child_by_field_name("arguments");
588
589 let func_name = if let Some(args) = arguments {
591 extract_first_symbol_from_arguments(args, content)
592 } else {
593 let mut cursor = node.walk();
595 let mut found_name = false;
596 let mut result = None;
597 for child in node.children(&mut cursor) {
598 if !child.is_named() {
599 continue;
600 }
601 if !found_name {
603 found_name = true;
604 continue;
605 }
606 if matches!(child.kind(), "symbol" | "simple_symbol")
608 && let Ok(text) = child.utf8_text(content)
609 {
610 result = Some(text.trim().trim_start_matches(':').to_string());
611 break;
612 }
613 }
614 result
615 };
616
617 let Some(func_name) = func_name else {
618 return;
619 };
620
621 let caller_name = if current_namespace.is_empty() {
623 "<module>".to_string()
624 } else {
625 current_namespace.join("::")
626 };
627
628 let caller_id = helper.add_module(&caller_name, None);
630
631 let ffi_func_name = format!("ffi::{func_name}");
633 let span = span_from_points(node.start_position(), node.end_position());
634 let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
635
636 helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
638}
639
640fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
642 let mut cursor = arguments.walk();
643 for child in arguments.children(&mut cursor) {
644 if matches!(child.kind(), "symbol" | "simple_symbol")
645 && let Ok(text) = child.utf8_text(content)
646 {
647 return Some(text.trim().trim_start_matches(':').to_string());
648 }
649 if child.kind() == "bare_symbol"
651 && let Ok(text) = child.utf8_text(content)
652 {
653 return Some(text.trim().to_string());
654 }
655 }
656 None
657}
658
659fn build_call_for_staging(
661 ast_graph: &ASTGraph,
662 call_node: Node<'_>,
663 content: &[u8],
664) -> GraphResult<Option<CallEdgeData>> {
665 let Some(call_context) = ast_graph.context_for_node(&call_node) else {
666 return Ok(None);
667 };
668
669 let Some(method_call) = extract_method_call(call_node, content)? else {
670 return Ok(None);
671 };
672
673 if is_visibility_command(&method_call) {
674 return Ok(None);
675 }
676
677 let source_qualified = call_context.qualified_name().to_string();
678 let target_name = resolve_callee(&method_call, call_context);
679
680 if target_name.is_empty() {
681 return Ok(None);
682 }
683
684 let span = span_from_node(call_node);
685 let argument_count = count_arguments(method_call.arguments, content);
686 let is_singleton = call_context.is_singleton();
687
688 Ok(Some((
689 source_qualified,
690 target_name,
691 argument_count,
692 span,
693 is_singleton,
694 )))
695}
696
697fn build_import_for_staging(
699 require_node: Node<'_>,
700 content: &[u8],
701 file_path: &str,
702) -> Option<(String, String)> {
703 let method_name = match require_node.kind() {
705 "command" => require_node
706 .child_by_field_name("name")
707 .and_then(|n| n.utf8_text(content).ok())
708 .map(|s| s.trim().to_string()),
709 "call" | "method_call" => require_node
710 .child_by_field_name("method")
711 .and_then(|n| n.utf8_text(content).ok())
712 .map(|s| s.trim().to_string()),
713 _ => None,
714 };
715
716 let method_name = method_name?;
717
718 if !matches!(method_name.as_str(), "require" | "require_relative") {
720 return None;
721 }
722
723 let arguments = require_node.child_by_field_name("arguments");
725 let module_name = if let Some(args) = arguments {
726 extract_require_module_name(args, content)
727 } else {
728 let mut cursor = require_node.walk();
730 let mut found_name = false;
731 let mut result = None;
732 for child in require_node.children(&mut cursor) {
733 if !child.is_named() {
734 continue;
735 }
736 if !found_name {
737 found_name = true;
738 continue;
739 }
740 result = extract_string_content(child, content);
742 break;
743 }
744 result
745 };
746
747 let module_name = module_name?;
748
749 if module_name.is_empty() {
750 return None;
751 }
752
753 let is_relative = method_name == "require_relative";
755 let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
756
757 Some(("<module>".to_string(), resolved_path))
759}
760
761fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
762 node.kind() == "identifier"
763 && node
764 .parent()
765 .is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
766}
767
768fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
778 let body_node = method_node.child_by_field_name("body");
780 if body_node.is_none() {
781 return false;
782 }
783 let body_node = body_node.unwrap();
784
785 if let Ok(body_text) = body_node.utf8_text(content) {
787 let body_lower = body_text.to_lowercase();
788
789 if body_lower.contains("fiber.")
791 || body_lower.contains("fiber.new")
792 || body_lower.contains("fiber.yield")
793 || body_lower.contains("fiber.resume")
794 || body_lower.contains("thread.new")
795 || body_lower.contains("thread.start")
796 || body_lower.contains("async do")
797 || body_lower.contains("async {")
798 || body_lower.contains("async.reactor")
799 || body_lower.contains("concurrent::")
800 {
801 return true;
802 }
803 }
804
805 false
806}
807
808fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
810 let method_name = match node.kind() {
811 "command" => node
812 .child_by_field_name("name")
813 .and_then(|n| n.utf8_text(content).ok()),
814 "call" | "method_call" => node
815 .child_by_field_name("method")
816 .and_then(|n| n.utf8_text(content).ok()),
817 _ => None,
818 };
819
820 method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
821}
822
823fn handle_include_extend(
831 node: Node<'_>,
832 content: &[u8],
833 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
834 current_namespace: &[String],
835) {
836 let module_name = if let Some(args) = node.child_by_field_name("arguments") {
838 extract_first_constant_from_arguments(args, content)
839 } else if node.kind() == "command" {
840 let mut cursor = node.walk();
842 let mut found_method = false;
843 let mut result = None;
844 for child in node.children(&mut cursor) {
845 if !child.is_named() {
846 continue;
847 }
848 if !found_method {
850 found_method = true;
851 continue;
852 }
853 if child.kind() == "constant"
855 && let Ok(text) = child.utf8_text(content)
856 {
857 result = Some(text.trim().to_string());
858 break;
859 }
860 }
861 result
862 } else {
863 None
864 };
865
866 let Some(module_name) = module_name else {
867 return;
868 };
869
870 let class_name = if current_namespace.is_empty() {
872 return; } else {
874 current_namespace.join("::")
875 };
876
877 let class_id = helper.add_class(&class_name, None);
879 let module_id = helper.add_module(&module_name, None);
880
881 helper.add_implements_edge(class_id, module_id);
883}
884
885fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
887 let mut cursor = args_node.walk();
888 for child in args_node.children(&mut cursor) {
889 if !child.is_named() {
890 continue;
891 }
892 if child.kind() == "constant"
894 && let Ok(text) = child.utf8_text(content)
895 {
896 return Some(text.trim().to_string());
897 }
898 }
899 None
900}
901
902fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
904 let method_name = match node.kind() {
905 "command" => node
906 .child_by_field_name("name")
907 .and_then(|n| n.utf8_text(content).ok()),
908 "call" | "method_call" => node
909 .child_by_field_name("method")
910 .and_then(|n| n.utf8_text(content).ok()),
911 _ => None,
912 };
913
914 method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
915}
916
917struct ContextBuilder<'a> {
918 contexts: Vec<RubyContext>,
919 node_to_context: HashMap<usize, usize>,
920 namespace: Vec<String>,
921 visibility_stack: Vec<Visibility>,
922 ffi_enabled_scopes: HashSet<Vec<String>>,
923 controller_dsl_hooks: Vec<ControllerDslHook>,
924 max_depth: usize,
925 content: &'a [u8],
926 guard: sqry_core::query::security::RecursionGuard,
927}
928
929impl<'a> ContextBuilder<'a> {
930 fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
931 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
932 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
933 let file_ops_depth = recursion_limits
934 .effective_file_ops_depth()
935 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
936 let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
937 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
938
939 Ok(Self {
940 contexts: Vec::new(),
941 node_to_context: HashMap::new(),
942 namespace: Vec::new(),
943 visibility_stack: vec![Visibility::Public],
944 ffi_enabled_scopes: HashSet::new(),
945 controller_dsl_hooks: Vec::new(),
946 max_depth,
947 content,
948 guard,
949 })
950 }
951
952 fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
956 self.guard
957 .enter()
958 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
959
960 match node.kind() {
961 "class" => self.visit_class(node)?,
962 "module" => self.visit_module(node)?,
963 "singleton_class" => self.visit_singleton_class(node)?,
964 "method" => self.visit_method(node)?,
965 "singleton_method" => self.visit_singleton_method(node)?,
966 "command" | "command_call" | "call" => {
967 self.detect_ffi_extend(node)?;
968 self.detect_controller_dsl(node)?;
969 self.adjust_visibility(node)?;
970 self.walk_children(node)?;
971 }
972 "identifier" => {
973 self.adjust_visibility_from_identifier(node)?;
976 self.walk_children(node)?;
977 }
978 _ => self.walk_children(node)?,
979 }
980
981 self.guard.exit();
982 Ok(())
983 }
984
985 fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
986 let name_node = node
987 .child_by_field_name("name")
988 .ok_or_else(|| "class node missing name".to_string())?;
989 let class_name = self.node_text(name_node)?;
990
991 if self.namespace.len() > self.max_depth {
992 return Ok(());
993 }
994
995 self.namespace.push(class_name);
996 self.visibility_stack.push(Visibility::Public);
997
998 self.walk_children(node)?;
999
1000 self.visibility_stack.pop();
1001 self.namespace.pop();
1002 Ok(())
1003 }
1004
1005 fn visit_module(&mut self, node: Node<'a>) -> Result<(), String> {
1006 let name_node = node
1007 .child_by_field_name("name")
1008 .ok_or_else(|| "module node missing name".to_string())?;
1009 let module_name = self.node_text(name_node)?;
1010
1011 if self.namespace.len() > self.max_depth {
1012 return Ok(());
1013 }
1014
1015 self.namespace.push(module_name);
1016 self.visibility_stack.push(Visibility::Public);
1017
1018 self.walk_children(node)?;
1019
1020 self.visibility_stack.pop();
1021 self.namespace.pop();
1022 Ok(())
1023 }
1024
1025 fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
1026 let name_node = node
1027 .child_by_field_name("name")
1028 .ok_or_else(|| "method node missing name".to_string())?;
1029 let method_name = self.node_text(name_node)?;
1030
1031 let (qualified_name, container) =
1032 method_qualified_name(&self.namespace, &method_name, false);
1033
1034 let visibility = inline_visibility_for_method(node, self.content)
1035 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1036
1037 let context = RubyContext {
1038 qualified_name,
1039 container,
1040 kind: RubyContextKind::Method,
1041 visibility,
1042 start_position: node.start_position(),
1043 end_position: node.end_position(),
1044 };
1045
1046 let idx = self.contexts.len();
1047 self.contexts.push(context);
1048 associate_descendants(node, idx, &mut self.node_to_context);
1049
1050 self.walk_children(node)?;
1051 Ok(())
1052 }
1053
1054 fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
1055 let value_node = node
1057 .child_by_field_name("value")
1058 .ok_or_else(|| "singleton_class missing value".to_string())?;
1059 let object_text = self.node_text(value_node)?;
1060
1061 let scope_name = if object_text == "self" {
1063 if let Some(current_class) = self.namespace.last() {
1065 format!("<<{current_class}>>")
1066 } else {
1067 "<<main>>".to_string()
1068 }
1069 } else {
1070 format!("<<{object_text}>>")
1072 };
1073
1074 if self.namespace.len() > self.max_depth {
1075 return Ok(());
1076 }
1077
1078 self.namespace.push(scope_name);
1080 self.visibility_stack.push(Visibility::Public);
1081
1082 self.visit_singleton_class_body(node)?;
1084
1085 self.visibility_stack.pop();
1087 self.namespace.pop();
1088 Ok(())
1089 }
1090
1091 fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
1092 let mut cursor = node.walk();
1093 for child in node.children(&mut cursor) {
1094 if !child.is_named() {
1095 continue;
1096 }
1097
1098 if child.kind() == "method" {
1100 self.visit_method_as_singleton(child)?;
1101 } else {
1102 self.walk(child)?;
1103 }
1104 }
1105 Ok(())
1106 }
1107
1108 fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
1109 let name_node = node
1110 .child_by_field_name("name")
1111 .ok_or_else(|| "method node missing name".to_string())?;
1112 let method_name = self.node_text(name_node)?;
1113
1114 let actual_namespace: Vec<String> = self
1116 .namespace
1117 .iter()
1118 .map(|s| {
1119 if s.starts_with("<<") && s.ends_with(">>") {
1120 s[2..s.len() - 2].to_string()
1122 } else {
1123 s.clone()
1124 }
1125 })
1126 .collect();
1127
1128 let (qualified_name, container) =
1129 method_qualified_name(&actual_namespace, &method_name, true);
1130
1131 let visibility = inline_visibility_for_method(node, self.content)
1132 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1133
1134 let context = RubyContext {
1135 qualified_name,
1136 container,
1137 kind: RubyContextKind::SingletonMethod,
1138 visibility,
1139 start_position: node.start_position(),
1140 end_position: node.end_position(),
1141 };
1142
1143 let idx = self.contexts.len();
1144 self.contexts.push(context);
1145 associate_descendants(node, idx, &mut self.node_to_context);
1146
1147 self.walk_children(node)?;
1148 Ok(())
1149 }
1150
1151 fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
1152 let name_node = node
1153 .child_by_field_name("name")
1154 .ok_or_else(|| "singleton_method missing name".to_string())?;
1155 let method_name = self.node_text(name_node)?;
1156
1157 let object_node = node
1158 .child_by_field_name("object")
1159 .ok_or_else(|| "singleton_method missing object".to_string())?;
1160 let object_text = self.node_text(object_node)?;
1161
1162 let (qualified_name, container) =
1163 singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
1164
1165 let visibility = inline_visibility_for_method(node, self.content)
1166 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1167
1168 let context = RubyContext {
1169 qualified_name,
1170 container,
1171 kind: RubyContextKind::SingletonMethod,
1172 visibility,
1173 start_position: node.start_position(),
1174 end_position: node.end_position(),
1175 };
1176
1177 let idx = self.contexts.len();
1178 self.contexts.push(context);
1179 associate_descendants(node, idx, &mut self.node_to_context);
1180
1181 self.walk_children(node)?;
1182 Ok(())
1183 }
1184
1185 fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
1186 let name_node = node.child_by_field_name("name");
1187 let Some(name_node) = name_node else {
1188 return Ok(());
1189 };
1190
1191 let keyword = self.node_text(name_node)?;
1192 if keyword.trim() != "extend" {
1193 return Ok(());
1194 }
1195
1196 let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
1197 node_text_raw(arguments, self.content).unwrap_or_default()
1198 } else {
1199 let mut cursor = node.walk();
1200 let mut found_name = false;
1201 let mut result = String::new();
1202 for child in node.children(&mut cursor) {
1203 if !child.is_named() {
1204 continue;
1205 }
1206 if !found_name {
1207 found_name = true;
1208 continue;
1209 }
1210 if let Some(text) = node_text_raw(child, self.content) {
1211 result = text;
1212 break;
1213 }
1214 }
1215 result
1216 };
1217
1218 if arg_text.contains("FFI::Library") {
1219 self.ffi_enabled_scopes.insert(self.namespace.clone());
1221 }
1222
1223 Ok(())
1224 }
1225
1226 fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
1227 let name_node = node
1228 .child_by_field_name("name")
1229 .or_else(|| node.child_by_field_name("method"));
1230 let Some(name_node) = name_node else {
1231 return Ok(());
1232 };
1233 let dsl = self.node_text(name_node)?;
1234
1235 let kind = match dsl.as_str() {
1236 "before_action" => Some(ControllerDslKind::Before),
1237 "after_action" => Some(ControllerDslKind::After),
1238 "around_action" => Some(ControllerDslKind::Around),
1239 _ => None,
1240 };
1241 let Some(kind) = kind else {
1242 return Ok(());
1243 };
1244
1245 if self.namespace.is_empty() {
1246 return Ok(());
1247 }
1248 let container = self.namespace.join("::");
1249
1250 let mut callbacks: Vec<String> = Vec::new();
1251 let mut only: Option<Vec<String>> = None;
1252 let mut except: Option<Vec<String>> = None;
1253
1254 if let Some(arguments) = node.child_by_field_name("arguments") {
1255 let mut cursor = arguments.walk();
1256 for child in arguments.children(&mut cursor) {
1257 if !child.is_named() {
1258 continue;
1259 }
1260 let kind = child.kind();
1261 match kind {
1262 "symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
1263 let mut v = extract_symbols_from_node(child, self.content);
1264 callbacks.append(&mut v);
1265 }
1266 "pair" => {
1267 let key = child.child_by_field_name("key");
1269 let val = child.child_by_field_name("value");
1270 if key.is_none() || val.is_none() {
1271 continue;
1272 }
1273 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1274 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1275 if key_text.contains("only") && !symbols.is_empty() {
1276 only = Some(symbols);
1277 } else if key_text.contains("except") && !symbols.is_empty() {
1278 except = Some(symbols);
1279 }
1280 }
1281 "hash" => {
1282 let mut hcur = child.walk();
1284 for pair in child.children(&mut hcur) {
1285 if !pair.is_named() {
1286 continue;
1287 }
1288 if pair.kind() != "pair" {
1289 continue;
1290 }
1291 let key = pair.child_by_field_name("key");
1292 let val = pair.child_by_field_name("value");
1293 if key.is_none() || val.is_none() {
1294 continue;
1295 }
1296 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1297 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1298 if key_text.contains("only") && !symbols.is_empty() {
1299 only = Some(symbols);
1300 } else if key_text.contains("except") && !symbols.is_empty() {
1301 except = Some(symbols);
1302 }
1303 }
1304 }
1305 _ => {}
1306 }
1307 }
1308 } else {
1309 if let Some(raw) = node_text_raw(node, self.content) {
1311 let (cbs, o, e) = parse_controller_dsl_args(&raw);
1312 callbacks = cbs;
1313 only = o;
1314 except = e;
1315 }
1316 }
1317
1318 if callbacks.is_empty() {
1319 return Ok(());
1320 }
1321
1322 self.controller_dsl_hooks.push(ControllerDslHook {
1323 container,
1324 kind,
1325 callbacks,
1326 only,
1327 except,
1328 });
1329 Ok(())
1330 }
1331
1332 fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
1333 let name_node = node.child_by_field_name("name");
1334 let Some(name_node) = name_node else {
1335 return Ok(());
1336 };
1337
1338 let keyword = self.node_text(name_node)?;
1339 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1340 return Ok(());
1341 };
1342
1343 if !has_call_arguments(node)
1345 && let Some(last) = self.visibility_stack.last_mut()
1346 {
1347 *last = new_visibility;
1348 }
1349 Ok(())
1350 }
1351
1352 fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
1354 let keyword = self.node_text(node)?;
1355 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1356 return Ok(());
1357 };
1358
1359 if let Some(last) = self.visibility_stack.last_mut() {
1361 *last = new_visibility;
1362 }
1363
1364 Ok(())
1365 }
1366
1367 fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
1368 let mut cursor = node.walk();
1369 for child in node.children(&mut cursor) {
1370 if child.is_named() {
1371 self.walk(child)?;
1372 }
1373 }
1374 Ok(())
1375 }
1376
1377 fn node_text(&self, node: Node<'a>) -> Result<String, String> {
1378 node.utf8_text(self.content)
1379 .map(|s| s.trim().to_string())
1380 .map_err(|err| err.to_string())
1381 }
1382}
1383
1384#[derive(Clone)]
1385struct MethodCall<'a> {
1386 name: String,
1387 receiver: Option<String>,
1388 arguments: Option<Node<'a>>,
1389 node: Node<'a>,
1390}
1391
1392fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
1393 let method_name = match node.kind() {
1394 "call" | "command_call" | "method_call" => {
1395 let method_node = node
1396 .child_by_field_name("method")
1397 .ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
1398 node_text(method_node, content)?
1399 }
1400 "command" => {
1401 let name_node = node
1402 .child_by_field_name("name")
1403 .ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
1404 node_text(name_node, content)?
1405 }
1406 "super" => "super".to_string(),
1407 "identifier" => {
1408 if !should_treat_identifier_as_call(node) {
1409 return Ok(None);
1410 }
1411 node_text(node, content)?
1412 }
1413 _ => return Ok(None),
1414 };
1415
1416 let receiver = match node.kind() {
1417 "call" | "command_call" | "method_call" => node
1418 .child_by_field_name("receiver")
1419 .and_then(|r| node_text(r, content).ok()),
1420 _ => None,
1421 };
1422
1423 let arguments = node.child_by_field_name("arguments");
1424
1425 Ok(Some(MethodCall {
1426 name: method_name,
1427 receiver,
1428 arguments,
1429 node,
1430 }))
1431}
1432
1433fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
1434 if let Some(parent) = node.parent() {
1435 let kind = parent.kind();
1436 if matches!(
1437 kind,
1438 "call"
1439 | "command"
1440 | "command_call"
1441 | "method_call"
1442 | "method"
1443 | "singleton_method"
1444 | "alias"
1445 | "symbol"
1446 ) {
1447 return false;
1448 }
1449
1450 if kind.contains("assignment")
1451 || matches!(
1452 kind,
1453 "parameters"
1454 | "method_parameters"
1455 | "block_parameters"
1456 | "lambda_parameters"
1457 | "constant_path"
1458 | "module"
1459 | "class"
1460 | "hash"
1461 | "pair"
1462 | "array"
1463 | "argument_list"
1464 )
1465 {
1466 return false;
1467 }
1468 }
1469
1470 true
1471}
1472
1473fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
1487 let name = method_call.name.trim();
1488 if name.is_empty() {
1489 return String::new();
1490 }
1491
1492 if name == "super" {
1494 return format!("super::{}", context.qualified_name());
1498 }
1499
1500 if let Some(receiver) = method_call.receiver.as_deref() {
1501 let receiver = receiver.trim();
1502 if receiver == "self" {
1503 if let Some(container) = context.container() {
1504 return format!("{container}.{name}");
1505 }
1506 return format!("self.{name}");
1507 }
1508
1509 if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
1510 let cleaned = receiver.trim_start_matches("::");
1511 if let Some(class_name) = cleaned.strip_suffix(".new") {
1513 return format!("{class_name}#{name}");
1514 }
1515 return format!("{cleaned}.{name}");
1516 }
1517
1518 return name.to_string();
1520 }
1521
1522 if context.is_singleton() {
1523 if let Some(container) = context.container() {
1524 return format!("{container}.{name}");
1525 }
1526 return name.to_string();
1527 }
1528
1529 if let Some(container) = context.container() {
1530 return format!("{container}#{name}");
1531 }
1532
1533 name.to_string()
1534}
1535
1536fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
1548 let Some(arguments) = arguments else {
1549 return 0;
1550 };
1551
1552 let mut count = 0;
1553 let mut cursor = arguments.walk();
1554 for child in arguments.children(&mut cursor) {
1555 if child.is_named()
1556 && !is_literal_delimiter(child.kind())
1557 && node_text(child, content)
1558 .map(|s| !s.trim().is_empty())
1559 .unwrap_or(false)
1560 {
1561 count += 1;
1562 }
1563 }
1564 count
1565}
1566
1567fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
1578 let mut stack = vec![node];
1579 while let Some(current) = stack.pop() {
1580 map.insert(current.id(), idx);
1581 let mut cursor = current.walk();
1582 for child in current.children(&mut cursor) {
1583 stack.push(child);
1584 }
1585 }
1586}
1587
1588fn method_qualified_name(
1602 namespace: &[String],
1603 method_name: &str,
1604 singleton: bool,
1605) -> (String, Option<String>) {
1606 if namespace.is_empty() {
1607 return (method_name.to_string(), None);
1608 }
1609
1610 let container = namespace.join("::");
1611 let qualified = if singleton {
1612 format!("{container}.{method_name}")
1613 } else {
1614 format!("{container}#{method_name}")
1615 };
1616 (qualified, Some(container))
1617}
1618
1619fn singleton_qualified_name(
1632 current_namespace: &[String],
1633 object_text: &str,
1634 method_name: &str,
1635) -> (String, Option<String>) {
1636 if object_text == "self" {
1637 if current_namespace.is_empty() {
1638 (method_name.to_string(), None)
1639 } else {
1640 let container = current_namespace.join("::");
1641 (format!("{container}.{method_name}"), Some(container))
1642 }
1643 } else {
1644 let parts = split_constant_path(object_text);
1645 if parts.is_empty() {
1646 (method_name.to_string(), None)
1647 } else {
1648 let container = parts.join("::");
1649 (format!("{container}.{method_name}"), Some(container))
1650 }
1651 }
1652}
1653
1654fn split_constant_path(path: &str) -> Vec<String> {
1668 path.trim()
1669 .trim_start_matches("::")
1670 .split("::")
1671 .filter_map(|seg| {
1672 let trimmed = seg.trim();
1673 if trimmed.is_empty() {
1674 None
1675 } else {
1676 Some(trimmed.to_string())
1677 }
1678 })
1679 .collect()
1680}
1681
1682fn is_constant(text: &str) -> bool {
1692 text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1693}
1694
1695fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
1706 matches!(
1707 method_call.name.as_str(),
1708 "public" | "private" | "protected"
1709 ) && method_call.receiver.is_none()
1710 && !has_call_arguments(method_call.node)
1711}
1712
1713fn has_call_arguments(node: Node<'_>) -> bool {
1723 if let Some(arguments) = node.child_by_field_name("arguments") {
1724 let mut cursor = arguments.walk();
1725 for child in arguments.children(&mut cursor) {
1726 if child.is_named() {
1727 return true;
1728 }
1729 }
1730 }
1731 false
1732}
1733
1734fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
1735 let parent = node.parent()?;
1736 let visibility_node = match parent.kind() {
1737 "call" | "command" | "command_call" => parent,
1738 "argument_list" => parent.parent()?,
1739 _ => return None,
1740 };
1741
1742 if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
1743 return None;
1744 }
1745
1746 let keyword_node = visibility_node
1747 .child_by_field_name("name")
1748 .or_else(|| visibility_node.child_by_field_name("method"))?;
1749 let keyword = node_text_raw(keyword_node, content)?;
1750 Visibility::from_keyword(keyword.trim())
1751}
1752
1753fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
1764 node.utf8_text(content)
1765 .map(|s| s.trim().to_string())
1766 .map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
1767}
1768
1769fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
1771 node.utf8_text(content)
1772 .ok()
1773 .map(std::string::ToString::to_string)
1774}
1775
1776fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
1785 GraphBuilderError::ParseError {
1786 span: span_from_node(node),
1787 reason: reason.to_string(),
1788 }
1789}
1790
1791#[allow(clippy::match_same_arms)]
1808fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
1809 let mut params = Vec::new();
1810 let mut cursor = params_node.walk();
1811
1812 for child in params_node.named_children(&mut cursor) {
1813 match child.kind() {
1814 "identifier" | "optional_parameter" => {
1817 if let Ok(text) = child.utf8_text(content) {
1818 params.push(text.to_string());
1819 }
1820 }
1821 "splat_parameter" => {
1823 if let Some(name_node) = child.child_by_field_name("name") {
1824 if let Ok(name) = name_node.utf8_text(content) {
1825 params.push(format!("*{name}"));
1826 }
1827 } else if let Ok(text) = child.utf8_text(content) {
1828 params.push(text.to_string());
1830 }
1831 }
1832 "hash_splat_parameter" => {
1834 if let Some(name_node) = child.child_by_field_name("name") {
1835 if let Ok(name) = name_node.utf8_text(content) {
1836 params.push(format!("**{name}"));
1837 }
1838 } else if let Ok(text) = child.utf8_text(content) {
1839 params.push(text.to_string());
1841 }
1842 }
1843 "block_parameter" => {
1845 if let Some(name_node) = child.child_by_field_name("name") {
1846 if let Ok(name) = name_node.utf8_text(content) {
1847 params.push(format!("&{name}"));
1848 }
1849 } else if let Ok(text) = child.utf8_text(content) {
1850 params.push(text.to_string());
1852 }
1853 }
1854 "keyword_parameter" => {
1856 if let Ok(text) = child.utf8_text(content) {
1857 params.push(text.to_string());
1858 }
1859 }
1860 "destructured_parameter" => {
1862 if let Ok(text) = child.utf8_text(content) {
1863 params.push(text.to_string());
1864 }
1865 }
1866 "forward_parameter" => {
1868 params.push("...".to_string());
1869 }
1870 "hash_splat_nil" => {
1872 params.push("**nil".to_string());
1873 }
1874 _ => {
1875 }
1877 }
1878 }
1879
1880 if params.is_empty() {
1881 None
1882 } else {
1883 Some(params.join(", "))
1884 }
1885}
1886
1887fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1901 if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
1903 return Some(return_type);
1904 }
1905
1906 if let Some(return_type) = extract_rbs_return_type(method_node, content) {
1908 return Some(return_type);
1909 }
1910
1911 if let Some(return_type) = extract_yard_return_type(method_node, content) {
1913 return Some(return_type);
1914 }
1915
1916 None
1917}
1918
1919fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1930 let mut sibling = method_node.prev_sibling()?;
1932
1933 while sibling.kind() == "comment" {
1935 sibling = sibling.prev_sibling()?;
1936 }
1937
1938 if sibling.kind() == "call"
1940 && let Some(method_name) = sibling.child_by_field_name("method")
1941 && let Ok(name_text) = method_name.utf8_text(content)
1942 && name_text == "sig"
1943 {
1944 if let Some(block_node) = sibling.child_by_field_name("block") {
1946 return extract_returns_from_sig_block(block_node, content);
1947 }
1948 }
1949
1950 None
1951}
1952
1953fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
1955 let mut cursor = block_node.walk();
1956
1957 for child in block_node.named_children(&mut cursor) {
1958 if child.kind() == "call"
1959 && let Some(method_name) = child.child_by_field_name("method")
1960 && let Ok(name_text) = method_name.utf8_text(content)
1961 && name_text == "returns"
1962 {
1963 if let Some(args) = child.child_by_field_name("arguments") {
1965 let mut args_cursor = args.walk();
1966 for arg in args.named_children(&mut args_cursor) {
1967 if arg.kind() != ","
1968 && let Ok(type_text) = arg.utf8_text(content)
1969 {
1970 return Some(type_text.to_string());
1971 }
1972 }
1973 }
1974 }
1975 if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
1977 return Some(nested_type);
1978 }
1979 }
1980
1981 None
1982}
1983
1984fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1995 let mut cursor = method_node.walk();
1997 for child in method_node.children(&mut cursor) {
1998 if child.kind() == "comment"
1999 && let Ok(comment_text) = child.utf8_text(content)
2000 {
2001 if comment_text.trim_start().starts_with("#:") {
2004 if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
2006 let return_part = &comment_text[arrow_pos + 2..];
2007 let return_type = return_part.trim().to_string();
2008 if !return_type.is_empty() {
2009 return Some(return_type);
2010 }
2011 }
2012 }
2013 }
2014 }
2015
2016 None
2017}
2018
2019fn find_top_level_arrow(text: &str) -> Option<usize> {
2023 let chars: Vec<char> = text.chars().collect();
2024 let mut depth: i32 = 0;
2025 let mut i = 0;
2026
2027 while i < chars.len() {
2028 match chars[i] {
2029 '(' | '[' | '{' => depth += 1,
2030 ')' | ']' | '}' => depth = depth.saturating_sub(1),
2031 '-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
2032 return Some(i);
2033 }
2034 _ => {}
2035 }
2036 i += 1;
2037 }
2038
2039 None
2040}
2041
2042fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2053 let mut sibling_opt = method_node.prev_sibling();
2055 let method_start_row = method_node.start_position().row;
2056
2057 let mut comments = Vec::new();
2059 let mut expected_row = method_start_row;
2060
2061 while let Some(sibling) = sibling_opt {
2062 if sibling.kind() == "comment" {
2063 let comment_end_row = sibling.end_position().row;
2064
2065 if comment_end_row + 1 >= expected_row {
2068 if let Ok(comment_text) = sibling.utf8_text(content) {
2069 comments.push(comment_text);
2070 }
2071 expected_row = sibling.start_position().row;
2072 sibling_opt = sibling.prev_sibling();
2073 } else {
2074 break;
2076 }
2077 } else {
2078 break;
2079 }
2080 }
2081
2082 for comment in comments.iter().rev() {
2084 if let Some(return_pos) = comment.find("@return") {
2085 let after_return = &comment[return_pos + 7..];
2086 if let Some(start_bracket) = after_return.find('[')
2088 && let Some(end_bracket) = after_return.find(']')
2089 && end_bracket > start_bracket
2090 {
2091 let return_type = &after_return[start_bracket + 1..end_bracket];
2092 return Some(return_type.trim().to_string());
2093 }
2094 }
2095 }
2096
2097 None
2098}
2099
2100fn span_from_node(node: Node<'_>) -> Span {
2108 span_from_points(node.start_position(), node.end_position())
2109}
2110
2111fn span_from_points(start: Point, end: Point) -> Span {
2120 Span::new(
2121 Position::new(start.row, start.column),
2122 Position::new(end.row, end.column),
2123 )
2124}
2125
2126fn is_literal_delimiter(kind: &str) -> bool {
2136 matches!(kind, "," | "(" | ")" | "[" | "]")
2137}
2138
2139fn parse_controller_dsl_args(
2142 text: &str,
2143) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
2144 let mut head = text;
2146 let mut tail = "";
2147 if let Some(idx) = text.find("only:") {
2148 head = &text[..idx];
2149 tail = &text[idx..];
2150 } else if let Some(idx) = text.find("except:") {
2151 head = &text[..idx];
2152 tail = &text[idx..];
2153 }
2154 let callbacks = extract_symbol_list_from_args(head);
2155 let only = extract_kw_symbol_list(tail, "only:");
2156 let except = extract_kw_symbol_list(tail, "except:");
2157 (callbacks, only, except)
2158}
2159
2160fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
2161 let mut out = Vec::new();
2162 let bytes = text.as_bytes();
2163 let mut i = 0;
2164 while i < bytes.len() {
2165 if bytes[i] == b':' {
2166 let start = i + 1;
2167 let mut j = start;
2168 while j < bytes.len() {
2169 let c = bytes[j] as char;
2170 if c.is_ascii_alphanumeric() || c == '_' {
2171 j += 1;
2172 } else {
2173 break;
2174 }
2175 }
2176 if j > start {
2177 out.push(text[start..j].to_string());
2178 i = j;
2179 continue;
2180 }
2181 }
2182 i += 1;
2183 }
2184 out
2185}
2186
2187fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
2188 let pos = text.find(kw)?;
2189 let mut after = &text[pos + kw.len()..];
2190 after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
2192 if after.starts_with('[')
2193 && let Some(end) = after.find(']')
2194 {
2195 return Some(extract_symbol_list_from_args(&after[..=end]));
2196 }
2197 if let Some(colon) = after.find(':') {
2199 let mut j = colon + 1;
2200 while j < after.len() {
2201 let ch = after.as_bytes()[j] as char;
2202 if ch.is_ascii_alphanumeric() || ch == '_' {
2203 j += 1;
2204 } else {
2205 break;
2206 }
2207 }
2208 if j > colon + 1 {
2209 return Some(vec![after[colon + 1..j].to_string()]);
2210 }
2211 }
2212 None
2213}
2214
2215fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
2216 let mut out = Vec::new();
2217 match node.kind() {
2218 "symbol" | "simple_symbol" => {
2219 if let Ok(t) = node_text(node, content) {
2220 out.push(t.trim_start_matches(':').to_string());
2221 }
2222 }
2223 "array" => {
2224 let mut c = node.walk();
2225 for ch in node.children(&mut c) {
2226 if matches!(ch.kind(), "symbol" | "simple_symbol")
2227 && let Ok(t) = node_text(ch, content)
2228 {
2229 out.push(t.trim_start_matches(':').to_string());
2230 }
2231 }
2232 }
2233 _ => {
2234 if let Some(txt) = node_text_raw(node, content) {
2236 out = extract_symbol_list_from_args(&txt);
2237 }
2238 }
2239 }
2240 out
2241}
2242
2243fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
2245 let mut cursor = arguments.walk();
2246 for child in arguments.children(&mut cursor) {
2247 if !child.is_named() {
2248 continue;
2249 }
2250 if let Some(s) = extract_string_content(child, content) {
2251 return Some(s);
2252 }
2253 }
2254 None
2255}
2256
2257fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
2259 let text = node.utf8_text(content).ok()?;
2260 let trimmed = text.trim();
2261
2262 if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
2264 || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
2265 && trimmed.len() >= 2
2266 {
2267 return Some(trimmed[1..trimmed.len() - 1].to_string());
2268 }
2269
2270 if matches!(node.kind(), "string" | "chained_string") {
2272 let mut cursor = node.walk();
2273 for child in node.children(&mut cursor) {
2274 if child.kind() == "string_content"
2275 && let Ok(s) = child.utf8_text(content)
2276 {
2277 return Some(s.to_string());
2278 }
2279 }
2280 }
2281
2282 None
2283}
2284
2285pub(crate) fn resolve_ruby_require(
2296 module_name: &str,
2297 is_relative: bool,
2298 source_file: &str,
2299) -> String {
2300 if is_relative {
2301 let source_path = std::path::Path::new(source_file);
2305 let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
2306
2307 let relative_path = std::path::Path::new(module_name);
2309 let resolved = source_dir.join(relative_path);
2310
2311 let normalized = normalize_path(&resolved);
2313
2314 let path_str = normalized.to_string_lossy();
2317 let separators: &[char] = &['/', '\\'];
2318 path_str
2319 .split(separators)
2320 .filter(|s| !s.is_empty())
2321 .collect::<Vec<_>>()
2322 .join("::")
2323 } else {
2324 module_name.replace('/', "::")
2327 }
2328}
2329
2330fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
2332 let mut components = Vec::new();
2333
2334 for component in path.components() {
2335 match component {
2336 std::path::Component::CurDir => {
2337 }
2339 std::path::Component::ParentDir => {
2340 if components
2342 .last()
2343 .is_some_and(|c| *c != std::path::Component::ParentDir)
2344 {
2345 components.pop();
2346 } else {
2347 components.push(component);
2348 }
2349 }
2350 _ => {
2351 components.push(component);
2352 }
2353 }
2354 }
2355
2356 components.iter().collect()
2357}
2358
2359fn process_yard_annotations(
2366 node: Node,
2367 content: &[u8],
2368 helper: &mut GraphBuildHelper,
2369) -> GraphResult<()> {
2370 match node.kind() {
2371 "method" => {
2372 process_method_yard(node, content, helper)?;
2373 }
2374 "singleton_method" => {
2375 process_singleton_method_yard(node, content, helper)?;
2376 }
2377 "call" | "command" | "command_call" => {
2378 if is_attr_call(node, content) {
2380 process_attr_yard(node, content, helper)?;
2381 }
2382 }
2383 "assignment" => {
2384 if is_instance_variable_assignment(node, content) {
2386 process_assignment_yard(node, content, helper)?;
2387 }
2388 }
2389 _ => {}
2390 }
2391
2392 let mut cursor = node.walk();
2394 for child in node.children(&mut cursor) {
2395 process_yard_annotations(child, content, helper)?;
2396 }
2397
2398 Ok(())
2399}
2400
2401fn process_method_yard(
2403 method_node: Node,
2404 content: &[u8],
2405 helper: &mut GraphBuildHelper,
2406) -> GraphResult<()> {
2407 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2409 return Ok(());
2410 };
2411
2412 let tags = parse_yard_tags(&yard_text);
2414
2415 let Some(name_node) = method_node.child_by_field_name("name") else {
2417 return Ok(());
2418 };
2419
2420 let method_name = name_node
2421 .utf8_text(content)
2422 .map_err(|_| GraphBuilderError::ParseError {
2423 span: span_from_node(method_node),
2424 reason: "failed to read method name".to_string(),
2425 })?
2426 .trim()
2427 .to_string();
2428
2429 if method_name.is_empty() {
2430 return Ok(());
2431 }
2432
2433 let class_name = get_enclosing_class_name(method_node, content);
2435
2436 let qualified_name = if let Some(class_name) = class_name {
2438 format!("{class_name}#{method_name}")
2439 } else {
2440 method_name.clone()
2441 };
2442
2443 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
2445
2446 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2448 let canonical_type = canonical_type_string(¶m_tag.type_str);
2450 let type_node_id = helper.add_type(&canonical_type, None);
2451 helper.add_typeof_edge_with_context(
2452 method_node_id,
2453 type_node_id,
2454 Some(TypeOfContext::Parameter),
2455 param_idx.try_into().ok(),
2456 Some(¶m_tag.name),
2457 );
2458
2459 let type_names = extract_type_names(¶m_tag.type_str);
2461 for type_name in type_names {
2462 let ref_type_id = helper.add_type(&type_name, None);
2463 helper.add_reference_edge(method_node_id, ref_type_id);
2464 }
2465 }
2466
2467 if let Some(return_type) = &tags.returns {
2469 let canonical_type = canonical_type_string(return_type);
2470 let type_node_id = helper.add_type(&canonical_type, None);
2471 helper.add_typeof_edge_with_context(
2472 method_node_id,
2473 type_node_id,
2474 Some(TypeOfContext::Return),
2475 Some(0),
2476 None,
2477 );
2478
2479 let type_names = extract_type_names(return_type);
2481 for type_name in type_names {
2482 let ref_type_id = helper.add_type(&type_name, None);
2483 helper.add_reference_edge(method_node_id, ref_type_id);
2484 }
2485 }
2486
2487 Ok(())
2488}
2489
2490fn process_singleton_method_yard(
2492 method_node: Node,
2493 content: &[u8],
2494 helper: &mut GraphBuildHelper,
2495) -> GraphResult<()> {
2496 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2498 return Ok(());
2499 };
2500
2501 let tags = parse_yard_tags(&yard_text);
2503
2504 let Some(name_node) = method_node.child_by_field_name("name") else {
2506 return Ok(());
2507 };
2508
2509 let method_name = name_node
2510 .utf8_text(content)
2511 .map_err(|_| GraphBuilderError::ParseError {
2512 span: span_from_node(method_node),
2513 reason: "failed to read method name".to_string(),
2514 })?
2515 .trim()
2516 .to_string();
2517
2518 if method_name.is_empty() {
2519 return Ok(());
2520 }
2521
2522 let class_name = get_enclosing_class_name(method_node, content);
2524
2525 let qualified_name = if let Some(class_name) = class_name {
2527 format!("{class_name}.{method_name}")
2528 } else {
2529 method_name.clone()
2530 };
2531
2532 let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
2534
2535 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2537 let canonical_type = canonical_type_string(¶m_tag.type_str);
2539 let type_node_id = helper.add_type(&canonical_type, None);
2540 helper.add_typeof_edge_with_context(
2541 method_node_id,
2542 type_node_id,
2543 Some(TypeOfContext::Parameter),
2544 param_idx.try_into().ok(),
2545 Some(¶m_tag.name),
2546 );
2547
2548 let type_names = extract_type_names(¶m_tag.type_str);
2550 for type_name in type_names {
2551 let ref_type_id = helper.add_type(&type_name, None);
2552 helper.add_reference_edge(method_node_id, ref_type_id);
2553 }
2554 }
2555
2556 if let Some(return_type) = &tags.returns {
2558 let canonical_type = canonical_type_string(return_type);
2559 let type_node_id = helper.add_type(&canonical_type, None);
2560 helper.add_typeof_edge_with_context(
2561 method_node_id,
2562 type_node_id,
2563 Some(TypeOfContext::Return),
2564 Some(0),
2565 None,
2566 );
2567
2568 let type_names = extract_type_names(return_type);
2570 for type_name in type_names {
2571 let ref_type_id = helper.add_type(&type_name, None);
2572 helper.add_reference_edge(method_node_id, ref_type_id);
2573 }
2574 }
2575
2576 Ok(())
2577}
2578
2579#[allow(clippy::unnecessary_wraps)]
2581fn process_attr_yard(
2582 attr_node: Node,
2583 content: &[u8],
2584 helper: &mut GraphBuildHelper,
2585) -> GraphResult<()> {
2586 let Some(yard_text) = extract_yard_comment(attr_node, content) else {
2588 return Ok(());
2589 };
2590
2591 let tags = parse_yard_tags(&yard_text);
2593
2594 let Some(var_type) = &tags.returns else {
2596 return Ok(());
2597 };
2598
2599 let attr_names = extract_attr_names(attr_node, content);
2601
2602 if attr_names.is_empty() {
2603 return Ok(());
2604 }
2605
2606 let class_name = get_enclosing_class_name(attr_node, content);
2608
2609 for attr_name in attr_names {
2611 let qualified_name = if let Some(ref class) = class_name {
2613 format!("{class}#{attr_name}")
2614 } else {
2615 attr_name.clone()
2616 };
2617
2618 let attr_node_id = helper.add_variable(&qualified_name, None);
2620
2621 let canonical_type = canonical_type_string(var_type);
2623 let type_node_id = helper.add_type(&canonical_type, None);
2624 helper.add_typeof_edge_with_context(
2625 attr_node_id,
2626 type_node_id,
2627 Some(TypeOfContext::Field),
2628 None,
2629 Some(&attr_name),
2630 );
2631
2632 let type_names = extract_type_names(var_type);
2634 for type_name in type_names {
2635 let ref_type_id = helper.add_type(&type_name, None);
2636 helper.add_reference_edge(attr_node_id, ref_type_id);
2637 }
2638 }
2639
2640 Ok(())
2641}
2642
2643fn process_assignment_yard(
2645 assignment_node: Node,
2646 content: &[u8],
2647 helper: &mut GraphBuildHelper,
2648) -> GraphResult<()> {
2649 let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
2651 return Ok(());
2652 };
2653
2654 let tags = parse_yard_tags(&yard_text);
2656
2657 let Some(var_type) = &tags.type_annotation else {
2659 return Ok(());
2660 };
2661
2662 let Some(left_node) = assignment_node.child_by_field_name("left") else {
2664 return Ok(());
2665 };
2666
2667 if left_node.kind() != "instance_variable" {
2668 return Ok(());
2669 }
2670
2671 let var_name = left_node
2672 .utf8_text(content)
2673 .map_err(|_| GraphBuilderError::ParseError {
2674 span: span_from_node(assignment_node),
2675 reason: "failed to read variable name".to_string(),
2676 })?
2677 .trim()
2678 .to_string();
2679
2680 if var_name.is_empty() {
2681 return Ok(());
2682 }
2683
2684 let class_name = get_enclosing_class_name(assignment_node, content);
2686
2687 let qualified_name = if let Some(class) = class_name {
2689 format!("{class}#{var_name}")
2690 } else {
2691 var_name.clone()
2692 };
2693
2694 let var_node_id = helper.add_variable(&qualified_name, None);
2696
2697 let canonical_type = canonical_type_string(var_type);
2699 let type_node_id = helper.add_type(&canonical_type, None);
2700 helper.add_typeof_edge_with_context(
2701 var_node_id,
2702 type_node_id,
2703 Some(TypeOfContext::Variable),
2704 None,
2705 Some(&var_name),
2706 );
2707
2708 let type_names = extract_type_names(var_type);
2710 for type_name in type_names {
2711 let ref_type_id = helper.add_type(&type_name, None);
2712 helper.add_reference_edge(var_node_id, ref_type_id);
2713 }
2714
2715 Ok(())
2716}
2717
2718fn is_attr_call(node: Node, content: &[u8]) -> bool {
2720 let method_name = match node.kind() {
2721 "command" => node
2722 .child_by_field_name("name")
2723 .and_then(|n| n.utf8_text(content).ok()),
2724 "call" | "command_call" => node
2725 .child_by_field_name("method")
2726 .and_then(|n| n.utf8_text(content).ok()),
2727 _ => None,
2728 };
2729
2730 method_name
2731 .is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
2732}
2733
2734fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
2736 if let Some(left_node) = node.child_by_field_name("left") {
2737 left_node.kind() == "instance_variable"
2738 } else {
2739 false
2740 }
2741}
2742
2743fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
2746 let mut names = Vec::new();
2747
2748 let arguments = attr_node.child_by_field_name("arguments");
2750
2751 if let Some(args) = arguments {
2752 let mut cursor = args.walk();
2754 for child in args.children(&mut cursor) {
2755 if matches!(child.kind(), "symbol" | "simple_symbol")
2756 && let Ok(text) = child.utf8_text(content)
2757 {
2758 let cleaned = text.trim().trim_start_matches(':');
2759 if !cleaned.is_empty() {
2760 names.push(cleaned.to_string());
2761 }
2762 } else if child.kind() == "string"
2763 && let Ok(text) = child.utf8_text(content)
2764 {
2765 let cleaned = text
2767 .trim()
2768 .trim_start_matches(['\'', '"'])
2769 .trim_end_matches(['\'', '"']);
2770 if !cleaned.is_empty() {
2771 names.push(cleaned.to_string());
2772 }
2773 }
2774 }
2775 } else if matches!(attr_node.kind(), "command" | "command_call") {
2776 let mut cursor = attr_node.walk();
2778 let mut found_method = false;
2779 for child in attr_node.children(&mut cursor) {
2780 if !child.is_named() {
2781 continue;
2782 }
2783 if !found_method {
2785 found_method = true;
2786 continue;
2787 }
2788 if matches!(child.kind(), "symbol" | "simple_symbol")
2790 && let Ok(text) = child.utf8_text(content)
2791 {
2792 let cleaned = text.trim().trim_start_matches(':');
2793 if !cleaned.is_empty() {
2794 names.push(cleaned.to_string());
2795 }
2796 } else if child.kind() == "string"
2797 && let Ok(text) = child.utf8_text(content)
2798 {
2799 let cleaned = text
2801 .trim()
2802 .trim_start_matches(['\'', '"'])
2803 .trim_end_matches(['\'', '"']);
2804 if !cleaned.is_empty() {
2805 names.push(cleaned.to_string());
2806 }
2807 }
2808 }
2809 }
2810
2811 names
2812}
2813
2814fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
2819 let mut current = node;
2820 let mut namespace_parts = Vec::new();
2821
2822 while let Some(parent) = current.parent() {
2824 if matches!(parent.kind(), "class" | "module") {
2825 if let Some(name_node) = parent.child_by_field_name("name")
2827 && let Ok(name_text) = name_node.utf8_text(content)
2828 {
2829 let trimmed = name_text.trim();
2830 if trimmed.starts_with("::") {
2832 namespace_parts.clear();
2834 namespace_parts.push(trimmed.trim_start_matches("::").to_string());
2835 break;
2836 }
2837 namespace_parts.insert(0, trimmed.to_string());
2839 }
2840 }
2841 current = parent;
2842 }
2843
2844 if namespace_parts.is_empty() {
2846 None
2847 } else {
2848 Some(namespace_parts.join("::"))
2849 }
2850}