1use std::{
2 collections::{HashMap, HashSet},
3 path::Path,
4};
5
6use std::sync::OnceLock;
7
8use sqry_core::graph::unified::build::helper::CalleeKindHint;
9use sqry_core::graph::unified::build::shape::{CfBucket, ShapeMapping};
10use sqry_core::graph::unified::edge::FfiConvention;
11use sqry_core::graph::unified::edge::kind::TypeOfContext;
12use sqry_core::graph::unified::storage::shape::SignatureShape;
13use sqry_core::graph::unified::{GraphBuildHelper, StagingGraph};
14use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
15use tree_sitter::{Node, Point, Tree};
16
17use super::type_extractor::{canonical_type_string, extract_type_names};
18use super::yard_parser::{extract_yard_comment, parse_yard_tags};
19
20const DEFAULT_SCOPE_DEPTH: usize = 4;
21
22const FILE_MODULE_NAME: &str = "<file_module>";
25
26type CallEdgeData = (String, String, usize, Span, bool);
27
28#[derive(Debug, Clone, Copy)]
34pub struct RubyGraphBuilder {
35 max_scope_depth: usize,
36}
37
38impl Default for RubyGraphBuilder {
39 fn default() -> Self {
40 Self {
41 max_scope_depth: DEFAULT_SCOPE_DEPTH,
42 }
43 }
44}
45
46impl RubyGraphBuilder {
47 #[must_use]
49 pub fn new(max_scope_depth: usize) -> Self {
50 Self { max_scope_depth }
51 }
52}
53
54impl GraphBuilder for RubyGraphBuilder {
55 fn build_graph(
56 &self,
57 tree: &Tree,
58 content: &[u8],
59 file: &Path,
60 staging: &mut StagingGraph,
61 ) -> GraphResult<()> {
62 let mut helper = GraphBuildHelper::new(staging, file, Language::Ruby);
64
65 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
67 GraphBuilderError::ParseError {
68 span: Span::default(),
69 reason: e,
70 }
71 })?;
72
73 walk_tree_for_graph(
75 tree.root_node(),
76 content,
77 &ast_graph,
78 &mut helper,
79 &ast_graph.ffi_enabled_scopes,
80 )?;
81
82 apply_controller_dsl_hooks(&ast_graph, &mut helper);
83
84 process_yard_annotations(tree.root_node(), content, &ast_graph, &mut helper)?;
86
87 Ok(())
88 }
89
90 fn language(&self) -> Language {
91 Language::Ruby
92 }
93
94 fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
95 Some(ruby_shape_mapping())
96 }
97}
98
99pub struct RubyShapeMapping {
109 cf_by_kind_id: Vec<Option<CfBucket>>,
110}
111
112impl RubyShapeMapping {
113 fn build() -> Self {
114 let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
115 let count = lang.node_kind_count();
116 let mut cf_by_kind_id = vec![None; count];
117 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
118 let Ok(kind_id) = u16::try_from(id) else {
119 break;
120 };
121 if !lang.node_kind_is_named(kind_id) {
122 continue;
123 }
124 if let Some(name) = lang.node_kind_for_id(kind_id) {
125 *slot = cf_bucket_for_ruby_kind(name);
126 }
127 }
128 Self { cf_by_kind_id }
129 }
130}
131
132impl ShapeMapping for RubyShapeMapping {
133 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
134 self.cf_by_kind_id
135 .get(ts_node_kind_id as usize)
136 .copied()
137 .flatten()
138 }
139
140 fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
141 let mut shape = SignatureShape::default();
142 if let Some(params) = fn_node.child_by_field_name("parameters") {
143 let mut cursor = params.walk();
144 for child in params.named_children(&mut cursor) {
145 match child.kind() {
146 "identifier" => {
147 shape.arity_positional = shape.arity_positional.saturating_add(1);
148 }
149 "optional_parameter" => {
150 shape.arity_positional = shape.arity_positional.saturating_add(1);
151 shape.has_defaults = true;
152 }
153 "keyword_parameter" => {
154 shape.arity_keyword_only = shape.arity_keyword_only.saturating_add(1);
155 }
156 "splat_parameter" | "forward_parameter" => shape.has_varargs = true,
157 "hash_splat_parameter" => shape.has_kwargs = true,
158 _ => {}
161 }
162 }
163 }
164 shape
165 }
166}
167
168fn cf_bucket_for_ruby_kind(name: &str) -> Option<CfBucket> {
171 let bucket = match name {
172 "if" | "elsif" | "else" | "unless" | "if_modifier" | "unless_modifier" | "conditional" => {
173 CfBucket::Branch
174 }
175 "while" | "until" | "for" | "while_modifier" | "until_modifier" => CfBucket::Loop,
176 "case" | "case_match" | "when" | "in" => CfBucket::Match,
177 "begin" => CfBucket::Try,
178 "rescue" | "rescue_modifier" => CfBucket::Catch,
179 "ensure" => CfBucket::Resource,
180 "return" => CfBucket::Return,
181 "yield" => CfBucket::Yield,
182 "break" | "next" | "redo" | "retry" => CfBucket::BreakContinue,
183 "call" | "command_call" | "method_call" => CfBucket::Call,
184 "assignment" | "operator_assignment" => CfBucket::Assign,
185 "do_block" | "block" | "lambda" => CfBucket::Closure,
186 _ => return None,
187 };
188 Some(bucket)
189}
190
191#[must_use]
193pub fn ruby_shape_mapping() -> &'static RubyShapeMapping {
194 static MAPPING: OnceLock<RubyShapeMapping> = OnceLock::new();
195 MAPPING.get_or_init(RubyShapeMapping::build)
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum Visibility {
200 Public,
201 Protected,
202 Private,
203}
204
205impl Visibility {
206 fn as_str(self) -> &'static str {
207 match self {
208 Visibility::Public => "public",
209 Visibility::Protected => "protected",
210 Visibility::Private => "private",
211 }
212 }
213
214 fn from_keyword(keyword: &str) -> Option<Self> {
215 match keyword {
216 "public" => Some(Visibility::Public),
217 "protected" => Some(Visibility::Protected),
218 "private" => Some(Visibility::Private),
219 _ => None,
220 }
221 }
222}
223
224#[derive(Debug, Clone)]
225enum RubyContextKind {
226 Method,
227 SingletonMethod,
228}
229
230#[derive(Debug, Clone)]
231struct ControllerDslHook {
232 container: String,
233 callbacks: Vec<String>,
234 only: Option<Vec<String>>, except: Option<Vec<String>>, }
237
238#[derive(Debug, Clone)]
239struct RubyContext {
240 qualified_name: String,
241 container: Option<String>,
242 kind: RubyContextKind,
243 visibility: Visibility,
244 start_position: Point,
245 end_position: Point,
246}
247
248impl RubyContext {
249 fn is_singleton(&self) -> bool {
250 matches!(self.kind, RubyContextKind::SingletonMethod)
251 }
252
253 fn qualified_name(&self) -> &str {
254 &self.qualified_name
255 }
256
257 fn container(&self) -> Option<&str> {
258 self.container.as_deref()
259 }
260
261 fn visibility(&self) -> Visibility {
262 self.visibility
263 }
264}
265
266struct ASTGraph {
267 contexts: Vec<RubyContext>,
268 node_to_context: HashMap<usize, usize>,
269 attr_visibility: HashMap<usize, Visibility>,
270 ffi_enabled_scopes: HashSet<Vec<String>>,
272 controller_dsl_hooks: Vec<ControllerDslHook>,
273}
274
275impl ASTGraph {
276 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
277 let mut builder = ContextBuilder::new(content, max_depth)?;
278 builder.walk(tree.root_node())?;
279 Ok(Self {
280 contexts: builder.contexts,
281 node_to_context: builder.node_to_context,
282 attr_visibility: builder.attr_visibility,
283 ffi_enabled_scopes: builder.ffi_enabled_scopes,
284 controller_dsl_hooks: builder.controller_dsl_hooks,
285 })
286 }
287
288 fn context_for_node(&self, node: &Node<'_>) -> Option<&RubyContext> {
289 self.node_to_context
290 .get(&node.id())
291 .and_then(|idx| self.contexts.get(*idx))
292 }
293
294 fn attr_visibility_for_node(&self, node: &Node<'_>) -> Visibility {
295 self.attr_visibility
296 .get(&node.id())
297 .copied()
298 .unwrap_or(Visibility::Public)
299 }
300}
301
302fn walk_tree_for_graph(
304 node: Node,
305 content: &[u8],
306 ast_graph: &ASTGraph,
307 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
308 ffi_enabled_scopes: &HashSet<Vec<String>>,
309) -> GraphResult<()> {
310 let mut current_namespace: Vec<String> = Vec::new();
312
313 walk_tree_for_graph_impl(
314 node,
315 content,
316 ast_graph,
317 helper,
318 ffi_enabled_scopes,
319 &mut current_namespace,
320 )
321}
322
323fn apply_controller_dsl_hooks(ast_graph: &ASTGraph, helper: &mut GraphBuildHelper) {
324 if ast_graph.controller_dsl_hooks.is_empty() {
325 return;
326 }
327
328 let mut actions_by_container: HashMap<String, Vec<String>> = HashMap::new();
329 for context in &ast_graph.contexts {
330 if !matches!(context.kind, RubyContextKind::Method) {
331 continue;
332 }
333 let Some(container) = context.container() else {
334 continue;
335 };
336 let Some(action_name) = context.qualified_name.rsplit('#').next() else {
337 continue;
338 };
339 actions_by_container
340 .entry(container.to_string())
341 .or_default()
342 .push(action_name.to_string());
343 }
344
345 let mut emitted: HashSet<(String, String)> = HashSet::new();
346 for hook in &ast_graph.controller_dsl_hooks {
347 let Some(actions) = actions_by_container.get(&hook.container) else {
348 continue;
349 };
350
351 for action in actions {
352 let included = if let Some(only) = &hook.only {
353 only.iter().any(|name| name == action)
354 } else if let Some(except) = &hook.except {
355 !except.iter().any(|name| name == action)
356 } else {
357 true
358 };
359
360 if !included {
361 continue;
362 }
363
364 for callback in &hook.callbacks {
365 if callback.trim().is_empty() {
366 continue;
367 }
368
369 let action_qname = format!("{}#{}", hook.container, action);
370 let callback_qname = format!("{}#{}", hook.container, callback);
371 if !emitted.insert((action_qname.clone(), callback_qname.clone())) {
372 continue;
373 }
374
375 let action_id = helper.ensure_method(&action_qname, None, false, false);
376 let callback_id = helper.ensure_method(&callback_qname, None, false, false);
377 helper.add_call_edge_full_with_span(action_id, callback_id, 255, false, vec![]);
378 }
379 }
380 }
381}
382
383#[allow(
385 clippy::too_many_lines,
386 reason = "Ruby graph extraction handles DSLs and FFI patterns in one traversal."
387)]
388fn walk_tree_for_graph_impl(
389 node: Node,
390 content: &[u8],
391 ast_graph: &ASTGraph,
392 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
393 ffi_enabled_scopes: &HashSet<Vec<String>>,
394 current_namespace: &mut Vec<String>,
395) -> GraphResult<()> {
396 match node.kind() {
397 "class" => {
398 if let Some(name_node) = node.child_by_field_name("name")
400 && let Ok(class_name) = name_node.utf8_text(content)
401 {
402 let span = span_from_points(node.start_position(), node.end_position());
403 let qualified_name = class_name.to_string();
404 let class_id = helper.add_class(&qualified_name, Some(span));
405 helper.mark_definition(class_id);
407
408 let module_id = helper.add_module(FILE_MODULE_NAME, None);
411 helper.add_export_edge(module_id, class_id);
412
413 if let Some(superclass_node) = node.child_by_field_name("superclass")
415 && let Ok(superclass_name) = superclass_node.utf8_text(content)
416 {
417 let superclass_name = superclass_name.trim();
418 if !superclass_name.is_empty() {
419 let parent_id = helper.add_class(superclass_name, None);
421 helper.add_inherits_edge(class_id, parent_id);
422 }
423 }
424
425 current_namespace.push(class_name.trim().to_string());
427
428 let mut cursor = node.walk();
430 for child in node.children(&mut cursor) {
431 walk_tree_for_graph_impl(
432 child,
433 content,
434 ast_graph,
435 helper,
436 ffi_enabled_scopes,
437 current_namespace,
438 )?;
439 }
440
441 current_namespace.pop();
442 return Ok(());
443 }
444 }
445 "module" => {
446 if let Some(name_node) = node.child_by_field_name("name")
448 && let Ok(module_name) = name_node.utf8_text(content)
449 {
450 let span = span_from_points(node.start_position(), node.end_position());
451 let qualified_name = module_name.to_string();
452 let mod_id = helper.add_module(&qualified_name, Some(span));
453 helper.mark_definition(mod_id);
455
456 let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
459 helper.add_export_edge(file_module_id, mod_id);
460
461 current_namespace.push(module_name.trim().to_string());
463
464 let mut cursor = node.walk();
466 for child in node.children(&mut cursor) {
467 walk_tree_for_graph_impl(
468 child,
469 content,
470 ast_graph,
471 helper,
472 ffi_enabled_scopes,
473 current_namespace,
474 )?;
475 }
476
477 current_namespace.pop();
478 return Ok(());
479 }
480 }
481 "method" | "singleton_method" => {
482 if let Some(context) = ast_graph.context_for_node(&node) {
484 let span = span_from_points(context.start_position, context.end_position);
485
486 let is_async = detect_async_method(node, content);
488
489 let params = node
491 .child_by_field_name("parameters")
492 .and_then(|params_node| extract_method_parameters(params_node, content));
493
494 let return_type = extract_return_type(node, content);
496
497 let signature = match (params.as_ref(), return_type.as_ref()) {
499 (Some(p), Some(r)) => Some(format!("{p} -> {r}")),
500 (Some(p), None) => Some(p.clone()),
501 (None, Some(r)) => Some(format!("-> {r}")),
502 (None, None) => None,
503 };
504
505 let visibility = context.visibility().as_str();
507
508 let method_id = helper.add_method_with_signature(
510 context.qualified_name(),
511 Some(span),
512 is_async,
513 context.is_singleton(),
514 Some(visibility),
515 signature.as_deref(),
516 );
517
518 if context.visibility() == Visibility::Public {
521 let module_id = helper.add_module(FILE_MODULE_NAME, None);
522 helper.add_export_edge(module_id, method_id);
523 }
524 }
525 }
526 "assignment" => {
527 if let Some(left_node) = node.child_by_field_name("left")
529 && left_node.kind() == "constant"
530 && let Ok(const_name) = left_node.utf8_text(content)
531 {
532 let qualified_name = if current_namespace.is_empty() {
534 const_name.to_string()
535 } else {
536 format!("{}::{}", current_namespace.join("::"), const_name)
537 };
538
539 let span = span_from_points(node.start_position(), node.end_position());
540 let const_id = helper.add_constant(&qualified_name, Some(span));
541
542 let module_id = helper.add_module(FILE_MODULE_NAME, None);
544 helper.add_export_edge(module_id, const_id);
545 }
546 }
547 "call" | "command" | "command_call" | "identifier" | "super" => {
548 if is_include_or_extend_statement(node, content) {
550 handle_include_extend(node, content, helper, current_namespace);
551 }
552 else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
556 } else if is_require_statement(node, content) {
558 if let Some((from_qname, to_qname)) =
560 build_import_for_staging(node, content, helper.file_path())
561 {
562 let from_id = helper.add_import(&from_qname, None);
564 let to_id = helper.add_import(
565 &to_qname,
566 Some(span_from_points(node.start_position(), node.end_position())),
567 );
568
569 helper.add_import_edge(from_id, to_id);
571 }
572 } else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
573 build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
575 } else {
576 if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
578 build_call_for_staging(ast_graph, node, content)
579 {
580 let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
582 let target_id =
583 helper.ensure_callee(&target_qname, span, CalleeKindHint::Function);
584
585 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
587 helper.add_call_edge_full_with_span(
588 source_id,
589 target_id,
590 argument_count,
591 false,
592 vec![span],
593 );
594 }
595 }
596 }
597 _ => {}
598 }
599
600 let mut cursor = node.walk();
602 for child in node.children(&mut cursor) {
603 walk_tree_for_graph_impl(
604 child,
605 content,
606 ast_graph,
607 helper,
608 ffi_enabled_scopes,
609 current_namespace,
610 )?;
611 }
612
613 Ok(())
614}
615
616fn is_ffi_attach_function(
627 node: Node,
628 content: &[u8],
629 ffi_enabled_scopes: &HashSet<Vec<String>>,
630 current_namespace: &[String],
631) -> bool {
632 let method_name = match node.kind() {
634 "command" => node
635 .child_by_field_name("name")
636 .and_then(|n| n.utf8_text(content).ok()),
637 "call" | "command_call" => node
638 .child_by_field_name("method")
639 .and_then(|n| n.utf8_text(content).ok()),
640 _ => None,
641 };
642
643 let Some(method_name) = method_name else {
644 return false;
645 };
646 let method_name = method_name.trim();
647 if !matches!(
648 method_name,
649 "attach_function" | "attach_variable" | "ffi_lib" | "callback"
650 ) {
651 return false;
652 }
653
654 let receiver = match node.kind() {
655 "call" | "command_call" | "method_call" => node
656 .child_by_field_name("receiver")
657 .and_then(|n| n.utf8_text(content).ok()),
658 _ => None,
659 };
660 if let Some(receiver) = receiver {
661 let trimmed = receiver.trim();
662 if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
663 return true;
664 }
665 }
666
667 ffi_enabled_scopes.contains(current_namespace)
668}
669
670fn build_ffi_edge_for_attach_function(
677 node: Node,
678 content: &[u8],
679 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
680 current_namespace: &[String],
681) {
682 let arguments = node.child_by_field_name("arguments");
684
685 let func_name = if let Some(args) = arguments {
687 extract_first_symbol_from_arguments(args, content)
688 } else {
689 let mut cursor = node.walk();
691 let mut found_name = false;
692 let mut result = None;
693 for child in node.children(&mut cursor) {
694 if !child.is_named() {
695 continue;
696 }
697 if !found_name {
699 found_name = true;
700 continue;
701 }
702 if matches!(child.kind(), "symbol" | "simple_symbol")
704 && let Ok(text) = child.utf8_text(content)
705 {
706 result = Some(text.trim().trim_start_matches(':').to_string());
707 break;
708 }
709 }
710 result
711 };
712
713 let Some(func_name) = func_name else {
714 return;
715 };
716
717 let caller_name = if current_namespace.is_empty() {
719 "<module>".to_string()
720 } else {
721 current_namespace.join("::")
722 };
723
724 let caller_id = helper.add_module(&caller_name, None);
726
727 let ffi_func_name = format!("ffi::{func_name}");
729 let span = span_from_points(node.start_position(), node.end_position());
730 let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
731
732 helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
734}
735
736fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
738 let mut cursor = arguments.walk();
739 for child in arguments.children(&mut cursor) {
740 if matches!(child.kind(), "symbol" | "simple_symbol")
741 && let Ok(text) = child.utf8_text(content)
742 {
743 return Some(text.trim().trim_start_matches(':').to_string());
744 }
745 if child.kind() == "bare_symbol"
747 && let Ok(text) = child.utf8_text(content)
748 {
749 return Some(text.trim().to_string());
750 }
751 }
752 None
753}
754
755fn build_call_for_staging(
757 ast_graph: &ASTGraph,
758 call_node: Node<'_>,
759 content: &[u8],
760) -> GraphResult<Option<CallEdgeData>> {
761 let Some(call_context) = ast_graph.context_for_node(&call_node) else {
762 return Ok(None);
763 };
764
765 let Some(method_call) = extract_method_call(call_node, content)? else {
766 return Ok(None);
767 };
768
769 if is_visibility_command(&method_call) {
770 return Ok(None);
771 }
772
773 let source_qualified = call_context.qualified_name().to_string();
774 let target_name = resolve_callee(&method_call, call_context);
775
776 if target_name.is_empty() {
777 return Ok(None);
778 }
779
780 let span = span_from_node(call_node);
781 let argument_count = count_arguments(method_call.arguments, content);
782 let is_singleton = call_context.is_singleton();
783
784 Ok(Some((
785 source_qualified,
786 target_name,
787 argument_count,
788 span,
789 is_singleton,
790 )))
791}
792
793fn build_import_for_staging(
795 require_node: Node<'_>,
796 content: &[u8],
797 file_path: &str,
798) -> Option<(String, String)> {
799 let method_name = match require_node.kind() {
801 "command" => require_node
802 .child_by_field_name("name")
803 .and_then(|n| n.utf8_text(content).ok())
804 .map(|s| s.trim().to_string()),
805 "call" | "method_call" => require_node
806 .child_by_field_name("method")
807 .and_then(|n| n.utf8_text(content).ok())
808 .map(|s| s.trim().to_string()),
809 _ => None,
810 };
811
812 let method_name = method_name?;
813
814 if !matches!(method_name.as_str(), "require" | "require_relative") {
816 return None;
817 }
818
819 let arguments = require_node.child_by_field_name("arguments");
821 let module_name = if let Some(args) = arguments {
822 extract_require_module_name(args, content)
823 } else {
824 let mut cursor = require_node.walk();
826 let mut found_name = false;
827 let mut result = None;
828 for child in require_node.children(&mut cursor) {
829 if !child.is_named() {
830 continue;
831 }
832 if !found_name {
833 found_name = true;
834 continue;
835 }
836 result = extract_string_content(child, content);
838 break;
839 }
840 result
841 };
842
843 let module_name = module_name?;
844
845 if module_name.is_empty() {
846 return None;
847 }
848
849 let is_relative = method_name == "require_relative";
851 let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
852
853 Some(("<module>".to_string(), resolved_path))
855}
856
857fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
858 node.kind() == "identifier"
859 && node
860 .parent()
861 .is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
862}
863
864fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
874 let body_node = method_node.child_by_field_name("body");
876 if body_node.is_none() {
877 return false;
878 }
879 let body_node = body_node.unwrap();
880
881 if let Ok(body_text) = body_node.utf8_text(content) {
883 let body_lower = body_text.to_lowercase();
884
885 if body_lower.contains("fiber.")
887 || body_lower.contains("fiber.new")
888 || body_lower.contains("fiber.yield")
889 || body_lower.contains("fiber.resume")
890 || body_lower.contains("thread.new")
891 || body_lower.contains("thread.start")
892 || body_lower.contains("async do")
893 || body_lower.contains("async {")
894 || body_lower.contains("async.reactor")
895 || body_lower.contains("concurrent::")
896 {
897 return true;
898 }
899 }
900
901 false
902}
903
904fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
906 let method_name = match node.kind() {
907 "command" => node
908 .child_by_field_name("name")
909 .and_then(|n| n.utf8_text(content).ok()),
910 "call" | "method_call" => node
911 .child_by_field_name("method")
912 .and_then(|n| n.utf8_text(content).ok()),
913 _ => None,
914 };
915
916 method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
917}
918
919fn handle_include_extend(
927 node: Node<'_>,
928 content: &[u8],
929 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
930 current_namespace: &[String],
931) {
932 let module_name = if let Some(args) = node.child_by_field_name("arguments") {
934 extract_first_constant_from_arguments(args, content)
935 } else if node.kind() == "command" {
936 let mut cursor = node.walk();
938 let mut found_method = false;
939 let mut result = None;
940 for child in node.children(&mut cursor) {
941 if !child.is_named() {
942 continue;
943 }
944 if !found_method {
946 found_method = true;
947 continue;
948 }
949 if child.kind() == "constant"
951 && let Ok(text) = child.utf8_text(content)
952 {
953 result = Some(text.trim().to_string());
954 break;
955 }
956 }
957 result
958 } else {
959 None
960 };
961
962 let Some(module_name) = module_name else {
963 return;
964 };
965
966 let class_name = if current_namespace.is_empty() {
968 return; } else {
970 current_namespace.join("::")
971 };
972
973 let class_id = helper.add_class(&class_name, None);
975 let module_id = helper.add_module(&module_name, None);
976
977 helper.add_implements_edge(class_id, module_id);
979}
980
981fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
983 let mut cursor = args_node.walk();
984 for child in args_node.children(&mut cursor) {
985 if !child.is_named() {
986 continue;
987 }
988 if child.kind() == "constant"
990 && let Ok(text) = child.utf8_text(content)
991 {
992 return Some(text.trim().to_string());
993 }
994 }
995 None
996}
997
998fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
1000 let method_name = match node.kind() {
1001 "command" => node
1002 .child_by_field_name("name")
1003 .and_then(|n| n.utf8_text(content).ok()),
1004 "call" | "method_call" => node
1005 .child_by_field_name("method")
1006 .and_then(|n| n.utf8_text(content).ok()),
1007 _ => None,
1008 };
1009
1010 method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
1011}
1012
1013struct ContextBuilder<'a> {
1014 contexts: Vec<RubyContext>,
1015 node_to_context: HashMap<usize, usize>,
1016 attr_visibility: HashMap<usize, Visibility>,
1017 namespace: Vec<String>,
1018 visibility_stack: Vec<Visibility>,
1019 ffi_enabled_scopes: HashSet<Vec<String>>,
1020 controller_dsl_hooks: Vec<ControllerDslHook>,
1021 max_depth: usize,
1022 content: &'a [u8],
1023 guard: sqry_core::query::security::RecursionGuard,
1024}
1025
1026impl<'a> ContextBuilder<'a> {
1027 fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
1028 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
1029 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
1030 let file_ops_depth = recursion_limits
1031 .effective_file_ops_depth()
1032 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
1033 let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
1034 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
1035
1036 Ok(Self {
1037 contexts: Vec::new(),
1038 node_to_context: HashMap::new(),
1039 attr_visibility: HashMap::new(),
1040 namespace: Vec::new(),
1041 visibility_stack: vec![Visibility::Public],
1042 ffi_enabled_scopes: HashSet::new(),
1043 controller_dsl_hooks: Vec::new(),
1044 max_depth,
1045 content,
1046 guard,
1047 })
1048 }
1049
1050 fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
1054 self.guard
1055 .enter()
1056 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
1057
1058 match node.kind() {
1059 "class" => self.visit_class(node)?,
1060 "module" => self.visit_module(node)?,
1061 "singleton_class" => self.visit_singleton_class(node)?,
1062 "method" => self.visit_method(node)?,
1063 "singleton_method" => self.visit_singleton_method(node)?,
1064 "command" | "command_call" | "call" => {
1065 self.detect_ffi_extend(node)?;
1066 self.detect_controller_dsl(node)?;
1067 self.record_attr_visibility(node);
1068 self.adjust_visibility(node)?;
1069 self.walk_children(node)?;
1070 }
1071 "identifier" => {
1072 self.adjust_visibility_from_identifier(node)?;
1075 self.walk_children(node)?;
1076 }
1077 _ => self.walk_children(node)?,
1078 }
1079
1080 self.guard.exit();
1081 Ok(())
1082 }
1083
1084 fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
1085 let name_node = node
1086 .child_by_field_name("name")
1087 .ok_or_else(|| "class node missing name".to_string())?;
1088 let class_name = self.node_text(name_node)?;
1089
1090 if self.namespace.len() > self.max_depth {
1091 return Ok(());
1092 }
1093
1094 self.namespace.push(class_name);
1095 self.visibility_stack.push(Visibility::Public);
1096
1097 self.walk_children(node)?;
1098
1099 self.visibility_stack.pop();
1100 self.namespace.pop();
1101 Ok(())
1102 }
1103
1104 fn visit_module(&mut self, node: Node<'a>) -> Result<(), String> {
1105 let name_node = node
1106 .child_by_field_name("name")
1107 .ok_or_else(|| "module node missing name".to_string())?;
1108 let module_name = self.node_text(name_node)?;
1109
1110 if self.namespace.len() > self.max_depth {
1111 return Ok(());
1112 }
1113
1114 self.namespace.push(module_name);
1115 self.visibility_stack.push(Visibility::Public);
1116
1117 self.walk_children(node)?;
1118
1119 self.visibility_stack.pop();
1120 self.namespace.pop();
1121 Ok(())
1122 }
1123
1124 fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
1125 let name_node = node
1126 .child_by_field_name("name")
1127 .ok_or_else(|| "method node missing name".to_string())?;
1128 let method_name = self.node_text(name_node)?;
1129
1130 let (qualified_name, container) =
1131 method_qualified_name(&self.namespace, &method_name, false);
1132
1133 let visibility = inline_visibility_for_method(node, self.content)
1134 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1135
1136 let context = RubyContext {
1137 qualified_name,
1138 container,
1139 kind: RubyContextKind::Method,
1140 visibility,
1141 start_position: node.start_position(),
1142 end_position: node.end_position(),
1143 };
1144
1145 let idx = self.contexts.len();
1146 self.contexts.push(context);
1147 associate_descendants(node, idx, &mut self.node_to_context);
1148
1149 self.walk_children(node)?;
1150 Ok(())
1151 }
1152
1153 fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
1154 let value_node = node
1156 .child_by_field_name("value")
1157 .ok_or_else(|| "singleton_class missing value".to_string())?;
1158 let object_text = self.node_text(value_node)?;
1159
1160 let scope_name = if object_text == "self" {
1162 if let Some(current_class) = self.namespace.last() {
1164 format!("<<{current_class}>>")
1165 } else {
1166 "<<main>>".to_string()
1167 }
1168 } else {
1169 format!("<<{object_text}>>")
1171 };
1172
1173 if self.namespace.len() > self.max_depth {
1174 return Ok(());
1175 }
1176
1177 self.namespace.push(scope_name);
1179 self.visibility_stack.push(Visibility::Public);
1180
1181 self.visit_singleton_class_body(node)?;
1183
1184 self.visibility_stack.pop();
1186 self.namespace.pop();
1187 Ok(())
1188 }
1189
1190 fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
1191 let mut cursor = node.walk();
1192 for child in node.children(&mut cursor) {
1193 if !child.is_named() {
1194 continue;
1195 }
1196
1197 if child.kind() == "method" {
1199 self.visit_method_as_singleton(child)?;
1200 } else {
1201 self.walk(child)?;
1202 }
1203 }
1204 Ok(())
1205 }
1206
1207 fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
1208 let name_node = node
1209 .child_by_field_name("name")
1210 .ok_or_else(|| "method node missing name".to_string())?;
1211 let method_name = self.node_text(name_node)?;
1212
1213 let actual_namespace: Vec<String> = self
1215 .namespace
1216 .iter()
1217 .map(|s| {
1218 if s.starts_with("<<") && s.ends_with(">>") {
1219 s[2..s.len() - 2].to_string()
1221 } else {
1222 s.clone()
1223 }
1224 })
1225 .collect();
1226
1227 let (qualified_name, container) =
1228 method_qualified_name(&actual_namespace, &method_name, true);
1229
1230 let visibility = inline_visibility_for_method(node, self.content)
1231 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1232
1233 let context = RubyContext {
1234 qualified_name,
1235 container,
1236 kind: RubyContextKind::SingletonMethod,
1237 visibility,
1238 start_position: node.start_position(),
1239 end_position: node.end_position(),
1240 };
1241
1242 let idx = self.contexts.len();
1243 self.contexts.push(context);
1244 associate_descendants(node, idx, &mut self.node_to_context);
1245
1246 self.walk_children(node)?;
1247 Ok(())
1248 }
1249
1250 fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
1251 let name_node = node
1252 .child_by_field_name("name")
1253 .ok_or_else(|| "singleton_method missing name".to_string())?;
1254 let method_name = self.node_text(name_node)?;
1255
1256 let object_node = node
1257 .child_by_field_name("object")
1258 .ok_or_else(|| "singleton_method missing object".to_string())?;
1259 let object_text = self.node_text(object_node)?;
1260
1261 let (qualified_name, container) =
1262 singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
1263
1264 let visibility = inline_visibility_for_method(node, self.content)
1265 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1266
1267 let context = RubyContext {
1268 qualified_name,
1269 container,
1270 kind: RubyContextKind::SingletonMethod,
1271 visibility,
1272 start_position: node.start_position(),
1273 end_position: node.end_position(),
1274 };
1275
1276 let idx = self.contexts.len();
1277 self.contexts.push(context);
1278 associate_descendants(node, idx, &mut self.node_to_context);
1279
1280 self.walk_children(node)?;
1281 Ok(())
1282 }
1283
1284 fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
1285 let name_node = node.child_by_field_name("name");
1286 let Some(name_node) = name_node else {
1287 return Ok(());
1288 };
1289
1290 let keyword = self.node_text(name_node)?;
1291 if keyword.trim() != "extend" {
1292 return Ok(());
1293 }
1294
1295 let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
1296 node_text_raw(arguments, self.content).unwrap_or_default()
1297 } else {
1298 let mut cursor = node.walk();
1299 let mut found_name = false;
1300 let mut result = String::new();
1301 for child in node.children(&mut cursor) {
1302 if !child.is_named() {
1303 continue;
1304 }
1305 if !found_name {
1306 found_name = true;
1307 continue;
1308 }
1309 if let Some(text) = node_text_raw(child, self.content) {
1310 result = text;
1311 break;
1312 }
1313 }
1314 result
1315 };
1316
1317 if arg_text.contains("FFI::Library") {
1318 self.ffi_enabled_scopes.insert(self.namespace.clone());
1320 }
1321
1322 Ok(())
1323 }
1324
1325 fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
1326 let name_node = node
1327 .child_by_field_name("name")
1328 .or_else(|| node.child_by_field_name("method"));
1329 let Some(name_node) = name_node else {
1330 return Ok(());
1331 };
1332 let dsl = self.node_text(name_node)?;
1333
1334 let is_controller_callback = matches!(
1335 dsl.as_str(),
1336 "before_action" | "after_action" | "around_action"
1337 );
1338 if !is_controller_callback {
1339 return Ok(());
1340 }
1341
1342 if self.namespace.is_empty() {
1343 return Ok(());
1344 }
1345 let container = self.namespace.join("::");
1346
1347 let mut callbacks: Vec<String> = Vec::new();
1348 let mut only: Option<Vec<String>> = None;
1349 let mut except: Option<Vec<String>> = None;
1350
1351 if let Some(arguments) = node.child_by_field_name("arguments") {
1352 let mut cursor = arguments.walk();
1353 for child in arguments.children(&mut cursor) {
1354 if !child.is_named() {
1355 continue;
1356 }
1357 let kind = child.kind();
1358 match kind {
1359 "symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
1360 let mut v = extract_symbols_from_node(child, self.content);
1361 callbacks.append(&mut v);
1362 }
1363 "pair" => {
1364 let key = child.child_by_field_name("key");
1366 let val = child.child_by_field_name("value");
1367 if key.is_none() || val.is_none() {
1368 continue;
1369 }
1370 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1371 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1372 if key_text.contains("only") && !symbols.is_empty() {
1373 only = Some(symbols);
1374 } else if key_text.contains("except") && !symbols.is_empty() {
1375 except = Some(symbols);
1376 }
1377 }
1378 "hash" => {
1379 let mut hcur = child.walk();
1381 for pair in child.children(&mut hcur) {
1382 if !pair.is_named() {
1383 continue;
1384 }
1385 if pair.kind() != "pair" {
1386 continue;
1387 }
1388 let key = pair.child_by_field_name("key");
1389 let val = pair.child_by_field_name("value");
1390 if key.is_none() || val.is_none() {
1391 continue;
1392 }
1393 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1394 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1395 if key_text.contains("only") && !symbols.is_empty() {
1396 only = Some(symbols);
1397 } else if key_text.contains("except") && !symbols.is_empty() {
1398 except = Some(symbols);
1399 }
1400 }
1401 }
1402 _ => {}
1403 }
1404 }
1405 } else {
1406 if let Some(raw) = node_text_raw(node, self.content) {
1408 let (cbs, o, e) = parse_controller_dsl_args(&raw);
1409 callbacks = cbs;
1410 only = o;
1411 except = e;
1412 }
1413 }
1414
1415 if callbacks.is_empty() {
1416 return Ok(());
1417 }
1418
1419 self.controller_dsl_hooks.push(ControllerDslHook {
1420 container,
1421 callbacks,
1422 only,
1423 except,
1424 });
1425 Ok(())
1426 }
1427
1428 fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
1429 let name_node = node.child_by_field_name("name");
1430 let Some(name_node) = name_node else {
1431 return Ok(());
1432 };
1433
1434 let keyword = self.node_text(name_node)?;
1435 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1436 return Ok(());
1437 };
1438
1439 if !has_call_arguments(node)
1441 && let Some(last) = self.visibility_stack.last_mut()
1442 {
1443 *last = new_visibility;
1444 }
1445 Ok(())
1446 }
1447
1448 fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
1450 let keyword = self.node_text(node)?;
1451 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1452 return Ok(());
1453 };
1454
1455 if let Some(last) = self.visibility_stack.last_mut() {
1457 *last = new_visibility;
1458 }
1459
1460 Ok(())
1461 }
1462
1463 fn record_attr_visibility(&mut self, node: Node<'a>) {
1464 if !is_attr_call(node, self.content) {
1465 return;
1466 }
1467
1468 let visibility = self
1469 .visibility_stack
1470 .last()
1471 .copied()
1472 .unwrap_or(Visibility::Public);
1473 self.attr_visibility.insert(node.id(), visibility);
1474 }
1475
1476 fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
1477 let mut cursor = node.walk();
1478 for child in node.children(&mut cursor) {
1479 if child.is_named() {
1480 self.walk(child)?;
1481 }
1482 }
1483 Ok(())
1484 }
1485
1486 fn node_text(&self, node: Node<'a>) -> Result<String, String> {
1487 node.utf8_text(self.content)
1488 .map(|s| s.trim().to_string())
1489 .map_err(|err| err.to_string())
1490 }
1491}
1492
1493#[derive(Clone)]
1494struct MethodCall<'a> {
1495 name: String,
1496 receiver: Option<String>,
1497 arguments: Option<Node<'a>>,
1498 node: Node<'a>,
1499}
1500
1501fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
1502 let method_name = match node.kind() {
1503 "call" | "command_call" | "method_call" => {
1504 let method_node = node
1505 .child_by_field_name("method")
1506 .ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
1507 node_text(method_node, content)?
1508 }
1509 "command" => {
1510 let name_node = node
1511 .child_by_field_name("name")
1512 .ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
1513 node_text(name_node, content)?
1514 }
1515 "super" => "super".to_string(),
1516 "identifier" => {
1517 if !should_treat_identifier_as_call(node) {
1518 return Ok(None);
1519 }
1520 node_text(node, content)?
1521 }
1522 _ => return Ok(None),
1523 };
1524
1525 let receiver = match node.kind() {
1526 "call" | "command_call" | "method_call" => node
1527 .child_by_field_name("receiver")
1528 .and_then(|r| node_text(r, content).ok()),
1529 _ => None,
1530 };
1531
1532 let arguments = node.child_by_field_name("arguments");
1533
1534 Ok(Some(MethodCall {
1535 name: method_name,
1536 receiver,
1537 arguments,
1538 node,
1539 }))
1540}
1541
1542fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
1543 if let Some(parent) = node.parent() {
1544 let kind = parent.kind();
1545 if matches!(
1546 kind,
1547 "call"
1548 | "command"
1549 | "command_call"
1550 | "method_call"
1551 | "method"
1552 | "singleton_method"
1553 | "alias"
1554 | "symbol"
1555 ) {
1556 return false;
1557 }
1558
1559 if kind.contains("assignment")
1560 || matches!(
1561 kind,
1562 "parameters"
1563 | "method_parameters"
1564 | "block_parameters"
1565 | "lambda_parameters"
1566 | "constant_path"
1567 | "module"
1568 | "class"
1569 | "hash"
1570 | "pair"
1571 | "array"
1572 | "argument_list"
1573 )
1574 {
1575 return false;
1576 }
1577 }
1578
1579 true
1580}
1581
1582fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
1596 let name = method_call.name.trim();
1597 if name.is_empty() {
1598 return String::new();
1599 }
1600
1601 if name == "super" {
1603 return format!("super::{}", context.qualified_name());
1607 }
1608
1609 if let Some(receiver) = method_call.receiver.as_deref() {
1610 let receiver = receiver.trim();
1611 if receiver == "self" {
1612 if let Some(container) = context.container() {
1613 return format!("{container}.{name}");
1614 }
1615 return format!("self.{name}");
1616 }
1617
1618 if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
1619 let cleaned = receiver.trim_start_matches("::");
1620 if let Some(class_name) = cleaned.strip_suffix(".new") {
1622 return format!("{class_name}#{name}");
1623 }
1624 return format!("{cleaned}.{name}");
1625 }
1626
1627 return name.to_string();
1629 }
1630
1631 if context.is_singleton() {
1632 if let Some(container) = context.container() {
1633 return format!("{container}.{name}");
1634 }
1635 return name.to_string();
1636 }
1637
1638 if let Some(container) = context.container() {
1639 return format!("{container}#{name}");
1640 }
1641
1642 name.to_string()
1643}
1644
1645fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
1657 let Some(arguments) = arguments else {
1658 return 0;
1659 };
1660
1661 let mut count = 0;
1662 let mut cursor = arguments.walk();
1663 for child in arguments.children(&mut cursor) {
1664 if child.is_named()
1665 && !is_literal_delimiter(child.kind())
1666 && node_text(child, content)
1667 .map(|s| !s.trim().is_empty())
1668 .unwrap_or(false)
1669 {
1670 count += 1;
1671 }
1672 }
1673 count
1674}
1675
1676fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
1687 let mut stack = vec![node];
1688 while let Some(current) = stack.pop() {
1689 map.insert(current.id(), idx);
1690 let mut cursor = current.walk();
1691 for child in current.children(&mut cursor) {
1692 stack.push(child);
1693 }
1694 }
1695}
1696
1697fn method_qualified_name(
1711 namespace: &[String],
1712 method_name: &str,
1713 singleton: bool,
1714) -> (String, Option<String>) {
1715 if namespace.is_empty() {
1716 return (method_name.to_string(), None);
1717 }
1718
1719 let container = namespace.join("::");
1720 let qualified = if singleton {
1721 format!("{container}.{method_name}")
1722 } else {
1723 format!("{container}#{method_name}")
1724 };
1725 (qualified, Some(container))
1726}
1727
1728fn singleton_qualified_name(
1741 current_namespace: &[String],
1742 object_text: &str,
1743 method_name: &str,
1744) -> (String, Option<String>) {
1745 if object_text == "self" {
1746 if current_namespace.is_empty() {
1747 (method_name.to_string(), None)
1748 } else {
1749 let container = current_namespace.join("::");
1750 (format!("{container}.{method_name}"), Some(container))
1751 }
1752 } else {
1753 let parts = split_constant_path(object_text);
1754 if parts.is_empty() {
1755 (method_name.to_string(), None)
1756 } else {
1757 let container = parts.join("::");
1758 (format!("{container}.{method_name}"), Some(container))
1759 }
1760 }
1761}
1762
1763fn split_constant_path(path: &str) -> Vec<String> {
1777 path.trim()
1778 .trim_start_matches("::")
1779 .split("::")
1780 .filter_map(|seg| {
1781 let trimmed = seg.trim();
1782 if trimmed.is_empty() {
1783 None
1784 } else {
1785 Some(trimmed.to_string())
1786 }
1787 })
1788 .collect()
1789}
1790
1791fn is_constant(text: &str) -> bool {
1801 text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1802}
1803
1804fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
1815 matches!(
1816 method_call.name.as_str(),
1817 "public" | "private" | "protected"
1818 ) && method_call.receiver.is_none()
1819 && !has_call_arguments(method_call.node)
1820}
1821
1822fn has_call_arguments(node: Node<'_>) -> bool {
1832 if let Some(arguments) = node.child_by_field_name("arguments") {
1833 let mut cursor = arguments.walk();
1834 for child in arguments.children(&mut cursor) {
1835 if child.is_named() {
1836 return true;
1837 }
1838 }
1839 }
1840 false
1841}
1842
1843fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
1844 let parent = node.parent()?;
1845 let visibility_node = match parent.kind() {
1846 "call" | "command" | "command_call" => parent,
1847 "argument_list" => parent.parent()?,
1848 _ => return None,
1849 };
1850
1851 if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
1852 return None;
1853 }
1854
1855 let keyword_node = visibility_node
1856 .child_by_field_name("name")
1857 .or_else(|| visibility_node.child_by_field_name("method"))?;
1858 let keyword = node_text_raw(keyword_node, content)?;
1859 Visibility::from_keyword(keyword.trim())
1860}
1861
1862fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
1873 node.utf8_text(content)
1874 .map(|s| s.trim().to_string())
1875 .map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
1876}
1877
1878fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
1880 node.utf8_text(content)
1881 .ok()
1882 .map(std::string::ToString::to_string)
1883}
1884
1885fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
1894 GraphBuilderError::ParseError {
1895 span: span_from_node(node),
1896 reason: reason.to_string(),
1897 }
1898}
1899
1900#[allow(clippy::match_same_arms)]
1917fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
1918 let mut params = Vec::new();
1919 let mut cursor = params_node.walk();
1920
1921 for child in params_node.named_children(&mut cursor) {
1922 match child.kind() {
1923 "identifier" | "optional_parameter" => {
1926 if let Ok(text) = child.utf8_text(content) {
1927 params.push(text.to_string());
1928 }
1929 }
1930 "splat_parameter" => {
1932 if let Some(name_node) = child.child_by_field_name("name") {
1933 if let Ok(name) = name_node.utf8_text(content) {
1934 params.push(format!("*{name}"));
1935 }
1936 } else if let Ok(text) = child.utf8_text(content) {
1937 params.push(text.to_string());
1939 }
1940 }
1941 "hash_splat_parameter" => {
1943 if let Some(name_node) = child.child_by_field_name("name") {
1944 if let Ok(name) = name_node.utf8_text(content) {
1945 params.push(format!("**{name}"));
1946 }
1947 } else if let Ok(text) = child.utf8_text(content) {
1948 params.push(text.to_string());
1950 }
1951 }
1952 "block_parameter" => {
1954 if let Some(name_node) = child.child_by_field_name("name") {
1955 if let Ok(name) = name_node.utf8_text(content) {
1956 params.push(format!("&{name}"));
1957 }
1958 } else if let Ok(text) = child.utf8_text(content) {
1959 params.push(text.to_string());
1961 }
1962 }
1963 "keyword_parameter" => {
1965 if let Ok(text) = child.utf8_text(content) {
1966 params.push(text.to_string());
1967 }
1968 }
1969 "destructured_parameter" => {
1971 if let Ok(text) = child.utf8_text(content) {
1972 params.push(text.to_string());
1973 }
1974 }
1975 "forward_parameter" => {
1977 params.push("...".to_string());
1978 }
1979 "hash_splat_nil" => {
1981 params.push("**nil".to_string());
1982 }
1983 _ => {
1984 }
1986 }
1987 }
1988
1989 if params.is_empty() {
1990 None
1991 } else {
1992 Some(params.join(", "))
1993 }
1994}
1995
1996fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2010 if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
2012 return Some(return_type);
2013 }
2014
2015 if let Some(return_type) = extract_rbs_return_type(method_node, content) {
2017 return Some(return_type);
2018 }
2019
2020 if let Some(return_type) = extract_yard_return_type(method_node, content) {
2022 return Some(return_type);
2023 }
2024
2025 None
2026}
2027
2028fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2039 let mut sibling = method_node.prev_sibling()?;
2041
2042 while sibling.kind() == "comment" {
2044 sibling = sibling.prev_sibling()?;
2045 }
2046
2047 if sibling.kind() == "call"
2049 && let Some(method_name) = sibling.child_by_field_name("method")
2050 && let Ok(name_text) = method_name.utf8_text(content)
2051 && name_text == "sig"
2052 {
2053 if let Some(block_node) = sibling.child_by_field_name("block") {
2055 return extract_returns_from_sig_block(block_node, content);
2056 }
2057 }
2058
2059 None
2060}
2061
2062fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
2064 let mut cursor = block_node.walk();
2065
2066 for child in block_node.named_children(&mut cursor) {
2067 if child.kind() == "call"
2068 && let Some(method_name) = child.child_by_field_name("method")
2069 && let Ok(name_text) = method_name.utf8_text(content)
2070 && name_text == "returns"
2071 {
2072 if let Some(args) = child.child_by_field_name("arguments") {
2074 let mut args_cursor = args.walk();
2075 for arg in args.named_children(&mut args_cursor) {
2076 if arg.kind() != ","
2077 && let Ok(type_text) = arg.utf8_text(content)
2078 {
2079 return Some(type_text.to_string());
2080 }
2081 }
2082 }
2083 }
2084 if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
2086 return Some(nested_type);
2087 }
2088 }
2089
2090 None
2091}
2092
2093fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2104 let mut cursor = method_node.walk();
2106 for child in method_node.children(&mut cursor) {
2107 if child.kind() == "comment"
2108 && let Ok(comment_text) = child.utf8_text(content)
2109 {
2110 if comment_text.trim_start().starts_with("#:") {
2113 if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
2115 let return_part = &comment_text[arrow_pos + 2..];
2116 let return_type = return_part.trim().to_string();
2117 if !return_type.is_empty() {
2118 return Some(return_type);
2119 }
2120 }
2121 }
2122 }
2123 }
2124
2125 None
2126}
2127
2128fn find_top_level_arrow(text: &str) -> Option<usize> {
2132 let chars: Vec<char> = text.chars().collect();
2133 let mut depth: i32 = 0;
2134 let mut i = 0;
2135
2136 while i < chars.len() {
2137 match chars[i] {
2138 '(' | '[' | '{' => depth += 1,
2139 ')' | ']' | '}' => depth = depth.saturating_sub(1),
2140 '-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
2141 return Some(i);
2142 }
2143 _ => {}
2144 }
2145 i += 1;
2146 }
2147
2148 None
2149}
2150
2151fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2162 let mut sibling_opt = method_node.prev_sibling();
2164 let method_start_row = method_node.start_position().row;
2165
2166 let mut comments = Vec::new();
2168 let mut expected_row = method_start_row;
2169
2170 while let Some(sibling) = sibling_opt {
2171 if sibling.kind() == "comment" {
2172 let comment_end_row = sibling.end_position().row;
2173
2174 if comment_end_row + 1 >= expected_row {
2177 if let Ok(comment_text) = sibling.utf8_text(content) {
2178 comments.push(comment_text);
2179 }
2180 expected_row = sibling.start_position().row;
2181 sibling_opt = sibling.prev_sibling();
2182 } else {
2183 break;
2185 }
2186 } else {
2187 break;
2188 }
2189 }
2190
2191 for comment in comments.iter().rev() {
2193 if let Some(return_pos) = comment.find("@return") {
2194 let after_return = &comment[return_pos + 7..];
2195 if let Some(start_bracket) = after_return.find('[')
2197 && let Some(end_bracket) = after_return.find(']')
2198 && end_bracket > start_bracket
2199 {
2200 let return_type = &after_return[start_bracket + 1..end_bracket];
2201 return Some(return_type.trim().to_string());
2202 }
2203 }
2204 }
2205
2206 None
2207}
2208
2209fn span_from_node(node: Node<'_>) -> Span {
2217 span_from_points(node.start_position(), node.end_position())
2218}
2219
2220fn span_from_points(start: Point, end: Point) -> Span {
2229 Span::new(
2230 Position::new(start.row, start.column),
2231 Position::new(end.row, end.column),
2232 )
2233}
2234
2235fn is_literal_delimiter(kind: &str) -> bool {
2245 matches!(kind, "," | "(" | ")" | "[" | "]")
2246}
2247
2248fn parse_controller_dsl_args(
2251 text: &str,
2252) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
2253 let mut head = text;
2255 let mut tail = "";
2256 if let Some(idx) = text.find("only:") {
2257 head = &text[..idx];
2258 tail = &text[idx..];
2259 } else if let Some(idx) = text.find("except:") {
2260 head = &text[..idx];
2261 tail = &text[idx..];
2262 }
2263 let callbacks = extract_symbol_list_from_args(head);
2264 let only = extract_kw_symbol_list(tail, "only:");
2265 let except = extract_kw_symbol_list(tail, "except:");
2266 (callbacks, only, except)
2267}
2268
2269fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
2270 let mut out = Vec::new();
2271 let bytes = text.as_bytes();
2272 let mut i = 0;
2273 while i < bytes.len() {
2274 if bytes[i] == b':' {
2275 let start = i + 1;
2276 let mut j = start;
2277 while j < bytes.len() {
2278 let c = bytes[j] as char;
2279 if c.is_ascii_alphanumeric() || c == '_' {
2280 j += 1;
2281 } else {
2282 break;
2283 }
2284 }
2285 if j > start {
2286 out.push(text[start..j].to_string());
2287 i = j;
2288 continue;
2289 }
2290 }
2291 i += 1;
2292 }
2293 out
2294}
2295
2296fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
2297 let pos = text.find(kw)?;
2298 let mut after = &text[pos + kw.len()..];
2299 after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
2301 if after.starts_with('[')
2302 && let Some(end) = after.find(']')
2303 {
2304 return Some(extract_symbol_list_from_args(&after[..=end]));
2305 }
2306 if let Some(colon) = after.find(':') {
2308 let mut j = colon + 1;
2309 while j < after.len() {
2310 let ch = after.as_bytes()[j] as char;
2311 if ch.is_ascii_alphanumeric() || ch == '_' {
2312 j += 1;
2313 } else {
2314 break;
2315 }
2316 }
2317 if j > colon + 1 {
2318 return Some(vec![after[colon + 1..j].to_string()]);
2319 }
2320 }
2321 None
2322}
2323
2324fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
2325 let mut out = Vec::new();
2326 match node.kind() {
2327 "symbol" | "simple_symbol" => {
2328 if let Ok(t) = node_text(node, content) {
2329 out.push(t.trim_start_matches(':').to_string());
2330 }
2331 }
2332 "array" => {
2333 let mut c = node.walk();
2334 for ch in node.children(&mut c) {
2335 if matches!(ch.kind(), "symbol" | "simple_symbol")
2336 && let Ok(t) = node_text(ch, content)
2337 {
2338 out.push(t.trim_start_matches(':').to_string());
2339 }
2340 }
2341 }
2342 _ => {
2343 if let Some(txt) = node_text_raw(node, content) {
2345 out = extract_symbol_list_from_args(&txt);
2346 }
2347 }
2348 }
2349 out
2350}
2351
2352fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
2354 let mut cursor = arguments.walk();
2355 for child in arguments.children(&mut cursor) {
2356 if !child.is_named() {
2357 continue;
2358 }
2359 if let Some(s) = extract_string_content(child, content) {
2360 return Some(s);
2361 }
2362 }
2363 None
2364}
2365
2366fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
2368 let text = node.utf8_text(content).ok()?;
2369 let trimmed = text.trim();
2370
2371 if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
2373 || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
2374 && trimmed.len() >= 2
2375 {
2376 return Some(trimmed[1..trimmed.len() - 1].to_string());
2377 }
2378
2379 if matches!(node.kind(), "string" | "chained_string") {
2381 let mut cursor = node.walk();
2382 for child in node.children(&mut cursor) {
2383 if child.kind() == "string_content"
2384 && let Ok(s) = child.utf8_text(content)
2385 {
2386 return Some(s.to_string());
2387 }
2388 }
2389 }
2390
2391 None
2392}
2393
2394pub(crate) fn resolve_ruby_require(
2405 module_name: &str,
2406 is_relative: bool,
2407 source_file: &str,
2408) -> String {
2409 if is_relative {
2410 let source_path = std::path::Path::new(source_file);
2414 let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
2415
2416 let relative_path = std::path::Path::new(module_name);
2418 let resolved = source_dir.join(relative_path);
2419
2420 let normalized = normalize_path(&resolved);
2422
2423 let path_str = normalized.to_string_lossy();
2426 let separators: &[char] = &['/', '\\'];
2427 path_str
2428 .split(separators)
2429 .filter(|s| !s.is_empty())
2430 .collect::<Vec<_>>()
2431 .join("::")
2432 } else {
2433 module_name.replace('/', "::")
2436 }
2437}
2438
2439fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
2441 let mut components = Vec::new();
2442
2443 for component in path.components() {
2444 match component {
2445 std::path::Component::CurDir => {
2446 }
2448 std::path::Component::ParentDir => {
2449 if components
2451 .last()
2452 .is_some_and(|c| *c != std::path::Component::ParentDir)
2453 {
2454 components.pop();
2455 } else {
2456 components.push(component);
2457 }
2458 }
2459 _ => {
2460 components.push(component);
2461 }
2462 }
2463 }
2464
2465 components.iter().collect()
2466}
2467
2468fn process_yard_annotations(
2475 node: Node,
2476 content: &[u8],
2477 ast_graph: &ASTGraph,
2478 helper: &mut GraphBuildHelper,
2479) -> GraphResult<()> {
2480 match node.kind() {
2481 "method" => {
2482 process_method_yard(node, content, helper)?;
2483 }
2484 "singleton_method" => {
2485 process_singleton_method_yard(node, content, helper)?;
2486 }
2487 "call" | "command" | "command_call" => {
2488 if is_attr_call(node, content) {
2490 process_attr_yard(node, content, ast_graph, helper)?;
2491 }
2492 }
2493 "assignment" => {
2494 if is_instance_variable_assignment(node, content) {
2496 process_assignment_yard(node, content, helper)?;
2497 }
2498 }
2499 _ => {}
2500 }
2501
2502 let mut cursor = node.walk();
2504 for child in node.children(&mut cursor) {
2505 process_yard_annotations(child, content, ast_graph, helper)?;
2506 }
2507
2508 Ok(())
2509}
2510
2511fn process_method_yard(
2513 method_node: Node,
2514 content: &[u8],
2515 helper: &mut GraphBuildHelper,
2516) -> GraphResult<()> {
2517 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2519 return Ok(());
2520 };
2521
2522 let tags = parse_yard_tags(&yard_text);
2524
2525 let Some(name_node) = method_node.child_by_field_name("name") else {
2527 return Ok(());
2528 };
2529
2530 let method_name = name_node
2531 .utf8_text(content)
2532 .map_err(|_| GraphBuilderError::ParseError {
2533 span: span_from_node(method_node),
2534 reason: "failed to read method name".to_string(),
2535 })?
2536 .trim()
2537 .to_string();
2538
2539 if method_name.is_empty() {
2540 return Ok(());
2541 }
2542
2543 let class_name = get_enclosing_class_name(method_node, content);
2545
2546 let qualified_name = if let Some(class_name) = class_name {
2548 format!("{class_name}#{method_name}")
2549 } else {
2550 method_name.clone()
2551 };
2552
2553 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
2555
2556 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2558 let canonical_type = canonical_type_string(¶m_tag.type_str);
2560 let type_node_id = helper.add_type(&canonical_type, None);
2561 helper.add_typeof_edge_with_context(
2562 method_node_id,
2563 type_node_id,
2564 Some(TypeOfContext::Parameter),
2565 param_idx.try_into().ok(),
2566 Some(¶m_tag.name),
2567 );
2568
2569 let type_names = extract_type_names(¶m_tag.type_str);
2571 for type_name in type_names {
2572 let ref_type_id = helper.add_type(&type_name, None);
2573 helper.add_reference_edge(method_node_id, ref_type_id);
2574 }
2575 }
2576
2577 if let Some(return_type) = &tags.returns {
2579 let canonical_type = canonical_type_string(return_type);
2580 let type_node_id = helper.add_type(&canonical_type, None);
2581 helper.add_typeof_edge_with_context(
2582 method_node_id,
2583 type_node_id,
2584 Some(TypeOfContext::Return),
2585 Some(0),
2586 None,
2587 );
2588
2589 let type_names = extract_type_names(return_type);
2591 for type_name in type_names {
2592 let ref_type_id = helper.add_type(&type_name, None);
2593 helper.add_reference_edge(method_node_id, ref_type_id);
2594 }
2595 }
2596
2597 Ok(())
2598}
2599
2600fn process_singleton_method_yard(
2602 method_node: Node,
2603 content: &[u8],
2604 helper: &mut GraphBuildHelper,
2605) -> GraphResult<()> {
2606 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2608 return Ok(());
2609 };
2610
2611 let tags = parse_yard_tags(&yard_text);
2613
2614 let Some(name_node) = method_node.child_by_field_name("name") else {
2616 return Ok(());
2617 };
2618
2619 let method_name = name_node
2620 .utf8_text(content)
2621 .map_err(|_| GraphBuilderError::ParseError {
2622 span: span_from_node(method_node),
2623 reason: "failed to read method name".to_string(),
2624 })?
2625 .trim()
2626 .to_string();
2627
2628 if method_name.is_empty() {
2629 return Ok(());
2630 }
2631
2632 let class_name = get_enclosing_class_name(method_node, content);
2634
2635 let qualified_name = if let Some(class_name) = class_name {
2637 format!("{class_name}.{method_name}")
2638 } else {
2639 method_name.clone()
2640 };
2641
2642 let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
2644
2645 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2647 let canonical_type = canonical_type_string(¶m_tag.type_str);
2649 let type_node_id = helper.add_type(&canonical_type, None);
2650 helper.add_typeof_edge_with_context(
2651 method_node_id,
2652 type_node_id,
2653 Some(TypeOfContext::Parameter),
2654 param_idx.try_into().ok(),
2655 Some(¶m_tag.name),
2656 );
2657
2658 let type_names = extract_type_names(¶m_tag.type_str);
2660 for type_name in type_names {
2661 let ref_type_id = helper.add_type(&type_name, None);
2662 helper.add_reference_edge(method_node_id, ref_type_id);
2663 }
2664 }
2665
2666 if let Some(return_type) = &tags.returns {
2668 let canonical_type = canonical_type_string(return_type);
2669 let type_node_id = helper.add_type(&canonical_type, None);
2670 helper.add_typeof_edge_with_context(
2671 method_node_id,
2672 type_node_id,
2673 Some(TypeOfContext::Return),
2674 Some(0),
2675 None,
2676 );
2677
2678 let type_names = extract_type_names(return_type);
2680 for type_name in type_names {
2681 let ref_type_id = helper.add_type(&type_name, None);
2682 helper.add_reference_edge(method_node_id, ref_type_id);
2683 }
2684 }
2685
2686 Ok(())
2687}
2688
2689#[allow(clippy::unnecessary_wraps)]
2709fn process_attr_yard(
2710 attr_node: Node,
2711 content: &[u8],
2712 ast_graph: &ASTGraph,
2713 helper: &mut GraphBuildHelper,
2714) -> GraphResult<()> {
2715 let Some(method_name) = attr_method_name(attr_node, content) else {
2720 return Ok(());
2721 };
2722 let is_reader = method_name == "attr_reader";
2723
2724 let attr_names = extract_attr_names(attr_node, content);
2727 if attr_names.is_empty() {
2728 return Ok(());
2729 }
2730
2731 let class_name = get_enclosing_class_name(attr_node, content);
2733
2734 let yard_return = extract_yard_comment(attr_node, content)
2737 .map(|yard_text| parse_yard_tags(&yard_text))
2738 .and_then(|tags| tags.returns);
2739
2740 let span = span_from_node(attr_node);
2743 let visibility = ast_graph.attr_visibility_for_node(&attr_node).as_str();
2744
2745 for attr_name in attr_names {
2746 let qualified_name = if let Some(ref class) = class_name {
2750 format!("{class}#{attr_name}")
2751 } else {
2752 attr_name.clone()
2753 };
2754
2755 let attr_node_id = if is_reader {
2759 helper.add_constant_with_static_and_visibility(
2760 &qualified_name,
2761 Some(span),
2762 false,
2763 Some(visibility),
2764 )
2765 } else {
2766 helper.add_property_with_static_and_visibility(
2767 &qualified_name,
2768 Some(span),
2769 false,
2770 Some(visibility),
2771 )
2772 };
2773
2774 if let Some(var_type) = &yard_return {
2778 let canonical_type = canonical_type_string(var_type);
2779 let type_node_id = helper.add_type(&canonical_type, None);
2780 helper.add_typeof_edge_with_context(
2781 attr_node_id,
2782 type_node_id,
2783 Some(TypeOfContext::Field),
2784 None,
2785 Some(&attr_name),
2786 );
2787
2788 for type_name in extract_type_names(var_type) {
2789 let ref_type_id = helper.add_type(&type_name, None);
2790 helper.add_reference_edge(attr_node_id, ref_type_id);
2791 }
2792 }
2793 }
2794
2795 Ok(())
2796}
2797
2798fn attr_method_name(node: Node, content: &[u8]) -> Option<String> {
2802 let raw = match node.kind() {
2803 "command" => node
2804 .child_by_field_name("name")
2805 .and_then(|n| n.utf8_text(content).ok()),
2806 "call" | "command_call" => node
2807 .child_by_field_name("method")
2808 .and_then(|n| n.utf8_text(content).ok()),
2809 _ => None,
2810 }?;
2811 Some(raw.trim().to_string())
2812}
2813
2814fn process_assignment_yard(
2816 assignment_node: Node,
2817 content: &[u8],
2818 helper: &mut GraphBuildHelper,
2819) -> GraphResult<()> {
2820 let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
2822 return Ok(());
2823 };
2824
2825 let tags = parse_yard_tags(&yard_text);
2827
2828 let Some(var_type) = &tags.type_annotation else {
2830 return Ok(());
2831 };
2832
2833 let Some(left_node) = assignment_node.child_by_field_name("left") else {
2835 return Ok(());
2836 };
2837
2838 if left_node.kind() != "instance_variable" {
2839 return Ok(());
2840 }
2841
2842 let var_name = left_node
2843 .utf8_text(content)
2844 .map_err(|_| GraphBuilderError::ParseError {
2845 span: span_from_node(assignment_node),
2846 reason: "failed to read variable name".to_string(),
2847 })?
2848 .trim()
2849 .to_string();
2850
2851 if var_name.is_empty() {
2852 return Ok(());
2853 }
2854
2855 let class_name = get_enclosing_class_name(assignment_node, content);
2857
2858 let qualified_name = if let Some(class) = class_name {
2860 format!("{class}#{var_name}")
2861 } else {
2862 var_name.clone()
2863 };
2864
2865 let var_node_id = helper.add_variable(&qualified_name, None);
2867
2868 let canonical_type = canonical_type_string(var_type);
2870 let type_node_id = helper.add_type(&canonical_type, None);
2871 helper.add_typeof_edge_with_context(
2872 var_node_id,
2873 type_node_id,
2874 Some(TypeOfContext::Variable),
2875 None,
2876 Some(&var_name),
2877 );
2878
2879 let type_names = extract_type_names(var_type);
2881 for type_name in type_names {
2882 let ref_type_id = helper.add_type(&type_name, None);
2883 helper.add_reference_edge(var_node_id, ref_type_id);
2884 }
2885
2886 Ok(())
2887}
2888
2889fn is_attr_call(node: Node, content: &[u8]) -> bool {
2891 let method_name = match node.kind() {
2892 "command" => node
2893 .child_by_field_name("name")
2894 .and_then(|n| n.utf8_text(content).ok()),
2895 "call" | "command_call" => node
2896 .child_by_field_name("method")
2897 .and_then(|n| n.utf8_text(content).ok()),
2898 _ => None,
2899 };
2900
2901 method_name
2902 .is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
2903}
2904
2905fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
2907 if let Some(left_node) = node.child_by_field_name("left") {
2908 left_node.kind() == "instance_variable"
2909 } else {
2910 false
2911 }
2912}
2913
2914fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
2917 let mut names = Vec::new();
2918
2919 let arguments = attr_node.child_by_field_name("arguments");
2921
2922 if let Some(args) = arguments {
2923 let mut cursor = args.walk();
2925 for child in args.children(&mut cursor) {
2926 if matches!(child.kind(), "symbol" | "simple_symbol")
2927 && let Ok(text) = child.utf8_text(content)
2928 {
2929 let cleaned = text.trim().trim_start_matches(':');
2930 if !cleaned.is_empty() {
2931 names.push(cleaned.to_string());
2932 }
2933 } else if child.kind() == "string"
2934 && let Ok(text) = child.utf8_text(content)
2935 {
2936 let cleaned = text
2938 .trim()
2939 .trim_start_matches(['\'', '"'])
2940 .trim_end_matches(['\'', '"']);
2941 if !cleaned.is_empty() {
2942 names.push(cleaned.to_string());
2943 }
2944 }
2945 }
2946 } else if matches!(attr_node.kind(), "command" | "command_call") {
2947 let mut cursor = attr_node.walk();
2949 let mut found_method = false;
2950 for child in attr_node.children(&mut cursor) {
2951 if !child.is_named() {
2952 continue;
2953 }
2954 if !found_method {
2956 found_method = true;
2957 continue;
2958 }
2959 if matches!(child.kind(), "symbol" | "simple_symbol")
2961 && let Ok(text) = child.utf8_text(content)
2962 {
2963 let cleaned = text.trim().trim_start_matches(':');
2964 if !cleaned.is_empty() {
2965 names.push(cleaned.to_string());
2966 }
2967 } else if child.kind() == "string"
2968 && let Ok(text) = child.utf8_text(content)
2969 {
2970 let cleaned = text
2972 .trim()
2973 .trim_start_matches(['\'', '"'])
2974 .trim_end_matches(['\'', '"']);
2975 if !cleaned.is_empty() {
2976 names.push(cleaned.to_string());
2977 }
2978 }
2979 }
2980 }
2981
2982 names
2983}
2984
2985fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
2990 let mut current = node;
2991 let mut namespace_parts = Vec::new();
2992
2993 while let Some(parent) = current.parent() {
2995 if matches!(parent.kind(), "class" | "module") {
2996 if let Some(name_node) = parent.child_by_field_name("name")
2998 && let Ok(name_text) = name_node.utf8_text(content)
2999 {
3000 let trimmed = name_text.trim();
3001 if trimmed.starts_with("::") {
3003 namespace_parts.clear();
3005 namespace_parts.push(trimmed.trim_start_matches("::").to_string());
3006 break;
3007 }
3008 namespace_parts.insert(0, trimmed.to_string());
3010 }
3011 }
3012 current = parent;
3013 }
3014
3015 if namespace_parts.is_empty() {
3017 None
3018 } else {
3019 Some(namespace_parts.join("::"))
3020 }
3021}
3022
3023#[cfg(test)]
3024mod field_emission_tests {
3025 use sqry_core::graph::GraphBuilder;
3043 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
3044 use sqry_core::graph::unified::build::test_helpers::{
3045 build_node_name_lookup, build_string_lookup,
3046 };
3047 use sqry_core::graph::unified::edge::EdgeKind;
3048 use sqry_core::graph::unified::edge::kind::TypeOfContext;
3049 use sqry_core::graph::unified::node::NodeKind;
3050 use std::path::Path;
3051 use tree_sitter::Parser;
3052
3053 use super::RubyGraphBuilder;
3054
3055 fn parse(source: &str) -> tree_sitter::Tree {
3056 let mut parser = Parser::new();
3057 parser
3058 .set_language(&tree_sitter_ruby::LANGUAGE.into())
3059 .expect("load Ruby grammar");
3060 parser.parse(source, None).expect("parse Ruby source")
3061 }
3062
3063 fn build(source: &str) -> StagingGraph {
3064 let tree = parse(source);
3065 let mut staging = StagingGraph::new();
3066 let builder = RubyGraphBuilder::default();
3067 builder
3068 .build_graph(&tree, source.as_bytes(), Path::new("test.rb"), &mut staging)
3069 .expect("build graph");
3070 staging
3071 }
3072
3073 fn find_node<'a>(
3077 staging: &'a StagingGraph,
3078 name: &str,
3079 kind: Option<NodeKind>,
3080 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
3081 let strings = build_string_lookup(staging);
3082 for op in staging.operations() {
3083 if let StagingOp::AddNode { entry, .. } = op {
3084 if let Some(k) = kind
3085 && entry.kind != k
3086 {
3087 continue;
3088 }
3089 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3090 if let Some(s) = strings.get(&name_idx)
3091 && s == name
3092 {
3093 return Some(entry);
3094 }
3095 }
3096 }
3097 None
3098 }
3099
3100 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
3101 let strings = build_string_lookup(staging);
3102 staging
3103 .operations()
3104 .iter()
3105 .filter(|op| {
3106 if let StagingOp::AddNode { entry, .. } = op {
3107 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3108 strings.get(&name_idx).is_some_and(|s| s == name)
3109 } else {
3110 false
3111 }
3112 })
3113 .count()
3114 }
3115
3116 fn visibility(
3117 staging: &StagingGraph,
3118 entry: &sqry_core::graph::unified::storage::NodeEntry,
3119 ) -> Option<String> {
3120 let strings = build_string_lookup(staging);
3121 entry
3122 .visibility
3123 .and_then(|visibility_id| strings.get(&visibility_id.index()).cloned())
3124 }
3125
3126 fn typeof_edges_for_node(
3127 staging: &StagingGraph,
3128 source_name: &str,
3129 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
3130 let names = build_node_name_lookup(staging);
3131 let strings = build_string_lookup(staging);
3132 let mut out = Vec::new();
3133 for op in staging.operations() {
3134 if let StagingOp::AddEdge {
3135 source,
3136 target,
3137 kind: EdgeKind::TypeOf { context, name, .. },
3138 ..
3139 } = op
3140 {
3141 let src = names.get(source).cloned().unwrap_or_default();
3142 if src != source_name {
3143 continue;
3144 }
3145 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3146 let target_name = names.get(target).cloned().unwrap_or_default();
3147 out.push((*context, edge_name, target_name));
3148 }
3149 }
3150 out
3151 }
3152
3153 #[test]
3156 fn req_r0001_attr_accessor_without_yard_emits_property_node() {
3157 let src = "class Foo\n attr_accessor :x\nend\n";
3158 let staging = build(src);
3159 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3160 .expect("Foo#x Property must be emitted without YARD");
3161 }
3162
3163 #[test]
3164 fn req_r0001_attr_reader_without_yard_emits_constant_node() {
3165 let src = "class Foo\n attr_reader :y\nend\n";
3166 let staging = build(src);
3167 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3168 .expect("Foo#y Constant must be emitted without YARD");
3169 }
3170
3171 #[test]
3172 fn req_r0001_attr_writer_without_yard_emits_property_node() {
3173 let src = "class Foo\n attr_writer :z\nend\n";
3174 let staging = build(src);
3175 find_node(&staging, "Foo::z", Some(NodeKind::Property))
3176 .expect("Foo#z Property must be emitted without YARD");
3177 }
3178
3179 #[test]
3180 fn req_r0001_attr_with_yard_still_emits() {
3181 let src = "class Foo\n # @return [String]\n attr_reader :y\nend\n";
3182 let staging = build(src);
3183 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3184 .expect("Foo#y Constant must be emitted when YARD is present too");
3185 }
3186
3187 #[test]
3190 fn req_r0023_attr_reader_branches_to_constant() {
3191 let src = "class Bar\n attr_reader :name\nend\n";
3192 let staging = build(src);
3193 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Constant))
3194 .expect("attr_reader must produce Constant");
3195 assert_eq!(entry.kind, NodeKind::Constant);
3196 assert!(
3197 find_node(&staging, "Bar::name", Some(NodeKind::Property)).is_none(),
3198 "attr_reader must NOT also produce a Property"
3199 );
3200 }
3201
3202 #[test]
3203 fn req_r0023_attr_writer_branches_to_property() {
3204 let src = "class Bar\n attr_writer :name\nend\n";
3205 let staging = build(src);
3206 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3207 .expect("attr_writer must produce Property");
3208 assert_eq!(entry.kind, NodeKind::Property);
3209 assert!(
3210 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3211 "attr_writer must NOT also produce a Constant"
3212 );
3213 }
3214
3215 #[test]
3216 fn req_r0023_attr_accessor_branches_to_property() {
3217 let src = "class Bar\n attr_accessor :name\nend\n";
3218 let staging = build(src);
3219 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3220 .expect("attr_accessor must produce Property");
3221 assert_eq!(entry.kind, NodeKind::Property);
3222 assert!(
3223 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3224 "attr_accessor must NOT also produce a Constant"
3225 );
3226 }
3227
3228 #[test]
3229 fn req_r0023_attr_accessor_emits_one_per_argument() {
3230 let src = "class Multi\n attr_accessor :a, :b, :c\nend\n";
3231 let staging = build(src);
3232 find_node(&staging, "Multi::a", Some(NodeKind::Property))
3233 .expect("Multi#a Property must exist");
3234 find_node(&staging, "Multi::b", Some(NodeKind::Property))
3235 .expect("Multi#b Property must exist");
3236 find_node(&staging, "Multi::c", Some(NodeKind::Property))
3237 .expect("Multi#c Property must exist");
3238 assert_eq!(count_nodes_named(&staging, "Multi::a"), 1);
3240 assert_eq!(count_nodes_named(&staging, "Multi::b"), 1);
3241 assert_eq!(count_nodes_named(&staging, "Multi::c"), 1);
3242 }
3243
3244 #[test]
3247 fn req_r0017_qualified_name_uses_ruby_hash_idiom() {
3248 let src = "class Foo\n attr_accessor :x\nend\n";
3255 let staging = build(src);
3256 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3258 .expect("canonical Foo::x must exist");
3259 assert!(
3261 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3262 "bare 'x' must not be the qualified name (would collide across classes)"
3263 );
3264 }
3265
3266 #[test]
3269 fn req_r0006_yard_type_tag_drives_typeof_field_edge() {
3270 let src = "class User\n # @return [String]\n attr_reader :name\nend\n";
3271 let staging = build(src);
3272 let edges = typeof_edges_for_node(&staging, "User::name");
3273 assert!(
3274 !edges.is_empty(),
3275 "User#name should have a TypeOf edge from YARD @return"
3276 );
3277 let has_string = edges.iter().any(|(_, _, t)| t == "String");
3278 assert!(
3279 has_string,
3280 "YARD @return [String] should produce a TypeOf target 'String', got {edges:?}"
3281 );
3282 }
3283
3284 #[test]
3285 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3286 let src = "class C\n # @return [String]\n attr_accessor :title\nend\n";
3287 let staging = build(src);
3288 let edges = typeof_edges_for_node(&staging, "C::title");
3289 assert!(!edges.is_empty(), "C#title should have a TypeOf edge");
3290 for (ctx, name, _) in &edges {
3291 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3292 assert_eq!(
3293 name.as_deref(),
3294 Some("title"),
3295 "edge name must be the bare attr name"
3296 );
3297 }
3298 }
3299
3300 #[test]
3301 fn req_r0006_no_yard_means_no_typeof_edge_but_node_emitted() {
3302 let src = "class C\n attr_accessor :untyped\nend\n";
3304 let staging = build(src);
3305 find_node(&staging, "C::untyped", Some(NodeKind::Property))
3306 .expect("Property must emit even without YARD type tag");
3307 let edges = typeof_edges_for_node(&staging, "C::untyped");
3308 assert!(
3309 edges.is_empty(),
3310 "no YARD => no TypeOf{{Field}} enrichment edge, got {edges:?}"
3311 );
3312 }
3313
3314 #[test]
3317 fn req_r0023_attr_node_visibility_defaults_to_public() {
3318 let src = "class V\n attr_accessor :x\nend\n";
3319 let staging = build(src);
3320 let entry =
3321 find_node(&staging, "V::x", Some(NodeKind::Property)).expect("V#x Property must exist");
3322 assert_eq!(
3323 visibility(&staging, entry).as_deref(),
3324 Some("public"),
3325 "Ruby attr_* nodes default to public visibility"
3326 );
3327 }
3328
3329 #[test]
3330 fn req_r0023_attr_node_visibility_tracks_private_and_protected_scope() {
3331 let src = "class V\n private\n attr_accessor :hidden\n protected\n attr_reader :guarded\nend\n";
3332 let staging = build(src);
3333 let hidden = find_node(&staging, "V::hidden", Some(NodeKind::Property))
3334 .expect("V#hidden Property must exist");
3335 let guarded = find_node(&staging, "V::guarded", Some(NodeKind::Constant))
3336 .expect("V#guarded Constant must exist");
3337 assert_eq!(
3338 visibility(&staging, hidden).as_deref(),
3339 Some("private"),
3340 "Ruby attr_* nodes must inherit private visibility scope"
3341 );
3342 assert_eq!(
3343 visibility(&staging, guarded).as_deref(),
3344 Some("protected"),
3345 "Ruby attr_reader nodes must inherit protected visibility scope"
3346 );
3347 }
3348
3349 #[test]
3350 fn req_r0023_attr_node_is_not_static() {
3351 let src = "class S\n attr_reader :y\nend\n";
3352 let staging = build(src);
3353 let entry =
3354 find_node(&staging, "S::y", Some(NodeKind::Constant)).expect("S#y Constant must exist");
3355 assert!(
3356 !entry.is_static,
3357 "attr_* nodes must have is_static=false (always instance per design §4.5)"
3358 );
3359 }
3360
3361 #[test]
3364 fn req_r0017_same_attr_name_across_classes_distinct_nodes() {
3365 let src = "class A\n attr_accessor :x\nend\nclass B\n attr_accessor :x\nend\n";
3366 let staging = build(src);
3367 find_node(&staging, "A::x", Some(NodeKind::Property)).expect("A#x Property must exist");
3368 find_node(&staging, "B::x", Some(NodeKind::Property)).expect("B#x Property must exist");
3369 assert!(
3370 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3371 "bare 'x' must not exist; qualified names disambiguate cross-class"
3372 );
3373 }
3374
3375 #[test]
3378 fn req_r0017_nested_module_class_qualifies_attr() {
3379 let src = "module M\n class Inner\n attr_accessor :n\n end\nend\n";
3380 let staging = build(src);
3381 find_node(&staging, "M::Inner::n", Some(NodeKind::Property))
3382 .expect("M::Inner#n Property must exist with full namespace");
3383 }
3384
3385 #[test]
3388 fn req_r0001_attr_reader_string_argument_emits_constant() {
3389 let src = "class User\n attr_reader \"username\"\nend\n";
3390 let staging = build(src);
3391 find_node(&staging, "User::username", Some(NodeKind::Constant))
3392 .expect("attr_reader with string arg must emit Constant");
3393 }
3394
3395 #[test]
3396 fn req_r0001_attr_accessor_command_call_form_emits_property() {
3397 let src = "class Service\n self.attr_accessor :logger\nend\n";
3398 let staging = build(src);
3399 find_node(&staging, "Service::logger", Some(NodeKind::Property))
3400 .expect("self.attr_accessor command_call must emit Property");
3401 }
3402}
3403
3404#[cfg(test)]
3405mod shape_tests {
3406 use super::{cf_bucket_for_ruby_kind, ruby_shape_mapping};
3410 use sqry_core::graph::unified::build::shape::{
3411 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3412 };
3413 use tree_sitter::{Node, Parser, Tree};
3414
3415 const SAMPLE: &str = include_str!(concat!(
3416 env!("CARGO_MANIFEST_DIR"),
3417 "/../test-fixtures/shape/dynamic/ruby.rb"
3418 ));
3419
3420 fn parse(src: &str) -> Tree {
3421 let mut parser = Parser::new();
3422 parser
3423 .set_language(&tree_sitter_ruby::LANGUAGE.into())
3424 .expect("load ruby grammar");
3425 parser.parse(src, None).expect("parse ruby")
3426 }
3427
3428 fn first_method<'t>(tree: &'t Tree) -> Node<'t> {
3429 let root = tree.root_node();
3430 let mut cursor = root.walk();
3431 for child in root.named_children(&mut cursor) {
3432 if child.kind() == "method" {
3433 return child;
3434 }
3435 }
3436 panic!("no method node in ruby fixture");
3437 }
3438
3439 #[test]
3440 fn mapping_is_non_empty_and_covers_real_kinds() {
3441 let mapping = ruby_shape_mapping();
3442 assert_eq!(cf_bucket_for_ruby_kind("if"), Some(CfBucket::Branch));
3444 assert_eq!(cf_bucket_for_ruby_kind("while"), Some(CfBucket::Loop));
3445 assert_eq!(cf_bucket_for_ruby_kind("case"), Some(CfBucket::Match));
3446 assert_eq!(cf_bucket_for_ruby_kind("begin"), Some(CfBucket::Try));
3447 assert_eq!(cf_bucket_for_ruby_kind("rescue"), Some(CfBucket::Catch));
3448 assert_eq!(cf_bucket_for_ruby_kind("ensure"), Some(CfBucket::Resource));
3449 assert_eq!(cf_bucket_for_ruby_kind("return"), Some(CfBucket::Return));
3450 assert_eq!(cf_bucket_for_ruby_kind("yield"), Some(CfBucket::Yield));
3451 assert_eq!(
3452 cf_bucket_for_ruby_kind("break"),
3453 Some(CfBucket::BreakContinue)
3454 );
3455 assert_eq!(cf_bucket_for_ruby_kind("call"), Some(CfBucket::Call));
3456 assert_eq!(cf_bucket_for_ruby_kind("do_block"), Some(CfBucket::Closure));
3457 assert_eq!(cf_bucket_for_ruby_kind("not_a_real_kind"), None);
3458
3459 let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
3461 let if_id = (0..lang.node_kind_count())
3462 .map(|i| i as u16)
3463 .find(|&i| lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if"))
3464 .expect("grammar exposes named `if`");
3465 assert_eq!(mapping.cf_bucket(if_id), Some(CfBucket::Branch));
3466 }
3467
3468 #[test]
3469 fn descriptor_covers_fixture_control_flow() {
3470 let tree = parse(SAMPLE);
3471 let func = first_method(&tree);
3472 let descriptor = compute_shape_descriptor(
3473 func,
3474 SAMPLE.as_bytes(),
3475 ruby_shape_mapping(),
3476 &ShapeBudget::default(),
3477 );
3478 let hist = descriptor.cf_histogram;
3479 assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if/elsif)");
3480 assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for)");
3481 assert!(hist[CfBucket::Match.index()] >= 1, "match (case/when)");
3482 assert!(hist[CfBucket::Try.index()] >= 1, "try (begin)");
3483 assert!(hist[CfBucket::Catch.index()] >= 1, "catch (rescue)");
3484 assert!(hist[CfBucket::Call.index()] >= 1, "call");
3485 assert!(hist[CfBucket::Closure.index()] >= 1, "closure (do_block)");
3486 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/next");
3487 }
3488
3489 #[test]
3490 fn signature_shape_reads_arity_and_kwargs() {
3491 let tree = parse(SAMPLE);
3492 let func = first_method(&tree);
3493 let shape = ruby_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3494 assert_eq!(shape.arity_positional, 1, "one positional: value");
3496 assert_eq!(shape.arity_keyword_only, 1, "one keyword: label");
3497 assert!(shape.has_varargs, "*rest");
3498 assert!(shape.has_kwargs, "**opts");
3499 }
3500}