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 #[allow(dead_code)] fn as_str(self) -> &'static str {
208 match self {
209 Visibility::Public => "public",
210 Visibility::Protected => "protected",
211 Visibility::Private => "private",
212 }
213 }
214
215 fn from_keyword(keyword: &str) -> Option<Self> {
216 match keyword {
217 "public" => Some(Visibility::Public),
218 "protected" => Some(Visibility::Protected),
219 "private" => Some(Visibility::Private),
220 _ => None,
221 }
222 }
223}
224
225#[derive(Debug, Clone)]
226enum RubyContextKind {
227 Method,
228 SingletonMethod,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum ControllerDslKind {
233 Before,
234 After,
235 Around,
236}
237
238#[allow(dead_code)] #[derive(Debug, Clone)]
240struct ControllerDslHook {
241 container: String,
242 kind: ControllerDslKind,
243 callbacks: Vec<String>,
244 only: Option<Vec<String>>, except: Option<Vec<String>>, }
247
248#[derive(Debug, Clone)]
249struct RubyContext {
250 qualified_name: String,
251 container: Option<String>,
252 kind: RubyContextKind,
253 visibility: Visibility,
254 start_position: Point,
255 end_position: Point,
256}
257
258impl RubyContext {
259 #[allow(dead_code)] fn is_method(&self) -> bool {
261 matches!(
262 self.kind,
263 RubyContextKind::Method | RubyContextKind::SingletonMethod
264 )
265 }
266
267 fn is_singleton(&self) -> bool {
268 matches!(self.kind, RubyContextKind::SingletonMethod)
269 }
270
271 fn qualified_name(&self) -> &str {
272 &self.qualified_name
273 }
274
275 fn container(&self) -> Option<&str> {
276 self.container.as_deref()
277 }
278
279 fn visibility(&self) -> Visibility {
280 self.visibility
281 }
282}
283
284struct ASTGraph {
285 contexts: Vec<RubyContext>,
286 node_to_context: HashMap<usize, usize>,
287 attr_visibility: HashMap<usize, Visibility>,
288 ffi_enabled_scopes: HashSet<Vec<String>>,
290 #[allow(dead_code)] controller_dsl_hooks: Vec<ControllerDslHook>,
292}
293
294impl ASTGraph {
295 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
296 let mut builder = ContextBuilder::new(content, max_depth)?;
297 builder.walk(tree.root_node())?;
298 Ok(Self {
299 contexts: builder.contexts,
300 node_to_context: builder.node_to_context,
301 attr_visibility: builder.attr_visibility,
302 ffi_enabled_scopes: builder.ffi_enabled_scopes,
303 controller_dsl_hooks: builder.controller_dsl_hooks,
304 })
305 }
306
307 #[allow(dead_code)] fn contexts(&self) -> &[RubyContext] {
309 &self.contexts
310 }
311
312 fn context_for_node(&self, node: &Node<'_>) -> Option<&RubyContext> {
313 self.node_to_context
314 .get(&node.id())
315 .and_then(|idx| self.contexts.get(*idx))
316 }
317
318 fn attr_visibility_for_node(&self, node: &Node<'_>) -> Visibility {
319 self.attr_visibility
320 .get(&node.id())
321 .copied()
322 .unwrap_or(Visibility::Public)
323 }
324}
325
326fn walk_tree_for_graph(
328 node: Node,
329 content: &[u8],
330 ast_graph: &ASTGraph,
331 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
332 ffi_enabled_scopes: &HashSet<Vec<String>>,
333) -> GraphResult<()> {
334 let mut current_namespace: Vec<String> = Vec::new();
336
337 walk_tree_for_graph_impl(
338 node,
339 content,
340 ast_graph,
341 helper,
342 ffi_enabled_scopes,
343 &mut current_namespace,
344 )
345}
346
347fn apply_controller_dsl_hooks(ast_graph: &ASTGraph, helper: &mut GraphBuildHelper) {
348 if ast_graph.controller_dsl_hooks.is_empty() {
349 return;
350 }
351
352 let mut actions_by_container: HashMap<String, Vec<String>> = HashMap::new();
353 for context in &ast_graph.contexts {
354 if !matches!(context.kind, RubyContextKind::Method) {
355 continue;
356 }
357 let Some(container) = context.container() else {
358 continue;
359 };
360 let Some(action_name) = context.qualified_name.rsplit('#').next() else {
361 continue;
362 };
363 actions_by_container
364 .entry(container.to_string())
365 .or_default()
366 .push(action_name.to_string());
367 }
368
369 let mut emitted: HashSet<(String, String)> = HashSet::new();
370 for hook in &ast_graph.controller_dsl_hooks {
371 let Some(actions) = actions_by_container.get(&hook.container) else {
372 continue;
373 };
374
375 for action in actions {
376 let included = if let Some(only) = &hook.only {
377 only.iter().any(|name| name == action)
378 } else if let Some(except) = &hook.except {
379 !except.iter().any(|name| name == action)
380 } else {
381 true
382 };
383
384 if !included {
385 continue;
386 }
387
388 for callback in &hook.callbacks {
389 if callback.trim().is_empty() {
390 continue;
391 }
392
393 let action_qname = format!("{}#{}", hook.container, action);
394 let callback_qname = format!("{}#{}", hook.container, callback);
395 if !emitted.insert((action_qname.clone(), callback_qname.clone())) {
396 continue;
397 }
398
399 let action_id = helper.ensure_method(&action_qname, None, false, false);
400 let callback_id = helper.ensure_method(&callback_qname, None, false, false);
401 helper.add_call_edge_full_with_span(action_id, callback_id, 255, false, vec![]);
402 }
403 }
404 }
405}
406
407#[allow(
409 clippy::too_many_lines,
410 reason = "Ruby graph extraction handles DSLs and FFI patterns in one traversal."
411)]
412fn walk_tree_for_graph_impl(
413 node: Node,
414 content: &[u8],
415 ast_graph: &ASTGraph,
416 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
417 ffi_enabled_scopes: &HashSet<Vec<String>>,
418 current_namespace: &mut Vec<String>,
419) -> GraphResult<()> {
420 match node.kind() {
421 "class" => {
422 if let Some(name_node) = node.child_by_field_name("name")
424 && let Ok(class_name) = name_node.utf8_text(content)
425 {
426 let span = span_from_points(node.start_position(), node.end_position());
427 let qualified_name = class_name.to_string();
428 let class_id = helper.add_class(&qualified_name, Some(span));
429 helper.mark_definition(class_id);
431
432 let module_id = helper.add_module(FILE_MODULE_NAME, None);
435 helper.add_export_edge(module_id, class_id);
436
437 if let Some(superclass_node) = node.child_by_field_name("superclass")
439 && let Ok(superclass_name) = superclass_node.utf8_text(content)
440 {
441 let superclass_name = superclass_name.trim();
442 if !superclass_name.is_empty() {
443 let parent_id = helper.add_class(superclass_name, None);
445 helper.add_inherits_edge(class_id, parent_id);
446 }
447 }
448
449 current_namespace.push(class_name.trim().to_string());
451
452 let mut cursor = node.walk();
454 for child in node.children(&mut cursor) {
455 walk_tree_for_graph_impl(
456 child,
457 content,
458 ast_graph,
459 helper,
460 ffi_enabled_scopes,
461 current_namespace,
462 )?;
463 }
464
465 current_namespace.pop();
466 return Ok(());
467 }
468 }
469 "module" => {
470 if let Some(name_node) = node.child_by_field_name("name")
472 && let Ok(module_name) = name_node.utf8_text(content)
473 {
474 let span = span_from_points(node.start_position(), node.end_position());
475 let qualified_name = module_name.to_string();
476 let mod_id = helper.add_module(&qualified_name, Some(span));
477 helper.mark_definition(mod_id);
479
480 let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
483 helper.add_export_edge(file_module_id, mod_id);
484
485 current_namespace.push(module_name.trim().to_string());
487
488 let mut cursor = node.walk();
490 for child in node.children(&mut cursor) {
491 walk_tree_for_graph_impl(
492 child,
493 content,
494 ast_graph,
495 helper,
496 ffi_enabled_scopes,
497 current_namespace,
498 )?;
499 }
500
501 current_namespace.pop();
502 return Ok(());
503 }
504 }
505 "method" | "singleton_method" => {
506 if let Some(context) = ast_graph.context_for_node(&node) {
508 let span = span_from_points(context.start_position, context.end_position);
509
510 let is_async = detect_async_method(node, content);
512
513 let params = node
515 .child_by_field_name("parameters")
516 .and_then(|params_node| extract_method_parameters(params_node, content));
517
518 let return_type = extract_return_type(node, content);
520
521 let signature = match (params.as_ref(), return_type.as_ref()) {
523 (Some(p), Some(r)) => Some(format!("{p} -> {r}")),
524 (Some(p), None) => Some(p.clone()),
525 (None, Some(r)) => Some(format!("-> {r}")),
526 (None, None) => None,
527 };
528
529 let visibility = context.visibility().as_str();
531
532 let method_id = helper.add_method_with_signature(
534 context.qualified_name(),
535 Some(span),
536 is_async,
537 context.is_singleton(),
538 Some(visibility),
539 signature.as_deref(),
540 );
541
542 if context.visibility() == Visibility::Public {
545 let module_id = helper.add_module(FILE_MODULE_NAME, None);
546 helper.add_export_edge(module_id, method_id);
547 }
548 }
549 }
550 "assignment" => {
551 if let Some(left_node) = node.child_by_field_name("left")
553 && left_node.kind() == "constant"
554 && let Ok(const_name) = left_node.utf8_text(content)
555 {
556 let qualified_name = if current_namespace.is_empty() {
558 const_name.to_string()
559 } else {
560 format!("{}::{}", current_namespace.join("::"), const_name)
561 };
562
563 let span = span_from_points(node.start_position(), node.end_position());
564 let const_id = helper.add_constant(&qualified_name, Some(span));
565
566 let module_id = helper.add_module(FILE_MODULE_NAME, None);
568 helper.add_export_edge(module_id, const_id);
569 }
570 }
571 "call" | "command" | "command_call" | "identifier" | "super" => {
572 if is_include_or_extend_statement(node, content) {
574 handle_include_extend(node, content, helper, current_namespace);
575 }
576 else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
580 } else if is_require_statement(node, content) {
582 if let Some((from_qname, to_qname)) =
584 build_import_for_staging(node, content, helper.file_path())
585 {
586 let from_id = helper.add_import(&from_qname, None);
588 let to_id = helper.add_import(
589 &to_qname,
590 Some(span_from_points(node.start_position(), node.end_position())),
591 );
592
593 helper.add_import_edge(from_id, to_id);
595 }
596 } else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
597 build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
599 } else {
600 if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
602 build_call_for_staging(ast_graph, node, content)
603 {
604 let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
606 let target_id =
607 helper.ensure_callee(&target_qname, span, CalleeKindHint::Function);
608
609 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
611 helper.add_call_edge_full_with_span(
612 source_id,
613 target_id,
614 argument_count,
615 false,
616 vec![span],
617 );
618 }
619 }
620 }
621 _ => {}
622 }
623
624 let mut cursor = node.walk();
626 for child in node.children(&mut cursor) {
627 walk_tree_for_graph_impl(
628 child,
629 content,
630 ast_graph,
631 helper,
632 ffi_enabled_scopes,
633 current_namespace,
634 )?;
635 }
636
637 Ok(())
638}
639
640fn is_ffi_attach_function(
651 node: Node,
652 content: &[u8],
653 ffi_enabled_scopes: &HashSet<Vec<String>>,
654 current_namespace: &[String],
655) -> bool {
656 let method_name = match node.kind() {
658 "command" => node
659 .child_by_field_name("name")
660 .and_then(|n| n.utf8_text(content).ok()),
661 "call" | "command_call" => node
662 .child_by_field_name("method")
663 .and_then(|n| n.utf8_text(content).ok()),
664 _ => None,
665 };
666
667 let Some(method_name) = method_name else {
668 return false;
669 };
670 let method_name = method_name.trim();
671 if !matches!(
672 method_name,
673 "attach_function" | "attach_variable" | "ffi_lib" | "callback"
674 ) {
675 return false;
676 }
677
678 let receiver = match node.kind() {
679 "call" | "command_call" | "method_call" => node
680 .child_by_field_name("receiver")
681 .and_then(|n| n.utf8_text(content).ok()),
682 _ => None,
683 };
684 if let Some(receiver) = receiver {
685 let trimmed = receiver.trim();
686 if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
687 return true;
688 }
689 }
690
691 ffi_enabled_scopes.contains(current_namespace)
692}
693
694fn build_ffi_edge_for_attach_function(
701 node: Node,
702 content: &[u8],
703 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
704 current_namespace: &[String],
705) {
706 let arguments = node.child_by_field_name("arguments");
708
709 let func_name = if let Some(args) = arguments {
711 extract_first_symbol_from_arguments(args, content)
712 } else {
713 let mut cursor = node.walk();
715 let mut found_name = false;
716 let mut result = None;
717 for child in node.children(&mut cursor) {
718 if !child.is_named() {
719 continue;
720 }
721 if !found_name {
723 found_name = true;
724 continue;
725 }
726 if matches!(child.kind(), "symbol" | "simple_symbol")
728 && let Ok(text) = child.utf8_text(content)
729 {
730 result = Some(text.trim().trim_start_matches(':').to_string());
731 break;
732 }
733 }
734 result
735 };
736
737 let Some(func_name) = func_name else {
738 return;
739 };
740
741 let caller_name = if current_namespace.is_empty() {
743 "<module>".to_string()
744 } else {
745 current_namespace.join("::")
746 };
747
748 let caller_id = helper.add_module(&caller_name, None);
750
751 let ffi_func_name = format!("ffi::{func_name}");
753 let span = span_from_points(node.start_position(), node.end_position());
754 let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
755
756 helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
758}
759
760fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
762 let mut cursor = arguments.walk();
763 for child in arguments.children(&mut cursor) {
764 if matches!(child.kind(), "symbol" | "simple_symbol")
765 && let Ok(text) = child.utf8_text(content)
766 {
767 return Some(text.trim().trim_start_matches(':').to_string());
768 }
769 if child.kind() == "bare_symbol"
771 && let Ok(text) = child.utf8_text(content)
772 {
773 return Some(text.trim().to_string());
774 }
775 }
776 None
777}
778
779fn build_call_for_staging(
781 ast_graph: &ASTGraph,
782 call_node: Node<'_>,
783 content: &[u8],
784) -> GraphResult<Option<CallEdgeData>> {
785 let Some(call_context) = ast_graph.context_for_node(&call_node) else {
786 return Ok(None);
787 };
788
789 let Some(method_call) = extract_method_call(call_node, content)? else {
790 return Ok(None);
791 };
792
793 if is_visibility_command(&method_call) {
794 return Ok(None);
795 }
796
797 let source_qualified = call_context.qualified_name().to_string();
798 let target_name = resolve_callee(&method_call, call_context);
799
800 if target_name.is_empty() {
801 return Ok(None);
802 }
803
804 let span = span_from_node(call_node);
805 let argument_count = count_arguments(method_call.arguments, content);
806 let is_singleton = call_context.is_singleton();
807
808 Ok(Some((
809 source_qualified,
810 target_name,
811 argument_count,
812 span,
813 is_singleton,
814 )))
815}
816
817fn build_import_for_staging(
819 require_node: Node<'_>,
820 content: &[u8],
821 file_path: &str,
822) -> Option<(String, String)> {
823 let method_name = match require_node.kind() {
825 "command" => require_node
826 .child_by_field_name("name")
827 .and_then(|n| n.utf8_text(content).ok())
828 .map(|s| s.trim().to_string()),
829 "call" | "method_call" => require_node
830 .child_by_field_name("method")
831 .and_then(|n| n.utf8_text(content).ok())
832 .map(|s| s.trim().to_string()),
833 _ => None,
834 };
835
836 let method_name = method_name?;
837
838 if !matches!(method_name.as_str(), "require" | "require_relative") {
840 return None;
841 }
842
843 let arguments = require_node.child_by_field_name("arguments");
845 let module_name = if let Some(args) = arguments {
846 extract_require_module_name(args, content)
847 } else {
848 let mut cursor = require_node.walk();
850 let mut found_name = false;
851 let mut result = None;
852 for child in require_node.children(&mut cursor) {
853 if !child.is_named() {
854 continue;
855 }
856 if !found_name {
857 found_name = true;
858 continue;
859 }
860 result = extract_string_content(child, content);
862 break;
863 }
864 result
865 };
866
867 let module_name = module_name?;
868
869 if module_name.is_empty() {
870 return None;
871 }
872
873 let is_relative = method_name == "require_relative";
875 let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
876
877 Some(("<module>".to_string(), resolved_path))
879}
880
881fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
882 node.kind() == "identifier"
883 && node
884 .parent()
885 .is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
886}
887
888fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
898 let body_node = method_node.child_by_field_name("body");
900 if body_node.is_none() {
901 return false;
902 }
903 let body_node = body_node.unwrap();
904
905 if let Ok(body_text) = body_node.utf8_text(content) {
907 let body_lower = body_text.to_lowercase();
908
909 if body_lower.contains("fiber.")
911 || body_lower.contains("fiber.new")
912 || body_lower.contains("fiber.yield")
913 || body_lower.contains("fiber.resume")
914 || body_lower.contains("thread.new")
915 || body_lower.contains("thread.start")
916 || body_lower.contains("async do")
917 || body_lower.contains("async {")
918 || body_lower.contains("async.reactor")
919 || body_lower.contains("concurrent::")
920 {
921 return true;
922 }
923 }
924
925 false
926}
927
928fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
930 let method_name = match node.kind() {
931 "command" => node
932 .child_by_field_name("name")
933 .and_then(|n| n.utf8_text(content).ok()),
934 "call" | "method_call" => node
935 .child_by_field_name("method")
936 .and_then(|n| n.utf8_text(content).ok()),
937 _ => None,
938 };
939
940 method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
941}
942
943fn handle_include_extend(
951 node: Node<'_>,
952 content: &[u8],
953 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
954 current_namespace: &[String],
955) {
956 let module_name = if let Some(args) = node.child_by_field_name("arguments") {
958 extract_first_constant_from_arguments(args, content)
959 } else if node.kind() == "command" {
960 let mut cursor = node.walk();
962 let mut found_method = false;
963 let mut result = None;
964 for child in node.children(&mut cursor) {
965 if !child.is_named() {
966 continue;
967 }
968 if !found_method {
970 found_method = true;
971 continue;
972 }
973 if child.kind() == "constant"
975 && let Ok(text) = child.utf8_text(content)
976 {
977 result = Some(text.trim().to_string());
978 break;
979 }
980 }
981 result
982 } else {
983 None
984 };
985
986 let Some(module_name) = module_name else {
987 return;
988 };
989
990 let class_name = if current_namespace.is_empty() {
992 return; } else {
994 current_namespace.join("::")
995 };
996
997 let class_id = helper.add_class(&class_name, None);
999 let module_id = helper.add_module(&module_name, None);
1000
1001 helper.add_implements_edge(class_id, module_id);
1003}
1004
1005fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
1007 let mut cursor = args_node.walk();
1008 for child in args_node.children(&mut cursor) {
1009 if !child.is_named() {
1010 continue;
1011 }
1012 if child.kind() == "constant"
1014 && let Ok(text) = child.utf8_text(content)
1015 {
1016 return Some(text.trim().to_string());
1017 }
1018 }
1019 None
1020}
1021
1022fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
1024 let method_name = match node.kind() {
1025 "command" => node
1026 .child_by_field_name("name")
1027 .and_then(|n| n.utf8_text(content).ok()),
1028 "call" | "method_call" => node
1029 .child_by_field_name("method")
1030 .and_then(|n| n.utf8_text(content).ok()),
1031 _ => None,
1032 };
1033
1034 method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
1035}
1036
1037struct ContextBuilder<'a> {
1038 contexts: Vec<RubyContext>,
1039 node_to_context: HashMap<usize, usize>,
1040 attr_visibility: HashMap<usize, Visibility>,
1041 namespace: Vec<String>,
1042 visibility_stack: Vec<Visibility>,
1043 ffi_enabled_scopes: HashSet<Vec<String>>,
1044 controller_dsl_hooks: Vec<ControllerDslHook>,
1045 max_depth: usize,
1046 content: &'a [u8],
1047 guard: sqry_core::query::security::RecursionGuard,
1048}
1049
1050impl<'a> ContextBuilder<'a> {
1051 fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
1052 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
1053 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
1054 let file_ops_depth = recursion_limits
1055 .effective_file_ops_depth()
1056 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
1057 let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
1058 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
1059
1060 Ok(Self {
1061 contexts: Vec::new(),
1062 node_to_context: HashMap::new(),
1063 attr_visibility: HashMap::new(),
1064 namespace: Vec::new(),
1065 visibility_stack: vec![Visibility::Public],
1066 ffi_enabled_scopes: HashSet::new(),
1067 controller_dsl_hooks: Vec::new(),
1068 max_depth,
1069 content,
1070 guard,
1071 })
1072 }
1073
1074 fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
1078 self.guard
1079 .enter()
1080 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
1081
1082 match node.kind() {
1083 "class" => self.visit_class(node)?,
1084 "module" => self.visit_module(node)?,
1085 "singleton_class" => self.visit_singleton_class(node)?,
1086 "method" => self.visit_method(node)?,
1087 "singleton_method" => self.visit_singleton_method(node)?,
1088 "command" | "command_call" | "call" => {
1089 self.detect_ffi_extend(node)?;
1090 self.detect_controller_dsl(node)?;
1091 self.record_attr_visibility(node);
1092 self.adjust_visibility(node)?;
1093 self.walk_children(node)?;
1094 }
1095 "identifier" => {
1096 self.adjust_visibility_from_identifier(node)?;
1099 self.walk_children(node)?;
1100 }
1101 _ => self.walk_children(node)?,
1102 }
1103
1104 self.guard.exit();
1105 Ok(())
1106 }
1107
1108 fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
1109 let name_node = node
1110 .child_by_field_name("name")
1111 .ok_or_else(|| "class node missing name".to_string())?;
1112 let class_name = self.node_text(name_node)?;
1113
1114 if self.namespace.len() > self.max_depth {
1115 return Ok(());
1116 }
1117
1118 self.namespace.push(class_name);
1119 self.visibility_stack.push(Visibility::Public);
1120
1121 self.walk_children(node)?;
1122
1123 self.visibility_stack.pop();
1124 self.namespace.pop();
1125 Ok(())
1126 }
1127
1128 fn visit_module(&mut self, node: Node<'a>) -> Result<(), String> {
1129 let name_node = node
1130 .child_by_field_name("name")
1131 .ok_or_else(|| "module node missing name".to_string())?;
1132 let module_name = self.node_text(name_node)?;
1133
1134 if self.namespace.len() > self.max_depth {
1135 return Ok(());
1136 }
1137
1138 self.namespace.push(module_name);
1139 self.visibility_stack.push(Visibility::Public);
1140
1141 self.walk_children(node)?;
1142
1143 self.visibility_stack.pop();
1144 self.namespace.pop();
1145 Ok(())
1146 }
1147
1148 fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
1149 let name_node = node
1150 .child_by_field_name("name")
1151 .ok_or_else(|| "method node missing name".to_string())?;
1152 let method_name = self.node_text(name_node)?;
1153
1154 let (qualified_name, container) =
1155 method_qualified_name(&self.namespace, &method_name, false);
1156
1157 let visibility = inline_visibility_for_method(node, self.content)
1158 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1159
1160 let context = RubyContext {
1161 qualified_name,
1162 container,
1163 kind: RubyContextKind::Method,
1164 visibility,
1165 start_position: node.start_position(),
1166 end_position: node.end_position(),
1167 };
1168
1169 let idx = self.contexts.len();
1170 self.contexts.push(context);
1171 associate_descendants(node, idx, &mut self.node_to_context);
1172
1173 self.walk_children(node)?;
1174 Ok(())
1175 }
1176
1177 fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
1178 let value_node = node
1180 .child_by_field_name("value")
1181 .ok_or_else(|| "singleton_class missing value".to_string())?;
1182 let object_text = self.node_text(value_node)?;
1183
1184 let scope_name = if object_text == "self" {
1186 if let Some(current_class) = self.namespace.last() {
1188 format!("<<{current_class}>>")
1189 } else {
1190 "<<main>>".to_string()
1191 }
1192 } else {
1193 format!("<<{object_text}>>")
1195 };
1196
1197 if self.namespace.len() > self.max_depth {
1198 return Ok(());
1199 }
1200
1201 self.namespace.push(scope_name);
1203 self.visibility_stack.push(Visibility::Public);
1204
1205 self.visit_singleton_class_body(node)?;
1207
1208 self.visibility_stack.pop();
1210 self.namespace.pop();
1211 Ok(())
1212 }
1213
1214 fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
1215 let mut cursor = node.walk();
1216 for child in node.children(&mut cursor) {
1217 if !child.is_named() {
1218 continue;
1219 }
1220
1221 if child.kind() == "method" {
1223 self.visit_method_as_singleton(child)?;
1224 } else {
1225 self.walk(child)?;
1226 }
1227 }
1228 Ok(())
1229 }
1230
1231 fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
1232 let name_node = node
1233 .child_by_field_name("name")
1234 .ok_or_else(|| "method node missing name".to_string())?;
1235 let method_name = self.node_text(name_node)?;
1236
1237 let actual_namespace: Vec<String> = self
1239 .namespace
1240 .iter()
1241 .map(|s| {
1242 if s.starts_with("<<") && s.ends_with(">>") {
1243 s[2..s.len() - 2].to_string()
1245 } else {
1246 s.clone()
1247 }
1248 })
1249 .collect();
1250
1251 let (qualified_name, container) =
1252 method_qualified_name(&actual_namespace, &method_name, true);
1253
1254 let visibility = inline_visibility_for_method(node, self.content)
1255 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1256
1257 let context = RubyContext {
1258 qualified_name,
1259 container,
1260 kind: RubyContextKind::SingletonMethod,
1261 visibility,
1262 start_position: node.start_position(),
1263 end_position: node.end_position(),
1264 };
1265
1266 let idx = self.contexts.len();
1267 self.contexts.push(context);
1268 associate_descendants(node, idx, &mut self.node_to_context);
1269
1270 self.walk_children(node)?;
1271 Ok(())
1272 }
1273
1274 fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
1275 let name_node = node
1276 .child_by_field_name("name")
1277 .ok_or_else(|| "singleton_method missing name".to_string())?;
1278 let method_name = self.node_text(name_node)?;
1279
1280 let object_node = node
1281 .child_by_field_name("object")
1282 .ok_or_else(|| "singleton_method missing object".to_string())?;
1283 let object_text = self.node_text(object_node)?;
1284
1285 let (qualified_name, container) =
1286 singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
1287
1288 let visibility = inline_visibility_for_method(node, self.content)
1289 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1290
1291 let context = RubyContext {
1292 qualified_name,
1293 container,
1294 kind: RubyContextKind::SingletonMethod,
1295 visibility,
1296 start_position: node.start_position(),
1297 end_position: node.end_position(),
1298 };
1299
1300 let idx = self.contexts.len();
1301 self.contexts.push(context);
1302 associate_descendants(node, idx, &mut self.node_to_context);
1303
1304 self.walk_children(node)?;
1305 Ok(())
1306 }
1307
1308 fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
1309 let name_node = node.child_by_field_name("name");
1310 let Some(name_node) = name_node else {
1311 return Ok(());
1312 };
1313
1314 let keyword = self.node_text(name_node)?;
1315 if keyword.trim() != "extend" {
1316 return Ok(());
1317 }
1318
1319 let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
1320 node_text_raw(arguments, self.content).unwrap_or_default()
1321 } else {
1322 let mut cursor = node.walk();
1323 let mut found_name = false;
1324 let mut result = String::new();
1325 for child in node.children(&mut cursor) {
1326 if !child.is_named() {
1327 continue;
1328 }
1329 if !found_name {
1330 found_name = true;
1331 continue;
1332 }
1333 if let Some(text) = node_text_raw(child, self.content) {
1334 result = text;
1335 break;
1336 }
1337 }
1338 result
1339 };
1340
1341 if arg_text.contains("FFI::Library") {
1342 self.ffi_enabled_scopes.insert(self.namespace.clone());
1344 }
1345
1346 Ok(())
1347 }
1348
1349 fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
1350 let name_node = node
1351 .child_by_field_name("name")
1352 .or_else(|| node.child_by_field_name("method"));
1353 let Some(name_node) = name_node else {
1354 return Ok(());
1355 };
1356 let dsl = self.node_text(name_node)?;
1357
1358 let kind = match dsl.as_str() {
1359 "before_action" => Some(ControllerDslKind::Before),
1360 "after_action" => Some(ControllerDslKind::After),
1361 "around_action" => Some(ControllerDslKind::Around),
1362 _ => None,
1363 };
1364 let Some(kind) = kind else {
1365 return Ok(());
1366 };
1367
1368 if self.namespace.is_empty() {
1369 return Ok(());
1370 }
1371 let container = self.namespace.join("::");
1372
1373 let mut callbacks: Vec<String> = Vec::new();
1374 let mut only: Option<Vec<String>> = None;
1375 let mut except: Option<Vec<String>> = None;
1376
1377 if let Some(arguments) = node.child_by_field_name("arguments") {
1378 let mut cursor = arguments.walk();
1379 for child in arguments.children(&mut cursor) {
1380 if !child.is_named() {
1381 continue;
1382 }
1383 let kind = child.kind();
1384 match kind {
1385 "symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
1386 let mut v = extract_symbols_from_node(child, self.content);
1387 callbacks.append(&mut v);
1388 }
1389 "pair" => {
1390 let key = child.child_by_field_name("key");
1392 let val = child.child_by_field_name("value");
1393 if key.is_none() || val.is_none() {
1394 continue;
1395 }
1396 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1397 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1398 if key_text.contains("only") && !symbols.is_empty() {
1399 only = Some(symbols);
1400 } else if key_text.contains("except") && !symbols.is_empty() {
1401 except = Some(symbols);
1402 }
1403 }
1404 "hash" => {
1405 let mut hcur = child.walk();
1407 for pair in child.children(&mut hcur) {
1408 if !pair.is_named() {
1409 continue;
1410 }
1411 if pair.kind() != "pair" {
1412 continue;
1413 }
1414 let key = pair.child_by_field_name("key");
1415 let val = pair.child_by_field_name("value");
1416 if key.is_none() || val.is_none() {
1417 continue;
1418 }
1419 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1420 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1421 if key_text.contains("only") && !symbols.is_empty() {
1422 only = Some(symbols);
1423 } else if key_text.contains("except") && !symbols.is_empty() {
1424 except = Some(symbols);
1425 }
1426 }
1427 }
1428 _ => {}
1429 }
1430 }
1431 } else {
1432 if let Some(raw) = node_text_raw(node, self.content) {
1434 let (cbs, o, e) = parse_controller_dsl_args(&raw);
1435 callbacks = cbs;
1436 only = o;
1437 except = e;
1438 }
1439 }
1440
1441 if callbacks.is_empty() {
1442 return Ok(());
1443 }
1444
1445 self.controller_dsl_hooks.push(ControllerDslHook {
1446 container,
1447 kind,
1448 callbacks,
1449 only,
1450 except,
1451 });
1452 Ok(())
1453 }
1454
1455 fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
1456 let name_node = node.child_by_field_name("name");
1457 let Some(name_node) = name_node else {
1458 return Ok(());
1459 };
1460
1461 let keyword = self.node_text(name_node)?;
1462 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1463 return Ok(());
1464 };
1465
1466 if !has_call_arguments(node)
1468 && let Some(last) = self.visibility_stack.last_mut()
1469 {
1470 *last = new_visibility;
1471 }
1472 Ok(())
1473 }
1474
1475 fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
1477 let keyword = self.node_text(node)?;
1478 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1479 return Ok(());
1480 };
1481
1482 if let Some(last) = self.visibility_stack.last_mut() {
1484 *last = new_visibility;
1485 }
1486
1487 Ok(())
1488 }
1489
1490 fn record_attr_visibility(&mut self, node: Node<'a>) {
1491 if !is_attr_call(node, self.content) {
1492 return;
1493 }
1494
1495 let visibility = self
1496 .visibility_stack
1497 .last()
1498 .copied()
1499 .unwrap_or(Visibility::Public);
1500 self.attr_visibility.insert(node.id(), visibility);
1501 }
1502
1503 fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
1504 let mut cursor = node.walk();
1505 for child in node.children(&mut cursor) {
1506 if child.is_named() {
1507 self.walk(child)?;
1508 }
1509 }
1510 Ok(())
1511 }
1512
1513 fn node_text(&self, node: Node<'a>) -> Result<String, String> {
1514 node.utf8_text(self.content)
1515 .map(|s| s.trim().to_string())
1516 .map_err(|err| err.to_string())
1517 }
1518}
1519
1520#[derive(Clone)]
1521struct MethodCall<'a> {
1522 name: String,
1523 receiver: Option<String>,
1524 arguments: Option<Node<'a>>,
1525 node: Node<'a>,
1526}
1527
1528fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
1529 let method_name = match node.kind() {
1530 "call" | "command_call" | "method_call" => {
1531 let method_node = node
1532 .child_by_field_name("method")
1533 .ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
1534 node_text(method_node, content)?
1535 }
1536 "command" => {
1537 let name_node = node
1538 .child_by_field_name("name")
1539 .ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
1540 node_text(name_node, content)?
1541 }
1542 "super" => "super".to_string(),
1543 "identifier" => {
1544 if !should_treat_identifier_as_call(node) {
1545 return Ok(None);
1546 }
1547 node_text(node, content)?
1548 }
1549 _ => return Ok(None),
1550 };
1551
1552 let receiver = match node.kind() {
1553 "call" | "command_call" | "method_call" => node
1554 .child_by_field_name("receiver")
1555 .and_then(|r| node_text(r, content).ok()),
1556 _ => None,
1557 };
1558
1559 let arguments = node.child_by_field_name("arguments");
1560
1561 Ok(Some(MethodCall {
1562 name: method_name,
1563 receiver,
1564 arguments,
1565 node,
1566 }))
1567}
1568
1569fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
1570 if let Some(parent) = node.parent() {
1571 let kind = parent.kind();
1572 if matches!(
1573 kind,
1574 "call"
1575 | "command"
1576 | "command_call"
1577 | "method_call"
1578 | "method"
1579 | "singleton_method"
1580 | "alias"
1581 | "symbol"
1582 ) {
1583 return false;
1584 }
1585
1586 if kind.contains("assignment")
1587 || matches!(
1588 kind,
1589 "parameters"
1590 | "method_parameters"
1591 | "block_parameters"
1592 | "lambda_parameters"
1593 | "constant_path"
1594 | "module"
1595 | "class"
1596 | "hash"
1597 | "pair"
1598 | "array"
1599 | "argument_list"
1600 )
1601 {
1602 return false;
1603 }
1604 }
1605
1606 true
1607}
1608
1609fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
1623 let name = method_call.name.trim();
1624 if name.is_empty() {
1625 return String::new();
1626 }
1627
1628 if name == "super" {
1630 return format!("super::{}", context.qualified_name());
1634 }
1635
1636 if let Some(receiver) = method_call.receiver.as_deref() {
1637 let receiver = receiver.trim();
1638 if receiver == "self" {
1639 if let Some(container) = context.container() {
1640 return format!("{container}.{name}");
1641 }
1642 return format!("self.{name}");
1643 }
1644
1645 if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
1646 let cleaned = receiver.trim_start_matches("::");
1647 if let Some(class_name) = cleaned.strip_suffix(".new") {
1649 return format!("{class_name}#{name}");
1650 }
1651 return format!("{cleaned}.{name}");
1652 }
1653
1654 return name.to_string();
1656 }
1657
1658 if context.is_singleton() {
1659 if let Some(container) = context.container() {
1660 return format!("{container}.{name}");
1661 }
1662 return name.to_string();
1663 }
1664
1665 if let Some(container) = context.container() {
1666 return format!("{container}#{name}");
1667 }
1668
1669 name.to_string()
1670}
1671
1672fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
1684 let Some(arguments) = arguments else {
1685 return 0;
1686 };
1687
1688 let mut count = 0;
1689 let mut cursor = arguments.walk();
1690 for child in arguments.children(&mut cursor) {
1691 if child.is_named()
1692 && !is_literal_delimiter(child.kind())
1693 && node_text(child, content)
1694 .map(|s| !s.trim().is_empty())
1695 .unwrap_or(false)
1696 {
1697 count += 1;
1698 }
1699 }
1700 count
1701}
1702
1703fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
1714 let mut stack = vec![node];
1715 while let Some(current) = stack.pop() {
1716 map.insert(current.id(), idx);
1717 let mut cursor = current.walk();
1718 for child in current.children(&mut cursor) {
1719 stack.push(child);
1720 }
1721 }
1722}
1723
1724fn method_qualified_name(
1738 namespace: &[String],
1739 method_name: &str,
1740 singleton: bool,
1741) -> (String, Option<String>) {
1742 if namespace.is_empty() {
1743 return (method_name.to_string(), None);
1744 }
1745
1746 let container = namespace.join("::");
1747 let qualified = if singleton {
1748 format!("{container}.{method_name}")
1749 } else {
1750 format!("{container}#{method_name}")
1751 };
1752 (qualified, Some(container))
1753}
1754
1755fn singleton_qualified_name(
1768 current_namespace: &[String],
1769 object_text: &str,
1770 method_name: &str,
1771) -> (String, Option<String>) {
1772 if object_text == "self" {
1773 if current_namespace.is_empty() {
1774 (method_name.to_string(), None)
1775 } else {
1776 let container = current_namespace.join("::");
1777 (format!("{container}.{method_name}"), Some(container))
1778 }
1779 } else {
1780 let parts = split_constant_path(object_text);
1781 if parts.is_empty() {
1782 (method_name.to_string(), None)
1783 } else {
1784 let container = parts.join("::");
1785 (format!("{container}.{method_name}"), Some(container))
1786 }
1787 }
1788}
1789
1790fn split_constant_path(path: &str) -> Vec<String> {
1804 path.trim()
1805 .trim_start_matches("::")
1806 .split("::")
1807 .filter_map(|seg| {
1808 let trimmed = seg.trim();
1809 if trimmed.is_empty() {
1810 None
1811 } else {
1812 Some(trimmed.to_string())
1813 }
1814 })
1815 .collect()
1816}
1817
1818fn is_constant(text: &str) -> bool {
1828 text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1829}
1830
1831fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
1842 matches!(
1843 method_call.name.as_str(),
1844 "public" | "private" | "protected"
1845 ) && method_call.receiver.is_none()
1846 && !has_call_arguments(method_call.node)
1847}
1848
1849fn has_call_arguments(node: Node<'_>) -> bool {
1859 if let Some(arguments) = node.child_by_field_name("arguments") {
1860 let mut cursor = arguments.walk();
1861 for child in arguments.children(&mut cursor) {
1862 if child.is_named() {
1863 return true;
1864 }
1865 }
1866 }
1867 false
1868}
1869
1870fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
1871 let parent = node.parent()?;
1872 let visibility_node = match parent.kind() {
1873 "call" | "command" | "command_call" => parent,
1874 "argument_list" => parent.parent()?,
1875 _ => return None,
1876 };
1877
1878 if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
1879 return None;
1880 }
1881
1882 let keyword_node = visibility_node
1883 .child_by_field_name("name")
1884 .or_else(|| visibility_node.child_by_field_name("method"))?;
1885 let keyword = node_text_raw(keyword_node, content)?;
1886 Visibility::from_keyword(keyword.trim())
1887}
1888
1889fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
1900 node.utf8_text(content)
1901 .map(|s| s.trim().to_string())
1902 .map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
1903}
1904
1905fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
1907 node.utf8_text(content)
1908 .ok()
1909 .map(std::string::ToString::to_string)
1910}
1911
1912fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
1921 GraphBuilderError::ParseError {
1922 span: span_from_node(node),
1923 reason: reason.to_string(),
1924 }
1925}
1926
1927#[allow(clippy::match_same_arms)]
1944fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
1945 let mut params = Vec::new();
1946 let mut cursor = params_node.walk();
1947
1948 for child in params_node.named_children(&mut cursor) {
1949 match child.kind() {
1950 "identifier" | "optional_parameter" => {
1953 if let Ok(text) = child.utf8_text(content) {
1954 params.push(text.to_string());
1955 }
1956 }
1957 "splat_parameter" => {
1959 if let Some(name_node) = child.child_by_field_name("name") {
1960 if let Ok(name) = name_node.utf8_text(content) {
1961 params.push(format!("*{name}"));
1962 }
1963 } else if let Ok(text) = child.utf8_text(content) {
1964 params.push(text.to_string());
1966 }
1967 }
1968 "hash_splat_parameter" => {
1970 if let Some(name_node) = child.child_by_field_name("name") {
1971 if let Ok(name) = name_node.utf8_text(content) {
1972 params.push(format!("**{name}"));
1973 }
1974 } else if let Ok(text) = child.utf8_text(content) {
1975 params.push(text.to_string());
1977 }
1978 }
1979 "block_parameter" => {
1981 if let Some(name_node) = child.child_by_field_name("name") {
1982 if let Ok(name) = name_node.utf8_text(content) {
1983 params.push(format!("&{name}"));
1984 }
1985 } else if let Ok(text) = child.utf8_text(content) {
1986 params.push(text.to_string());
1988 }
1989 }
1990 "keyword_parameter" => {
1992 if let Ok(text) = child.utf8_text(content) {
1993 params.push(text.to_string());
1994 }
1995 }
1996 "destructured_parameter" => {
1998 if let Ok(text) = child.utf8_text(content) {
1999 params.push(text.to_string());
2000 }
2001 }
2002 "forward_parameter" => {
2004 params.push("...".to_string());
2005 }
2006 "hash_splat_nil" => {
2008 params.push("**nil".to_string());
2009 }
2010 _ => {
2011 }
2013 }
2014 }
2015
2016 if params.is_empty() {
2017 None
2018 } else {
2019 Some(params.join(", "))
2020 }
2021}
2022
2023fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2037 if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
2039 return Some(return_type);
2040 }
2041
2042 if let Some(return_type) = extract_rbs_return_type(method_node, content) {
2044 return Some(return_type);
2045 }
2046
2047 if let Some(return_type) = extract_yard_return_type(method_node, content) {
2049 return Some(return_type);
2050 }
2051
2052 None
2053}
2054
2055fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2066 let mut sibling = method_node.prev_sibling()?;
2068
2069 while sibling.kind() == "comment" {
2071 sibling = sibling.prev_sibling()?;
2072 }
2073
2074 if sibling.kind() == "call"
2076 && let Some(method_name) = sibling.child_by_field_name("method")
2077 && let Ok(name_text) = method_name.utf8_text(content)
2078 && name_text == "sig"
2079 {
2080 if let Some(block_node) = sibling.child_by_field_name("block") {
2082 return extract_returns_from_sig_block(block_node, content);
2083 }
2084 }
2085
2086 None
2087}
2088
2089fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
2091 let mut cursor = block_node.walk();
2092
2093 for child in block_node.named_children(&mut cursor) {
2094 if child.kind() == "call"
2095 && let Some(method_name) = child.child_by_field_name("method")
2096 && let Ok(name_text) = method_name.utf8_text(content)
2097 && name_text == "returns"
2098 {
2099 if let Some(args) = child.child_by_field_name("arguments") {
2101 let mut args_cursor = args.walk();
2102 for arg in args.named_children(&mut args_cursor) {
2103 if arg.kind() != ","
2104 && let Ok(type_text) = arg.utf8_text(content)
2105 {
2106 return Some(type_text.to_string());
2107 }
2108 }
2109 }
2110 }
2111 if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
2113 return Some(nested_type);
2114 }
2115 }
2116
2117 None
2118}
2119
2120fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2131 let mut cursor = method_node.walk();
2133 for child in method_node.children(&mut cursor) {
2134 if child.kind() == "comment"
2135 && let Ok(comment_text) = child.utf8_text(content)
2136 {
2137 if comment_text.trim_start().starts_with("#:") {
2140 if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
2142 let return_part = &comment_text[arrow_pos + 2..];
2143 let return_type = return_part.trim().to_string();
2144 if !return_type.is_empty() {
2145 return Some(return_type);
2146 }
2147 }
2148 }
2149 }
2150 }
2151
2152 None
2153}
2154
2155fn find_top_level_arrow(text: &str) -> Option<usize> {
2159 let chars: Vec<char> = text.chars().collect();
2160 let mut depth: i32 = 0;
2161 let mut i = 0;
2162
2163 while i < chars.len() {
2164 match chars[i] {
2165 '(' | '[' | '{' => depth += 1,
2166 ')' | ']' | '}' => depth = depth.saturating_sub(1),
2167 '-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
2168 return Some(i);
2169 }
2170 _ => {}
2171 }
2172 i += 1;
2173 }
2174
2175 None
2176}
2177
2178fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2189 let mut sibling_opt = method_node.prev_sibling();
2191 let method_start_row = method_node.start_position().row;
2192
2193 let mut comments = Vec::new();
2195 let mut expected_row = method_start_row;
2196
2197 while let Some(sibling) = sibling_opt {
2198 if sibling.kind() == "comment" {
2199 let comment_end_row = sibling.end_position().row;
2200
2201 if comment_end_row + 1 >= expected_row {
2204 if let Ok(comment_text) = sibling.utf8_text(content) {
2205 comments.push(comment_text);
2206 }
2207 expected_row = sibling.start_position().row;
2208 sibling_opt = sibling.prev_sibling();
2209 } else {
2210 break;
2212 }
2213 } else {
2214 break;
2215 }
2216 }
2217
2218 for comment in comments.iter().rev() {
2220 if let Some(return_pos) = comment.find("@return") {
2221 let after_return = &comment[return_pos + 7..];
2222 if let Some(start_bracket) = after_return.find('[')
2224 && let Some(end_bracket) = after_return.find(']')
2225 && end_bracket > start_bracket
2226 {
2227 let return_type = &after_return[start_bracket + 1..end_bracket];
2228 return Some(return_type.trim().to_string());
2229 }
2230 }
2231 }
2232
2233 None
2234}
2235
2236fn span_from_node(node: Node<'_>) -> Span {
2244 span_from_points(node.start_position(), node.end_position())
2245}
2246
2247fn span_from_points(start: Point, end: Point) -> Span {
2256 Span::new(
2257 Position::new(start.row, start.column),
2258 Position::new(end.row, end.column),
2259 )
2260}
2261
2262fn is_literal_delimiter(kind: &str) -> bool {
2272 matches!(kind, "," | "(" | ")" | "[" | "]")
2273}
2274
2275fn parse_controller_dsl_args(
2278 text: &str,
2279) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
2280 let mut head = text;
2282 let mut tail = "";
2283 if let Some(idx) = text.find("only:") {
2284 head = &text[..idx];
2285 tail = &text[idx..];
2286 } else if let Some(idx) = text.find("except:") {
2287 head = &text[..idx];
2288 tail = &text[idx..];
2289 }
2290 let callbacks = extract_symbol_list_from_args(head);
2291 let only = extract_kw_symbol_list(tail, "only:");
2292 let except = extract_kw_symbol_list(tail, "except:");
2293 (callbacks, only, except)
2294}
2295
2296fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
2297 let mut out = Vec::new();
2298 let bytes = text.as_bytes();
2299 let mut i = 0;
2300 while i < bytes.len() {
2301 if bytes[i] == b':' {
2302 let start = i + 1;
2303 let mut j = start;
2304 while j < bytes.len() {
2305 let c = bytes[j] as char;
2306 if c.is_ascii_alphanumeric() || c == '_' {
2307 j += 1;
2308 } else {
2309 break;
2310 }
2311 }
2312 if j > start {
2313 out.push(text[start..j].to_string());
2314 i = j;
2315 continue;
2316 }
2317 }
2318 i += 1;
2319 }
2320 out
2321}
2322
2323fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
2324 let pos = text.find(kw)?;
2325 let mut after = &text[pos + kw.len()..];
2326 after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
2328 if after.starts_with('[')
2329 && let Some(end) = after.find(']')
2330 {
2331 return Some(extract_symbol_list_from_args(&after[..=end]));
2332 }
2333 if let Some(colon) = after.find(':') {
2335 let mut j = colon + 1;
2336 while j < after.len() {
2337 let ch = after.as_bytes()[j] as char;
2338 if ch.is_ascii_alphanumeric() || ch == '_' {
2339 j += 1;
2340 } else {
2341 break;
2342 }
2343 }
2344 if j > colon + 1 {
2345 return Some(vec![after[colon + 1..j].to_string()]);
2346 }
2347 }
2348 None
2349}
2350
2351fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
2352 let mut out = Vec::new();
2353 match node.kind() {
2354 "symbol" | "simple_symbol" => {
2355 if let Ok(t) = node_text(node, content) {
2356 out.push(t.trim_start_matches(':').to_string());
2357 }
2358 }
2359 "array" => {
2360 let mut c = node.walk();
2361 for ch in node.children(&mut c) {
2362 if matches!(ch.kind(), "symbol" | "simple_symbol")
2363 && let Ok(t) = node_text(ch, content)
2364 {
2365 out.push(t.trim_start_matches(':').to_string());
2366 }
2367 }
2368 }
2369 _ => {
2370 if let Some(txt) = node_text_raw(node, content) {
2372 out = extract_symbol_list_from_args(&txt);
2373 }
2374 }
2375 }
2376 out
2377}
2378
2379fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
2381 let mut cursor = arguments.walk();
2382 for child in arguments.children(&mut cursor) {
2383 if !child.is_named() {
2384 continue;
2385 }
2386 if let Some(s) = extract_string_content(child, content) {
2387 return Some(s);
2388 }
2389 }
2390 None
2391}
2392
2393fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
2395 let text = node.utf8_text(content).ok()?;
2396 let trimmed = text.trim();
2397
2398 if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
2400 || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
2401 && trimmed.len() >= 2
2402 {
2403 return Some(trimmed[1..trimmed.len() - 1].to_string());
2404 }
2405
2406 if matches!(node.kind(), "string" | "chained_string") {
2408 let mut cursor = node.walk();
2409 for child in node.children(&mut cursor) {
2410 if child.kind() == "string_content"
2411 && let Ok(s) = child.utf8_text(content)
2412 {
2413 return Some(s.to_string());
2414 }
2415 }
2416 }
2417
2418 None
2419}
2420
2421pub(crate) fn resolve_ruby_require(
2432 module_name: &str,
2433 is_relative: bool,
2434 source_file: &str,
2435) -> String {
2436 if is_relative {
2437 let source_path = std::path::Path::new(source_file);
2441 let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
2442
2443 let relative_path = std::path::Path::new(module_name);
2445 let resolved = source_dir.join(relative_path);
2446
2447 let normalized = normalize_path(&resolved);
2449
2450 let path_str = normalized.to_string_lossy();
2453 let separators: &[char] = &['/', '\\'];
2454 path_str
2455 .split(separators)
2456 .filter(|s| !s.is_empty())
2457 .collect::<Vec<_>>()
2458 .join("::")
2459 } else {
2460 module_name.replace('/', "::")
2463 }
2464}
2465
2466fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
2468 let mut components = Vec::new();
2469
2470 for component in path.components() {
2471 match component {
2472 std::path::Component::CurDir => {
2473 }
2475 std::path::Component::ParentDir => {
2476 if components
2478 .last()
2479 .is_some_and(|c| *c != std::path::Component::ParentDir)
2480 {
2481 components.pop();
2482 } else {
2483 components.push(component);
2484 }
2485 }
2486 _ => {
2487 components.push(component);
2488 }
2489 }
2490 }
2491
2492 components.iter().collect()
2493}
2494
2495fn process_yard_annotations(
2502 node: Node,
2503 content: &[u8],
2504 ast_graph: &ASTGraph,
2505 helper: &mut GraphBuildHelper,
2506) -> GraphResult<()> {
2507 match node.kind() {
2508 "method" => {
2509 process_method_yard(node, content, helper)?;
2510 }
2511 "singleton_method" => {
2512 process_singleton_method_yard(node, content, helper)?;
2513 }
2514 "call" | "command" | "command_call" => {
2515 if is_attr_call(node, content) {
2517 process_attr_yard(node, content, ast_graph, helper)?;
2518 }
2519 }
2520 "assignment" => {
2521 if is_instance_variable_assignment(node, content) {
2523 process_assignment_yard(node, content, helper)?;
2524 }
2525 }
2526 _ => {}
2527 }
2528
2529 let mut cursor = node.walk();
2531 for child in node.children(&mut cursor) {
2532 process_yard_annotations(child, content, ast_graph, helper)?;
2533 }
2534
2535 Ok(())
2536}
2537
2538fn process_method_yard(
2540 method_node: Node,
2541 content: &[u8],
2542 helper: &mut GraphBuildHelper,
2543) -> GraphResult<()> {
2544 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2546 return Ok(());
2547 };
2548
2549 let tags = parse_yard_tags(&yard_text);
2551
2552 let Some(name_node) = method_node.child_by_field_name("name") else {
2554 return Ok(());
2555 };
2556
2557 let method_name = name_node
2558 .utf8_text(content)
2559 .map_err(|_| GraphBuilderError::ParseError {
2560 span: span_from_node(method_node),
2561 reason: "failed to read method name".to_string(),
2562 })?
2563 .trim()
2564 .to_string();
2565
2566 if method_name.is_empty() {
2567 return Ok(());
2568 }
2569
2570 let class_name = get_enclosing_class_name(method_node, content);
2572
2573 let qualified_name = if let Some(class_name) = class_name {
2575 format!("{class_name}#{method_name}")
2576 } else {
2577 method_name.clone()
2578 };
2579
2580 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
2582
2583 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2585 let canonical_type = canonical_type_string(¶m_tag.type_str);
2587 let type_node_id = helper.add_type(&canonical_type, None);
2588 helper.add_typeof_edge_with_context(
2589 method_node_id,
2590 type_node_id,
2591 Some(TypeOfContext::Parameter),
2592 param_idx.try_into().ok(),
2593 Some(¶m_tag.name),
2594 );
2595
2596 let type_names = extract_type_names(¶m_tag.type_str);
2598 for type_name in type_names {
2599 let ref_type_id = helper.add_type(&type_name, None);
2600 helper.add_reference_edge(method_node_id, ref_type_id);
2601 }
2602 }
2603
2604 if let Some(return_type) = &tags.returns {
2606 let canonical_type = canonical_type_string(return_type);
2607 let type_node_id = helper.add_type(&canonical_type, None);
2608 helper.add_typeof_edge_with_context(
2609 method_node_id,
2610 type_node_id,
2611 Some(TypeOfContext::Return),
2612 Some(0),
2613 None,
2614 );
2615
2616 let type_names = extract_type_names(return_type);
2618 for type_name in type_names {
2619 let ref_type_id = helper.add_type(&type_name, None);
2620 helper.add_reference_edge(method_node_id, ref_type_id);
2621 }
2622 }
2623
2624 Ok(())
2625}
2626
2627fn process_singleton_method_yard(
2629 method_node: Node,
2630 content: &[u8],
2631 helper: &mut GraphBuildHelper,
2632) -> GraphResult<()> {
2633 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2635 return Ok(());
2636 };
2637
2638 let tags = parse_yard_tags(&yard_text);
2640
2641 let Some(name_node) = method_node.child_by_field_name("name") else {
2643 return Ok(());
2644 };
2645
2646 let method_name = name_node
2647 .utf8_text(content)
2648 .map_err(|_| GraphBuilderError::ParseError {
2649 span: span_from_node(method_node),
2650 reason: "failed to read method name".to_string(),
2651 })?
2652 .trim()
2653 .to_string();
2654
2655 if method_name.is_empty() {
2656 return Ok(());
2657 }
2658
2659 let class_name = get_enclosing_class_name(method_node, content);
2661
2662 let qualified_name = if let Some(class_name) = class_name {
2664 format!("{class_name}.{method_name}")
2665 } else {
2666 method_name.clone()
2667 };
2668
2669 let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
2671
2672 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2674 let canonical_type = canonical_type_string(¶m_tag.type_str);
2676 let type_node_id = helper.add_type(&canonical_type, None);
2677 helper.add_typeof_edge_with_context(
2678 method_node_id,
2679 type_node_id,
2680 Some(TypeOfContext::Parameter),
2681 param_idx.try_into().ok(),
2682 Some(¶m_tag.name),
2683 );
2684
2685 let type_names = extract_type_names(¶m_tag.type_str);
2687 for type_name in type_names {
2688 let ref_type_id = helper.add_type(&type_name, None);
2689 helper.add_reference_edge(method_node_id, ref_type_id);
2690 }
2691 }
2692
2693 if let Some(return_type) = &tags.returns {
2695 let canonical_type = canonical_type_string(return_type);
2696 let type_node_id = helper.add_type(&canonical_type, None);
2697 helper.add_typeof_edge_with_context(
2698 method_node_id,
2699 type_node_id,
2700 Some(TypeOfContext::Return),
2701 Some(0),
2702 None,
2703 );
2704
2705 let type_names = extract_type_names(return_type);
2707 for type_name in type_names {
2708 let ref_type_id = helper.add_type(&type_name, None);
2709 helper.add_reference_edge(method_node_id, ref_type_id);
2710 }
2711 }
2712
2713 Ok(())
2714}
2715
2716#[allow(clippy::unnecessary_wraps)]
2736fn process_attr_yard(
2737 attr_node: Node,
2738 content: &[u8],
2739 ast_graph: &ASTGraph,
2740 helper: &mut GraphBuildHelper,
2741) -> GraphResult<()> {
2742 let Some(method_name) = attr_method_name(attr_node, content) else {
2747 return Ok(());
2748 };
2749 let is_reader = method_name == "attr_reader";
2750
2751 let attr_names = extract_attr_names(attr_node, content);
2754 if attr_names.is_empty() {
2755 return Ok(());
2756 }
2757
2758 let class_name = get_enclosing_class_name(attr_node, content);
2760
2761 let yard_return = extract_yard_comment(attr_node, content)
2764 .map(|yard_text| parse_yard_tags(&yard_text))
2765 .and_then(|tags| tags.returns);
2766
2767 let span = span_from_node(attr_node);
2770 let visibility = ast_graph.attr_visibility_for_node(&attr_node).as_str();
2771
2772 for attr_name in attr_names {
2773 let qualified_name = if let Some(ref class) = class_name {
2777 format!("{class}#{attr_name}")
2778 } else {
2779 attr_name.clone()
2780 };
2781
2782 let attr_node_id = if is_reader {
2786 helper.add_constant_with_static_and_visibility(
2787 &qualified_name,
2788 Some(span),
2789 false,
2790 Some(visibility),
2791 )
2792 } else {
2793 helper.add_property_with_static_and_visibility(
2794 &qualified_name,
2795 Some(span),
2796 false,
2797 Some(visibility),
2798 )
2799 };
2800
2801 if let Some(var_type) = &yard_return {
2805 let canonical_type = canonical_type_string(var_type);
2806 let type_node_id = helper.add_type(&canonical_type, None);
2807 helper.add_typeof_edge_with_context(
2808 attr_node_id,
2809 type_node_id,
2810 Some(TypeOfContext::Field),
2811 None,
2812 Some(&attr_name),
2813 );
2814
2815 for type_name in extract_type_names(var_type) {
2816 let ref_type_id = helper.add_type(&type_name, None);
2817 helper.add_reference_edge(attr_node_id, ref_type_id);
2818 }
2819 }
2820 }
2821
2822 Ok(())
2823}
2824
2825fn attr_method_name(node: Node, content: &[u8]) -> Option<String> {
2829 let raw = match node.kind() {
2830 "command" => node
2831 .child_by_field_name("name")
2832 .and_then(|n| n.utf8_text(content).ok()),
2833 "call" | "command_call" => node
2834 .child_by_field_name("method")
2835 .and_then(|n| n.utf8_text(content).ok()),
2836 _ => None,
2837 }?;
2838 Some(raw.trim().to_string())
2839}
2840
2841fn process_assignment_yard(
2843 assignment_node: Node,
2844 content: &[u8],
2845 helper: &mut GraphBuildHelper,
2846) -> GraphResult<()> {
2847 let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
2849 return Ok(());
2850 };
2851
2852 let tags = parse_yard_tags(&yard_text);
2854
2855 let Some(var_type) = &tags.type_annotation else {
2857 return Ok(());
2858 };
2859
2860 let Some(left_node) = assignment_node.child_by_field_name("left") else {
2862 return Ok(());
2863 };
2864
2865 if left_node.kind() != "instance_variable" {
2866 return Ok(());
2867 }
2868
2869 let var_name = left_node
2870 .utf8_text(content)
2871 .map_err(|_| GraphBuilderError::ParseError {
2872 span: span_from_node(assignment_node),
2873 reason: "failed to read variable name".to_string(),
2874 })?
2875 .trim()
2876 .to_string();
2877
2878 if var_name.is_empty() {
2879 return Ok(());
2880 }
2881
2882 let class_name = get_enclosing_class_name(assignment_node, content);
2884
2885 let qualified_name = if let Some(class) = class_name {
2887 format!("{class}#{var_name}")
2888 } else {
2889 var_name.clone()
2890 };
2891
2892 let var_node_id = helper.add_variable(&qualified_name, None);
2894
2895 let canonical_type = canonical_type_string(var_type);
2897 let type_node_id = helper.add_type(&canonical_type, None);
2898 helper.add_typeof_edge_with_context(
2899 var_node_id,
2900 type_node_id,
2901 Some(TypeOfContext::Variable),
2902 None,
2903 Some(&var_name),
2904 );
2905
2906 let type_names = extract_type_names(var_type);
2908 for type_name in type_names {
2909 let ref_type_id = helper.add_type(&type_name, None);
2910 helper.add_reference_edge(var_node_id, ref_type_id);
2911 }
2912
2913 Ok(())
2914}
2915
2916fn is_attr_call(node: Node, content: &[u8]) -> bool {
2918 let method_name = match node.kind() {
2919 "command" => node
2920 .child_by_field_name("name")
2921 .and_then(|n| n.utf8_text(content).ok()),
2922 "call" | "command_call" => node
2923 .child_by_field_name("method")
2924 .and_then(|n| n.utf8_text(content).ok()),
2925 _ => None,
2926 };
2927
2928 method_name
2929 .is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
2930}
2931
2932fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
2934 if let Some(left_node) = node.child_by_field_name("left") {
2935 left_node.kind() == "instance_variable"
2936 } else {
2937 false
2938 }
2939}
2940
2941fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
2944 let mut names = Vec::new();
2945
2946 let arguments = attr_node.child_by_field_name("arguments");
2948
2949 if let Some(args) = arguments {
2950 let mut cursor = args.walk();
2952 for child in args.children(&mut cursor) {
2953 if matches!(child.kind(), "symbol" | "simple_symbol")
2954 && let Ok(text) = child.utf8_text(content)
2955 {
2956 let cleaned = text.trim().trim_start_matches(':');
2957 if !cleaned.is_empty() {
2958 names.push(cleaned.to_string());
2959 }
2960 } else if child.kind() == "string"
2961 && let Ok(text) = child.utf8_text(content)
2962 {
2963 let cleaned = text
2965 .trim()
2966 .trim_start_matches(['\'', '"'])
2967 .trim_end_matches(['\'', '"']);
2968 if !cleaned.is_empty() {
2969 names.push(cleaned.to_string());
2970 }
2971 }
2972 }
2973 } else if matches!(attr_node.kind(), "command" | "command_call") {
2974 let mut cursor = attr_node.walk();
2976 let mut found_method = false;
2977 for child in attr_node.children(&mut cursor) {
2978 if !child.is_named() {
2979 continue;
2980 }
2981 if !found_method {
2983 found_method = true;
2984 continue;
2985 }
2986 if matches!(child.kind(), "symbol" | "simple_symbol")
2988 && let Ok(text) = child.utf8_text(content)
2989 {
2990 let cleaned = text.trim().trim_start_matches(':');
2991 if !cleaned.is_empty() {
2992 names.push(cleaned.to_string());
2993 }
2994 } else if child.kind() == "string"
2995 && let Ok(text) = child.utf8_text(content)
2996 {
2997 let cleaned = text
2999 .trim()
3000 .trim_start_matches(['\'', '"'])
3001 .trim_end_matches(['\'', '"']);
3002 if !cleaned.is_empty() {
3003 names.push(cleaned.to_string());
3004 }
3005 }
3006 }
3007 }
3008
3009 names
3010}
3011
3012fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
3017 let mut current = node;
3018 let mut namespace_parts = Vec::new();
3019
3020 while let Some(parent) = current.parent() {
3022 if matches!(parent.kind(), "class" | "module") {
3023 if let Some(name_node) = parent.child_by_field_name("name")
3025 && let Ok(name_text) = name_node.utf8_text(content)
3026 {
3027 let trimmed = name_text.trim();
3028 if trimmed.starts_with("::") {
3030 namespace_parts.clear();
3032 namespace_parts.push(trimmed.trim_start_matches("::").to_string());
3033 break;
3034 }
3035 namespace_parts.insert(0, trimmed.to_string());
3037 }
3038 }
3039 current = parent;
3040 }
3041
3042 if namespace_parts.is_empty() {
3044 None
3045 } else {
3046 Some(namespace_parts.join("::"))
3047 }
3048}
3049
3050#[cfg(test)]
3051mod field_emission_tests {
3052 use sqry_core::graph::GraphBuilder;
3070 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
3071 use sqry_core::graph::unified::build::test_helpers::{
3072 build_node_name_lookup, build_string_lookup,
3073 };
3074 use sqry_core::graph::unified::edge::EdgeKind;
3075 use sqry_core::graph::unified::edge::kind::TypeOfContext;
3076 use sqry_core::graph::unified::node::NodeKind;
3077 use std::path::Path;
3078 use tree_sitter::Parser;
3079
3080 use super::RubyGraphBuilder;
3081
3082 fn parse(source: &str) -> tree_sitter::Tree {
3083 let mut parser = Parser::new();
3084 parser
3085 .set_language(&tree_sitter_ruby::LANGUAGE.into())
3086 .expect("load Ruby grammar");
3087 parser.parse(source, None).expect("parse Ruby source")
3088 }
3089
3090 fn build(source: &str) -> StagingGraph {
3091 let tree = parse(source);
3092 let mut staging = StagingGraph::new();
3093 let builder = RubyGraphBuilder::default();
3094 builder
3095 .build_graph(&tree, source.as_bytes(), Path::new("test.rb"), &mut staging)
3096 .expect("build graph");
3097 staging
3098 }
3099
3100 fn find_node<'a>(
3104 staging: &'a StagingGraph,
3105 name: &str,
3106 kind: Option<NodeKind>,
3107 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
3108 let strings = build_string_lookup(staging);
3109 for op in staging.operations() {
3110 if let StagingOp::AddNode { entry, .. } = op {
3111 if let Some(k) = kind
3112 && entry.kind != k
3113 {
3114 continue;
3115 }
3116 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3117 if let Some(s) = strings.get(&name_idx)
3118 && s == name
3119 {
3120 return Some(entry);
3121 }
3122 }
3123 }
3124 None
3125 }
3126
3127 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
3128 let strings = build_string_lookup(staging);
3129 staging
3130 .operations()
3131 .iter()
3132 .filter(|op| {
3133 if let StagingOp::AddNode { entry, .. } = op {
3134 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3135 strings.get(&name_idx).is_some_and(|s| s == name)
3136 } else {
3137 false
3138 }
3139 })
3140 .count()
3141 }
3142
3143 fn visibility(
3144 staging: &StagingGraph,
3145 entry: &sqry_core::graph::unified::storage::NodeEntry,
3146 ) -> Option<String> {
3147 let strings = build_string_lookup(staging);
3148 entry
3149 .visibility
3150 .and_then(|visibility_id| strings.get(&visibility_id.index()).cloned())
3151 }
3152
3153 fn typeof_edges_for_node(
3154 staging: &StagingGraph,
3155 source_name: &str,
3156 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
3157 let names = build_node_name_lookup(staging);
3158 let strings = build_string_lookup(staging);
3159 let mut out = Vec::new();
3160 for op in staging.operations() {
3161 if let StagingOp::AddEdge {
3162 source,
3163 target,
3164 kind: EdgeKind::TypeOf { context, name, .. },
3165 ..
3166 } = op
3167 {
3168 let src = names.get(source).cloned().unwrap_or_default();
3169 if src != source_name {
3170 continue;
3171 }
3172 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3173 let target_name = names.get(target).cloned().unwrap_or_default();
3174 out.push((*context, edge_name, target_name));
3175 }
3176 }
3177 out
3178 }
3179
3180 #[test]
3183 fn req_r0001_attr_accessor_without_yard_emits_property_node() {
3184 let src = "class Foo\n attr_accessor :x\nend\n";
3185 let staging = build(src);
3186 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3187 .expect("Foo#x Property must be emitted without YARD");
3188 }
3189
3190 #[test]
3191 fn req_r0001_attr_reader_without_yard_emits_constant_node() {
3192 let src = "class Foo\n attr_reader :y\nend\n";
3193 let staging = build(src);
3194 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3195 .expect("Foo#y Constant must be emitted without YARD");
3196 }
3197
3198 #[test]
3199 fn req_r0001_attr_writer_without_yard_emits_property_node() {
3200 let src = "class Foo\n attr_writer :z\nend\n";
3201 let staging = build(src);
3202 find_node(&staging, "Foo::z", Some(NodeKind::Property))
3203 .expect("Foo#z Property must be emitted without YARD");
3204 }
3205
3206 #[test]
3207 fn req_r0001_attr_with_yard_still_emits() {
3208 let src = "class Foo\n # @return [String]\n attr_reader :y\nend\n";
3209 let staging = build(src);
3210 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3211 .expect("Foo#y Constant must be emitted when YARD is present too");
3212 }
3213
3214 #[test]
3217 fn req_r0023_attr_reader_branches_to_constant() {
3218 let src = "class Bar\n attr_reader :name\nend\n";
3219 let staging = build(src);
3220 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Constant))
3221 .expect("attr_reader must produce Constant");
3222 assert_eq!(entry.kind, NodeKind::Constant);
3223 assert!(
3224 find_node(&staging, "Bar::name", Some(NodeKind::Property)).is_none(),
3225 "attr_reader must NOT also produce a Property"
3226 );
3227 }
3228
3229 #[test]
3230 fn req_r0023_attr_writer_branches_to_property() {
3231 let src = "class Bar\n attr_writer :name\nend\n";
3232 let staging = build(src);
3233 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3234 .expect("attr_writer must produce Property");
3235 assert_eq!(entry.kind, NodeKind::Property);
3236 assert!(
3237 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3238 "attr_writer must NOT also produce a Constant"
3239 );
3240 }
3241
3242 #[test]
3243 fn req_r0023_attr_accessor_branches_to_property() {
3244 let src = "class Bar\n attr_accessor :name\nend\n";
3245 let staging = build(src);
3246 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3247 .expect("attr_accessor must produce Property");
3248 assert_eq!(entry.kind, NodeKind::Property);
3249 assert!(
3250 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3251 "attr_accessor must NOT also produce a Constant"
3252 );
3253 }
3254
3255 #[test]
3256 fn req_r0023_attr_accessor_emits_one_per_argument() {
3257 let src = "class Multi\n attr_accessor :a, :b, :c\nend\n";
3258 let staging = build(src);
3259 find_node(&staging, "Multi::a", Some(NodeKind::Property))
3260 .expect("Multi#a Property must exist");
3261 find_node(&staging, "Multi::b", Some(NodeKind::Property))
3262 .expect("Multi#b Property must exist");
3263 find_node(&staging, "Multi::c", Some(NodeKind::Property))
3264 .expect("Multi#c Property must exist");
3265 assert_eq!(count_nodes_named(&staging, "Multi::a"), 1);
3267 assert_eq!(count_nodes_named(&staging, "Multi::b"), 1);
3268 assert_eq!(count_nodes_named(&staging, "Multi::c"), 1);
3269 }
3270
3271 #[test]
3274 fn req_r0017_qualified_name_uses_ruby_hash_idiom() {
3275 let src = "class Foo\n attr_accessor :x\nend\n";
3282 let staging = build(src);
3283 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3285 .expect("canonical Foo::x must exist");
3286 assert!(
3288 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3289 "bare 'x' must not be the qualified name (would collide across classes)"
3290 );
3291 }
3292
3293 #[test]
3296 fn req_r0006_yard_type_tag_drives_typeof_field_edge() {
3297 let src = "class User\n # @return [String]\n attr_reader :name\nend\n";
3298 let staging = build(src);
3299 let edges = typeof_edges_for_node(&staging, "User::name");
3300 assert!(
3301 !edges.is_empty(),
3302 "User#name should have a TypeOf edge from YARD @return"
3303 );
3304 let has_string = edges.iter().any(|(_, _, t)| t == "String");
3305 assert!(
3306 has_string,
3307 "YARD @return [String] should produce a TypeOf target 'String', got {edges:?}"
3308 );
3309 }
3310
3311 #[test]
3312 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3313 let src = "class C\n # @return [String]\n attr_accessor :title\nend\n";
3314 let staging = build(src);
3315 let edges = typeof_edges_for_node(&staging, "C::title");
3316 assert!(!edges.is_empty(), "C#title should have a TypeOf edge");
3317 for (ctx, name, _) in &edges {
3318 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3319 assert_eq!(
3320 name.as_deref(),
3321 Some("title"),
3322 "edge name must be the bare attr name"
3323 );
3324 }
3325 }
3326
3327 #[test]
3328 fn req_r0006_no_yard_means_no_typeof_edge_but_node_emitted() {
3329 let src = "class C\n attr_accessor :untyped\nend\n";
3331 let staging = build(src);
3332 find_node(&staging, "C::untyped", Some(NodeKind::Property))
3333 .expect("Property must emit even without YARD type tag");
3334 let edges = typeof_edges_for_node(&staging, "C::untyped");
3335 assert!(
3336 edges.is_empty(),
3337 "no YARD => no TypeOf{{Field}} enrichment edge, got {edges:?}"
3338 );
3339 }
3340
3341 #[test]
3344 fn req_r0023_attr_node_visibility_defaults_to_public() {
3345 let src = "class V\n attr_accessor :x\nend\n";
3346 let staging = build(src);
3347 let entry =
3348 find_node(&staging, "V::x", Some(NodeKind::Property)).expect("V#x Property must exist");
3349 assert_eq!(
3350 visibility(&staging, entry).as_deref(),
3351 Some("public"),
3352 "Ruby attr_* nodes default to public visibility"
3353 );
3354 }
3355
3356 #[test]
3357 fn req_r0023_attr_node_visibility_tracks_private_and_protected_scope() {
3358 let src = "class V\n private\n attr_accessor :hidden\n protected\n attr_reader :guarded\nend\n";
3359 let staging = build(src);
3360 let hidden = find_node(&staging, "V::hidden", Some(NodeKind::Property))
3361 .expect("V#hidden Property must exist");
3362 let guarded = find_node(&staging, "V::guarded", Some(NodeKind::Constant))
3363 .expect("V#guarded Constant must exist");
3364 assert_eq!(
3365 visibility(&staging, hidden).as_deref(),
3366 Some("private"),
3367 "Ruby attr_* nodes must inherit private visibility scope"
3368 );
3369 assert_eq!(
3370 visibility(&staging, guarded).as_deref(),
3371 Some("protected"),
3372 "Ruby attr_reader nodes must inherit protected visibility scope"
3373 );
3374 }
3375
3376 #[test]
3377 fn req_r0023_attr_node_is_not_static() {
3378 let src = "class S\n attr_reader :y\nend\n";
3379 let staging = build(src);
3380 let entry =
3381 find_node(&staging, "S::y", Some(NodeKind::Constant)).expect("S#y Constant must exist");
3382 assert!(
3383 !entry.is_static,
3384 "attr_* nodes must have is_static=false (always instance per design §4.5)"
3385 );
3386 }
3387
3388 #[test]
3391 fn req_r0017_same_attr_name_across_classes_distinct_nodes() {
3392 let src = "class A\n attr_accessor :x\nend\nclass B\n attr_accessor :x\nend\n";
3393 let staging = build(src);
3394 find_node(&staging, "A::x", Some(NodeKind::Property)).expect("A#x Property must exist");
3395 find_node(&staging, "B::x", Some(NodeKind::Property)).expect("B#x Property must exist");
3396 assert!(
3397 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3398 "bare 'x' must not exist; qualified names disambiguate cross-class"
3399 );
3400 }
3401
3402 #[test]
3405 fn req_r0017_nested_module_class_qualifies_attr() {
3406 let src = "module M\n class Inner\n attr_accessor :n\n end\nend\n";
3407 let staging = build(src);
3408 find_node(&staging, "M::Inner::n", Some(NodeKind::Property))
3409 .expect("M::Inner#n Property must exist with full namespace");
3410 }
3411
3412 #[test]
3415 fn req_r0001_attr_reader_string_argument_emits_constant() {
3416 let src = "class User\n attr_reader \"username\"\nend\n";
3417 let staging = build(src);
3418 find_node(&staging, "User::username", Some(NodeKind::Constant))
3419 .expect("attr_reader with string arg must emit Constant");
3420 }
3421
3422 #[test]
3423 fn req_r0001_attr_accessor_command_call_form_emits_property() {
3424 let src = "class Service\n self.attr_accessor :logger\nend\n";
3425 let staging = build(src);
3426 find_node(&staging, "Service::logger", Some(NodeKind::Property))
3427 .expect("self.attr_accessor command_call must emit Property");
3428 }
3429}
3430
3431#[cfg(test)]
3432mod shape_tests {
3433 use super::{cf_bucket_for_ruby_kind, ruby_shape_mapping};
3437 use sqry_core::graph::unified::build::shape::{
3438 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3439 };
3440 use tree_sitter::{Node, Parser, Tree};
3441
3442 const SAMPLE: &str = include_str!(concat!(
3443 env!("CARGO_MANIFEST_DIR"),
3444 "/../test-fixtures/shape/dynamic/ruby.rb"
3445 ));
3446
3447 fn parse(src: &str) -> Tree {
3448 let mut parser = Parser::new();
3449 parser
3450 .set_language(&tree_sitter_ruby::LANGUAGE.into())
3451 .expect("load ruby grammar");
3452 parser.parse(src, None).expect("parse ruby")
3453 }
3454
3455 fn first_method<'t>(tree: &'t Tree) -> Node<'t> {
3456 let root = tree.root_node();
3457 let mut cursor = root.walk();
3458 for child in root.named_children(&mut cursor) {
3459 if child.kind() == "method" {
3460 return child;
3461 }
3462 }
3463 panic!("no method node in ruby fixture");
3464 }
3465
3466 #[test]
3467 fn mapping_is_non_empty_and_covers_real_kinds() {
3468 let mapping = ruby_shape_mapping();
3469 assert_eq!(cf_bucket_for_ruby_kind("if"), Some(CfBucket::Branch));
3471 assert_eq!(cf_bucket_for_ruby_kind("while"), Some(CfBucket::Loop));
3472 assert_eq!(cf_bucket_for_ruby_kind("case"), Some(CfBucket::Match));
3473 assert_eq!(cf_bucket_for_ruby_kind("begin"), Some(CfBucket::Try));
3474 assert_eq!(cf_bucket_for_ruby_kind("rescue"), Some(CfBucket::Catch));
3475 assert_eq!(cf_bucket_for_ruby_kind("ensure"), Some(CfBucket::Resource));
3476 assert_eq!(cf_bucket_for_ruby_kind("return"), Some(CfBucket::Return));
3477 assert_eq!(cf_bucket_for_ruby_kind("yield"), Some(CfBucket::Yield));
3478 assert_eq!(
3479 cf_bucket_for_ruby_kind("break"),
3480 Some(CfBucket::BreakContinue)
3481 );
3482 assert_eq!(cf_bucket_for_ruby_kind("call"), Some(CfBucket::Call));
3483 assert_eq!(cf_bucket_for_ruby_kind("do_block"), Some(CfBucket::Closure));
3484 assert_eq!(cf_bucket_for_ruby_kind("not_a_real_kind"), None);
3485
3486 let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
3488 let if_id = (0..lang.node_kind_count())
3489 .map(|i| i as u16)
3490 .find(|&i| lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if"))
3491 .expect("grammar exposes named `if`");
3492 assert_eq!(mapping.cf_bucket(if_id), Some(CfBucket::Branch));
3493 }
3494
3495 #[test]
3496 fn descriptor_covers_fixture_control_flow() {
3497 let tree = parse(SAMPLE);
3498 let func = first_method(&tree);
3499 let descriptor = compute_shape_descriptor(
3500 func,
3501 SAMPLE.as_bytes(),
3502 ruby_shape_mapping(),
3503 &ShapeBudget::default(),
3504 );
3505 let hist = descriptor.cf_histogram;
3506 assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if/elsif)");
3507 assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for)");
3508 assert!(hist[CfBucket::Match.index()] >= 1, "match (case/when)");
3509 assert!(hist[CfBucket::Try.index()] >= 1, "try (begin)");
3510 assert!(hist[CfBucket::Catch.index()] >= 1, "catch (rescue)");
3511 assert!(hist[CfBucket::Call.index()] >= 1, "call");
3512 assert!(hist[CfBucket::Closure.index()] >= 1, "closure (do_block)");
3513 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/next");
3514 }
3515
3516 #[test]
3517 fn signature_shape_reads_arity_and_kwargs() {
3518 let tree = parse(SAMPLE);
3519 let func = first_method(&tree);
3520 let shape = ruby_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3521 assert_eq!(shape.arity_positional, 1, "one positional: value");
3523 assert_eq!(shape.arity_keyword_only, 1, "one keyword: label");
3524 assert!(shape.has_varargs, "*rest");
3525 assert!(shape.has_kwargs, "**opts");
3526 }
3527}