1use std::{
2 collections::{HashMap, HashSet},
3 path::Path,
4};
5
6use sqry_core::graph::unified::edge::FfiConvention;
7use sqry_core::graph::unified::edge::kind::TypeOfContext;
8use sqry_core::graph::unified::{GraphBuildHelper, StagingGraph};
9use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
10use tree_sitter::{Node, Point, Tree};
11
12use super::type_extractor::{canonical_type_string, extract_type_names};
13use super::yard_parser::{extract_yard_comment, parse_yard_tags};
14
15const DEFAULT_SCOPE_DEPTH: usize = 4;
16
17const FILE_MODULE_NAME: &str = "<file_module>";
20
21type CallEdgeData = (String, String, usize, Span, bool);
22
23#[derive(Debug, Clone, Copy)]
29pub struct RubyGraphBuilder {
30 max_scope_depth: usize,
31}
32
33impl Default for RubyGraphBuilder {
34 fn default() -> Self {
35 Self {
36 max_scope_depth: DEFAULT_SCOPE_DEPTH,
37 }
38 }
39}
40
41impl RubyGraphBuilder {
42 #[must_use]
44 pub fn new(max_scope_depth: usize) -> Self {
45 Self { max_scope_depth }
46 }
47}
48
49impl GraphBuilder for RubyGraphBuilder {
50 fn build_graph(
51 &self,
52 tree: &Tree,
53 content: &[u8],
54 file: &Path,
55 staging: &mut StagingGraph,
56 ) -> GraphResult<()> {
57 let mut helper = GraphBuildHelper::new(staging, file, Language::Ruby);
59
60 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
62 GraphBuilderError::ParseError {
63 span: Span::default(),
64 reason: e,
65 }
66 })?;
67
68 walk_tree_for_graph(
70 tree.root_node(),
71 content,
72 &ast_graph,
73 &mut helper,
74 &ast_graph.ffi_enabled_scopes,
75 )?;
76
77 apply_controller_dsl_hooks(&ast_graph, &mut helper);
78
79 process_yard_annotations(tree.root_node(), content, &mut helper)?;
81
82 Ok(())
83 }
84
85 fn language(&self) -> Language {
86 Language::Ruby
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum Visibility {
92 Public,
93 Protected,
94 Private,
95}
96
97impl Visibility {
98 #[allow(dead_code)] fn as_str(self) -> &'static str {
100 match self {
101 Visibility::Public => "public",
102 Visibility::Protected => "protected",
103 Visibility::Private => "private",
104 }
105 }
106
107 fn from_keyword(keyword: &str) -> Option<Self> {
108 match keyword {
109 "public" => Some(Visibility::Public),
110 "protected" => Some(Visibility::Protected),
111 "private" => Some(Visibility::Private),
112 _ => None,
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
118enum RubyContextKind {
119 Method,
120 SingletonMethod,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum ControllerDslKind {
125 Before,
126 After,
127 Around,
128}
129
130#[allow(dead_code)] #[derive(Debug, Clone)]
132struct ControllerDslHook {
133 container: String,
134 kind: ControllerDslKind,
135 callbacks: Vec<String>,
136 only: Option<Vec<String>>, except: Option<Vec<String>>, }
139
140#[derive(Debug, Clone)]
141struct RubyContext {
142 qualified_name: String,
143 container: Option<String>,
144 kind: RubyContextKind,
145 visibility: Visibility,
146 start_position: Point,
147 end_position: Point,
148}
149
150impl RubyContext {
151 #[allow(dead_code)] fn is_method(&self) -> bool {
153 matches!(
154 self.kind,
155 RubyContextKind::Method | RubyContextKind::SingletonMethod
156 )
157 }
158
159 fn is_singleton(&self) -> bool {
160 matches!(self.kind, RubyContextKind::SingletonMethod)
161 }
162
163 fn qualified_name(&self) -> &str {
164 &self.qualified_name
165 }
166
167 fn container(&self) -> Option<&str> {
168 self.container.as_deref()
169 }
170
171 fn visibility(&self) -> Visibility {
172 self.visibility
173 }
174}
175
176struct ASTGraph {
177 contexts: Vec<RubyContext>,
178 node_to_context: HashMap<usize, usize>,
179 ffi_enabled_scopes: HashSet<Vec<String>>,
181 #[allow(dead_code)] controller_dsl_hooks: Vec<ControllerDslHook>,
183}
184
185impl ASTGraph {
186 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
187 let mut builder = ContextBuilder::new(content, max_depth)?;
188 builder.walk(tree.root_node())?;
189 Ok(Self {
190 contexts: builder.contexts,
191 node_to_context: builder.node_to_context,
192 ffi_enabled_scopes: builder.ffi_enabled_scopes,
193 controller_dsl_hooks: builder.controller_dsl_hooks,
194 })
195 }
196
197 #[allow(dead_code)] fn contexts(&self) -> &[RubyContext] {
199 &self.contexts
200 }
201
202 fn context_for_node(&self, node: &Node<'_>) -> Option<&RubyContext> {
203 self.node_to_context
204 .get(&node.id())
205 .and_then(|idx| self.contexts.get(*idx))
206 }
207}
208
209fn walk_tree_for_graph(
211 node: Node,
212 content: &[u8],
213 ast_graph: &ASTGraph,
214 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
215 ffi_enabled_scopes: &HashSet<Vec<String>>,
216) -> GraphResult<()> {
217 let mut current_namespace: Vec<String> = Vec::new();
219
220 walk_tree_for_graph_impl(
221 node,
222 content,
223 ast_graph,
224 helper,
225 ffi_enabled_scopes,
226 &mut current_namespace,
227 )
228}
229
230fn apply_controller_dsl_hooks(ast_graph: &ASTGraph, helper: &mut GraphBuildHelper) {
231 if ast_graph.controller_dsl_hooks.is_empty() {
232 return;
233 }
234
235 let mut actions_by_container: HashMap<String, Vec<String>> = HashMap::new();
236 for context in &ast_graph.contexts {
237 if !matches!(context.kind, RubyContextKind::Method) {
238 continue;
239 }
240 let Some(container) = context.container() else {
241 continue;
242 };
243 let Some(action_name) = context.qualified_name.rsplit('#').next() else {
244 continue;
245 };
246 actions_by_container
247 .entry(container.to_string())
248 .or_default()
249 .push(action_name.to_string());
250 }
251
252 let mut emitted: HashSet<(String, String)> = HashSet::new();
253 for hook in &ast_graph.controller_dsl_hooks {
254 let Some(actions) = actions_by_container.get(&hook.container) else {
255 continue;
256 };
257
258 for action in actions {
259 let included = if let Some(only) = &hook.only {
260 only.iter().any(|name| name == action)
261 } else if let Some(except) = &hook.except {
262 !except.iter().any(|name| name == action)
263 } else {
264 true
265 };
266
267 if !included {
268 continue;
269 }
270
271 for callback in &hook.callbacks {
272 if callback.trim().is_empty() {
273 continue;
274 }
275
276 let action_qname = format!("{}#{}", hook.container, action);
277 let callback_qname = format!("{}#{}", hook.container, callback);
278 if !emitted.insert((action_qname.clone(), callback_qname.clone())) {
279 continue;
280 }
281
282 let action_id = helper.ensure_method(&action_qname, None, false, false);
283 let callback_id = helper.ensure_method(&callback_qname, None, false, false);
284 helper.add_call_edge_full_with_span(action_id, callback_id, 255, false, vec![]);
285 }
286 }
287 }
288}
289
290#[allow(
292 clippy::too_many_lines,
293 reason = "Ruby graph extraction handles DSLs and FFI patterns in one traversal."
294)]
295fn walk_tree_for_graph_impl(
296 node: Node,
297 content: &[u8],
298 ast_graph: &ASTGraph,
299 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
300 ffi_enabled_scopes: &HashSet<Vec<String>>,
301 current_namespace: &mut Vec<String>,
302) -> GraphResult<()> {
303 match node.kind() {
304 "class" => {
305 if let Some(name_node) = node.child_by_field_name("name")
307 && let Ok(class_name) = name_node.utf8_text(content)
308 {
309 let span = span_from_points(node.start_position(), node.end_position());
310 let qualified_name = class_name.to_string();
311 let class_id = helper.add_class(&qualified_name, Some(span));
312
313 let module_id = helper.add_module(FILE_MODULE_NAME, None);
316 helper.add_export_edge(module_id, class_id);
317
318 if let Some(superclass_node) = node.child_by_field_name("superclass")
320 && let Ok(superclass_name) = superclass_node.utf8_text(content)
321 {
322 let superclass_name = superclass_name.trim();
323 if !superclass_name.is_empty() {
324 let parent_id = helper.add_class(superclass_name, None);
326 helper.add_inherits_edge(class_id, parent_id);
327 }
328 }
329
330 current_namespace.push(class_name.trim().to_string());
332
333 let mut cursor = node.walk();
335 for child in node.children(&mut cursor) {
336 walk_tree_for_graph_impl(
337 child,
338 content,
339 ast_graph,
340 helper,
341 ffi_enabled_scopes,
342 current_namespace,
343 )?;
344 }
345
346 current_namespace.pop();
347 return Ok(());
348 }
349 }
350 "module" => {
351 if let Some(name_node) = node.child_by_field_name("name")
353 && let Ok(module_name) = name_node.utf8_text(content)
354 {
355 let span = span_from_points(node.start_position(), node.end_position());
356 let qualified_name = module_name.to_string();
357 let mod_id = helper.add_module(&qualified_name, Some(span));
358
359 let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
362 helper.add_export_edge(file_module_id, mod_id);
363
364 current_namespace.push(module_name.trim().to_string());
366
367 let mut cursor = node.walk();
369 for child in node.children(&mut cursor) {
370 walk_tree_for_graph_impl(
371 child,
372 content,
373 ast_graph,
374 helper,
375 ffi_enabled_scopes,
376 current_namespace,
377 )?;
378 }
379
380 current_namespace.pop();
381 return Ok(());
382 }
383 }
384 "method" | "singleton_method" => {
385 if let Some(context) = ast_graph.context_for_node(&node) {
387 let span = span_from_points(context.start_position, context.end_position);
388
389 let is_async = detect_async_method(node, content);
391
392 let params = node
394 .child_by_field_name("parameters")
395 .and_then(|params_node| extract_method_parameters(params_node, content));
396
397 let return_type = extract_return_type(node, content);
399
400 let signature = match (params.as_ref(), return_type.as_ref()) {
402 (Some(p), Some(r)) => Some(format!("{p} -> {r}")),
403 (Some(p), None) => Some(p.clone()),
404 (None, Some(r)) => Some(format!("-> {r}")),
405 (None, None) => None,
406 };
407
408 let visibility = context.visibility().as_str();
410
411 let method_id = helper.add_method_with_signature(
413 context.qualified_name(),
414 Some(span),
415 is_async,
416 context.is_singleton(),
417 Some(visibility),
418 signature.as_deref(),
419 );
420
421 if context.visibility() == Visibility::Public {
424 let module_id = helper.add_module(FILE_MODULE_NAME, None);
425 helper.add_export_edge(module_id, method_id);
426 }
427 }
428 }
429 "assignment" => {
430 if let Some(left_node) = node.child_by_field_name("left")
432 && left_node.kind() == "constant"
433 && let Ok(const_name) = left_node.utf8_text(content)
434 {
435 let qualified_name = if current_namespace.is_empty() {
437 const_name.to_string()
438 } else {
439 format!("{}::{}", current_namespace.join("::"), const_name)
440 };
441
442 let span = span_from_points(node.start_position(), node.end_position());
443 let const_id = helper.add_constant(&qualified_name, Some(span));
444
445 let module_id = helper.add_module(FILE_MODULE_NAME, None);
447 helper.add_export_edge(module_id, const_id);
448 }
449 }
450 "call" | "command" | "command_call" | "identifier" | "super" => {
451 if is_include_or_extend_statement(node, content) {
453 handle_include_extend(node, content, helper, current_namespace);
454 }
455 else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
459 } else if is_require_statement(node, content) {
461 if let Some((from_qname, to_qname)) =
463 build_import_for_staging(node, content, helper.file_path())
464 {
465 let from_id = helper.add_import(&from_qname, None);
467 let to_id = helper.add_import(
468 &to_qname,
469 Some(span_from_points(node.start_position(), node.end_position())),
470 );
471
472 helper.add_import_edge(from_id, to_id);
474 }
475 } else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
476 build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
478 } else {
479 if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
481 build_call_for_staging(ast_graph, node, content)
482 {
483 let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
485 let target_id = helper.ensure_function(&target_qname, None, false, false);
486
487 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
489 helper.add_call_edge_full_with_span(
490 source_id,
491 target_id,
492 argument_count,
493 false,
494 vec![span],
495 );
496 }
497 }
498 }
499 _ => {}
500 }
501
502 let mut cursor = node.walk();
504 for child in node.children(&mut cursor) {
505 walk_tree_for_graph_impl(
506 child,
507 content,
508 ast_graph,
509 helper,
510 ffi_enabled_scopes,
511 current_namespace,
512 )?;
513 }
514
515 Ok(())
516}
517
518fn is_ffi_attach_function(
529 node: Node,
530 content: &[u8],
531 ffi_enabled_scopes: &HashSet<Vec<String>>,
532 current_namespace: &[String],
533) -> bool {
534 let method_name = match node.kind() {
536 "command" => node
537 .child_by_field_name("name")
538 .and_then(|n| n.utf8_text(content).ok()),
539 "call" | "command_call" => node
540 .child_by_field_name("method")
541 .and_then(|n| n.utf8_text(content).ok()),
542 _ => None,
543 };
544
545 let Some(method_name) = method_name else {
546 return false;
547 };
548 let method_name = method_name.trim();
549 if !matches!(
550 method_name,
551 "attach_function" | "attach_variable" | "ffi_lib" | "callback"
552 ) {
553 return false;
554 }
555
556 let receiver = match node.kind() {
557 "call" | "command_call" | "method_call" => node
558 .child_by_field_name("receiver")
559 .and_then(|n| n.utf8_text(content).ok()),
560 _ => None,
561 };
562 if let Some(receiver) = receiver {
563 let trimmed = receiver.trim();
564 if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
565 return true;
566 }
567 }
568
569 ffi_enabled_scopes.contains(current_namespace)
570}
571
572fn build_ffi_edge_for_attach_function(
579 node: Node,
580 content: &[u8],
581 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
582 current_namespace: &[String],
583) {
584 let arguments = node.child_by_field_name("arguments");
586
587 let func_name = if let Some(args) = arguments {
589 extract_first_symbol_from_arguments(args, content)
590 } else {
591 let mut cursor = node.walk();
593 let mut found_name = false;
594 let mut result = None;
595 for child in node.children(&mut cursor) {
596 if !child.is_named() {
597 continue;
598 }
599 if !found_name {
601 found_name = true;
602 continue;
603 }
604 if matches!(child.kind(), "symbol" | "simple_symbol")
606 && let Ok(text) = child.utf8_text(content)
607 {
608 result = Some(text.trim().trim_start_matches(':').to_string());
609 break;
610 }
611 }
612 result
613 };
614
615 let Some(func_name) = func_name else {
616 return;
617 };
618
619 let caller_name = if current_namespace.is_empty() {
621 "<module>".to_string()
622 } else {
623 current_namespace.join("::")
624 };
625
626 let caller_id = helper.add_module(&caller_name, None);
628
629 let ffi_func_name = format!("ffi::{func_name}");
631 let span = span_from_points(node.start_position(), node.end_position());
632 let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
633
634 helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
636}
637
638fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
640 let mut cursor = arguments.walk();
641 for child in arguments.children(&mut cursor) {
642 if matches!(child.kind(), "symbol" | "simple_symbol")
643 && let Ok(text) = child.utf8_text(content)
644 {
645 return Some(text.trim().trim_start_matches(':').to_string());
646 }
647 if child.kind() == "bare_symbol"
649 && let Ok(text) = child.utf8_text(content)
650 {
651 return Some(text.trim().to_string());
652 }
653 }
654 None
655}
656
657fn build_call_for_staging(
659 ast_graph: &ASTGraph,
660 call_node: Node<'_>,
661 content: &[u8],
662) -> GraphResult<Option<CallEdgeData>> {
663 let Some(call_context) = ast_graph.context_for_node(&call_node) else {
664 return Ok(None);
665 };
666
667 let Some(method_call) = extract_method_call(call_node, content)? else {
668 return Ok(None);
669 };
670
671 if is_visibility_command(&method_call) {
672 return Ok(None);
673 }
674
675 let source_qualified = call_context.qualified_name().to_string();
676 let target_name = resolve_callee(&method_call, call_context);
677
678 if target_name.is_empty() {
679 return Ok(None);
680 }
681
682 let span = span_from_node(call_node);
683 let argument_count = count_arguments(method_call.arguments, content);
684 let is_singleton = call_context.is_singleton();
685
686 Ok(Some((
687 source_qualified,
688 target_name,
689 argument_count,
690 span,
691 is_singleton,
692 )))
693}
694
695fn build_import_for_staging(
697 require_node: Node<'_>,
698 content: &[u8],
699 file_path: &str,
700) -> Option<(String, String)> {
701 let method_name = match require_node.kind() {
703 "command" => require_node
704 .child_by_field_name("name")
705 .and_then(|n| n.utf8_text(content).ok())
706 .map(|s| s.trim().to_string()),
707 "call" | "method_call" => require_node
708 .child_by_field_name("method")
709 .and_then(|n| n.utf8_text(content).ok())
710 .map(|s| s.trim().to_string()),
711 _ => None,
712 };
713
714 let method_name = method_name?;
715
716 if !matches!(method_name.as_str(), "require" | "require_relative") {
718 return None;
719 }
720
721 let arguments = require_node.child_by_field_name("arguments");
723 let module_name = if let Some(args) = arguments {
724 extract_require_module_name(args, content)
725 } else {
726 let mut cursor = require_node.walk();
728 let mut found_name = false;
729 let mut result = None;
730 for child in require_node.children(&mut cursor) {
731 if !child.is_named() {
732 continue;
733 }
734 if !found_name {
735 found_name = true;
736 continue;
737 }
738 result = extract_string_content(child, content);
740 break;
741 }
742 result
743 };
744
745 let module_name = module_name?;
746
747 if module_name.is_empty() {
748 return None;
749 }
750
751 let is_relative = method_name == "require_relative";
753 let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
754
755 Some(("<module>".to_string(), resolved_path))
757}
758
759fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
760 node.kind() == "identifier"
761 && node
762 .parent()
763 .is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
764}
765
766fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
776 let body_node = method_node.child_by_field_name("body");
778 if body_node.is_none() {
779 return false;
780 }
781 let body_node = body_node.unwrap();
782
783 if let Ok(body_text) = body_node.utf8_text(content) {
785 let body_lower = body_text.to_lowercase();
786
787 if body_lower.contains("fiber.")
789 || body_lower.contains("fiber.new")
790 || body_lower.contains("fiber.yield")
791 || body_lower.contains("fiber.resume")
792 || body_lower.contains("thread.new")
793 || body_lower.contains("thread.start")
794 || body_lower.contains("async do")
795 || body_lower.contains("async {")
796 || body_lower.contains("async.reactor")
797 || body_lower.contains("concurrent::")
798 {
799 return true;
800 }
801 }
802
803 false
804}
805
806fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
808 let method_name = match node.kind() {
809 "command" => node
810 .child_by_field_name("name")
811 .and_then(|n| n.utf8_text(content).ok()),
812 "call" | "method_call" => node
813 .child_by_field_name("method")
814 .and_then(|n| n.utf8_text(content).ok()),
815 _ => None,
816 };
817
818 method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
819}
820
821fn handle_include_extend(
829 node: Node<'_>,
830 content: &[u8],
831 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
832 current_namespace: &[String],
833) {
834 let module_name = if let Some(args) = node.child_by_field_name("arguments") {
836 extract_first_constant_from_arguments(args, content)
837 } else if node.kind() == "command" {
838 let mut cursor = node.walk();
840 let mut found_method = false;
841 let mut result = None;
842 for child in node.children(&mut cursor) {
843 if !child.is_named() {
844 continue;
845 }
846 if !found_method {
848 found_method = true;
849 continue;
850 }
851 if child.kind() == "constant"
853 && let Ok(text) = child.utf8_text(content)
854 {
855 result = Some(text.trim().to_string());
856 break;
857 }
858 }
859 result
860 } else {
861 None
862 };
863
864 let Some(module_name) = module_name else {
865 return;
866 };
867
868 let class_name = if current_namespace.is_empty() {
870 return; } else {
872 current_namespace.join("::")
873 };
874
875 let class_id = helper.add_class(&class_name, None);
877 let module_id = helper.add_module(&module_name, None);
878
879 helper.add_implements_edge(class_id, module_id);
881}
882
883fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
885 let mut cursor = args_node.walk();
886 for child in args_node.children(&mut cursor) {
887 if !child.is_named() {
888 continue;
889 }
890 if child.kind() == "constant"
892 && let Ok(text) = child.utf8_text(content)
893 {
894 return Some(text.trim().to_string());
895 }
896 }
897 None
898}
899
900fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
902 let method_name = match node.kind() {
903 "command" => node
904 .child_by_field_name("name")
905 .and_then(|n| n.utf8_text(content).ok()),
906 "call" | "method_call" => node
907 .child_by_field_name("method")
908 .and_then(|n| n.utf8_text(content).ok()),
909 _ => None,
910 };
911
912 method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
913}
914
915struct ContextBuilder<'a> {
916 contexts: Vec<RubyContext>,
917 node_to_context: HashMap<usize, usize>,
918 namespace: Vec<String>,
919 visibility_stack: Vec<Visibility>,
920 ffi_enabled_scopes: HashSet<Vec<String>>,
921 controller_dsl_hooks: Vec<ControllerDslHook>,
922 max_depth: usize,
923 content: &'a [u8],
924 guard: sqry_core::query::security::RecursionGuard,
925}
926
927impl<'a> ContextBuilder<'a> {
928 fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
929 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
930 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
931 let file_ops_depth = recursion_limits
932 .effective_file_ops_depth()
933 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
934 let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
935 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
936
937 Ok(Self {
938 contexts: Vec::new(),
939 node_to_context: HashMap::new(),
940 namespace: Vec::new(),
941 visibility_stack: vec![Visibility::Public],
942 ffi_enabled_scopes: HashSet::new(),
943 controller_dsl_hooks: Vec::new(),
944 max_depth,
945 content,
946 guard,
947 })
948 }
949
950 fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
954 self.guard
955 .enter()
956 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
957
958 match node.kind() {
959 "class" => self.visit_class(node)?,
960 "module" => self.visit_module(node)?,
961 "singleton_class" => self.visit_singleton_class(node)?,
962 "method" => self.visit_method(node)?,
963 "singleton_method" => self.visit_singleton_method(node)?,
964 "command" | "command_call" | "call" => {
965 self.detect_ffi_extend(node)?;
966 self.detect_controller_dsl(node)?;
967 self.adjust_visibility(node)?;
968 self.walk_children(node)?;
969 }
970 "identifier" => {
971 self.adjust_visibility_from_identifier(node)?;
974 self.walk_children(node)?;
975 }
976 _ => self.walk_children(node)?,
977 }
978
979 self.guard.exit();
980 Ok(())
981 }
982
983 fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
984 let name_node = node
985 .child_by_field_name("name")
986 .ok_or_else(|| "class node missing name".to_string())?;
987 let class_name = self.node_text(name_node)?;
988
989 if self.namespace.len() > self.max_depth {
990 return Ok(());
991 }
992
993 self.namespace.push(class_name);
994 self.visibility_stack.push(Visibility::Public);
995
996 self.walk_children(node)?;
997
998 self.visibility_stack.pop();
999 self.namespace.pop();
1000 Ok(())
1001 }
1002
1003 fn visit_module(&mut self, node: Node<'a>) -> Result<(), String> {
1004 let name_node = node
1005 .child_by_field_name("name")
1006 .ok_or_else(|| "module node missing name".to_string())?;
1007 let module_name = self.node_text(name_node)?;
1008
1009 if self.namespace.len() > self.max_depth {
1010 return Ok(());
1011 }
1012
1013 self.namespace.push(module_name);
1014 self.visibility_stack.push(Visibility::Public);
1015
1016 self.walk_children(node)?;
1017
1018 self.visibility_stack.pop();
1019 self.namespace.pop();
1020 Ok(())
1021 }
1022
1023 fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
1024 let name_node = node
1025 .child_by_field_name("name")
1026 .ok_or_else(|| "method node missing name".to_string())?;
1027 let method_name = self.node_text(name_node)?;
1028
1029 let (qualified_name, container) =
1030 method_qualified_name(&self.namespace, &method_name, false);
1031
1032 let visibility = inline_visibility_for_method(node, self.content)
1033 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1034
1035 let context = RubyContext {
1036 qualified_name,
1037 container,
1038 kind: RubyContextKind::Method,
1039 visibility,
1040 start_position: node.start_position(),
1041 end_position: node.end_position(),
1042 };
1043
1044 let idx = self.contexts.len();
1045 self.contexts.push(context);
1046 associate_descendants(node, idx, &mut self.node_to_context);
1047
1048 self.walk_children(node)?;
1049 Ok(())
1050 }
1051
1052 fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
1053 let value_node = node
1055 .child_by_field_name("value")
1056 .ok_or_else(|| "singleton_class missing value".to_string())?;
1057 let object_text = self.node_text(value_node)?;
1058
1059 let scope_name = if object_text == "self" {
1061 if let Some(current_class) = self.namespace.last() {
1063 format!("<<{current_class}>>")
1064 } else {
1065 "<<main>>".to_string()
1066 }
1067 } else {
1068 format!("<<{object_text}>>")
1070 };
1071
1072 if self.namespace.len() > self.max_depth {
1073 return Ok(());
1074 }
1075
1076 self.namespace.push(scope_name);
1078 self.visibility_stack.push(Visibility::Public);
1079
1080 self.visit_singleton_class_body(node)?;
1082
1083 self.visibility_stack.pop();
1085 self.namespace.pop();
1086 Ok(())
1087 }
1088
1089 fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
1090 let mut cursor = node.walk();
1091 for child in node.children(&mut cursor) {
1092 if !child.is_named() {
1093 continue;
1094 }
1095
1096 if child.kind() == "method" {
1098 self.visit_method_as_singleton(child)?;
1099 } else {
1100 self.walk(child)?;
1101 }
1102 }
1103 Ok(())
1104 }
1105
1106 fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
1107 let name_node = node
1108 .child_by_field_name("name")
1109 .ok_or_else(|| "method node missing name".to_string())?;
1110 let method_name = self.node_text(name_node)?;
1111
1112 let actual_namespace: Vec<String> = self
1114 .namespace
1115 .iter()
1116 .map(|s| {
1117 if s.starts_with("<<") && s.ends_with(">>") {
1118 s[2..s.len() - 2].to_string()
1120 } else {
1121 s.clone()
1122 }
1123 })
1124 .collect();
1125
1126 let (qualified_name, container) =
1127 method_qualified_name(&actual_namespace, &method_name, true);
1128
1129 let visibility = inline_visibility_for_method(node, self.content)
1130 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1131
1132 let context = RubyContext {
1133 qualified_name,
1134 container,
1135 kind: RubyContextKind::SingletonMethod,
1136 visibility,
1137 start_position: node.start_position(),
1138 end_position: node.end_position(),
1139 };
1140
1141 let idx = self.contexts.len();
1142 self.contexts.push(context);
1143 associate_descendants(node, idx, &mut self.node_to_context);
1144
1145 self.walk_children(node)?;
1146 Ok(())
1147 }
1148
1149 fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
1150 let name_node = node
1151 .child_by_field_name("name")
1152 .ok_or_else(|| "singleton_method missing name".to_string())?;
1153 let method_name = self.node_text(name_node)?;
1154
1155 let object_node = node
1156 .child_by_field_name("object")
1157 .ok_or_else(|| "singleton_method missing object".to_string())?;
1158 let object_text = self.node_text(object_node)?;
1159
1160 let (qualified_name, container) =
1161 singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
1162
1163 let visibility = inline_visibility_for_method(node, self.content)
1164 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1165
1166 let context = RubyContext {
1167 qualified_name,
1168 container,
1169 kind: RubyContextKind::SingletonMethod,
1170 visibility,
1171 start_position: node.start_position(),
1172 end_position: node.end_position(),
1173 };
1174
1175 let idx = self.contexts.len();
1176 self.contexts.push(context);
1177 associate_descendants(node, idx, &mut self.node_to_context);
1178
1179 self.walk_children(node)?;
1180 Ok(())
1181 }
1182
1183 fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
1184 let name_node = node.child_by_field_name("name");
1185 let Some(name_node) = name_node else {
1186 return Ok(());
1187 };
1188
1189 let keyword = self.node_text(name_node)?;
1190 if keyword.trim() != "extend" {
1191 return Ok(());
1192 }
1193
1194 let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
1195 node_text_raw(arguments, self.content).unwrap_or_default()
1196 } else {
1197 let mut cursor = node.walk();
1198 let mut found_name = false;
1199 let mut result = String::new();
1200 for child in node.children(&mut cursor) {
1201 if !child.is_named() {
1202 continue;
1203 }
1204 if !found_name {
1205 found_name = true;
1206 continue;
1207 }
1208 if let Some(text) = node_text_raw(child, self.content) {
1209 result = text;
1210 break;
1211 }
1212 }
1213 result
1214 };
1215
1216 if arg_text.contains("FFI::Library") {
1217 self.ffi_enabled_scopes.insert(self.namespace.clone());
1219 }
1220
1221 Ok(())
1222 }
1223
1224 fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
1225 let name_node = node
1226 .child_by_field_name("name")
1227 .or_else(|| node.child_by_field_name("method"));
1228 let Some(name_node) = name_node else {
1229 return Ok(());
1230 };
1231 let dsl = self.node_text(name_node)?;
1232
1233 let kind = match dsl.as_str() {
1234 "before_action" => Some(ControllerDslKind::Before),
1235 "after_action" => Some(ControllerDslKind::After),
1236 "around_action" => Some(ControllerDslKind::Around),
1237 _ => None,
1238 };
1239 let Some(kind) = kind else {
1240 return Ok(());
1241 };
1242
1243 if self.namespace.is_empty() {
1244 return Ok(());
1245 }
1246 let container = self.namespace.join("::");
1247
1248 let mut callbacks: Vec<String> = Vec::new();
1249 let mut only: Option<Vec<String>> = None;
1250 let mut except: Option<Vec<String>> = None;
1251
1252 if let Some(arguments) = node.child_by_field_name("arguments") {
1253 let mut cursor = arguments.walk();
1254 for child in arguments.children(&mut cursor) {
1255 if !child.is_named() {
1256 continue;
1257 }
1258 let kind = child.kind();
1259 match kind {
1260 "symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
1261 let mut v = extract_symbols_from_node(child, self.content);
1262 callbacks.append(&mut v);
1263 }
1264 "pair" => {
1265 let key = child.child_by_field_name("key");
1267 let val = child.child_by_field_name("value");
1268 if key.is_none() || val.is_none() {
1269 continue;
1270 }
1271 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1272 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1273 if key_text.contains("only") && !symbols.is_empty() {
1274 only = Some(symbols);
1275 } else if key_text.contains("except") && !symbols.is_empty() {
1276 except = Some(symbols);
1277 }
1278 }
1279 "hash" => {
1280 let mut hcur = child.walk();
1282 for pair in child.children(&mut hcur) {
1283 if !pair.is_named() {
1284 continue;
1285 }
1286 if pair.kind() != "pair" {
1287 continue;
1288 }
1289 let key = pair.child_by_field_name("key");
1290 let val = pair.child_by_field_name("value");
1291 if key.is_none() || val.is_none() {
1292 continue;
1293 }
1294 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1295 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1296 if key_text.contains("only") && !symbols.is_empty() {
1297 only = Some(symbols);
1298 } else if key_text.contains("except") && !symbols.is_empty() {
1299 except = Some(symbols);
1300 }
1301 }
1302 }
1303 _ => {}
1304 }
1305 }
1306 } else {
1307 if let Some(raw) = node_text_raw(node, self.content) {
1309 let (cbs, o, e) = parse_controller_dsl_args(&raw);
1310 callbacks = cbs;
1311 only = o;
1312 except = e;
1313 }
1314 }
1315
1316 if callbacks.is_empty() {
1317 return Ok(());
1318 }
1319
1320 self.controller_dsl_hooks.push(ControllerDslHook {
1321 container,
1322 kind,
1323 callbacks,
1324 only,
1325 except,
1326 });
1327 Ok(())
1328 }
1329
1330 fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
1331 let name_node = node.child_by_field_name("name");
1332 let Some(name_node) = name_node else {
1333 return Ok(());
1334 };
1335
1336 let keyword = self.node_text(name_node)?;
1337 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1338 return Ok(());
1339 };
1340
1341 if !has_call_arguments(node)
1343 && let Some(last) = self.visibility_stack.last_mut()
1344 {
1345 *last = new_visibility;
1346 }
1347 Ok(())
1348 }
1349
1350 fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
1352 let keyword = self.node_text(node)?;
1353 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1354 return Ok(());
1355 };
1356
1357 if let Some(last) = self.visibility_stack.last_mut() {
1359 *last = new_visibility;
1360 }
1361
1362 Ok(())
1363 }
1364
1365 fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
1366 let mut cursor = node.walk();
1367 for child in node.children(&mut cursor) {
1368 if child.is_named() {
1369 self.walk(child)?;
1370 }
1371 }
1372 Ok(())
1373 }
1374
1375 fn node_text(&self, node: Node<'a>) -> Result<String, String> {
1376 node.utf8_text(self.content)
1377 .map(|s| s.trim().to_string())
1378 .map_err(|err| err.to_string())
1379 }
1380}
1381
1382#[derive(Clone)]
1383struct MethodCall<'a> {
1384 name: String,
1385 receiver: Option<String>,
1386 arguments: Option<Node<'a>>,
1387 node: Node<'a>,
1388}
1389
1390fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
1391 let method_name = match node.kind() {
1392 "call" | "command_call" | "method_call" => {
1393 let method_node = node
1394 .child_by_field_name("method")
1395 .ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
1396 node_text(method_node, content)?
1397 }
1398 "command" => {
1399 let name_node = node
1400 .child_by_field_name("name")
1401 .ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
1402 node_text(name_node, content)?
1403 }
1404 "super" => "super".to_string(),
1405 "identifier" => {
1406 if !should_treat_identifier_as_call(node) {
1407 return Ok(None);
1408 }
1409 node_text(node, content)?
1410 }
1411 _ => return Ok(None),
1412 };
1413
1414 let receiver = match node.kind() {
1415 "call" | "command_call" | "method_call" => node
1416 .child_by_field_name("receiver")
1417 .and_then(|r| node_text(r, content).ok()),
1418 _ => None,
1419 };
1420
1421 let arguments = node.child_by_field_name("arguments");
1422
1423 Ok(Some(MethodCall {
1424 name: method_name,
1425 receiver,
1426 arguments,
1427 node,
1428 }))
1429}
1430
1431fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
1432 if let Some(parent) = node.parent() {
1433 let kind = parent.kind();
1434 if matches!(
1435 kind,
1436 "call"
1437 | "command"
1438 | "command_call"
1439 | "method_call"
1440 | "method"
1441 | "singleton_method"
1442 | "alias"
1443 | "symbol"
1444 ) {
1445 return false;
1446 }
1447
1448 if kind.contains("assignment")
1449 || matches!(
1450 kind,
1451 "parameters"
1452 | "method_parameters"
1453 | "block_parameters"
1454 | "lambda_parameters"
1455 | "constant_path"
1456 | "module"
1457 | "class"
1458 | "hash"
1459 | "pair"
1460 | "array"
1461 | "argument_list"
1462 )
1463 {
1464 return false;
1465 }
1466 }
1467
1468 true
1469}
1470
1471fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
1485 let name = method_call.name.trim();
1486 if name.is_empty() {
1487 return String::new();
1488 }
1489
1490 if name == "super" {
1492 return format!("super::{}", context.qualified_name());
1496 }
1497
1498 if let Some(receiver) = method_call.receiver.as_deref() {
1499 let receiver = receiver.trim();
1500 if receiver == "self" {
1501 if let Some(container) = context.container() {
1502 return format!("{container}.{name}");
1503 }
1504 return format!("self.{name}");
1505 }
1506
1507 if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
1508 let cleaned = receiver.trim_start_matches("::");
1509 if let Some(class_name) = cleaned.strip_suffix(".new") {
1511 return format!("{class_name}#{name}");
1512 }
1513 return format!("{cleaned}.{name}");
1514 }
1515
1516 return name.to_string();
1518 }
1519
1520 if context.is_singleton() {
1521 if let Some(container) = context.container() {
1522 return format!("{container}.{name}");
1523 }
1524 return name.to_string();
1525 }
1526
1527 if let Some(container) = context.container() {
1528 return format!("{container}#{name}");
1529 }
1530
1531 name.to_string()
1532}
1533
1534fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
1546 let Some(arguments) = arguments else {
1547 return 0;
1548 };
1549
1550 let mut count = 0;
1551 let mut cursor = arguments.walk();
1552 for child in arguments.children(&mut cursor) {
1553 if child.is_named()
1554 && !is_literal_delimiter(child.kind())
1555 && node_text(child, content)
1556 .map(|s| !s.trim().is_empty())
1557 .unwrap_or(false)
1558 {
1559 count += 1;
1560 }
1561 }
1562 count
1563}
1564
1565fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
1576 let mut stack = vec![node];
1577 while let Some(current) = stack.pop() {
1578 map.insert(current.id(), idx);
1579 let mut cursor = current.walk();
1580 for child in current.children(&mut cursor) {
1581 stack.push(child);
1582 }
1583 }
1584}
1585
1586fn method_qualified_name(
1600 namespace: &[String],
1601 method_name: &str,
1602 singleton: bool,
1603) -> (String, Option<String>) {
1604 if namespace.is_empty() {
1605 return (method_name.to_string(), None);
1606 }
1607
1608 let container = namespace.join("::");
1609 let qualified = if singleton {
1610 format!("{container}.{method_name}")
1611 } else {
1612 format!("{container}#{method_name}")
1613 };
1614 (qualified, Some(container))
1615}
1616
1617fn singleton_qualified_name(
1630 current_namespace: &[String],
1631 object_text: &str,
1632 method_name: &str,
1633) -> (String, Option<String>) {
1634 if object_text == "self" {
1635 if current_namespace.is_empty() {
1636 (method_name.to_string(), None)
1637 } else {
1638 let container = current_namespace.join("::");
1639 (format!("{container}.{method_name}"), Some(container))
1640 }
1641 } else {
1642 let parts = split_constant_path(object_text);
1643 if parts.is_empty() {
1644 (method_name.to_string(), None)
1645 } else {
1646 let container = parts.join("::");
1647 (format!("{container}.{method_name}"), Some(container))
1648 }
1649 }
1650}
1651
1652fn split_constant_path(path: &str) -> Vec<String> {
1666 path.trim()
1667 .trim_start_matches("::")
1668 .split("::")
1669 .filter_map(|seg| {
1670 let trimmed = seg.trim();
1671 if trimmed.is_empty() {
1672 None
1673 } else {
1674 Some(trimmed.to_string())
1675 }
1676 })
1677 .collect()
1678}
1679
1680fn is_constant(text: &str) -> bool {
1690 text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1691}
1692
1693fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
1704 matches!(
1705 method_call.name.as_str(),
1706 "public" | "private" | "protected"
1707 ) && method_call.receiver.is_none()
1708 && !has_call_arguments(method_call.node)
1709}
1710
1711fn has_call_arguments(node: Node<'_>) -> bool {
1721 if let Some(arguments) = node.child_by_field_name("arguments") {
1722 let mut cursor = arguments.walk();
1723 for child in arguments.children(&mut cursor) {
1724 if child.is_named() {
1725 return true;
1726 }
1727 }
1728 }
1729 false
1730}
1731
1732fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
1733 let parent = node.parent()?;
1734 let visibility_node = match parent.kind() {
1735 "call" | "command" | "command_call" => parent,
1736 "argument_list" => parent.parent()?,
1737 _ => return None,
1738 };
1739
1740 if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
1741 return None;
1742 }
1743
1744 let keyword_node = visibility_node
1745 .child_by_field_name("name")
1746 .or_else(|| visibility_node.child_by_field_name("method"))?;
1747 let keyword = node_text_raw(keyword_node, content)?;
1748 Visibility::from_keyword(keyword.trim())
1749}
1750
1751fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
1762 node.utf8_text(content)
1763 .map(|s| s.trim().to_string())
1764 .map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
1765}
1766
1767fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
1769 node.utf8_text(content)
1770 .ok()
1771 .map(std::string::ToString::to_string)
1772}
1773
1774fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
1783 GraphBuilderError::ParseError {
1784 span: span_from_node(node),
1785 reason: reason.to_string(),
1786 }
1787}
1788
1789#[allow(clippy::match_same_arms)]
1806fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
1807 let mut params = Vec::new();
1808 let mut cursor = params_node.walk();
1809
1810 for child in params_node.named_children(&mut cursor) {
1811 match child.kind() {
1812 "identifier" | "optional_parameter" => {
1815 if let Ok(text) = child.utf8_text(content) {
1816 params.push(text.to_string());
1817 }
1818 }
1819 "splat_parameter" => {
1821 if let Some(name_node) = child.child_by_field_name("name") {
1822 if let Ok(name) = name_node.utf8_text(content) {
1823 params.push(format!("*{name}"));
1824 }
1825 } else if let Ok(text) = child.utf8_text(content) {
1826 params.push(text.to_string());
1828 }
1829 }
1830 "hash_splat_parameter" => {
1832 if let Some(name_node) = child.child_by_field_name("name") {
1833 if let Ok(name) = name_node.utf8_text(content) {
1834 params.push(format!("**{name}"));
1835 }
1836 } else if let Ok(text) = child.utf8_text(content) {
1837 params.push(text.to_string());
1839 }
1840 }
1841 "block_parameter" => {
1843 if let Some(name_node) = child.child_by_field_name("name") {
1844 if let Ok(name) = name_node.utf8_text(content) {
1845 params.push(format!("&{name}"));
1846 }
1847 } else if let Ok(text) = child.utf8_text(content) {
1848 params.push(text.to_string());
1850 }
1851 }
1852 "keyword_parameter" => {
1854 if let Ok(text) = child.utf8_text(content) {
1855 params.push(text.to_string());
1856 }
1857 }
1858 "destructured_parameter" => {
1860 if let Ok(text) = child.utf8_text(content) {
1861 params.push(text.to_string());
1862 }
1863 }
1864 "forward_parameter" => {
1866 params.push("...".to_string());
1867 }
1868 "hash_splat_nil" => {
1870 params.push("**nil".to_string());
1871 }
1872 _ => {
1873 }
1875 }
1876 }
1877
1878 if params.is_empty() {
1879 None
1880 } else {
1881 Some(params.join(", "))
1882 }
1883}
1884
1885fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1899 if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
1901 return Some(return_type);
1902 }
1903
1904 if let Some(return_type) = extract_rbs_return_type(method_node, content) {
1906 return Some(return_type);
1907 }
1908
1909 if let Some(return_type) = extract_yard_return_type(method_node, content) {
1911 return Some(return_type);
1912 }
1913
1914 None
1915}
1916
1917fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1928 let mut sibling = method_node.prev_sibling()?;
1930
1931 while sibling.kind() == "comment" {
1933 sibling = sibling.prev_sibling()?;
1934 }
1935
1936 if sibling.kind() == "call"
1938 && let Some(method_name) = sibling.child_by_field_name("method")
1939 && let Ok(name_text) = method_name.utf8_text(content)
1940 && name_text == "sig"
1941 {
1942 if let Some(block_node) = sibling.child_by_field_name("block") {
1944 return extract_returns_from_sig_block(block_node, content);
1945 }
1946 }
1947
1948 None
1949}
1950
1951fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
1953 let mut cursor = block_node.walk();
1954
1955 for child in block_node.named_children(&mut cursor) {
1956 if child.kind() == "call"
1957 && let Some(method_name) = child.child_by_field_name("method")
1958 && let Ok(name_text) = method_name.utf8_text(content)
1959 && name_text == "returns"
1960 {
1961 if let Some(args) = child.child_by_field_name("arguments") {
1963 let mut args_cursor = args.walk();
1964 for arg in args.named_children(&mut args_cursor) {
1965 if arg.kind() != ","
1966 && let Ok(type_text) = arg.utf8_text(content)
1967 {
1968 return Some(type_text.to_string());
1969 }
1970 }
1971 }
1972 }
1973 if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
1975 return Some(nested_type);
1976 }
1977 }
1978
1979 None
1980}
1981
1982fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1993 let mut cursor = method_node.walk();
1995 for child in method_node.children(&mut cursor) {
1996 if child.kind() == "comment"
1997 && let Ok(comment_text) = child.utf8_text(content)
1998 {
1999 if comment_text.trim_start().starts_with("#:") {
2002 if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
2004 let return_part = &comment_text[arrow_pos + 2..];
2005 let return_type = return_part.trim().to_string();
2006 if !return_type.is_empty() {
2007 return Some(return_type);
2008 }
2009 }
2010 }
2011 }
2012 }
2013
2014 None
2015}
2016
2017fn find_top_level_arrow(text: &str) -> Option<usize> {
2021 let chars: Vec<char> = text.chars().collect();
2022 let mut depth: i32 = 0;
2023 let mut i = 0;
2024
2025 while i < chars.len() {
2026 match chars[i] {
2027 '(' | '[' | '{' => depth += 1,
2028 ')' | ']' | '}' => depth = depth.saturating_sub(1),
2029 '-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
2030 return Some(i);
2031 }
2032 _ => {}
2033 }
2034 i += 1;
2035 }
2036
2037 None
2038}
2039
2040fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2051 let mut sibling_opt = method_node.prev_sibling();
2053 let method_start_row = method_node.start_position().row;
2054
2055 let mut comments = Vec::new();
2057 let mut expected_row = method_start_row;
2058
2059 while let Some(sibling) = sibling_opt {
2060 if sibling.kind() == "comment" {
2061 let comment_end_row = sibling.end_position().row;
2062
2063 if comment_end_row + 1 >= expected_row {
2066 if let Ok(comment_text) = sibling.utf8_text(content) {
2067 comments.push(comment_text);
2068 }
2069 expected_row = sibling.start_position().row;
2070 sibling_opt = sibling.prev_sibling();
2071 } else {
2072 break;
2074 }
2075 } else {
2076 break;
2077 }
2078 }
2079
2080 for comment in comments.iter().rev() {
2082 if let Some(return_pos) = comment.find("@return") {
2083 let after_return = &comment[return_pos + 7..];
2084 if let Some(start_bracket) = after_return.find('[')
2086 && let Some(end_bracket) = after_return.find(']')
2087 && end_bracket > start_bracket
2088 {
2089 let return_type = &after_return[start_bracket + 1..end_bracket];
2090 return Some(return_type.trim().to_string());
2091 }
2092 }
2093 }
2094
2095 None
2096}
2097
2098fn span_from_node(node: Node<'_>) -> Span {
2106 span_from_points(node.start_position(), node.end_position())
2107}
2108
2109fn span_from_points(start: Point, end: Point) -> Span {
2118 Span::new(
2119 Position::new(start.row, start.column),
2120 Position::new(end.row, end.column),
2121 )
2122}
2123
2124fn is_literal_delimiter(kind: &str) -> bool {
2134 matches!(kind, "," | "(" | ")" | "[" | "]")
2135}
2136
2137fn parse_controller_dsl_args(
2140 text: &str,
2141) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
2142 let mut head = text;
2144 let mut tail = "";
2145 if let Some(idx) = text.find("only:") {
2146 head = &text[..idx];
2147 tail = &text[idx..];
2148 } else if let Some(idx) = text.find("except:") {
2149 head = &text[..idx];
2150 tail = &text[idx..];
2151 }
2152 let callbacks = extract_symbol_list_from_args(head);
2153 let only = extract_kw_symbol_list(tail, "only:");
2154 let except = extract_kw_symbol_list(tail, "except:");
2155 (callbacks, only, except)
2156}
2157
2158fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
2159 let mut out = Vec::new();
2160 let bytes = text.as_bytes();
2161 let mut i = 0;
2162 while i < bytes.len() {
2163 if bytes[i] == b':' {
2164 let start = i + 1;
2165 let mut j = start;
2166 while j < bytes.len() {
2167 let c = bytes[j] as char;
2168 if c.is_ascii_alphanumeric() || c == '_' {
2169 j += 1;
2170 } else {
2171 break;
2172 }
2173 }
2174 if j > start {
2175 out.push(text[start..j].to_string());
2176 i = j;
2177 continue;
2178 }
2179 }
2180 i += 1;
2181 }
2182 out
2183}
2184
2185fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
2186 let pos = text.find(kw)?;
2187 let mut after = &text[pos + kw.len()..];
2188 after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
2190 if after.starts_with('[')
2191 && let Some(end) = after.find(']')
2192 {
2193 return Some(extract_symbol_list_from_args(&after[..=end]));
2194 }
2195 if let Some(colon) = after.find(':') {
2197 let mut j = colon + 1;
2198 while j < after.len() {
2199 let ch = after.as_bytes()[j] as char;
2200 if ch.is_ascii_alphanumeric() || ch == '_' {
2201 j += 1;
2202 } else {
2203 break;
2204 }
2205 }
2206 if j > colon + 1 {
2207 return Some(vec![after[colon + 1..j].to_string()]);
2208 }
2209 }
2210 None
2211}
2212
2213fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
2214 let mut out = Vec::new();
2215 match node.kind() {
2216 "symbol" | "simple_symbol" => {
2217 if let Ok(t) = node_text(node, content) {
2218 out.push(t.trim_start_matches(':').to_string());
2219 }
2220 }
2221 "array" => {
2222 let mut c = node.walk();
2223 for ch in node.children(&mut c) {
2224 if matches!(ch.kind(), "symbol" | "simple_symbol")
2225 && let Ok(t) = node_text(ch, content)
2226 {
2227 out.push(t.trim_start_matches(':').to_string());
2228 }
2229 }
2230 }
2231 _ => {
2232 if let Some(txt) = node_text_raw(node, content) {
2234 out = extract_symbol_list_from_args(&txt);
2235 }
2236 }
2237 }
2238 out
2239}
2240
2241fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
2243 let mut cursor = arguments.walk();
2244 for child in arguments.children(&mut cursor) {
2245 if !child.is_named() {
2246 continue;
2247 }
2248 if let Some(s) = extract_string_content(child, content) {
2249 return Some(s);
2250 }
2251 }
2252 None
2253}
2254
2255fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
2257 let text = node.utf8_text(content).ok()?;
2258 let trimmed = text.trim();
2259
2260 if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
2262 || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
2263 && trimmed.len() >= 2
2264 {
2265 return Some(trimmed[1..trimmed.len() - 1].to_string());
2266 }
2267
2268 if matches!(node.kind(), "string" | "chained_string") {
2270 let mut cursor = node.walk();
2271 for child in node.children(&mut cursor) {
2272 if child.kind() == "string_content"
2273 && let Ok(s) = child.utf8_text(content)
2274 {
2275 return Some(s.to_string());
2276 }
2277 }
2278 }
2279
2280 None
2281}
2282
2283pub(crate) fn resolve_ruby_require(
2294 module_name: &str,
2295 is_relative: bool,
2296 source_file: &str,
2297) -> String {
2298 if is_relative {
2299 let source_path = std::path::Path::new(source_file);
2303 let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
2304
2305 let relative_path = std::path::Path::new(module_name);
2307 let resolved = source_dir.join(relative_path);
2308
2309 let normalized = normalize_path(&resolved);
2311
2312 let path_str = normalized.to_string_lossy();
2315 let separators: &[char] = &['/', '\\'];
2316 path_str
2317 .split(separators)
2318 .filter(|s| !s.is_empty())
2319 .collect::<Vec<_>>()
2320 .join("::")
2321 } else {
2322 module_name.replace('/', "::")
2325 }
2326}
2327
2328fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
2330 let mut components = Vec::new();
2331
2332 for component in path.components() {
2333 match component {
2334 std::path::Component::CurDir => {
2335 }
2337 std::path::Component::ParentDir => {
2338 if components
2340 .last()
2341 .is_some_and(|c| *c != std::path::Component::ParentDir)
2342 {
2343 components.pop();
2344 } else {
2345 components.push(component);
2346 }
2347 }
2348 _ => {
2349 components.push(component);
2350 }
2351 }
2352 }
2353
2354 components.iter().collect()
2355}
2356
2357fn process_yard_annotations(
2364 node: Node,
2365 content: &[u8],
2366 helper: &mut GraphBuildHelper,
2367) -> GraphResult<()> {
2368 match node.kind() {
2369 "method" => {
2370 process_method_yard(node, content, helper)?;
2371 }
2372 "singleton_method" => {
2373 process_singleton_method_yard(node, content, helper)?;
2374 }
2375 "call" | "command" | "command_call" => {
2376 if is_attr_call(node, content) {
2378 process_attr_yard(node, content, helper)?;
2379 }
2380 }
2381 "assignment" => {
2382 if is_instance_variable_assignment(node, content) {
2384 process_assignment_yard(node, content, helper)?;
2385 }
2386 }
2387 _ => {}
2388 }
2389
2390 let mut cursor = node.walk();
2392 for child in node.children(&mut cursor) {
2393 process_yard_annotations(child, content, helper)?;
2394 }
2395
2396 Ok(())
2397}
2398
2399fn process_method_yard(
2401 method_node: Node,
2402 content: &[u8],
2403 helper: &mut GraphBuildHelper,
2404) -> GraphResult<()> {
2405 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2407 return Ok(());
2408 };
2409
2410 let tags = parse_yard_tags(&yard_text);
2412
2413 let Some(name_node) = method_node.child_by_field_name("name") else {
2415 return Ok(());
2416 };
2417
2418 let method_name = name_node
2419 .utf8_text(content)
2420 .map_err(|_| GraphBuilderError::ParseError {
2421 span: span_from_node(method_node),
2422 reason: "failed to read method name".to_string(),
2423 })?
2424 .trim()
2425 .to_string();
2426
2427 if method_name.is_empty() {
2428 return Ok(());
2429 }
2430
2431 let class_name = get_enclosing_class_name(method_node, content);
2433
2434 let qualified_name = if let Some(class_name) = class_name {
2436 format!("{class_name}#{method_name}")
2437 } else {
2438 method_name.clone()
2439 };
2440
2441 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
2443
2444 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2446 let canonical_type = canonical_type_string(¶m_tag.type_str);
2448 let type_node_id = helper.add_type(&canonical_type, None);
2449 helper.add_typeof_edge_with_context(
2450 method_node_id,
2451 type_node_id,
2452 Some(TypeOfContext::Parameter),
2453 param_idx.try_into().ok(),
2454 Some(¶m_tag.name),
2455 );
2456
2457 let type_names = extract_type_names(¶m_tag.type_str);
2459 for type_name in type_names {
2460 let ref_type_id = helper.add_type(&type_name, None);
2461 helper.add_reference_edge(method_node_id, ref_type_id);
2462 }
2463 }
2464
2465 if let Some(return_type) = &tags.returns {
2467 let canonical_type = canonical_type_string(return_type);
2468 let type_node_id = helper.add_type(&canonical_type, None);
2469 helper.add_typeof_edge_with_context(
2470 method_node_id,
2471 type_node_id,
2472 Some(TypeOfContext::Return),
2473 Some(0),
2474 None,
2475 );
2476
2477 let type_names = extract_type_names(return_type);
2479 for type_name in type_names {
2480 let ref_type_id = helper.add_type(&type_name, None);
2481 helper.add_reference_edge(method_node_id, ref_type_id);
2482 }
2483 }
2484
2485 Ok(())
2486}
2487
2488fn process_singleton_method_yard(
2490 method_node: Node,
2491 content: &[u8],
2492 helper: &mut GraphBuildHelper,
2493) -> GraphResult<()> {
2494 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2496 return Ok(());
2497 };
2498
2499 let tags = parse_yard_tags(&yard_text);
2501
2502 let Some(name_node) = method_node.child_by_field_name("name") else {
2504 return Ok(());
2505 };
2506
2507 let method_name = name_node
2508 .utf8_text(content)
2509 .map_err(|_| GraphBuilderError::ParseError {
2510 span: span_from_node(method_node),
2511 reason: "failed to read method name".to_string(),
2512 })?
2513 .trim()
2514 .to_string();
2515
2516 if method_name.is_empty() {
2517 return Ok(());
2518 }
2519
2520 let class_name = get_enclosing_class_name(method_node, content);
2522
2523 let qualified_name = if let Some(class_name) = class_name {
2525 format!("{class_name}.{method_name}")
2526 } else {
2527 method_name.clone()
2528 };
2529
2530 let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
2532
2533 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2535 let canonical_type = canonical_type_string(¶m_tag.type_str);
2537 let type_node_id = helper.add_type(&canonical_type, None);
2538 helper.add_typeof_edge_with_context(
2539 method_node_id,
2540 type_node_id,
2541 Some(TypeOfContext::Parameter),
2542 param_idx.try_into().ok(),
2543 Some(¶m_tag.name),
2544 );
2545
2546 let type_names = extract_type_names(¶m_tag.type_str);
2548 for type_name in type_names {
2549 let ref_type_id = helper.add_type(&type_name, None);
2550 helper.add_reference_edge(method_node_id, ref_type_id);
2551 }
2552 }
2553
2554 if let Some(return_type) = &tags.returns {
2556 let canonical_type = canonical_type_string(return_type);
2557 let type_node_id = helper.add_type(&canonical_type, None);
2558 helper.add_typeof_edge_with_context(
2559 method_node_id,
2560 type_node_id,
2561 Some(TypeOfContext::Return),
2562 Some(0),
2563 None,
2564 );
2565
2566 let type_names = extract_type_names(return_type);
2568 for type_name in type_names {
2569 let ref_type_id = helper.add_type(&type_name, None);
2570 helper.add_reference_edge(method_node_id, ref_type_id);
2571 }
2572 }
2573
2574 Ok(())
2575}
2576
2577#[allow(clippy::unnecessary_wraps)]
2579fn process_attr_yard(
2580 attr_node: Node,
2581 content: &[u8],
2582 helper: &mut GraphBuildHelper,
2583) -> GraphResult<()> {
2584 let Some(yard_text) = extract_yard_comment(attr_node, content) else {
2586 return Ok(());
2587 };
2588
2589 let tags = parse_yard_tags(&yard_text);
2591
2592 let Some(var_type) = &tags.returns else {
2594 return Ok(());
2595 };
2596
2597 let attr_names = extract_attr_names(attr_node, content);
2599
2600 if attr_names.is_empty() {
2601 return Ok(());
2602 }
2603
2604 let class_name = get_enclosing_class_name(attr_node, content);
2606
2607 for attr_name in attr_names {
2609 let qualified_name = if let Some(ref class) = class_name {
2611 format!("{class}#{attr_name}")
2612 } else {
2613 attr_name.clone()
2614 };
2615
2616 let attr_node_id = helper.add_variable(&qualified_name, None);
2618
2619 let canonical_type = canonical_type_string(var_type);
2621 let type_node_id = helper.add_type(&canonical_type, None);
2622 helper.add_typeof_edge_with_context(
2623 attr_node_id,
2624 type_node_id,
2625 Some(TypeOfContext::Field),
2626 None,
2627 Some(&attr_name),
2628 );
2629
2630 let type_names = extract_type_names(var_type);
2632 for type_name in type_names {
2633 let ref_type_id = helper.add_type(&type_name, None);
2634 helper.add_reference_edge(attr_node_id, ref_type_id);
2635 }
2636 }
2637
2638 Ok(())
2639}
2640
2641fn process_assignment_yard(
2643 assignment_node: Node,
2644 content: &[u8],
2645 helper: &mut GraphBuildHelper,
2646) -> GraphResult<()> {
2647 let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
2649 return Ok(());
2650 };
2651
2652 let tags = parse_yard_tags(&yard_text);
2654
2655 let Some(var_type) = &tags.type_annotation else {
2657 return Ok(());
2658 };
2659
2660 let Some(left_node) = assignment_node.child_by_field_name("left") else {
2662 return Ok(());
2663 };
2664
2665 if left_node.kind() != "instance_variable" {
2666 return Ok(());
2667 }
2668
2669 let var_name = left_node
2670 .utf8_text(content)
2671 .map_err(|_| GraphBuilderError::ParseError {
2672 span: span_from_node(assignment_node),
2673 reason: "failed to read variable name".to_string(),
2674 })?
2675 .trim()
2676 .to_string();
2677
2678 if var_name.is_empty() {
2679 return Ok(());
2680 }
2681
2682 let class_name = get_enclosing_class_name(assignment_node, content);
2684
2685 let qualified_name = if let Some(class) = class_name {
2687 format!("{class}#{var_name}")
2688 } else {
2689 var_name.clone()
2690 };
2691
2692 let var_node_id = helper.add_variable(&qualified_name, None);
2694
2695 let canonical_type = canonical_type_string(var_type);
2697 let type_node_id = helper.add_type(&canonical_type, None);
2698 helper.add_typeof_edge_with_context(
2699 var_node_id,
2700 type_node_id,
2701 Some(TypeOfContext::Variable),
2702 None,
2703 Some(&var_name),
2704 );
2705
2706 let type_names = extract_type_names(var_type);
2708 for type_name in type_names {
2709 let ref_type_id = helper.add_type(&type_name, None);
2710 helper.add_reference_edge(var_node_id, ref_type_id);
2711 }
2712
2713 Ok(())
2714}
2715
2716fn is_attr_call(node: Node, content: &[u8]) -> bool {
2718 let method_name = 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
2728 method_name
2729 .is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
2730}
2731
2732fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
2734 if let Some(left_node) = node.child_by_field_name("left") {
2735 left_node.kind() == "instance_variable"
2736 } else {
2737 false
2738 }
2739}
2740
2741fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
2744 let mut names = Vec::new();
2745
2746 let arguments = attr_node.child_by_field_name("arguments");
2748
2749 if let Some(args) = arguments {
2750 let mut cursor = args.walk();
2752 for child in args.children(&mut cursor) {
2753 if matches!(child.kind(), "symbol" | "simple_symbol")
2754 && let Ok(text) = child.utf8_text(content)
2755 {
2756 let cleaned = text.trim().trim_start_matches(':');
2757 if !cleaned.is_empty() {
2758 names.push(cleaned.to_string());
2759 }
2760 } else if child.kind() == "string"
2761 && let Ok(text) = child.utf8_text(content)
2762 {
2763 let cleaned = text
2765 .trim()
2766 .trim_start_matches(['\'', '"'])
2767 .trim_end_matches(['\'', '"']);
2768 if !cleaned.is_empty() {
2769 names.push(cleaned.to_string());
2770 }
2771 }
2772 }
2773 } else if matches!(attr_node.kind(), "command" | "command_call") {
2774 let mut cursor = attr_node.walk();
2776 let mut found_method = false;
2777 for child in attr_node.children(&mut cursor) {
2778 if !child.is_named() {
2779 continue;
2780 }
2781 if !found_method {
2783 found_method = true;
2784 continue;
2785 }
2786 if matches!(child.kind(), "symbol" | "simple_symbol")
2788 && let Ok(text) = child.utf8_text(content)
2789 {
2790 let cleaned = text.trim().trim_start_matches(':');
2791 if !cleaned.is_empty() {
2792 names.push(cleaned.to_string());
2793 }
2794 } else if child.kind() == "string"
2795 && let Ok(text) = child.utf8_text(content)
2796 {
2797 let cleaned = text
2799 .trim()
2800 .trim_start_matches(['\'', '"'])
2801 .trim_end_matches(['\'', '"']);
2802 if !cleaned.is_empty() {
2803 names.push(cleaned.to_string());
2804 }
2805 }
2806 }
2807 }
2808
2809 names
2810}
2811
2812fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
2817 let mut current = node;
2818 let mut namespace_parts = Vec::new();
2819
2820 while let Some(parent) = current.parent() {
2822 if matches!(parent.kind(), "class" | "module") {
2823 if let Some(name_node) = parent.child_by_field_name("name")
2825 && let Ok(name_text) = name_node.utf8_text(content)
2826 {
2827 let trimmed = name_text.trim();
2828 if trimmed.starts_with("::") {
2830 namespace_parts.clear();
2832 namespace_parts.push(trimmed.trim_start_matches("::").to_string());
2833 break;
2834 }
2835 namespace_parts.insert(0, trimmed.to_string());
2837 }
2838 }
2839 current = parent;
2840 }
2841
2842 if namespace_parts.is_empty() {
2844 None
2845 } else {
2846 Some(namespace_parts.join("::"))
2847 }
2848}