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
430 let module_id = helper.add_module(FILE_MODULE_NAME, None);
433 helper.add_export_edge(module_id, class_id);
434
435 if let Some(superclass_node) = node.child_by_field_name("superclass")
437 && let Ok(superclass_name) = superclass_node.utf8_text(content)
438 {
439 let superclass_name = superclass_name.trim();
440 if !superclass_name.is_empty() {
441 let parent_id = helper.add_class(superclass_name, None);
443 helper.add_inherits_edge(class_id, parent_id);
444 }
445 }
446
447 current_namespace.push(class_name.trim().to_string());
449
450 let mut cursor = node.walk();
452 for child in node.children(&mut cursor) {
453 walk_tree_for_graph_impl(
454 child,
455 content,
456 ast_graph,
457 helper,
458 ffi_enabled_scopes,
459 current_namespace,
460 )?;
461 }
462
463 current_namespace.pop();
464 return Ok(());
465 }
466 }
467 "module" => {
468 if let Some(name_node) = node.child_by_field_name("name")
470 && let Ok(module_name) = name_node.utf8_text(content)
471 {
472 let span = span_from_points(node.start_position(), node.end_position());
473 let qualified_name = module_name.to_string();
474 let mod_id = helper.add_module(&qualified_name, Some(span));
475
476 let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
479 helper.add_export_edge(file_module_id, mod_id);
480
481 current_namespace.push(module_name.trim().to_string());
483
484 let mut cursor = node.walk();
486 for child in node.children(&mut cursor) {
487 walk_tree_for_graph_impl(
488 child,
489 content,
490 ast_graph,
491 helper,
492 ffi_enabled_scopes,
493 current_namespace,
494 )?;
495 }
496
497 current_namespace.pop();
498 return Ok(());
499 }
500 }
501 "method" | "singleton_method" => {
502 if let Some(context) = ast_graph.context_for_node(&node) {
504 let span = span_from_points(context.start_position, context.end_position);
505
506 let is_async = detect_async_method(node, content);
508
509 let params = node
511 .child_by_field_name("parameters")
512 .and_then(|params_node| extract_method_parameters(params_node, content));
513
514 let return_type = extract_return_type(node, content);
516
517 let signature = match (params.as_ref(), return_type.as_ref()) {
519 (Some(p), Some(r)) => Some(format!("{p} -> {r}")),
520 (Some(p), None) => Some(p.clone()),
521 (None, Some(r)) => Some(format!("-> {r}")),
522 (None, None) => None,
523 };
524
525 let visibility = context.visibility().as_str();
527
528 let method_id = helper.add_method_with_signature(
530 context.qualified_name(),
531 Some(span),
532 is_async,
533 context.is_singleton(),
534 Some(visibility),
535 signature.as_deref(),
536 );
537
538 if context.visibility() == Visibility::Public {
541 let module_id = helper.add_module(FILE_MODULE_NAME, None);
542 helper.add_export_edge(module_id, method_id);
543 }
544 }
545 }
546 "assignment" => {
547 if let Some(left_node) = node.child_by_field_name("left")
549 && left_node.kind() == "constant"
550 && let Ok(const_name) = left_node.utf8_text(content)
551 {
552 let qualified_name = if current_namespace.is_empty() {
554 const_name.to_string()
555 } else {
556 format!("{}::{}", current_namespace.join("::"), const_name)
557 };
558
559 let span = span_from_points(node.start_position(), node.end_position());
560 let const_id = helper.add_constant(&qualified_name, Some(span));
561
562 let module_id = helper.add_module(FILE_MODULE_NAME, None);
564 helper.add_export_edge(module_id, const_id);
565 }
566 }
567 "call" | "command" | "command_call" | "identifier" | "super" => {
568 if is_include_or_extend_statement(node, content) {
570 handle_include_extend(node, content, helper, current_namespace);
571 }
572 else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
576 } else if is_require_statement(node, content) {
578 if let Some((from_qname, to_qname)) =
580 build_import_for_staging(node, content, helper.file_path())
581 {
582 let from_id = helper.add_import(&from_qname, None);
584 let to_id = helper.add_import(
585 &to_qname,
586 Some(span_from_points(node.start_position(), node.end_position())),
587 );
588
589 helper.add_import_edge(from_id, to_id);
591 }
592 } else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
593 build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
595 } else {
596 if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
598 build_call_for_staging(ast_graph, node, content)
599 {
600 let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
602 let target_id =
603 helper.ensure_callee(&target_qname, span, CalleeKindHint::Function);
604
605 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
607 helper.add_call_edge_full_with_span(
608 source_id,
609 target_id,
610 argument_count,
611 false,
612 vec![span],
613 );
614 }
615 }
616 }
617 _ => {}
618 }
619
620 let mut cursor = node.walk();
622 for child in node.children(&mut cursor) {
623 walk_tree_for_graph_impl(
624 child,
625 content,
626 ast_graph,
627 helper,
628 ffi_enabled_scopes,
629 current_namespace,
630 )?;
631 }
632
633 Ok(())
634}
635
636fn is_ffi_attach_function(
647 node: Node,
648 content: &[u8],
649 ffi_enabled_scopes: &HashSet<Vec<String>>,
650 current_namespace: &[String],
651) -> bool {
652 let method_name = match node.kind() {
654 "command" => node
655 .child_by_field_name("name")
656 .and_then(|n| n.utf8_text(content).ok()),
657 "call" | "command_call" => node
658 .child_by_field_name("method")
659 .and_then(|n| n.utf8_text(content).ok()),
660 _ => None,
661 };
662
663 let Some(method_name) = method_name else {
664 return false;
665 };
666 let method_name = method_name.trim();
667 if !matches!(
668 method_name,
669 "attach_function" | "attach_variable" | "ffi_lib" | "callback"
670 ) {
671 return false;
672 }
673
674 let receiver = match node.kind() {
675 "call" | "command_call" | "method_call" => node
676 .child_by_field_name("receiver")
677 .and_then(|n| n.utf8_text(content).ok()),
678 _ => None,
679 };
680 if let Some(receiver) = receiver {
681 let trimmed = receiver.trim();
682 if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
683 return true;
684 }
685 }
686
687 ffi_enabled_scopes.contains(current_namespace)
688}
689
690fn build_ffi_edge_for_attach_function(
697 node: Node,
698 content: &[u8],
699 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
700 current_namespace: &[String],
701) {
702 let arguments = node.child_by_field_name("arguments");
704
705 let func_name = if let Some(args) = arguments {
707 extract_first_symbol_from_arguments(args, content)
708 } else {
709 let mut cursor = node.walk();
711 let mut found_name = false;
712 let mut result = None;
713 for child in node.children(&mut cursor) {
714 if !child.is_named() {
715 continue;
716 }
717 if !found_name {
719 found_name = true;
720 continue;
721 }
722 if matches!(child.kind(), "symbol" | "simple_symbol")
724 && let Ok(text) = child.utf8_text(content)
725 {
726 result = Some(text.trim().trim_start_matches(':').to_string());
727 break;
728 }
729 }
730 result
731 };
732
733 let Some(func_name) = func_name else {
734 return;
735 };
736
737 let caller_name = if current_namespace.is_empty() {
739 "<module>".to_string()
740 } else {
741 current_namespace.join("::")
742 };
743
744 let caller_id = helper.add_module(&caller_name, None);
746
747 let ffi_func_name = format!("ffi::{func_name}");
749 let span = span_from_points(node.start_position(), node.end_position());
750 let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
751
752 helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
754}
755
756fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
758 let mut cursor = arguments.walk();
759 for child in arguments.children(&mut cursor) {
760 if matches!(child.kind(), "symbol" | "simple_symbol")
761 && let Ok(text) = child.utf8_text(content)
762 {
763 return Some(text.trim().trim_start_matches(':').to_string());
764 }
765 if child.kind() == "bare_symbol"
767 && let Ok(text) = child.utf8_text(content)
768 {
769 return Some(text.trim().to_string());
770 }
771 }
772 None
773}
774
775fn build_call_for_staging(
777 ast_graph: &ASTGraph,
778 call_node: Node<'_>,
779 content: &[u8],
780) -> GraphResult<Option<CallEdgeData>> {
781 let Some(call_context) = ast_graph.context_for_node(&call_node) else {
782 return Ok(None);
783 };
784
785 let Some(method_call) = extract_method_call(call_node, content)? else {
786 return Ok(None);
787 };
788
789 if is_visibility_command(&method_call) {
790 return Ok(None);
791 }
792
793 let source_qualified = call_context.qualified_name().to_string();
794 let target_name = resolve_callee(&method_call, call_context);
795
796 if target_name.is_empty() {
797 return Ok(None);
798 }
799
800 let span = span_from_node(call_node);
801 let argument_count = count_arguments(method_call.arguments, content);
802 let is_singleton = call_context.is_singleton();
803
804 Ok(Some((
805 source_qualified,
806 target_name,
807 argument_count,
808 span,
809 is_singleton,
810 )))
811}
812
813fn build_import_for_staging(
815 require_node: Node<'_>,
816 content: &[u8],
817 file_path: &str,
818) -> Option<(String, String)> {
819 let method_name = match require_node.kind() {
821 "command" => require_node
822 .child_by_field_name("name")
823 .and_then(|n| n.utf8_text(content).ok())
824 .map(|s| s.trim().to_string()),
825 "call" | "method_call" => require_node
826 .child_by_field_name("method")
827 .and_then(|n| n.utf8_text(content).ok())
828 .map(|s| s.trim().to_string()),
829 _ => None,
830 };
831
832 let method_name = method_name?;
833
834 if !matches!(method_name.as_str(), "require" | "require_relative") {
836 return None;
837 }
838
839 let arguments = require_node.child_by_field_name("arguments");
841 let module_name = if let Some(args) = arguments {
842 extract_require_module_name(args, content)
843 } else {
844 let mut cursor = require_node.walk();
846 let mut found_name = false;
847 let mut result = None;
848 for child in require_node.children(&mut cursor) {
849 if !child.is_named() {
850 continue;
851 }
852 if !found_name {
853 found_name = true;
854 continue;
855 }
856 result = extract_string_content(child, content);
858 break;
859 }
860 result
861 };
862
863 let module_name = module_name?;
864
865 if module_name.is_empty() {
866 return None;
867 }
868
869 let is_relative = method_name == "require_relative";
871 let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
872
873 Some(("<module>".to_string(), resolved_path))
875}
876
877fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
878 node.kind() == "identifier"
879 && node
880 .parent()
881 .is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
882}
883
884fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
894 let body_node = method_node.child_by_field_name("body");
896 if body_node.is_none() {
897 return false;
898 }
899 let body_node = body_node.unwrap();
900
901 if let Ok(body_text) = body_node.utf8_text(content) {
903 let body_lower = body_text.to_lowercase();
904
905 if body_lower.contains("fiber.")
907 || body_lower.contains("fiber.new")
908 || body_lower.contains("fiber.yield")
909 || body_lower.contains("fiber.resume")
910 || body_lower.contains("thread.new")
911 || body_lower.contains("thread.start")
912 || body_lower.contains("async do")
913 || body_lower.contains("async {")
914 || body_lower.contains("async.reactor")
915 || body_lower.contains("concurrent::")
916 {
917 return true;
918 }
919 }
920
921 false
922}
923
924fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
926 let method_name = match node.kind() {
927 "command" => node
928 .child_by_field_name("name")
929 .and_then(|n| n.utf8_text(content).ok()),
930 "call" | "method_call" => node
931 .child_by_field_name("method")
932 .and_then(|n| n.utf8_text(content).ok()),
933 _ => None,
934 };
935
936 method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
937}
938
939fn handle_include_extend(
947 node: Node<'_>,
948 content: &[u8],
949 helper: &mut sqry_core::graph::unified::GraphBuildHelper,
950 current_namespace: &[String],
951) {
952 let module_name = if let Some(args) = node.child_by_field_name("arguments") {
954 extract_first_constant_from_arguments(args, content)
955 } else if node.kind() == "command" {
956 let mut cursor = node.walk();
958 let mut found_method = false;
959 let mut result = None;
960 for child in node.children(&mut cursor) {
961 if !child.is_named() {
962 continue;
963 }
964 if !found_method {
966 found_method = true;
967 continue;
968 }
969 if child.kind() == "constant"
971 && let Ok(text) = child.utf8_text(content)
972 {
973 result = Some(text.trim().to_string());
974 break;
975 }
976 }
977 result
978 } else {
979 None
980 };
981
982 let Some(module_name) = module_name else {
983 return;
984 };
985
986 let class_name = if current_namespace.is_empty() {
988 return; } else {
990 current_namespace.join("::")
991 };
992
993 let class_id = helper.add_class(&class_name, None);
995 let module_id = helper.add_module(&module_name, None);
996
997 helper.add_implements_edge(class_id, module_id);
999}
1000
1001fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
1003 let mut cursor = args_node.walk();
1004 for child in args_node.children(&mut cursor) {
1005 if !child.is_named() {
1006 continue;
1007 }
1008 if child.kind() == "constant"
1010 && let Ok(text) = child.utf8_text(content)
1011 {
1012 return Some(text.trim().to_string());
1013 }
1014 }
1015 None
1016}
1017
1018fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
1020 let method_name = match node.kind() {
1021 "command" => node
1022 .child_by_field_name("name")
1023 .and_then(|n| n.utf8_text(content).ok()),
1024 "call" | "method_call" => node
1025 .child_by_field_name("method")
1026 .and_then(|n| n.utf8_text(content).ok()),
1027 _ => None,
1028 };
1029
1030 method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
1031}
1032
1033struct ContextBuilder<'a> {
1034 contexts: Vec<RubyContext>,
1035 node_to_context: HashMap<usize, usize>,
1036 attr_visibility: HashMap<usize, Visibility>,
1037 namespace: Vec<String>,
1038 visibility_stack: Vec<Visibility>,
1039 ffi_enabled_scopes: HashSet<Vec<String>>,
1040 controller_dsl_hooks: Vec<ControllerDslHook>,
1041 max_depth: usize,
1042 content: &'a [u8],
1043 guard: sqry_core::query::security::RecursionGuard,
1044}
1045
1046impl<'a> ContextBuilder<'a> {
1047 fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
1048 let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
1049 .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
1050 let file_ops_depth = recursion_limits
1051 .effective_file_ops_depth()
1052 .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
1053 let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
1054 .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
1055
1056 Ok(Self {
1057 contexts: Vec::new(),
1058 node_to_context: HashMap::new(),
1059 attr_visibility: HashMap::new(),
1060 namespace: Vec::new(),
1061 visibility_stack: vec![Visibility::Public],
1062 ffi_enabled_scopes: HashSet::new(),
1063 controller_dsl_hooks: Vec::new(),
1064 max_depth,
1065 content,
1066 guard,
1067 })
1068 }
1069
1070 fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
1074 self.guard
1075 .enter()
1076 .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
1077
1078 match node.kind() {
1079 "class" => self.visit_class(node)?,
1080 "module" => self.visit_module(node)?,
1081 "singleton_class" => self.visit_singleton_class(node)?,
1082 "method" => self.visit_method(node)?,
1083 "singleton_method" => self.visit_singleton_method(node)?,
1084 "command" | "command_call" | "call" => {
1085 self.detect_ffi_extend(node)?;
1086 self.detect_controller_dsl(node)?;
1087 self.record_attr_visibility(node);
1088 self.adjust_visibility(node)?;
1089 self.walk_children(node)?;
1090 }
1091 "identifier" => {
1092 self.adjust_visibility_from_identifier(node)?;
1095 self.walk_children(node)?;
1096 }
1097 _ => self.walk_children(node)?,
1098 }
1099
1100 self.guard.exit();
1101 Ok(())
1102 }
1103
1104 fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
1105 let name_node = node
1106 .child_by_field_name("name")
1107 .ok_or_else(|| "class node missing name".to_string())?;
1108 let class_name = self.node_text(name_node)?;
1109
1110 if self.namespace.len() > self.max_depth {
1111 return Ok(());
1112 }
1113
1114 self.namespace.push(class_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_module(&mut self, node: Node<'a>) -> Result<(), String> {
1125 let name_node = node
1126 .child_by_field_name("name")
1127 .ok_or_else(|| "module node missing name".to_string())?;
1128 let module_name = self.node_text(name_node)?;
1129
1130 if self.namespace.len() > self.max_depth {
1131 return Ok(());
1132 }
1133
1134 self.namespace.push(module_name);
1135 self.visibility_stack.push(Visibility::Public);
1136
1137 self.walk_children(node)?;
1138
1139 self.visibility_stack.pop();
1140 self.namespace.pop();
1141 Ok(())
1142 }
1143
1144 fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
1145 let name_node = node
1146 .child_by_field_name("name")
1147 .ok_or_else(|| "method node missing name".to_string())?;
1148 let method_name = self.node_text(name_node)?;
1149
1150 let (qualified_name, container) =
1151 method_qualified_name(&self.namespace, &method_name, false);
1152
1153 let visibility = inline_visibility_for_method(node, self.content)
1154 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1155
1156 let context = RubyContext {
1157 qualified_name,
1158 container,
1159 kind: RubyContextKind::Method,
1160 visibility,
1161 start_position: node.start_position(),
1162 end_position: node.end_position(),
1163 };
1164
1165 let idx = self.contexts.len();
1166 self.contexts.push(context);
1167 associate_descendants(node, idx, &mut self.node_to_context);
1168
1169 self.walk_children(node)?;
1170 Ok(())
1171 }
1172
1173 fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
1174 let value_node = node
1176 .child_by_field_name("value")
1177 .ok_or_else(|| "singleton_class missing value".to_string())?;
1178 let object_text = self.node_text(value_node)?;
1179
1180 let scope_name = if object_text == "self" {
1182 if let Some(current_class) = self.namespace.last() {
1184 format!("<<{current_class}>>")
1185 } else {
1186 "<<main>>".to_string()
1187 }
1188 } else {
1189 format!("<<{object_text}>>")
1191 };
1192
1193 if self.namespace.len() > self.max_depth {
1194 return Ok(());
1195 }
1196
1197 self.namespace.push(scope_name);
1199 self.visibility_stack.push(Visibility::Public);
1200
1201 self.visit_singleton_class_body(node)?;
1203
1204 self.visibility_stack.pop();
1206 self.namespace.pop();
1207 Ok(())
1208 }
1209
1210 fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
1211 let mut cursor = node.walk();
1212 for child in node.children(&mut cursor) {
1213 if !child.is_named() {
1214 continue;
1215 }
1216
1217 if child.kind() == "method" {
1219 self.visit_method_as_singleton(child)?;
1220 } else {
1221 self.walk(child)?;
1222 }
1223 }
1224 Ok(())
1225 }
1226
1227 fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
1228 let name_node = node
1229 .child_by_field_name("name")
1230 .ok_or_else(|| "method node missing name".to_string())?;
1231 let method_name = self.node_text(name_node)?;
1232
1233 let actual_namespace: Vec<String> = self
1235 .namespace
1236 .iter()
1237 .map(|s| {
1238 if s.starts_with("<<") && s.ends_with(">>") {
1239 s[2..s.len() - 2].to_string()
1241 } else {
1242 s.clone()
1243 }
1244 })
1245 .collect();
1246
1247 let (qualified_name, container) =
1248 method_qualified_name(&actual_namespace, &method_name, true);
1249
1250 let visibility = inline_visibility_for_method(node, self.content)
1251 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1252
1253 let context = RubyContext {
1254 qualified_name,
1255 container,
1256 kind: RubyContextKind::SingletonMethod,
1257 visibility,
1258 start_position: node.start_position(),
1259 end_position: node.end_position(),
1260 };
1261
1262 let idx = self.contexts.len();
1263 self.contexts.push(context);
1264 associate_descendants(node, idx, &mut self.node_to_context);
1265
1266 self.walk_children(node)?;
1267 Ok(())
1268 }
1269
1270 fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
1271 let name_node = node
1272 .child_by_field_name("name")
1273 .ok_or_else(|| "singleton_method missing name".to_string())?;
1274 let method_name = self.node_text(name_node)?;
1275
1276 let object_node = node
1277 .child_by_field_name("object")
1278 .ok_or_else(|| "singleton_method missing object".to_string())?;
1279 let object_text = self.node_text(object_node)?;
1280
1281 let (qualified_name, container) =
1282 singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
1283
1284 let visibility = inline_visibility_for_method(node, self.content)
1285 .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1286
1287 let context = RubyContext {
1288 qualified_name,
1289 container,
1290 kind: RubyContextKind::SingletonMethod,
1291 visibility,
1292 start_position: node.start_position(),
1293 end_position: node.end_position(),
1294 };
1295
1296 let idx = self.contexts.len();
1297 self.contexts.push(context);
1298 associate_descendants(node, idx, &mut self.node_to_context);
1299
1300 self.walk_children(node)?;
1301 Ok(())
1302 }
1303
1304 fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
1305 let name_node = node.child_by_field_name("name");
1306 let Some(name_node) = name_node else {
1307 return Ok(());
1308 };
1309
1310 let keyword = self.node_text(name_node)?;
1311 if keyword.trim() != "extend" {
1312 return Ok(());
1313 }
1314
1315 let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
1316 node_text_raw(arguments, self.content).unwrap_or_default()
1317 } else {
1318 let mut cursor = node.walk();
1319 let mut found_name = false;
1320 let mut result = String::new();
1321 for child in node.children(&mut cursor) {
1322 if !child.is_named() {
1323 continue;
1324 }
1325 if !found_name {
1326 found_name = true;
1327 continue;
1328 }
1329 if let Some(text) = node_text_raw(child, self.content) {
1330 result = text;
1331 break;
1332 }
1333 }
1334 result
1335 };
1336
1337 if arg_text.contains("FFI::Library") {
1338 self.ffi_enabled_scopes.insert(self.namespace.clone());
1340 }
1341
1342 Ok(())
1343 }
1344
1345 fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
1346 let name_node = node
1347 .child_by_field_name("name")
1348 .or_else(|| node.child_by_field_name("method"));
1349 let Some(name_node) = name_node else {
1350 return Ok(());
1351 };
1352 let dsl = self.node_text(name_node)?;
1353
1354 let kind = match dsl.as_str() {
1355 "before_action" => Some(ControllerDslKind::Before),
1356 "after_action" => Some(ControllerDslKind::After),
1357 "around_action" => Some(ControllerDslKind::Around),
1358 _ => None,
1359 };
1360 let Some(kind) = kind else {
1361 return Ok(());
1362 };
1363
1364 if self.namespace.is_empty() {
1365 return Ok(());
1366 }
1367 let container = self.namespace.join("::");
1368
1369 let mut callbacks: Vec<String> = Vec::new();
1370 let mut only: Option<Vec<String>> = None;
1371 let mut except: Option<Vec<String>> = None;
1372
1373 if let Some(arguments) = node.child_by_field_name("arguments") {
1374 let mut cursor = arguments.walk();
1375 for child in arguments.children(&mut cursor) {
1376 if !child.is_named() {
1377 continue;
1378 }
1379 let kind = child.kind();
1380 match kind {
1381 "symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
1382 let mut v = extract_symbols_from_node(child, self.content);
1383 callbacks.append(&mut v);
1384 }
1385 "pair" => {
1386 let key = child.child_by_field_name("key");
1388 let val = child.child_by_field_name("value");
1389 if key.is_none() || val.is_none() {
1390 continue;
1391 }
1392 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1393 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1394 if key_text.contains("only") && !symbols.is_empty() {
1395 only = Some(symbols);
1396 } else if key_text.contains("except") && !symbols.is_empty() {
1397 except = Some(symbols);
1398 }
1399 }
1400 "hash" => {
1401 let mut hcur = child.walk();
1403 for pair in child.children(&mut hcur) {
1404 if !pair.is_named() {
1405 continue;
1406 }
1407 if pair.kind() != "pair" {
1408 continue;
1409 }
1410 let key = pair.child_by_field_name("key");
1411 let val = pair.child_by_field_name("value");
1412 if key.is_none() || val.is_none() {
1413 continue;
1414 }
1415 let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1416 let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1417 if key_text.contains("only") && !symbols.is_empty() {
1418 only = Some(symbols);
1419 } else if key_text.contains("except") && !symbols.is_empty() {
1420 except = Some(symbols);
1421 }
1422 }
1423 }
1424 _ => {}
1425 }
1426 }
1427 } else {
1428 if let Some(raw) = node_text_raw(node, self.content) {
1430 let (cbs, o, e) = parse_controller_dsl_args(&raw);
1431 callbacks = cbs;
1432 only = o;
1433 except = e;
1434 }
1435 }
1436
1437 if callbacks.is_empty() {
1438 return Ok(());
1439 }
1440
1441 self.controller_dsl_hooks.push(ControllerDslHook {
1442 container,
1443 kind,
1444 callbacks,
1445 only,
1446 except,
1447 });
1448 Ok(())
1449 }
1450
1451 fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
1452 let name_node = node.child_by_field_name("name");
1453 let Some(name_node) = name_node else {
1454 return Ok(());
1455 };
1456
1457 let keyword = self.node_text(name_node)?;
1458 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1459 return Ok(());
1460 };
1461
1462 if !has_call_arguments(node)
1464 && let Some(last) = self.visibility_stack.last_mut()
1465 {
1466 *last = new_visibility;
1467 }
1468 Ok(())
1469 }
1470
1471 fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
1473 let keyword = self.node_text(node)?;
1474 let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1475 return Ok(());
1476 };
1477
1478 if let Some(last) = self.visibility_stack.last_mut() {
1480 *last = new_visibility;
1481 }
1482
1483 Ok(())
1484 }
1485
1486 fn record_attr_visibility(&mut self, node: Node<'a>) {
1487 if !is_attr_call(node, self.content) {
1488 return;
1489 }
1490
1491 let visibility = self
1492 .visibility_stack
1493 .last()
1494 .copied()
1495 .unwrap_or(Visibility::Public);
1496 self.attr_visibility.insert(node.id(), visibility);
1497 }
1498
1499 fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
1500 let mut cursor = node.walk();
1501 for child in node.children(&mut cursor) {
1502 if child.is_named() {
1503 self.walk(child)?;
1504 }
1505 }
1506 Ok(())
1507 }
1508
1509 fn node_text(&self, node: Node<'a>) -> Result<String, String> {
1510 node.utf8_text(self.content)
1511 .map(|s| s.trim().to_string())
1512 .map_err(|err| err.to_string())
1513 }
1514}
1515
1516#[derive(Clone)]
1517struct MethodCall<'a> {
1518 name: String,
1519 receiver: Option<String>,
1520 arguments: Option<Node<'a>>,
1521 node: Node<'a>,
1522}
1523
1524fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
1525 let method_name = match node.kind() {
1526 "call" | "command_call" | "method_call" => {
1527 let method_node = node
1528 .child_by_field_name("method")
1529 .ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
1530 node_text(method_node, content)?
1531 }
1532 "command" => {
1533 let name_node = node
1534 .child_by_field_name("name")
1535 .ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
1536 node_text(name_node, content)?
1537 }
1538 "super" => "super".to_string(),
1539 "identifier" => {
1540 if !should_treat_identifier_as_call(node) {
1541 return Ok(None);
1542 }
1543 node_text(node, content)?
1544 }
1545 _ => return Ok(None),
1546 };
1547
1548 let receiver = match node.kind() {
1549 "call" | "command_call" | "method_call" => node
1550 .child_by_field_name("receiver")
1551 .and_then(|r| node_text(r, content).ok()),
1552 _ => None,
1553 };
1554
1555 let arguments = node.child_by_field_name("arguments");
1556
1557 Ok(Some(MethodCall {
1558 name: method_name,
1559 receiver,
1560 arguments,
1561 node,
1562 }))
1563}
1564
1565fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
1566 if let Some(parent) = node.parent() {
1567 let kind = parent.kind();
1568 if matches!(
1569 kind,
1570 "call"
1571 | "command"
1572 | "command_call"
1573 | "method_call"
1574 | "method"
1575 | "singleton_method"
1576 | "alias"
1577 | "symbol"
1578 ) {
1579 return false;
1580 }
1581
1582 if kind.contains("assignment")
1583 || matches!(
1584 kind,
1585 "parameters"
1586 | "method_parameters"
1587 | "block_parameters"
1588 | "lambda_parameters"
1589 | "constant_path"
1590 | "module"
1591 | "class"
1592 | "hash"
1593 | "pair"
1594 | "array"
1595 | "argument_list"
1596 )
1597 {
1598 return false;
1599 }
1600 }
1601
1602 true
1603}
1604
1605fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
1619 let name = method_call.name.trim();
1620 if name.is_empty() {
1621 return String::new();
1622 }
1623
1624 if name == "super" {
1626 return format!("super::{}", context.qualified_name());
1630 }
1631
1632 if let Some(receiver) = method_call.receiver.as_deref() {
1633 let receiver = receiver.trim();
1634 if receiver == "self" {
1635 if let Some(container) = context.container() {
1636 return format!("{container}.{name}");
1637 }
1638 return format!("self.{name}");
1639 }
1640
1641 if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
1642 let cleaned = receiver.trim_start_matches("::");
1643 if let Some(class_name) = cleaned.strip_suffix(".new") {
1645 return format!("{class_name}#{name}");
1646 }
1647 return format!("{cleaned}.{name}");
1648 }
1649
1650 return name.to_string();
1652 }
1653
1654 if context.is_singleton() {
1655 if let Some(container) = context.container() {
1656 return format!("{container}.{name}");
1657 }
1658 return name.to_string();
1659 }
1660
1661 if let Some(container) = context.container() {
1662 return format!("{container}#{name}");
1663 }
1664
1665 name.to_string()
1666}
1667
1668fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
1680 let Some(arguments) = arguments else {
1681 return 0;
1682 };
1683
1684 let mut count = 0;
1685 let mut cursor = arguments.walk();
1686 for child in arguments.children(&mut cursor) {
1687 if child.is_named()
1688 && !is_literal_delimiter(child.kind())
1689 && node_text(child, content)
1690 .map(|s| !s.trim().is_empty())
1691 .unwrap_or(false)
1692 {
1693 count += 1;
1694 }
1695 }
1696 count
1697}
1698
1699fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
1710 let mut stack = vec![node];
1711 while let Some(current) = stack.pop() {
1712 map.insert(current.id(), idx);
1713 let mut cursor = current.walk();
1714 for child in current.children(&mut cursor) {
1715 stack.push(child);
1716 }
1717 }
1718}
1719
1720fn method_qualified_name(
1734 namespace: &[String],
1735 method_name: &str,
1736 singleton: bool,
1737) -> (String, Option<String>) {
1738 if namespace.is_empty() {
1739 return (method_name.to_string(), None);
1740 }
1741
1742 let container = namespace.join("::");
1743 let qualified = if singleton {
1744 format!("{container}.{method_name}")
1745 } else {
1746 format!("{container}#{method_name}")
1747 };
1748 (qualified, Some(container))
1749}
1750
1751fn singleton_qualified_name(
1764 current_namespace: &[String],
1765 object_text: &str,
1766 method_name: &str,
1767) -> (String, Option<String>) {
1768 if object_text == "self" {
1769 if current_namespace.is_empty() {
1770 (method_name.to_string(), None)
1771 } else {
1772 let container = current_namespace.join("::");
1773 (format!("{container}.{method_name}"), Some(container))
1774 }
1775 } else {
1776 let parts = split_constant_path(object_text);
1777 if parts.is_empty() {
1778 (method_name.to_string(), None)
1779 } else {
1780 let container = parts.join("::");
1781 (format!("{container}.{method_name}"), Some(container))
1782 }
1783 }
1784}
1785
1786fn split_constant_path(path: &str) -> Vec<String> {
1800 path.trim()
1801 .trim_start_matches("::")
1802 .split("::")
1803 .filter_map(|seg| {
1804 let trimmed = seg.trim();
1805 if trimmed.is_empty() {
1806 None
1807 } else {
1808 Some(trimmed.to_string())
1809 }
1810 })
1811 .collect()
1812}
1813
1814fn is_constant(text: &str) -> bool {
1824 text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1825}
1826
1827fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
1838 matches!(
1839 method_call.name.as_str(),
1840 "public" | "private" | "protected"
1841 ) && method_call.receiver.is_none()
1842 && !has_call_arguments(method_call.node)
1843}
1844
1845fn has_call_arguments(node: Node<'_>) -> bool {
1855 if let Some(arguments) = node.child_by_field_name("arguments") {
1856 let mut cursor = arguments.walk();
1857 for child in arguments.children(&mut cursor) {
1858 if child.is_named() {
1859 return true;
1860 }
1861 }
1862 }
1863 false
1864}
1865
1866fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
1867 let parent = node.parent()?;
1868 let visibility_node = match parent.kind() {
1869 "call" | "command" | "command_call" => parent,
1870 "argument_list" => parent.parent()?,
1871 _ => return None,
1872 };
1873
1874 if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
1875 return None;
1876 }
1877
1878 let keyword_node = visibility_node
1879 .child_by_field_name("name")
1880 .or_else(|| visibility_node.child_by_field_name("method"))?;
1881 let keyword = node_text_raw(keyword_node, content)?;
1882 Visibility::from_keyword(keyword.trim())
1883}
1884
1885fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
1896 node.utf8_text(content)
1897 .map(|s| s.trim().to_string())
1898 .map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
1899}
1900
1901fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
1903 node.utf8_text(content)
1904 .ok()
1905 .map(std::string::ToString::to_string)
1906}
1907
1908fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
1917 GraphBuilderError::ParseError {
1918 span: span_from_node(node),
1919 reason: reason.to_string(),
1920 }
1921}
1922
1923#[allow(clippy::match_same_arms)]
1940fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
1941 let mut params = Vec::new();
1942 let mut cursor = params_node.walk();
1943
1944 for child in params_node.named_children(&mut cursor) {
1945 match child.kind() {
1946 "identifier" | "optional_parameter" => {
1949 if let Ok(text) = child.utf8_text(content) {
1950 params.push(text.to_string());
1951 }
1952 }
1953 "splat_parameter" => {
1955 if let Some(name_node) = child.child_by_field_name("name") {
1956 if let Ok(name) = name_node.utf8_text(content) {
1957 params.push(format!("*{name}"));
1958 }
1959 } else if let Ok(text) = child.utf8_text(content) {
1960 params.push(text.to_string());
1962 }
1963 }
1964 "hash_splat_parameter" => {
1966 if let Some(name_node) = child.child_by_field_name("name") {
1967 if let Ok(name) = name_node.utf8_text(content) {
1968 params.push(format!("**{name}"));
1969 }
1970 } else if let Ok(text) = child.utf8_text(content) {
1971 params.push(text.to_string());
1973 }
1974 }
1975 "block_parameter" => {
1977 if let Some(name_node) = child.child_by_field_name("name") {
1978 if let Ok(name) = name_node.utf8_text(content) {
1979 params.push(format!("&{name}"));
1980 }
1981 } else if let Ok(text) = child.utf8_text(content) {
1982 params.push(text.to_string());
1984 }
1985 }
1986 "keyword_parameter" => {
1988 if let Ok(text) = child.utf8_text(content) {
1989 params.push(text.to_string());
1990 }
1991 }
1992 "destructured_parameter" => {
1994 if let Ok(text) = child.utf8_text(content) {
1995 params.push(text.to_string());
1996 }
1997 }
1998 "forward_parameter" => {
2000 params.push("...".to_string());
2001 }
2002 "hash_splat_nil" => {
2004 params.push("**nil".to_string());
2005 }
2006 _ => {
2007 }
2009 }
2010 }
2011
2012 if params.is_empty() {
2013 None
2014 } else {
2015 Some(params.join(", "))
2016 }
2017}
2018
2019fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2033 if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
2035 return Some(return_type);
2036 }
2037
2038 if let Some(return_type) = extract_rbs_return_type(method_node, content) {
2040 return Some(return_type);
2041 }
2042
2043 if let Some(return_type) = extract_yard_return_type(method_node, content) {
2045 return Some(return_type);
2046 }
2047
2048 None
2049}
2050
2051fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2062 let mut sibling = method_node.prev_sibling()?;
2064
2065 while sibling.kind() == "comment" {
2067 sibling = sibling.prev_sibling()?;
2068 }
2069
2070 if sibling.kind() == "call"
2072 && let Some(method_name) = sibling.child_by_field_name("method")
2073 && let Ok(name_text) = method_name.utf8_text(content)
2074 && name_text == "sig"
2075 {
2076 if let Some(block_node) = sibling.child_by_field_name("block") {
2078 return extract_returns_from_sig_block(block_node, content);
2079 }
2080 }
2081
2082 None
2083}
2084
2085fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
2087 let mut cursor = block_node.walk();
2088
2089 for child in block_node.named_children(&mut cursor) {
2090 if child.kind() == "call"
2091 && let Some(method_name) = child.child_by_field_name("method")
2092 && let Ok(name_text) = method_name.utf8_text(content)
2093 && name_text == "returns"
2094 {
2095 if let Some(args) = child.child_by_field_name("arguments") {
2097 let mut args_cursor = args.walk();
2098 for arg in args.named_children(&mut args_cursor) {
2099 if arg.kind() != ","
2100 && let Ok(type_text) = arg.utf8_text(content)
2101 {
2102 return Some(type_text.to_string());
2103 }
2104 }
2105 }
2106 }
2107 if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
2109 return Some(nested_type);
2110 }
2111 }
2112
2113 None
2114}
2115
2116fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2127 let mut cursor = method_node.walk();
2129 for child in method_node.children(&mut cursor) {
2130 if child.kind() == "comment"
2131 && let Ok(comment_text) = child.utf8_text(content)
2132 {
2133 if comment_text.trim_start().starts_with("#:") {
2136 if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
2138 let return_part = &comment_text[arrow_pos + 2..];
2139 let return_type = return_part.trim().to_string();
2140 if !return_type.is_empty() {
2141 return Some(return_type);
2142 }
2143 }
2144 }
2145 }
2146 }
2147
2148 None
2149}
2150
2151fn find_top_level_arrow(text: &str) -> Option<usize> {
2155 let chars: Vec<char> = text.chars().collect();
2156 let mut depth: i32 = 0;
2157 let mut i = 0;
2158
2159 while i < chars.len() {
2160 match chars[i] {
2161 '(' | '[' | '{' => depth += 1,
2162 ')' | ']' | '}' => depth = depth.saturating_sub(1),
2163 '-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
2164 return Some(i);
2165 }
2166 _ => {}
2167 }
2168 i += 1;
2169 }
2170
2171 None
2172}
2173
2174fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2185 let mut sibling_opt = method_node.prev_sibling();
2187 let method_start_row = method_node.start_position().row;
2188
2189 let mut comments = Vec::new();
2191 let mut expected_row = method_start_row;
2192
2193 while let Some(sibling) = sibling_opt {
2194 if sibling.kind() == "comment" {
2195 let comment_end_row = sibling.end_position().row;
2196
2197 if comment_end_row + 1 >= expected_row {
2200 if let Ok(comment_text) = sibling.utf8_text(content) {
2201 comments.push(comment_text);
2202 }
2203 expected_row = sibling.start_position().row;
2204 sibling_opt = sibling.prev_sibling();
2205 } else {
2206 break;
2208 }
2209 } else {
2210 break;
2211 }
2212 }
2213
2214 for comment in comments.iter().rev() {
2216 if let Some(return_pos) = comment.find("@return") {
2217 let after_return = &comment[return_pos + 7..];
2218 if let Some(start_bracket) = after_return.find('[')
2220 && let Some(end_bracket) = after_return.find(']')
2221 && end_bracket > start_bracket
2222 {
2223 let return_type = &after_return[start_bracket + 1..end_bracket];
2224 return Some(return_type.trim().to_string());
2225 }
2226 }
2227 }
2228
2229 None
2230}
2231
2232fn span_from_node(node: Node<'_>) -> Span {
2240 span_from_points(node.start_position(), node.end_position())
2241}
2242
2243fn span_from_points(start: Point, end: Point) -> Span {
2252 Span::new(
2253 Position::new(start.row, start.column),
2254 Position::new(end.row, end.column),
2255 )
2256}
2257
2258fn is_literal_delimiter(kind: &str) -> bool {
2268 matches!(kind, "," | "(" | ")" | "[" | "]")
2269}
2270
2271fn parse_controller_dsl_args(
2274 text: &str,
2275) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
2276 let mut head = text;
2278 let mut tail = "";
2279 if let Some(idx) = text.find("only:") {
2280 head = &text[..idx];
2281 tail = &text[idx..];
2282 } else if let Some(idx) = text.find("except:") {
2283 head = &text[..idx];
2284 tail = &text[idx..];
2285 }
2286 let callbacks = extract_symbol_list_from_args(head);
2287 let only = extract_kw_symbol_list(tail, "only:");
2288 let except = extract_kw_symbol_list(tail, "except:");
2289 (callbacks, only, except)
2290}
2291
2292fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
2293 let mut out = Vec::new();
2294 let bytes = text.as_bytes();
2295 let mut i = 0;
2296 while i < bytes.len() {
2297 if bytes[i] == b':' {
2298 let start = i + 1;
2299 let mut j = start;
2300 while j < bytes.len() {
2301 let c = bytes[j] as char;
2302 if c.is_ascii_alphanumeric() || c == '_' {
2303 j += 1;
2304 } else {
2305 break;
2306 }
2307 }
2308 if j > start {
2309 out.push(text[start..j].to_string());
2310 i = j;
2311 continue;
2312 }
2313 }
2314 i += 1;
2315 }
2316 out
2317}
2318
2319fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
2320 let pos = text.find(kw)?;
2321 let mut after = &text[pos + kw.len()..];
2322 after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
2324 if after.starts_with('[')
2325 && let Some(end) = after.find(']')
2326 {
2327 return Some(extract_symbol_list_from_args(&after[..=end]));
2328 }
2329 if let Some(colon) = after.find(':') {
2331 let mut j = colon + 1;
2332 while j < after.len() {
2333 let ch = after.as_bytes()[j] as char;
2334 if ch.is_ascii_alphanumeric() || ch == '_' {
2335 j += 1;
2336 } else {
2337 break;
2338 }
2339 }
2340 if j > colon + 1 {
2341 return Some(vec![after[colon + 1..j].to_string()]);
2342 }
2343 }
2344 None
2345}
2346
2347fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
2348 let mut out = Vec::new();
2349 match node.kind() {
2350 "symbol" | "simple_symbol" => {
2351 if let Ok(t) = node_text(node, content) {
2352 out.push(t.trim_start_matches(':').to_string());
2353 }
2354 }
2355 "array" => {
2356 let mut c = node.walk();
2357 for ch in node.children(&mut c) {
2358 if matches!(ch.kind(), "symbol" | "simple_symbol")
2359 && let Ok(t) = node_text(ch, content)
2360 {
2361 out.push(t.trim_start_matches(':').to_string());
2362 }
2363 }
2364 }
2365 _ => {
2366 if let Some(txt) = node_text_raw(node, content) {
2368 out = extract_symbol_list_from_args(&txt);
2369 }
2370 }
2371 }
2372 out
2373}
2374
2375fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
2377 let mut cursor = arguments.walk();
2378 for child in arguments.children(&mut cursor) {
2379 if !child.is_named() {
2380 continue;
2381 }
2382 if let Some(s) = extract_string_content(child, content) {
2383 return Some(s);
2384 }
2385 }
2386 None
2387}
2388
2389fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
2391 let text = node.utf8_text(content).ok()?;
2392 let trimmed = text.trim();
2393
2394 if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
2396 || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
2397 && trimmed.len() >= 2
2398 {
2399 return Some(trimmed[1..trimmed.len() - 1].to_string());
2400 }
2401
2402 if matches!(node.kind(), "string" | "chained_string") {
2404 let mut cursor = node.walk();
2405 for child in node.children(&mut cursor) {
2406 if child.kind() == "string_content"
2407 && let Ok(s) = child.utf8_text(content)
2408 {
2409 return Some(s.to_string());
2410 }
2411 }
2412 }
2413
2414 None
2415}
2416
2417pub(crate) fn resolve_ruby_require(
2428 module_name: &str,
2429 is_relative: bool,
2430 source_file: &str,
2431) -> String {
2432 if is_relative {
2433 let source_path = std::path::Path::new(source_file);
2437 let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
2438
2439 let relative_path = std::path::Path::new(module_name);
2441 let resolved = source_dir.join(relative_path);
2442
2443 let normalized = normalize_path(&resolved);
2445
2446 let path_str = normalized.to_string_lossy();
2449 let separators: &[char] = &['/', '\\'];
2450 path_str
2451 .split(separators)
2452 .filter(|s| !s.is_empty())
2453 .collect::<Vec<_>>()
2454 .join("::")
2455 } else {
2456 module_name.replace('/', "::")
2459 }
2460}
2461
2462fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
2464 let mut components = Vec::new();
2465
2466 for component in path.components() {
2467 match component {
2468 std::path::Component::CurDir => {
2469 }
2471 std::path::Component::ParentDir => {
2472 if components
2474 .last()
2475 .is_some_and(|c| *c != std::path::Component::ParentDir)
2476 {
2477 components.pop();
2478 } else {
2479 components.push(component);
2480 }
2481 }
2482 _ => {
2483 components.push(component);
2484 }
2485 }
2486 }
2487
2488 components.iter().collect()
2489}
2490
2491fn process_yard_annotations(
2498 node: Node,
2499 content: &[u8],
2500 ast_graph: &ASTGraph,
2501 helper: &mut GraphBuildHelper,
2502) -> GraphResult<()> {
2503 match node.kind() {
2504 "method" => {
2505 process_method_yard(node, content, helper)?;
2506 }
2507 "singleton_method" => {
2508 process_singleton_method_yard(node, content, helper)?;
2509 }
2510 "call" | "command" | "command_call" => {
2511 if is_attr_call(node, content) {
2513 process_attr_yard(node, content, ast_graph, helper)?;
2514 }
2515 }
2516 "assignment" => {
2517 if is_instance_variable_assignment(node, content) {
2519 process_assignment_yard(node, content, helper)?;
2520 }
2521 }
2522 _ => {}
2523 }
2524
2525 let mut cursor = node.walk();
2527 for child in node.children(&mut cursor) {
2528 process_yard_annotations(child, content, ast_graph, helper)?;
2529 }
2530
2531 Ok(())
2532}
2533
2534fn process_method_yard(
2536 method_node: Node,
2537 content: &[u8],
2538 helper: &mut GraphBuildHelper,
2539) -> GraphResult<()> {
2540 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2542 return Ok(());
2543 };
2544
2545 let tags = parse_yard_tags(&yard_text);
2547
2548 let Some(name_node) = method_node.child_by_field_name("name") else {
2550 return Ok(());
2551 };
2552
2553 let method_name = name_node
2554 .utf8_text(content)
2555 .map_err(|_| GraphBuilderError::ParseError {
2556 span: span_from_node(method_node),
2557 reason: "failed to read method name".to_string(),
2558 })?
2559 .trim()
2560 .to_string();
2561
2562 if method_name.is_empty() {
2563 return Ok(());
2564 }
2565
2566 let class_name = get_enclosing_class_name(method_node, content);
2568
2569 let qualified_name = if let Some(class_name) = class_name {
2571 format!("{class_name}#{method_name}")
2572 } else {
2573 method_name.clone()
2574 };
2575
2576 let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
2578
2579 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2581 let canonical_type = canonical_type_string(¶m_tag.type_str);
2583 let type_node_id = helper.add_type(&canonical_type, None);
2584 helper.add_typeof_edge_with_context(
2585 method_node_id,
2586 type_node_id,
2587 Some(TypeOfContext::Parameter),
2588 param_idx.try_into().ok(),
2589 Some(¶m_tag.name),
2590 );
2591
2592 let type_names = extract_type_names(¶m_tag.type_str);
2594 for type_name in type_names {
2595 let ref_type_id = helper.add_type(&type_name, None);
2596 helper.add_reference_edge(method_node_id, ref_type_id);
2597 }
2598 }
2599
2600 if let Some(return_type) = &tags.returns {
2602 let canonical_type = canonical_type_string(return_type);
2603 let type_node_id = helper.add_type(&canonical_type, None);
2604 helper.add_typeof_edge_with_context(
2605 method_node_id,
2606 type_node_id,
2607 Some(TypeOfContext::Return),
2608 Some(0),
2609 None,
2610 );
2611
2612 let type_names = extract_type_names(return_type);
2614 for type_name in type_names {
2615 let ref_type_id = helper.add_type(&type_name, None);
2616 helper.add_reference_edge(method_node_id, ref_type_id);
2617 }
2618 }
2619
2620 Ok(())
2621}
2622
2623fn process_singleton_method_yard(
2625 method_node: Node,
2626 content: &[u8],
2627 helper: &mut GraphBuildHelper,
2628) -> GraphResult<()> {
2629 let Some(yard_text) = extract_yard_comment(method_node, content) else {
2631 return Ok(());
2632 };
2633
2634 let tags = parse_yard_tags(&yard_text);
2636
2637 let Some(name_node) = method_node.child_by_field_name("name") else {
2639 return Ok(());
2640 };
2641
2642 let method_name = name_node
2643 .utf8_text(content)
2644 .map_err(|_| GraphBuilderError::ParseError {
2645 span: span_from_node(method_node),
2646 reason: "failed to read method name".to_string(),
2647 })?
2648 .trim()
2649 .to_string();
2650
2651 if method_name.is_empty() {
2652 return Ok(());
2653 }
2654
2655 let class_name = get_enclosing_class_name(method_node, content);
2657
2658 let qualified_name = if let Some(class_name) = class_name {
2660 format!("{class_name}.{method_name}")
2661 } else {
2662 method_name.clone()
2663 };
2664
2665 let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
2667
2668 for (param_idx, param_tag) in tags.params.iter().enumerate() {
2670 let canonical_type = canonical_type_string(¶m_tag.type_str);
2672 let type_node_id = helper.add_type(&canonical_type, None);
2673 helper.add_typeof_edge_with_context(
2674 method_node_id,
2675 type_node_id,
2676 Some(TypeOfContext::Parameter),
2677 param_idx.try_into().ok(),
2678 Some(¶m_tag.name),
2679 );
2680
2681 let type_names = extract_type_names(¶m_tag.type_str);
2683 for type_name in type_names {
2684 let ref_type_id = helper.add_type(&type_name, None);
2685 helper.add_reference_edge(method_node_id, ref_type_id);
2686 }
2687 }
2688
2689 if let Some(return_type) = &tags.returns {
2691 let canonical_type = canonical_type_string(return_type);
2692 let type_node_id = helper.add_type(&canonical_type, None);
2693 helper.add_typeof_edge_with_context(
2694 method_node_id,
2695 type_node_id,
2696 Some(TypeOfContext::Return),
2697 Some(0),
2698 None,
2699 );
2700
2701 let type_names = extract_type_names(return_type);
2703 for type_name in type_names {
2704 let ref_type_id = helper.add_type(&type_name, None);
2705 helper.add_reference_edge(method_node_id, ref_type_id);
2706 }
2707 }
2708
2709 Ok(())
2710}
2711
2712#[allow(clippy::unnecessary_wraps)]
2732fn process_attr_yard(
2733 attr_node: Node,
2734 content: &[u8],
2735 ast_graph: &ASTGraph,
2736 helper: &mut GraphBuildHelper,
2737) -> GraphResult<()> {
2738 let Some(method_name) = attr_method_name(attr_node, content) else {
2743 return Ok(());
2744 };
2745 let is_reader = method_name == "attr_reader";
2746
2747 let attr_names = extract_attr_names(attr_node, content);
2750 if attr_names.is_empty() {
2751 return Ok(());
2752 }
2753
2754 let class_name = get_enclosing_class_name(attr_node, content);
2756
2757 let yard_return = extract_yard_comment(attr_node, content)
2760 .map(|yard_text| parse_yard_tags(&yard_text))
2761 .and_then(|tags| tags.returns);
2762
2763 let span = span_from_node(attr_node);
2766 let visibility = ast_graph.attr_visibility_for_node(&attr_node).as_str();
2767
2768 for attr_name in attr_names {
2769 let qualified_name = if let Some(ref class) = class_name {
2773 format!("{class}#{attr_name}")
2774 } else {
2775 attr_name.clone()
2776 };
2777
2778 let attr_node_id = if is_reader {
2782 helper.add_constant_with_static_and_visibility(
2783 &qualified_name,
2784 Some(span),
2785 false,
2786 Some(visibility),
2787 )
2788 } else {
2789 helper.add_property_with_static_and_visibility(
2790 &qualified_name,
2791 Some(span),
2792 false,
2793 Some(visibility),
2794 )
2795 };
2796
2797 if let Some(var_type) = &yard_return {
2801 let canonical_type = canonical_type_string(var_type);
2802 let type_node_id = helper.add_type(&canonical_type, None);
2803 helper.add_typeof_edge_with_context(
2804 attr_node_id,
2805 type_node_id,
2806 Some(TypeOfContext::Field),
2807 None,
2808 Some(&attr_name),
2809 );
2810
2811 for type_name in extract_type_names(var_type) {
2812 let ref_type_id = helper.add_type(&type_name, None);
2813 helper.add_reference_edge(attr_node_id, ref_type_id);
2814 }
2815 }
2816 }
2817
2818 Ok(())
2819}
2820
2821fn attr_method_name(node: Node, content: &[u8]) -> Option<String> {
2825 let raw = match node.kind() {
2826 "command" => node
2827 .child_by_field_name("name")
2828 .and_then(|n| n.utf8_text(content).ok()),
2829 "call" | "command_call" => node
2830 .child_by_field_name("method")
2831 .and_then(|n| n.utf8_text(content).ok()),
2832 _ => None,
2833 }?;
2834 Some(raw.trim().to_string())
2835}
2836
2837fn process_assignment_yard(
2839 assignment_node: Node,
2840 content: &[u8],
2841 helper: &mut GraphBuildHelper,
2842) -> GraphResult<()> {
2843 let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
2845 return Ok(());
2846 };
2847
2848 let tags = parse_yard_tags(&yard_text);
2850
2851 let Some(var_type) = &tags.type_annotation else {
2853 return Ok(());
2854 };
2855
2856 let Some(left_node) = assignment_node.child_by_field_name("left") else {
2858 return Ok(());
2859 };
2860
2861 if left_node.kind() != "instance_variable" {
2862 return Ok(());
2863 }
2864
2865 let var_name = left_node
2866 .utf8_text(content)
2867 .map_err(|_| GraphBuilderError::ParseError {
2868 span: span_from_node(assignment_node),
2869 reason: "failed to read variable name".to_string(),
2870 })?
2871 .trim()
2872 .to_string();
2873
2874 if var_name.is_empty() {
2875 return Ok(());
2876 }
2877
2878 let class_name = get_enclosing_class_name(assignment_node, content);
2880
2881 let qualified_name = if let Some(class) = class_name {
2883 format!("{class}#{var_name}")
2884 } else {
2885 var_name.clone()
2886 };
2887
2888 let var_node_id = helper.add_variable(&qualified_name, None);
2890
2891 let canonical_type = canonical_type_string(var_type);
2893 let type_node_id = helper.add_type(&canonical_type, None);
2894 helper.add_typeof_edge_with_context(
2895 var_node_id,
2896 type_node_id,
2897 Some(TypeOfContext::Variable),
2898 None,
2899 Some(&var_name),
2900 );
2901
2902 let type_names = extract_type_names(var_type);
2904 for type_name in type_names {
2905 let ref_type_id = helper.add_type(&type_name, None);
2906 helper.add_reference_edge(var_node_id, ref_type_id);
2907 }
2908
2909 Ok(())
2910}
2911
2912fn is_attr_call(node: Node, content: &[u8]) -> bool {
2914 let method_name = match node.kind() {
2915 "command" => node
2916 .child_by_field_name("name")
2917 .and_then(|n| n.utf8_text(content).ok()),
2918 "call" | "command_call" => node
2919 .child_by_field_name("method")
2920 .and_then(|n| n.utf8_text(content).ok()),
2921 _ => None,
2922 };
2923
2924 method_name
2925 .is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
2926}
2927
2928fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
2930 if let Some(left_node) = node.child_by_field_name("left") {
2931 left_node.kind() == "instance_variable"
2932 } else {
2933 false
2934 }
2935}
2936
2937fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
2940 let mut names = Vec::new();
2941
2942 let arguments = attr_node.child_by_field_name("arguments");
2944
2945 if let Some(args) = arguments {
2946 let mut cursor = args.walk();
2948 for child in args.children(&mut cursor) {
2949 if matches!(child.kind(), "symbol" | "simple_symbol")
2950 && let Ok(text) = child.utf8_text(content)
2951 {
2952 let cleaned = text.trim().trim_start_matches(':');
2953 if !cleaned.is_empty() {
2954 names.push(cleaned.to_string());
2955 }
2956 } else if child.kind() == "string"
2957 && let Ok(text) = child.utf8_text(content)
2958 {
2959 let cleaned = text
2961 .trim()
2962 .trim_start_matches(['\'', '"'])
2963 .trim_end_matches(['\'', '"']);
2964 if !cleaned.is_empty() {
2965 names.push(cleaned.to_string());
2966 }
2967 }
2968 }
2969 } else if matches!(attr_node.kind(), "command" | "command_call") {
2970 let mut cursor = attr_node.walk();
2972 let mut found_method = false;
2973 for child in attr_node.children(&mut cursor) {
2974 if !child.is_named() {
2975 continue;
2976 }
2977 if !found_method {
2979 found_method = true;
2980 continue;
2981 }
2982 if matches!(child.kind(), "symbol" | "simple_symbol")
2984 && let Ok(text) = child.utf8_text(content)
2985 {
2986 let cleaned = text.trim().trim_start_matches(':');
2987 if !cleaned.is_empty() {
2988 names.push(cleaned.to_string());
2989 }
2990 } else if child.kind() == "string"
2991 && let Ok(text) = child.utf8_text(content)
2992 {
2993 let cleaned = text
2995 .trim()
2996 .trim_start_matches(['\'', '"'])
2997 .trim_end_matches(['\'', '"']);
2998 if !cleaned.is_empty() {
2999 names.push(cleaned.to_string());
3000 }
3001 }
3002 }
3003 }
3004
3005 names
3006}
3007
3008fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
3013 let mut current = node;
3014 let mut namespace_parts = Vec::new();
3015
3016 while let Some(parent) = current.parent() {
3018 if matches!(parent.kind(), "class" | "module") {
3019 if let Some(name_node) = parent.child_by_field_name("name")
3021 && let Ok(name_text) = name_node.utf8_text(content)
3022 {
3023 let trimmed = name_text.trim();
3024 if trimmed.starts_with("::") {
3026 namespace_parts.clear();
3028 namespace_parts.push(trimmed.trim_start_matches("::").to_string());
3029 break;
3030 }
3031 namespace_parts.insert(0, trimmed.to_string());
3033 }
3034 }
3035 current = parent;
3036 }
3037
3038 if namespace_parts.is_empty() {
3040 None
3041 } else {
3042 Some(namespace_parts.join("::"))
3043 }
3044}
3045
3046#[cfg(test)]
3047mod field_emission_tests {
3048 use sqry_core::graph::GraphBuilder;
3066 use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
3067 use sqry_core::graph::unified::build::test_helpers::{
3068 build_node_name_lookup, build_string_lookup,
3069 };
3070 use sqry_core::graph::unified::edge::EdgeKind;
3071 use sqry_core::graph::unified::edge::kind::TypeOfContext;
3072 use sqry_core::graph::unified::node::NodeKind;
3073 use std::path::Path;
3074 use tree_sitter::Parser;
3075
3076 use super::RubyGraphBuilder;
3077
3078 fn parse(source: &str) -> tree_sitter::Tree {
3079 let mut parser = Parser::new();
3080 parser
3081 .set_language(&tree_sitter_ruby::LANGUAGE.into())
3082 .expect("load Ruby grammar");
3083 parser.parse(source, None).expect("parse Ruby source")
3084 }
3085
3086 fn build(source: &str) -> StagingGraph {
3087 let tree = parse(source);
3088 let mut staging = StagingGraph::new();
3089 let builder = RubyGraphBuilder::default();
3090 builder
3091 .build_graph(&tree, source.as_bytes(), Path::new("test.rb"), &mut staging)
3092 .expect("build graph");
3093 staging
3094 }
3095
3096 fn find_node<'a>(
3100 staging: &'a StagingGraph,
3101 name: &str,
3102 kind: Option<NodeKind>,
3103 ) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
3104 let strings = build_string_lookup(staging);
3105 for op in staging.operations() {
3106 if let StagingOp::AddNode { entry, .. } = op {
3107 if let Some(k) = kind
3108 && entry.kind != k
3109 {
3110 continue;
3111 }
3112 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3113 if let Some(s) = strings.get(&name_idx)
3114 && s == name
3115 {
3116 return Some(entry);
3117 }
3118 }
3119 }
3120 None
3121 }
3122
3123 fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
3124 let strings = build_string_lookup(staging);
3125 staging
3126 .operations()
3127 .iter()
3128 .filter(|op| {
3129 if let StagingOp::AddNode { entry, .. } = op {
3130 let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
3131 strings.get(&name_idx).is_some_and(|s| s == name)
3132 } else {
3133 false
3134 }
3135 })
3136 .count()
3137 }
3138
3139 fn visibility(
3140 staging: &StagingGraph,
3141 entry: &sqry_core::graph::unified::storage::NodeEntry,
3142 ) -> Option<String> {
3143 let strings = build_string_lookup(staging);
3144 entry
3145 .visibility
3146 .and_then(|visibility_id| strings.get(&visibility_id.index()).cloned())
3147 }
3148
3149 fn typeof_edges_for_node(
3150 staging: &StagingGraph,
3151 source_name: &str,
3152 ) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
3153 let names = build_node_name_lookup(staging);
3154 let strings = build_string_lookup(staging);
3155 let mut out = Vec::new();
3156 for op in staging.operations() {
3157 if let StagingOp::AddEdge {
3158 source,
3159 target,
3160 kind: EdgeKind::TypeOf { context, name, .. },
3161 ..
3162 } = op
3163 {
3164 let src = names.get(source).cloned().unwrap_or_default();
3165 if src != source_name {
3166 continue;
3167 }
3168 let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
3169 let target_name = names.get(target).cloned().unwrap_or_default();
3170 out.push((*context, edge_name, target_name));
3171 }
3172 }
3173 out
3174 }
3175
3176 #[test]
3179 fn req_r0001_attr_accessor_without_yard_emits_property_node() {
3180 let src = "class Foo\n attr_accessor :x\nend\n";
3181 let staging = build(src);
3182 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3183 .expect("Foo#x Property must be emitted without YARD");
3184 }
3185
3186 #[test]
3187 fn req_r0001_attr_reader_without_yard_emits_constant_node() {
3188 let src = "class Foo\n attr_reader :y\nend\n";
3189 let staging = build(src);
3190 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3191 .expect("Foo#y Constant must be emitted without YARD");
3192 }
3193
3194 #[test]
3195 fn req_r0001_attr_writer_without_yard_emits_property_node() {
3196 let src = "class Foo\n attr_writer :z\nend\n";
3197 let staging = build(src);
3198 find_node(&staging, "Foo::z", Some(NodeKind::Property))
3199 .expect("Foo#z Property must be emitted without YARD");
3200 }
3201
3202 #[test]
3203 fn req_r0001_attr_with_yard_still_emits() {
3204 let src = "class Foo\n # @return [String]\n attr_reader :y\nend\n";
3205 let staging = build(src);
3206 find_node(&staging, "Foo::y", Some(NodeKind::Constant))
3207 .expect("Foo#y Constant must be emitted when YARD is present too");
3208 }
3209
3210 #[test]
3213 fn req_r0023_attr_reader_branches_to_constant() {
3214 let src = "class Bar\n attr_reader :name\nend\n";
3215 let staging = build(src);
3216 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Constant))
3217 .expect("attr_reader must produce Constant");
3218 assert_eq!(entry.kind, NodeKind::Constant);
3219 assert!(
3220 find_node(&staging, "Bar::name", Some(NodeKind::Property)).is_none(),
3221 "attr_reader must NOT also produce a Property"
3222 );
3223 }
3224
3225 #[test]
3226 fn req_r0023_attr_writer_branches_to_property() {
3227 let src = "class Bar\n attr_writer :name\nend\n";
3228 let staging = build(src);
3229 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3230 .expect("attr_writer must produce Property");
3231 assert_eq!(entry.kind, NodeKind::Property);
3232 assert!(
3233 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3234 "attr_writer must NOT also produce a Constant"
3235 );
3236 }
3237
3238 #[test]
3239 fn req_r0023_attr_accessor_branches_to_property() {
3240 let src = "class Bar\n attr_accessor :name\nend\n";
3241 let staging = build(src);
3242 let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
3243 .expect("attr_accessor must produce Property");
3244 assert_eq!(entry.kind, NodeKind::Property);
3245 assert!(
3246 find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
3247 "attr_accessor must NOT also produce a Constant"
3248 );
3249 }
3250
3251 #[test]
3252 fn req_r0023_attr_accessor_emits_one_per_argument() {
3253 let src = "class Multi\n attr_accessor :a, :b, :c\nend\n";
3254 let staging = build(src);
3255 find_node(&staging, "Multi::a", Some(NodeKind::Property))
3256 .expect("Multi#a Property must exist");
3257 find_node(&staging, "Multi::b", Some(NodeKind::Property))
3258 .expect("Multi#b Property must exist");
3259 find_node(&staging, "Multi::c", Some(NodeKind::Property))
3260 .expect("Multi#c Property must exist");
3261 assert_eq!(count_nodes_named(&staging, "Multi::a"), 1);
3263 assert_eq!(count_nodes_named(&staging, "Multi::b"), 1);
3264 assert_eq!(count_nodes_named(&staging, "Multi::c"), 1);
3265 }
3266
3267 #[test]
3270 fn req_r0017_qualified_name_uses_ruby_hash_idiom() {
3271 let src = "class Foo\n attr_accessor :x\nend\n";
3278 let staging = build(src);
3279 find_node(&staging, "Foo::x", Some(NodeKind::Property))
3281 .expect("canonical Foo::x must exist");
3282 assert!(
3284 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3285 "bare 'x' must not be the qualified name (would collide across classes)"
3286 );
3287 }
3288
3289 #[test]
3292 fn req_r0006_yard_type_tag_drives_typeof_field_edge() {
3293 let src = "class User\n # @return [String]\n attr_reader :name\nend\n";
3294 let staging = build(src);
3295 let edges = typeof_edges_for_node(&staging, "User::name");
3296 assert!(
3297 !edges.is_empty(),
3298 "User#name should have a TypeOf edge from YARD @return"
3299 );
3300 let has_string = edges.iter().any(|(_, _, t)| t == "String");
3301 assert!(
3302 has_string,
3303 "YARD @return [String] should produce a TypeOf target 'String', got {edges:?}"
3304 );
3305 }
3306
3307 #[test]
3308 fn req_r0006_typeof_uses_field_context_and_bare_name() {
3309 let src = "class C\n # @return [String]\n attr_accessor :title\nend\n";
3310 let staging = build(src);
3311 let edges = typeof_edges_for_node(&staging, "C::title");
3312 assert!(!edges.is_empty(), "C#title should have a TypeOf edge");
3313 for (ctx, name, _) in &edges {
3314 assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
3315 assert_eq!(
3316 name.as_deref(),
3317 Some("title"),
3318 "edge name must be the bare attr name"
3319 );
3320 }
3321 }
3322
3323 #[test]
3324 fn req_r0006_no_yard_means_no_typeof_edge_but_node_emitted() {
3325 let src = "class C\n attr_accessor :untyped\nend\n";
3327 let staging = build(src);
3328 find_node(&staging, "C::untyped", Some(NodeKind::Property))
3329 .expect("Property must emit even without YARD type tag");
3330 let edges = typeof_edges_for_node(&staging, "C::untyped");
3331 assert!(
3332 edges.is_empty(),
3333 "no YARD => no TypeOf{{Field}} enrichment edge, got {edges:?}"
3334 );
3335 }
3336
3337 #[test]
3340 fn req_r0023_attr_node_visibility_defaults_to_public() {
3341 let src = "class V\n attr_accessor :x\nend\n";
3342 let staging = build(src);
3343 let entry =
3344 find_node(&staging, "V::x", Some(NodeKind::Property)).expect("V#x Property must exist");
3345 assert_eq!(
3346 visibility(&staging, entry).as_deref(),
3347 Some("public"),
3348 "Ruby attr_* nodes default to public visibility"
3349 );
3350 }
3351
3352 #[test]
3353 fn req_r0023_attr_node_visibility_tracks_private_and_protected_scope() {
3354 let src = "class V\n private\n attr_accessor :hidden\n protected\n attr_reader :guarded\nend\n";
3355 let staging = build(src);
3356 let hidden = find_node(&staging, "V::hidden", Some(NodeKind::Property))
3357 .expect("V#hidden Property must exist");
3358 let guarded = find_node(&staging, "V::guarded", Some(NodeKind::Constant))
3359 .expect("V#guarded Constant must exist");
3360 assert_eq!(
3361 visibility(&staging, hidden).as_deref(),
3362 Some("private"),
3363 "Ruby attr_* nodes must inherit private visibility scope"
3364 );
3365 assert_eq!(
3366 visibility(&staging, guarded).as_deref(),
3367 Some("protected"),
3368 "Ruby attr_reader nodes must inherit protected visibility scope"
3369 );
3370 }
3371
3372 #[test]
3373 fn req_r0023_attr_node_is_not_static() {
3374 let src = "class S\n attr_reader :y\nend\n";
3375 let staging = build(src);
3376 let entry =
3377 find_node(&staging, "S::y", Some(NodeKind::Constant)).expect("S#y Constant must exist");
3378 assert!(
3379 !entry.is_static,
3380 "attr_* nodes must have is_static=false (always instance per design §4.5)"
3381 );
3382 }
3383
3384 #[test]
3387 fn req_r0017_same_attr_name_across_classes_distinct_nodes() {
3388 let src = "class A\n attr_accessor :x\nend\nclass B\n attr_accessor :x\nend\n";
3389 let staging = build(src);
3390 find_node(&staging, "A::x", Some(NodeKind::Property)).expect("A#x Property must exist");
3391 find_node(&staging, "B::x", Some(NodeKind::Property)).expect("B#x Property must exist");
3392 assert!(
3393 find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
3394 "bare 'x' must not exist; qualified names disambiguate cross-class"
3395 );
3396 }
3397
3398 #[test]
3401 fn req_r0017_nested_module_class_qualifies_attr() {
3402 let src = "module M\n class Inner\n attr_accessor :n\n end\nend\n";
3403 let staging = build(src);
3404 find_node(&staging, "M::Inner::n", Some(NodeKind::Property))
3405 .expect("M::Inner#n Property must exist with full namespace");
3406 }
3407
3408 #[test]
3411 fn req_r0001_attr_reader_string_argument_emits_constant() {
3412 let src = "class User\n attr_reader \"username\"\nend\n";
3413 let staging = build(src);
3414 find_node(&staging, "User::username", Some(NodeKind::Constant))
3415 .expect("attr_reader with string arg must emit Constant");
3416 }
3417
3418 #[test]
3419 fn req_r0001_attr_accessor_command_call_form_emits_property() {
3420 let src = "class Service\n self.attr_accessor :logger\nend\n";
3421 let staging = build(src);
3422 find_node(&staging, "Service::logger", Some(NodeKind::Property))
3423 .expect("self.attr_accessor command_call must emit Property");
3424 }
3425}
3426
3427#[cfg(test)]
3428mod shape_tests {
3429 use super::{cf_bucket_for_ruby_kind, ruby_shape_mapping};
3433 use sqry_core::graph::unified::build::shape::{
3434 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
3435 };
3436 use tree_sitter::{Node, Parser, Tree};
3437
3438 const SAMPLE: &str = include_str!(concat!(
3439 env!("CARGO_MANIFEST_DIR"),
3440 "/../test-fixtures/shape/dynamic/ruby.rb"
3441 ));
3442
3443 fn parse(src: &str) -> Tree {
3444 let mut parser = Parser::new();
3445 parser
3446 .set_language(&tree_sitter_ruby::LANGUAGE.into())
3447 .expect("load ruby grammar");
3448 parser.parse(src, None).expect("parse ruby")
3449 }
3450
3451 fn first_method<'t>(tree: &'t Tree) -> Node<'t> {
3452 let root = tree.root_node();
3453 let mut cursor = root.walk();
3454 for child in root.named_children(&mut cursor) {
3455 if child.kind() == "method" {
3456 return child;
3457 }
3458 }
3459 panic!("no method node in ruby fixture");
3460 }
3461
3462 #[test]
3463 fn mapping_is_non_empty_and_covers_real_kinds() {
3464 let mapping = ruby_shape_mapping();
3465 assert_eq!(cf_bucket_for_ruby_kind("if"), Some(CfBucket::Branch));
3467 assert_eq!(cf_bucket_for_ruby_kind("while"), Some(CfBucket::Loop));
3468 assert_eq!(cf_bucket_for_ruby_kind("case"), Some(CfBucket::Match));
3469 assert_eq!(cf_bucket_for_ruby_kind("begin"), Some(CfBucket::Try));
3470 assert_eq!(cf_bucket_for_ruby_kind("rescue"), Some(CfBucket::Catch));
3471 assert_eq!(cf_bucket_for_ruby_kind("ensure"), Some(CfBucket::Resource));
3472 assert_eq!(cf_bucket_for_ruby_kind("return"), Some(CfBucket::Return));
3473 assert_eq!(cf_bucket_for_ruby_kind("yield"), Some(CfBucket::Yield));
3474 assert_eq!(
3475 cf_bucket_for_ruby_kind("break"),
3476 Some(CfBucket::BreakContinue)
3477 );
3478 assert_eq!(cf_bucket_for_ruby_kind("call"), Some(CfBucket::Call));
3479 assert_eq!(cf_bucket_for_ruby_kind("do_block"), Some(CfBucket::Closure));
3480 assert_eq!(cf_bucket_for_ruby_kind("not_a_real_kind"), None);
3481
3482 let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
3484 let if_id = (0..lang.node_kind_count())
3485 .map(|i| i as u16)
3486 .find(|&i| lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if"))
3487 .expect("grammar exposes named `if`");
3488 assert_eq!(mapping.cf_bucket(if_id), Some(CfBucket::Branch));
3489 }
3490
3491 #[test]
3492 fn descriptor_covers_fixture_control_flow() {
3493 let tree = parse(SAMPLE);
3494 let func = first_method(&tree);
3495 let descriptor = compute_shape_descriptor(
3496 func,
3497 SAMPLE.as_bytes(),
3498 ruby_shape_mapping(),
3499 &ShapeBudget::default(),
3500 );
3501 let hist = descriptor.cf_histogram;
3502 assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if/elsif)");
3503 assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for)");
3504 assert!(hist[CfBucket::Match.index()] >= 1, "match (case/when)");
3505 assert!(hist[CfBucket::Try.index()] >= 1, "try (begin)");
3506 assert!(hist[CfBucket::Catch.index()] >= 1, "catch (rescue)");
3507 assert!(hist[CfBucket::Call.index()] >= 1, "call");
3508 assert!(hist[CfBucket::Closure.index()] >= 1, "closure (do_block)");
3509 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break/next");
3510 }
3511
3512 #[test]
3513 fn signature_shape_reads_arity_and_kwargs() {
3514 let tree = parse(SAMPLE);
3515 let func = first_method(&tree);
3516 let shape = ruby_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
3517 assert_eq!(shape.arity_positional, 1, "one positional: value");
3519 assert_eq!(shape.arity_keyword_only, 1, "one keyword: label");
3520 assert!(shape.has_varargs, "*rest");
3521 assert!(shape.has_kwargs, "**opts");
3522 }
3523}