Skip to main content

sqry_lang_ruby/relations/
graph_builder.rs

1use std::{
2    collections::{HashMap, HashSet},
3    path::Path,
4};
5
6use sqry_core::graph::unified::build::helper::CalleeKindHint;
7use sqry_core::graph::unified::edge::FfiConvention;
8use sqry_core::graph::unified::edge::kind::TypeOfContext;
9use sqry_core::graph::unified::{GraphBuildHelper, StagingGraph};
10use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
11use tree_sitter::{Node, Point, Tree};
12
13use super::type_extractor::{canonical_type_string, extract_type_names};
14use super::yard_parser::{extract_yard_comment, parse_yard_tags};
15
16const DEFAULT_SCOPE_DEPTH: usize = 4;
17
18/// File-level module name for exports.
19/// In Ruby, public classes, modules, and methods are exported.
20const FILE_MODULE_NAME: &str = "<file_module>";
21
22type CallEdgeData = (String, String, usize, Span, bool);
23
24/// Graph builder for Ruby source files.
25///
26/// This implementation follows the unified `ASTGraph` pattern used by other
27/// language plugins. It builds method contexts first, then performs a second
28/// traversal to emit call edges, FFI hooks, and call-site metadata.
29#[derive(Debug, Clone, Copy)]
30pub struct RubyGraphBuilder {
31    max_scope_depth: usize,
32}
33
34impl Default for RubyGraphBuilder {
35    fn default() -> Self {
36        Self {
37            max_scope_depth: DEFAULT_SCOPE_DEPTH,
38        }
39    }
40}
41
42impl RubyGraphBuilder {
43    /// Create a builder with custom scope depth.
44    #[must_use]
45    pub fn new(max_scope_depth: usize) -> Self {
46        Self { max_scope_depth }
47    }
48}
49
50impl GraphBuilder for RubyGraphBuilder {
51    fn build_graph(
52        &self,
53        tree: &Tree,
54        content: &[u8],
55        file: &Path,
56        staging: &mut StagingGraph,
57    ) -> GraphResult<()> {
58        // Create helper for staging graph population
59        let mut helper = GraphBuildHelper::new(staging, file, Language::Ruby);
60
61        // Build AST graph for call context tracking
62        let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
63            GraphBuilderError::ParseError {
64                span: Span::default(),
65                reason: e,
66            }
67        })?;
68
69        // Walk tree to find methods, classes, modules, calls, imports, and FFI
70        walk_tree_for_graph(
71            tree.root_node(),
72            content,
73            &ast_graph,
74            &mut helper,
75            &ast_graph.ffi_enabled_scopes,
76        )?;
77
78        apply_controller_dsl_hooks(&ast_graph, &mut helper);
79
80        // Phase: Process YARD annotations for TypeOf and Reference edges
81        process_yard_annotations(tree.root_node(), content, &mut helper)?;
82
83        Ok(())
84    }
85
86    fn language(&self) -> Language {
87        Language::Ruby
88    }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum Visibility {
93    Public,
94    Protected,
95    Private,
96}
97
98impl Visibility {
99    #[allow(dead_code)] // Reserved for visibility filtering in graph queries
100    fn as_str(self) -> &'static str {
101        match self {
102            Visibility::Public => "public",
103            Visibility::Protected => "protected",
104            Visibility::Private => "private",
105        }
106    }
107
108    fn from_keyword(keyword: &str) -> Option<Self> {
109        match keyword {
110            "public" => Some(Visibility::Public),
111            "protected" => Some(Visibility::Protected),
112            "private" => Some(Visibility::Private),
113            _ => None,
114        }
115    }
116}
117
118#[derive(Debug, Clone)]
119enum RubyContextKind {
120    Method,
121    SingletonMethod,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125enum ControllerDslKind {
126    Before,
127    After,
128    Around,
129}
130
131#[allow(dead_code)] // Scaffolding for Rails controller DSL analysis
132#[derive(Debug, Clone)]
133struct ControllerDslHook {
134    container: String,
135    kind: ControllerDslKind,
136    callbacks: Vec<String>,
137    only: Option<Vec<String>>,   // action filters
138    except: Option<Vec<String>>, // action filters
139}
140
141#[derive(Debug, Clone)]
142struct RubyContext {
143    qualified_name: String,
144    container: Option<String>,
145    kind: RubyContextKind,
146    visibility: Visibility,
147    start_position: Point,
148    end_position: Point,
149}
150
151impl RubyContext {
152    #[allow(dead_code)] // Reserved for future filtering logic
153    fn is_method(&self) -> bool {
154        matches!(
155            self.kind,
156            RubyContextKind::Method | RubyContextKind::SingletonMethod
157        )
158    }
159
160    fn is_singleton(&self) -> bool {
161        matches!(self.kind, RubyContextKind::SingletonMethod)
162    }
163
164    fn qualified_name(&self) -> &str {
165        &self.qualified_name
166    }
167
168    fn container(&self) -> Option<&str> {
169        self.container.as_deref()
170    }
171
172    fn visibility(&self) -> Visibility {
173        self.visibility
174    }
175}
176
177struct ASTGraph {
178    contexts: Vec<RubyContext>,
179    node_to_context: HashMap<usize, usize>,
180    /// Scopes (namespaces) that have `extend FFI::Library` - used for FFI edge emission
181    ffi_enabled_scopes: HashSet<Vec<String>>,
182    #[allow(dead_code)] // Reserved for Rails controller DSL analysis
183    controller_dsl_hooks: Vec<ControllerDslHook>,
184}
185
186impl ASTGraph {
187    fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
188        let mut builder = ContextBuilder::new(content, max_depth)?;
189        builder.walk(tree.root_node())?;
190        Ok(Self {
191            contexts: builder.contexts,
192            node_to_context: builder.node_to_context,
193            ffi_enabled_scopes: builder.ffi_enabled_scopes,
194            controller_dsl_hooks: builder.controller_dsl_hooks,
195        })
196    }
197
198    #[allow(dead_code)] // Reserved for future context queries
199    fn contexts(&self) -> &[RubyContext] {
200        &self.contexts
201    }
202
203    fn context_for_node(&self, node: &Node<'_>) -> Option<&RubyContext> {
204        self.node_to_context
205            .get(&node.id())
206            .and_then(|idx| self.contexts.get(*idx))
207    }
208}
209
210/// Walk the tree and populate the staging graph.
211fn walk_tree_for_graph(
212    node: Node,
213    content: &[u8],
214    ast_graph: &ASTGraph,
215    helper: &mut sqry_core::graph::unified::GraphBuildHelper,
216    ffi_enabled_scopes: &HashSet<Vec<String>>,
217) -> GraphResult<()> {
218    // Track current namespace for FFI scope detection
219    let mut current_namespace: Vec<String> = Vec::new();
220
221    walk_tree_for_graph_impl(
222        node,
223        content,
224        ast_graph,
225        helper,
226        ffi_enabled_scopes,
227        &mut current_namespace,
228    )
229}
230
231fn apply_controller_dsl_hooks(ast_graph: &ASTGraph, helper: &mut GraphBuildHelper) {
232    if ast_graph.controller_dsl_hooks.is_empty() {
233        return;
234    }
235
236    let mut actions_by_container: HashMap<String, Vec<String>> = HashMap::new();
237    for context in &ast_graph.contexts {
238        if !matches!(context.kind, RubyContextKind::Method) {
239            continue;
240        }
241        let Some(container) = context.container() else {
242            continue;
243        };
244        let Some(action_name) = context.qualified_name.rsplit('#').next() else {
245            continue;
246        };
247        actions_by_container
248            .entry(container.to_string())
249            .or_default()
250            .push(action_name.to_string());
251    }
252
253    let mut emitted: HashSet<(String, String)> = HashSet::new();
254    for hook in &ast_graph.controller_dsl_hooks {
255        let Some(actions) = actions_by_container.get(&hook.container) else {
256            continue;
257        };
258
259        for action in actions {
260            let included = if let Some(only) = &hook.only {
261                only.iter().any(|name| name == action)
262            } else if let Some(except) = &hook.except {
263                !except.iter().any(|name| name == action)
264            } else {
265                true
266            };
267
268            if !included {
269                continue;
270            }
271
272            for callback in &hook.callbacks {
273                if callback.trim().is_empty() {
274                    continue;
275                }
276
277                let action_qname = format!("{}#{}", hook.container, action);
278                let callback_qname = format!("{}#{}", hook.container, callback);
279                if !emitted.insert((action_qname.clone(), callback_qname.clone())) {
280                    continue;
281                }
282
283                let action_id = helper.ensure_method(&action_qname, None, false, false);
284                let callback_id = helper.ensure_method(&callback_qname, None, false, false);
285                helper.add_call_edge_full_with_span(action_id, callback_id, 255, false, vec![]);
286            }
287        }
288    }
289}
290
291/// Internal implementation that tracks namespace context.
292#[allow(
293    clippy::too_many_lines,
294    reason = "Ruby graph extraction handles DSLs and FFI patterns in one traversal."
295)]
296fn walk_tree_for_graph_impl(
297    node: Node,
298    content: &[u8],
299    ast_graph: &ASTGraph,
300    helper: &mut sqry_core::graph::unified::GraphBuildHelper,
301    ffi_enabled_scopes: &HashSet<Vec<String>>,
302    current_namespace: &mut Vec<String>,
303) -> GraphResult<()> {
304    match node.kind() {
305        "class" => {
306            // Extract class name
307            if let Some(name_node) = node.child_by_field_name("name")
308                && let Ok(class_name) = name_node.utf8_text(content)
309            {
310                let span = span_from_points(node.start_position(), node.end_position());
311                let qualified_name = class_name.to_string();
312                let class_id = helper.add_class(&qualified_name, Some(span));
313
314                // Export all classes from the file module
315                // In Ruby, all classes are public by default and accessible from outside
316                let module_id = helper.add_module(FILE_MODULE_NAME, None);
317                helper.add_export_edge(module_id, class_id);
318
319                // Check for superclass (class Foo < Bar)
320                if let Some(superclass_node) = node.child_by_field_name("superclass")
321                    && let Ok(superclass_name) = superclass_node.utf8_text(content)
322                {
323                    let superclass_name = superclass_name.trim();
324                    if !superclass_name.is_empty() {
325                        // Create node for the parent class and add Inherits edge
326                        let parent_id = helper.add_class(superclass_name, None);
327                        helper.add_inherits_edge(class_id, parent_id);
328                    }
329                }
330
331                // Push class name to namespace for FFI scope tracking
332                current_namespace.push(class_name.trim().to_string());
333
334                // Recurse into children with updated namespace
335                let mut cursor = node.walk();
336                for child in node.children(&mut cursor) {
337                    walk_tree_for_graph_impl(
338                        child,
339                        content,
340                        ast_graph,
341                        helper,
342                        ffi_enabled_scopes,
343                        current_namespace,
344                    )?;
345                }
346
347                current_namespace.pop();
348                return Ok(());
349            }
350        }
351        "module" => {
352            // Extract module name
353            if let Some(name_node) = node.child_by_field_name("name")
354                && let Ok(module_name) = name_node.utf8_text(content)
355            {
356                let span = span_from_points(node.start_position(), node.end_position());
357                let qualified_name = module_name.to_string();
358                let mod_id = helper.add_module(&qualified_name, Some(span));
359
360                // Export all modules from the file module
361                // In Ruby, all modules are public by default and accessible from outside
362                let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
363                helper.add_export_edge(file_module_id, mod_id);
364
365                // Push module name to namespace for FFI scope tracking
366                current_namespace.push(module_name.trim().to_string());
367
368                // Recurse into children with updated namespace
369                let mut cursor = node.walk();
370                for child in node.children(&mut cursor) {
371                    walk_tree_for_graph_impl(
372                        child,
373                        content,
374                        ast_graph,
375                        helper,
376                        ffi_enabled_scopes,
377                        current_namespace,
378                    )?;
379                }
380
381                current_namespace.pop();
382                return Ok(());
383            }
384        }
385        "method" | "singleton_method" => {
386            // Extract method context from AST graph
387            if let Some(context) = ast_graph.context_for_node(&node) {
388                let span = span_from_points(context.start_position, context.end_position);
389
390                // Detect async patterns in Ruby (Fiber, Thread, async gem patterns)
391                let is_async = detect_async_method(node, content);
392
393                // Extract parameter signature
394                let params = node
395                    .child_by_field_name("parameters")
396                    .and_then(|params_node| extract_method_parameters(params_node, content));
397
398                // Extract return type from type annotations
399                let return_type = extract_return_type(node, content);
400
401                // Build complete signature: "params -> return_type" or just "params" or just "-> return_type"
402                let signature = match (params.as_ref(), return_type.as_ref()) {
403                    (Some(p), Some(r)) => Some(format!("{p} -> {r}")),
404                    (Some(p), None) => Some(p.clone()),
405                    (None, Some(r)) => Some(format!("-> {r}")),
406                    (None, None) => None,
407                };
408
409                // Get visibility from context
410                let visibility = context.visibility().as_str();
411
412                // Add method node with signature metadata
413                let method_id = helper.add_method_with_signature(
414                    context.qualified_name(),
415                    Some(span),
416                    is_async,
417                    context.is_singleton(),
418                    Some(visibility),
419                    signature.as_deref(),
420                );
421
422                // Export public methods from file module
423                // Private/protected methods should NOT be exported
424                if context.visibility() == Visibility::Public {
425                    let module_id = helper.add_module(FILE_MODULE_NAME, None);
426                    helper.add_export_edge(module_id, method_id);
427                }
428            }
429        }
430        "assignment" => {
431            // Handle constant assignments (CONSTANT = value)
432            if let Some(left_node) = node.child_by_field_name("left")
433                && left_node.kind() == "constant"
434                && let Ok(const_name) = left_node.utf8_text(content)
435            {
436                // Create qualified name with namespace
437                let qualified_name = if current_namespace.is_empty() {
438                    const_name.to_string()
439                } else {
440                    format!("{}::{}", current_namespace.join("::"), const_name)
441                };
442
443                let span = span_from_points(node.start_position(), node.end_position());
444                let const_id = helper.add_constant(&qualified_name, Some(span));
445
446                // Export public constants from file module
447                let module_id = helper.add_module(FILE_MODULE_NAME, None);
448                helper.add_export_edge(module_id, const_id);
449            }
450        }
451        "call" | "command" | "command_call" | "identifier" | "super" => {
452            // Check for include/extend statements (mixin pattern)
453            if is_include_or_extend_statement(node, content) {
454                handle_include_extend(node, content, helper, current_namespace);
455            }
456            // Ruby allows bare identifier statements like `validate` which can either be a local
457            // variable reference or an implicit receiver method call. We only attempt to treat
458            // identifiers as calls when they appear in statement position.
459            else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
460                // Not a standalone statement; avoid misclassifying identifiers inside expressions.
461            } else if is_require_statement(node, content) {
462                // Build import edge
463                if let Some((from_qname, to_qname)) =
464                    build_import_for_staging(node, content, helper.file_path())
465                {
466                    // Ensure both module nodes exist
467                    let from_id = helper.add_import(&from_qname, None);
468                    let to_id = helper.add_import(
469                        &to_qname,
470                        Some(span_from_points(node.start_position(), node.end_position())),
471                    );
472
473                    // Add import edge
474                    helper.add_import_edge(from_id, to_id);
475                }
476            } else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
477                // FFI attach_function call - create FfiCall edge
478                build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
479            } else {
480                // Build call edge
481                if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
482                    build_call_for_staging(ast_graph, node, content)
483                {
484                    // Ensure both nodes exist
485                    let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
486                    let target_id =
487                        helper.ensure_callee(&target_qname, span, CalleeKindHint::Function);
488
489                    // Add call edge
490                    let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
491                    helper.add_call_edge_full_with_span(
492                        source_id,
493                        target_id,
494                        argument_count,
495                        false,
496                        vec![span],
497                    );
498                }
499            }
500        }
501        _ => {}
502    }
503
504    // Recurse into children
505    let mut cursor = node.walk();
506    for child in node.children(&mut cursor) {
507        walk_tree_for_graph_impl(
508            child,
509            content,
510            ast_graph,
511            helper,
512            ffi_enabled_scopes,
513            current_namespace,
514        )?;
515    }
516
517    Ok(())
518}
519
520/// Check if a call is an FFI `attach_function` within an FFI-enabled scope.
521///
522/// Ruby FFI pattern:
523/// ```ruby
524/// module MyLib
525///   extend FFI::Library
526///   ffi_lib 'c'
527///   attach_function :puts, [:string], :int
528/// end
529/// ```
530fn is_ffi_attach_function(
531    node: Node,
532    content: &[u8],
533    ffi_enabled_scopes: &HashSet<Vec<String>>,
534    current_namespace: &[String],
535) -> bool {
536    // Extract method name
537    let method_name = match node.kind() {
538        "command" => node
539            .child_by_field_name("name")
540            .and_then(|n| n.utf8_text(content).ok()),
541        "call" | "command_call" => node
542            .child_by_field_name("method")
543            .and_then(|n| n.utf8_text(content).ok()),
544        _ => None,
545    };
546
547    let Some(method_name) = method_name else {
548        return false;
549    };
550    let method_name = method_name.trim();
551    if !matches!(
552        method_name,
553        "attach_function" | "attach_variable" | "ffi_lib" | "callback"
554    ) {
555        return false;
556    }
557
558    let receiver = match node.kind() {
559        "call" | "command_call" | "method_call" => node
560            .child_by_field_name("receiver")
561            .and_then(|n| n.utf8_text(content).ok()),
562        _ => None,
563    };
564    if let Some(receiver) = receiver {
565        let trimmed = receiver.trim();
566        if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
567            return true;
568        }
569    }
570
571    ffi_enabled_scopes.contains(current_namespace)
572}
573
574/// Build an `FfiCall` edge for an FFI `attach_function` call.
575///
576/// Extracts the Ruby method name and native function name from:
577/// `attach_function :ruby_name, :native_name, [:args], :return_type`
578/// or
579/// `attach_function :name, [:args], :return_type` (same name for both)
580fn build_ffi_edge_for_attach_function(
581    node: Node,
582    content: &[u8],
583    helper: &mut sqry_core::graph::unified::GraphBuildHelper,
584    current_namespace: &[String],
585) {
586    // Extract the function name from the first symbol argument
587    let arguments = node.child_by_field_name("arguments");
588
589    // For command nodes, arguments might be inline children
590    let func_name = if let Some(args) = arguments {
591        extract_first_symbol_from_arguments(args, content)
592    } else {
593        // Try to find symbol children directly (for command nodes)
594        let mut cursor = node.walk();
595        let mut found_name = false;
596        let mut result = None;
597        for child in node.children(&mut cursor) {
598            if !child.is_named() {
599                continue;
600            }
601            // Skip the method name itself
602            if !found_name {
603                found_name = true;
604                continue;
605            }
606            // First symbol after method name is the function name
607            if matches!(child.kind(), "symbol" | "simple_symbol")
608                && let Ok(text) = child.utf8_text(content)
609            {
610                result = Some(text.trim().trim_start_matches(':').to_string());
611                break;
612            }
613        }
614        result
615    };
616
617    let Some(func_name) = func_name else {
618        return;
619    };
620
621    // Build qualified caller name (the module containing the FFI binding)
622    let caller_name = if current_namespace.is_empty() {
623        "<module>".to_string()
624    } else {
625        current_namespace.join("::")
626    };
627
628    // Create caller node (the FFI module)
629    let caller_id = helper.add_module(&caller_name, None);
630
631    // Create FFI function node (the native function being bound)
632    let ffi_func_name = format!("ffi::{func_name}");
633    let span = span_from_points(node.start_position(), node.end_position());
634    let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
635
636    // Add FfiCall edge with C convention (Ruby FFI uses C ABI)
637    helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
638}
639
640/// Extract the first symbol from arguments (for `attach_function`).
641fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
642    let mut cursor = arguments.walk();
643    for child in arguments.children(&mut cursor) {
644        if matches!(child.kind(), "symbol" | "simple_symbol")
645            && let Ok(text) = child.utf8_text(content)
646        {
647            return Some(text.trim().trim_start_matches(':').to_string());
648        }
649        // Handle bare_symbol (just the identifier after :)
650        if child.kind() == "bare_symbol"
651            && let Ok(text) = child.utf8_text(content)
652        {
653            return Some(text.trim().to_string());
654        }
655    }
656    None
657}
658
659/// Build call edge information for the staging graph.
660fn build_call_for_staging(
661    ast_graph: &ASTGraph,
662    call_node: Node<'_>,
663    content: &[u8],
664) -> GraphResult<Option<CallEdgeData>> {
665    let Some(call_context) = ast_graph.context_for_node(&call_node) else {
666        return Ok(None);
667    };
668
669    let Some(method_call) = extract_method_call(call_node, content)? else {
670        return Ok(None);
671    };
672
673    if is_visibility_command(&method_call) {
674        return Ok(None);
675    }
676
677    let source_qualified = call_context.qualified_name().to_string();
678    let target_name = resolve_callee(&method_call, call_context);
679
680    if target_name.is_empty() {
681        return Ok(None);
682    }
683
684    let span = span_from_node(call_node);
685    let argument_count = count_arguments(method_call.arguments, content);
686    let is_singleton = call_context.is_singleton();
687
688    Ok(Some((
689        source_qualified,
690        target_name,
691        argument_count,
692        span,
693        is_singleton,
694    )))
695}
696
697/// Build import edge information for the staging graph.
698fn build_import_for_staging(
699    require_node: Node<'_>,
700    content: &[u8],
701    file_path: &str,
702) -> Option<(String, String)> {
703    // Extract the method name (require or require_relative)
704    let method_name = match require_node.kind() {
705        "command" => require_node
706            .child_by_field_name("name")
707            .and_then(|n| n.utf8_text(content).ok())
708            .map(|s| s.trim().to_string()),
709        "call" | "method_call" => require_node
710            .child_by_field_name("method")
711            .and_then(|n| n.utf8_text(content).ok())
712            .map(|s| s.trim().to_string()),
713        _ => None,
714    };
715
716    let method_name = method_name?;
717
718    // Only handle require and require_relative
719    if !matches!(method_name.as_str(), "require" | "require_relative") {
720        return None;
721    }
722
723    // Extract the module name from arguments
724    let arguments = require_node.child_by_field_name("arguments");
725    let module_name = if let Some(args) = arguments {
726        extract_require_module_name(args, content)
727    } else {
728        // For command nodes, the first child after the method name is the argument
729        let mut cursor = require_node.walk();
730        let mut found_name = false;
731        let mut result = None;
732        for child in require_node.children(&mut cursor) {
733            if !child.is_named() {
734                continue;
735            }
736            if !found_name {
737                found_name = true;
738                continue;
739            }
740            // This is the argument (string node)
741            result = extract_string_content(child, content);
742            break;
743        }
744        result
745    };
746
747    let module_name = module_name?;
748
749    if module_name.is_empty() {
750        return None;
751    }
752
753    // Resolve the import path to a canonical module identifier
754    let is_relative = method_name == "require_relative";
755    let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
756
757    // Return from/to qualified names
758    Some(("<module>".to_string(), resolved_path))
759}
760
761fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
762    node.kind() == "identifier"
763        && node
764            .parent()
765            .is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
766}
767
768/// Detect async patterns in Ruby methods (best-effort detection).
769///
770/// Ruby doesn't have native async/await, but uses patterns like:
771/// - `Fiber` class for coroutines
772/// - `Thread` class for threading
773/// - `async` gem patterns (`Async do ... end`)
774/// - `concurrent-ruby` patterns
775///
776/// This is a heuristic check looking for async-related keywords in method body.
777fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
778    // Get method body
779    let body_node = method_node.child_by_field_name("body");
780    if body_node.is_none() {
781        return false;
782    }
783    let body_node = body_node.unwrap();
784
785    // Convert body to text and look for async patterns
786    if let Ok(body_text) = body_node.utf8_text(content) {
787        let body_lower = body_text.to_lowercase();
788
789        // Check for common async patterns
790        if body_lower.contains("fiber.")
791            || body_lower.contains("fiber.new")
792            || body_lower.contains("fiber.yield")
793            || body_lower.contains("fiber.resume")
794            || body_lower.contains("thread.new")
795            || body_lower.contains("thread.start")
796            || body_lower.contains("async do")
797            || body_lower.contains("async {")
798            || body_lower.contains("async.reactor")
799            || body_lower.contains("concurrent::")
800        {
801            return true;
802        }
803    }
804
805    false
806}
807
808/// Check if a Ruby call node is an `include` or `extend` statement (mixin pattern).
809fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
810    let method_name = match node.kind() {
811        "command" => node
812            .child_by_field_name("name")
813            .and_then(|n| n.utf8_text(content).ok()),
814        "call" | "method_call" => node
815            .child_by_field_name("method")
816            .and_then(|n| n.utf8_text(content).ok()),
817        _ => None,
818    };
819
820    method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
821}
822
823/// Handle `include` or `extend` statements to create Implements edges.
824///
825/// Ruby mixins work as follows:
826/// - `include ModuleName`: Instance methods from module become instance methods
827/// - `extend ModuleName`: Instance methods from module become class methods
828///
829/// Both are represented as Implements edges from the class to the module.
830fn handle_include_extend(
831    node: Node<'_>,
832    content: &[u8],
833    helper: &mut sqry_core::graph::unified::GraphBuildHelper,
834    current_namespace: &[String],
835) {
836    // Extract the module name from arguments
837    let module_name = if let Some(args) = node.child_by_field_name("arguments") {
838        extract_first_constant_from_arguments(args, content)
839    } else if node.kind() == "command" {
840        // For command nodes, the first named child after the method name is the module
841        let mut cursor = node.walk();
842        let mut found_method = false;
843        let mut result = None;
844        for child in node.children(&mut cursor) {
845            if !child.is_named() {
846                continue;
847            }
848            // Skip the method name itself
849            if !found_method {
850                found_method = true;
851                continue;
852            }
853            // First constant after method name is the module name
854            if child.kind() == "constant"
855                && let Ok(text) = child.utf8_text(content)
856            {
857                result = Some(text.trim().to_string());
858                break;
859            }
860        }
861        result
862    } else {
863        None
864    };
865
866    let Some(module_name) = module_name else {
867        return;
868    };
869
870    // Build qualified class name (the class doing the include/extend)
871    let class_name = if current_namespace.is_empty() {
872        return; // Can't include/extend outside a class
873    } else {
874        current_namespace.join("::")
875    };
876
877    // Create nodes
878    let class_id = helper.add_class(&class_name, None);
879    let module_id = helper.add_module(&module_name, None);
880
881    // Add Implements edge (class implements module)
882    helper.add_implements_edge(class_id, module_id);
883}
884
885/// Extract the first constant from an argument list.
886fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
887    let mut cursor = args_node.walk();
888    for child in args_node.children(&mut cursor) {
889        if !child.is_named() {
890            continue;
891        }
892        // Look for constant nodes
893        if child.kind() == "constant"
894            && let Ok(text) = child.utf8_text(content)
895        {
896            return Some(text.trim().to_string());
897        }
898    }
899    None
900}
901
902/// Check if a node is a `require/require_relative` statement.
903fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
904    let method_name = match node.kind() {
905        "command" => node
906            .child_by_field_name("name")
907            .and_then(|n| n.utf8_text(content).ok()),
908        "call" | "method_call" => node
909            .child_by_field_name("method")
910            .and_then(|n| n.utf8_text(content).ok()),
911        _ => None,
912    };
913
914    method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
915}
916
917struct ContextBuilder<'a> {
918    contexts: Vec<RubyContext>,
919    node_to_context: HashMap<usize, usize>,
920    namespace: Vec<String>,
921    visibility_stack: Vec<Visibility>,
922    ffi_enabled_scopes: HashSet<Vec<String>>,
923    controller_dsl_hooks: Vec<ControllerDslHook>,
924    max_depth: usize,
925    content: &'a [u8],
926    guard: sqry_core::query::security::RecursionGuard,
927}
928
929impl<'a> ContextBuilder<'a> {
930    fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
931        let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
932            .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
933        let file_ops_depth = recursion_limits
934            .effective_file_ops_depth()
935            .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
936        let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
937            .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
938
939        Ok(Self {
940            contexts: Vec::new(),
941            node_to_context: HashMap::new(),
942            namespace: Vec::new(),
943            visibility_stack: vec![Visibility::Public],
944            ffi_enabled_scopes: HashSet::new(),
945            controller_dsl_hooks: Vec::new(),
946            max_depth,
947            content,
948            guard,
949        })
950    }
951
952    /// # Errors
953    ///
954    /// Returns error if recursion depth exceeds the guard's limit.
955    fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
956        self.guard
957            .enter()
958            .map_err(|e| format!("Recursion limit exceeded: {e}"))?;
959
960        match node.kind() {
961            "class" => self.visit_class(node)?,
962            "module" => self.visit_module(node)?,
963            "singleton_class" => self.visit_singleton_class(node)?,
964            "method" => self.visit_method(node)?,
965            "singleton_method" => self.visit_singleton_method(node)?,
966            "command" | "command_call" | "call" => {
967                self.detect_ffi_extend(node)?;
968                self.detect_controller_dsl(node)?;
969                self.adjust_visibility(node)?;
970                self.walk_children(node)?;
971            }
972            "identifier" => {
973                // Bare identifiers like `private`, `protected`, `public` at statement level
974                // can adjust visibility scope
975                self.adjust_visibility_from_identifier(node)?;
976                self.walk_children(node)?;
977            }
978            _ => self.walk_children(node)?,
979        }
980
981        self.guard.exit();
982        Ok(())
983    }
984
985    fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
986        let name_node = node
987            .child_by_field_name("name")
988            .ok_or_else(|| "class node missing name".to_string())?;
989        let class_name = self.node_text(name_node)?;
990
991        if self.namespace.len() > self.max_depth {
992            return Ok(());
993        }
994
995        self.namespace.push(class_name);
996        self.visibility_stack.push(Visibility::Public);
997
998        self.walk_children(node)?;
999
1000        self.visibility_stack.pop();
1001        self.namespace.pop();
1002        Ok(())
1003    }
1004
1005    fn visit_module(&mut self, node: Node<'a>) -> Result<(), String> {
1006        let name_node = node
1007            .child_by_field_name("name")
1008            .ok_or_else(|| "module node missing name".to_string())?;
1009        let module_name = self.node_text(name_node)?;
1010
1011        if self.namespace.len() > self.max_depth {
1012            return Ok(());
1013        }
1014
1015        self.namespace.push(module_name);
1016        self.visibility_stack.push(Visibility::Public);
1017
1018        self.walk_children(node)?;
1019
1020        self.visibility_stack.pop();
1021        self.namespace.pop();
1022        Ok(())
1023    }
1024
1025    fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
1026        let name_node = node
1027            .child_by_field_name("name")
1028            .ok_or_else(|| "method node missing name".to_string())?;
1029        let method_name = self.node_text(name_node)?;
1030
1031        let (qualified_name, container) =
1032            method_qualified_name(&self.namespace, &method_name, false);
1033
1034        let visibility = inline_visibility_for_method(node, self.content)
1035            .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1036
1037        let context = RubyContext {
1038            qualified_name,
1039            container,
1040            kind: RubyContextKind::Method,
1041            visibility,
1042            start_position: node.start_position(),
1043            end_position: node.end_position(),
1044        };
1045
1046        let idx = self.contexts.len();
1047        self.contexts.push(context);
1048        associate_descendants(node, idx, &mut self.node_to_context);
1049
1050        self.walk_children(node)?;
1051        Ok(())
1052    }
1053
1054    fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
1055        // Extract the object: class << self, class << MyClass, etc.
1056        let value_node = node
1057            .child_by_field_name("value")
1058            .ok_or_else(|| "singleton_class missing value".to_string())?;
1059        let object_text = self.node_text(value_node)?;
1060
1061        // Determine the scope name for methods in this singleton class
1062        let scope_name = if object_text == "self" {
1063            // class << self inside Foo → methods are Foo.method
1064            if let Some(current_class) = self.namespace.last() {
1065                format!("<<{current_class}>>")
1066            } else {
1067                "<<main>>".to_string()
1068            }
1069        } else {
1070            // class << SomeClass → methods are SomeClass.method
1071            format!("<<{object_text}>>")
1072        };
1073
1074        if self.namespace.len() > self.max_depth {
1075            return Ok(());
1076        }
1077
1078        // Push the singleton scope
1079        self.namespace.push(scope_name);
1080        self.visibility_stack.push(Visibility::Public);
1081
1082        // Walk children, converting methods to singleton methods
1083        self.visit_singleton_class_body(node)?;
1084
1085        // Pop the singleton scope
1086        self.visibility_stack.pop();
1087        self.namespace.pop();
1088        Ok(())
1089    }
1090
1091    fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
1092        let mut cursor = node.walk();
1093        for child in node.children(&mut cursor) {
1094            if !child.is_named() {
1095                continue;
1096            }
1097
1098            // Methods inside singleton_class are automatically singleton methods
1099            if child.kind() == "method" {
1100                self.visit_method_as_singleton(child)?;
1101            } else {
1102                self.walk(child)?;
1103            }
1104        }
1105        Ok(())
1106    }
1107
1108    fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
1109        let name_node = node
1110            .child_by_field_name("name")
1111            .ok_or_else(|| "method node missing name".to_string())?;
1112        let method_name = self.node_text(name_node)?;
1113
1114        // Build as singleton method - strip the <<...>> wrapper from namespace
1115        let actual_namespace: Vec<String> = self
1116            .namespace
1117            .iter()
1118            .map(|s| {
1119                if s.starts_with("<<") && s.ends_with(">>") {
1120                    // Extract the class name from <<ClassName>>
1121                    s[2..s.len() - 2].to_string()
1122                } else {
1123                    s.clone()
1124                }
1125            })
1126            .collect();
1127
1128        let (qualified_name, container) =
1129            method_qualified_name(&actual_namespace, &method_name, true);
1130
1131        let visibility = inline_visibility_for_method(node, self.content)
1132            .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1133
1134        let context = RubyContext {
1135            qualified_name,
1136            container,
1137            kind: RubyContextKind::SingletonMethod,
1138            visibility,
1139            start_position: node.start_position(),
1140            end_position: node.end_position(),
1141        };
1142
1143        let idx = self.contexts.len();
1144        self.contexts.push(context);
1145        associate_descendants(node, idx, &mut self.node_to_context);
1146
1147        self.walk_children(node)?;
1148        Ok(())
1149    }
1150
1151    fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
1152        let name_node = node
1153            .child_by_field_name("name")
1154            .ok_or_else(|| "singleton_method missing name".to_string())?;
1155        let method_name = self.node_text(name_node)?;
1156
1157        let object_node = node
1158            .child_by_field_name("object")
1159            .ok_or_else(|| "singleton_method missing object".to_string())?;
1160        let object_text = self.node_text(object_node)?;
1161
1162        let (qualified_name, container) =
1163            singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
1164
1165        let visibility = inline_visibility_for_method(node, self.content)
1166            .unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
1167
1168        let context = RubyContext {
1169            qualified_name,
1170            container,
1171            kind: RubyContextKind::SingletonMethod,
1172            visibility,
1173            start_position: node.start_position(),
1174            end_position: node.end_position(),
1175        };
1176
1177        let idx = self.contexts.len();
1178        self.contexts.push(context);
1179        associate_descendants(node, idx, &mut self.node_to_context);
1180
1181        self.walk_children(node)?;
1182        Ok(())
1183    }
1184
1185    fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
1186        let name_node = node.child_by_field_name("name");
1187        let Some(name_node) = name_node else {
1188            return Ok(());
1189        };
1190
1191        let keyword = self.node_text(name_node)?;
1192        if keyword.trim() != "extend" {
1193            return Ok(());
1194        }
1195
1196        let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
1197            node_text_raw(arguments, self.content).unwrap_or_default()
1198        } else {
1199            let mut cursor = node.walk();
1200            let mut found_name = false;
1201            let mut result = String::new();
1202            for child in node.children(&mut cursor) {
1203                if !child.is_named() {
1204                    continue;
1205                }
1206                if !found_name {
1207                    found_name = true;
1208                    continue;
1209                }
1210                if let Some(text) = node_text_raw(child, self.content) {
1211                    result = text;
1212                    break;
1213                }
1214            }
1215            result
1216        };
1217
1218        if arg_text.contains("FFI::Library") {
1219            // Mark current scope as FFI-enabled
1220            self.ffi_enabled_scopes.insert(self.namespace.clone());
1221        }
1222
1223        Ok(())
1224    }
1225
1226    fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
1227        let name_node = node
1228            .child_by_field_name("name")
1229            .or_else(|| node.child_by_field_name("method"));
1230        let Some(name_node) = name_node else {
1231            return Ok(());
1232        };
1233        let dsl = self.node_text(name_node)?;
1234
1235        let kind = match dsl.as_str() {
1236            "before_action" => Some(ControllerDslKind::Before),
1237            "after_action" => Some(ControllerDslKind::After),
1238            "around_action" => Some(ControllerDslKind::Around),
1239            _ => None,
1240        };
1241        let Some(kind) = kind else {
1242            return Ok(());
1243        };
1244
1245        if self.namespace.is_empty() {
1246            return Ok(());
1247        }
1248        let container = self.namespace.join("::");
1249
1250        let mut callbacks: Vec<String> = Vec::new();
1251        let mut only: Option<Vec<String>> = None;
1252        let mut except: Option<Vec<String>> = None;
1253
1254        if let Some(arguments) = node.child_by_field_name("arguments") {
1255            let mut cursor = arguments.walk();
1256            for child in arguments.children(&mut cursor) {
1257                if !child.is_named() {
1258                    continue;
1259                }
1260                let kind = child.kind();
1261                match kind {
1262                    "symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
1263                        let mut v = extract_symbols_from_node(child, self.content);
1264                        callbacks.append(&mut v);
1265                    }
1266                    "pair" => {
1267                        // Handle direct pair node (only: [...] or except: [...])
1268                        let key = child.child_by_field_name("key");
1269                        let val = child.child_by_field_name("value");
1270                        if key.is_none() || val.is_none() {
1271                            continue;
1272                        }
1273                        let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1274                        let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1275                        if key_text.contains("only") && !symbols.is_empty() {
1276                            only = Some(symbols);
1277                        } else if key_text.contains("except") && !symbols.is_empty() {
1278                            except = Some(symbols);
1279                        }
1280                    }
1281                    "hash" => {
1282                        // Parse pairs like only: [:new, :create]
1283                        let mut hcur = child.walk();
1284                        for pair in child.children(&mut hcur) {
1285                            if !pair.is_named() {
1286                                continue;
1287                            }
1288                            if pair.kind() != "pair" {
1289                                continue;
1290                            }
1291                            let key = pair.child_by_field_name("key");
1292                            let val = pair.child_by_field_name("value");
1293                            if key.is_none() || val.is_none() {
1294                                continue;
1295                            }
1296                            let key_text = self.node_text(key.unwrap()).unwrap_or_default();
1297                            let symbols = extract_symbols_from_node(val.unwrap(), self.content);
1298                            if key_text.contains("only") && !symbols.is_empty() {
1299                                only = Some(symbols);
1300                            } else if key_text.contains("except") && !symbols.is_empty() {
1301                                except = Some(symbols);
1302                            }
1303                        }
1304                    }
1305                    _ => {}
1306                }
1307            }
1308        } else {
1309            // Fallback: parse from raw node text
1310            if let Some(raw) = node_text_raw(node, self.content) {
1311                let (cbs, o, e) = parse_controller_dsl_args(&raw);
1312                callbacks = cbs;
1313                only = o;
1314                except = e;
1315            }
1316        }
1317
1318        if callbacks.is_empty() {
1319            return Ok(());
1320        }
1321
1322        self.controller_dsl_hooks.push(ControllerDslHook {
1323            container,
1324            kind,
1325            callbacks,
1326            only,
1327            except,
1328        });
1329        Ok(())
1330    }
1331
1332    fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
1333        let name_node = node.child_by_field_name("name");
1334        let Some(name_node) = name_node else {
1335            return Ok(());
1336        };
1337
1338        let keyword = self.node_text(name_node)?;
1339        let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1340            return Ok(());
1341        };
1342
1343        // Only adjust default visibility when command has no arguments.
1344        if !has_call_arguments(node)
1345            && let Some(last) = self.visibility_stack.last_mut()
1346        {
1347            *last = new_visibility;
1348        }
1349        Ok(())
1350    }
1351
1352    /// Handle bare identifiers that can be visibility keywords (private, protected, public)
1353    fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
1354        let keyword = self.node_text(node)?;
1355        let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
1356            return Ok(());
1357        };
1358
1359        // Bare identifier as statement adjusts visibility for following methods
1360        if let Some(last) = self.visibility_stack.last_mut() {
1361            *last = new_visibility;
1362        }
1363
1364        Ok(())
1365    }
1366
1367    fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
1368        let mut cursor = node.walk();
1369        for child in node.children(&mut cursor) {
1370            if child.is_named() {
1371                self.walk(child)?;
1372            }
1373        }
1374        Ok(())
1375    }
1376
1377    fn node_text(&self, node: Node<'a>) -> Result<String, String> {
1378        node.utf8_text(self.content)
1379            .map(|s| s.trim().to_string())
1380            .map_err(|err| err.to_string())
1381    }
1382}
1383
1384#[derive(Clone)]
1385struct MethodCall<'a> {
1386    name: String,
1387    receiver: Option<String>,
1388    arguments: Option<Node<'a>>,
1389    node: Node<'a>,
1390}
1391
1392fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
1393    let method_name = match node.kind() {
1394        "call" | "command_call" | "method_call" => {
1395            let method_node = node
1396                .child_by_field_name("method")
1397                .ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
1398            node_text(method_node, content)?
1399        }
1400        "command" => {
1401            let name_node = node
1402                .child_by_field_name("name")
1403                .ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
1404            node_text(name_node, content)?
1405        }
1406        "super" => "super".to_string(),
1407        "identifier" => {
1408            if !should_treat_identifier_as_call(node) {
1409                return Ok(None);
1410            }
1411            node_text(node, content)?
1412        }
1413        _ => return Ok(None),
1414    };
1415
1416    let receiver = match node.kind() {
1417        "call" | "command_call" | "method_call" => node
1418            .child_by_field_name("receiver")
1419            .and_then(|r| node_text(r, content).ok()),
1420        _ => None,
1421    };
1422
1423    let arguments = node.child_by_field_name("arguments");
1424
1425    Ok(Some(MethodCall {
1426        name: method_name,
1427        receiver,
1428        arguments,
1429        node,
1430    }))
1431}
1432
1433fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
1434    if let Some(parent) = node.parent() {
1435        let kind = parent.kind();
1436        if matches!(
1437            kind,
1438            "call"
1439                | "command"
1440                | "command_call"
1441                | "method_call"
1442                | "method"
1443                | "singleton_method"
1444                | "alias"
1445                | "symbol"
1446        ) {
1447            return false;
1448        }
1449
1450        if kind.contains("assignment")
1451            || matches!(
1452                kind,
1453                "parameters"
1454                    | "method_parameters"
1455                    | "block_parameters"
1456                    | "lambda_parameters"
1457                    | "constant_path"
1458                    | "module"
1459                    | "class"
1460                    | "hash"
1461                    | "pair"
1462                    | "array"
1463                    | "argument_list"
1464            )
1465        {
1466            return false;
1467        }
1468    }
1469
1470    true
1471}
1472
1473/// Resolves the fully-qualified name of a method call's target (callee).
1474///
1475/// Handles different receiver patterns:
1476/// - `self.method` → qualified with current container
1477/// - `Constant.method` → qualified with constant name
1478/// - Bare `method` → qualified based on context (instance vs singleton)
1479///
1480/// # Arguments
1481/// * `method_call` - The extracted method call information
1482/// * `context` - The containing Ruby context (method, class, etc.)
1483///
1484/// # Returns
1485/// Fully-qualified callee name, or bare method name if no context available
1486fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
1487    let name = method_call.name.trim();
1488    if name.is_empty() {
1489        return String::new();
1490    }
1491
1492    // Special handling for inheritance super calls
1493    if name == "super" {
1494        // Use current method's qualified name as the target of the super call
1495        // This acts as a placeholder that downstream processors can resolve
1496        // to the actual parent implementation when available.
1497        return format!("super::{}", context.qualified_name());
1498    }
1499
1500    if let Some(receiver) = method_call.receiver.as_deref() {
1501        let receiver = receiver.trim();
1502        if receiver == "self" {
1503            if let Some(container) = context.container() {
1504                return format!("{container}.{name}");
1505            }
1506            return format!("self.{name}");
1507        }
1508
1509        if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
1510            let cleaned = receiver.trim_start_matches("::");
1511            // Handle Class.new.method pattern → Class#method (instance method)
1512            if let Some(class_name) = cleaned.strip_suffix(".new") {
1513                return format!("{class_name}#{name}");
1514            }
1515            return format!("{cleaned}.{name}");
1516        }
1517
1518        // Instance variable or expression receiver - fall back to method name.
1519        return name.to_string();
1520    }
1521
1522    if context.is_singleton() {
1523        if let Some(container) = context.container() {
1524            return format!("{container}.{name}");
1525        }
1526        return name.to_string();
1527    }
1528
1529    if let Some(container) = context.container() {
1530        return format!("{container}#{name}");
1531    }
1532
1533    name.to_string()
1534}
1535
1536/// Counts the number of actual arguments in a method call.
1537///
1538/// Filters out delimiters (parentheses, commas) and empty nodes to count
1539/// only meaningful arguments.
1540///
1541/// # Arguments
1542/// * `arguments` - Optional `argument_list` AST node
1543/// * `content` - Source file bytes for text extraction
1544///
1545/// # Returns
1546/// Number of non-empty, non-delimiter arguments
1547fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
1548    let Some(arguments) = arguments else {
1549        return 0;
1550    };
1551
1552    let mut count = 0;
1553    let mut cursor = arguments.walk();
1554    for child in arguments.children(&mut cursor) {
1555        if child.is_named()
1556            && !is_literal_delimiter(child.kind())
1557            && node_text(child, content)
1558                .map(|s| !s.trim().is_empty())
1559                .unwrap_or(false)
1560        {
1561            count += 1;
1562        }
1563    }
1564    count
1565}
1566
1567/// Associates all descendant AST nodes with a context index.
1568///
1569/// Performs a depth-first traversal to map every child node ID to the
1570/// parent context (method, class, etc.). This enables fast context lookup
1571/// during call edge extraction.
1572///
1573/// # Arguments
1574/// * `node` - Root AST node to traverse
1575/// * `idx` - Context index to associate with all descendants
1576/// * `map` - Mutable `node_id` → `context_index` map
1577fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
1578    let mut stack = vec![node];
1579    while let Some(current) = stack.pop() {
1580        map.insert(current.id(), idx);
1581        let mut cursor = current.walk();
1582        for child in current.children(&mut cursor) {
1583            stack.push(child);
1584        }
1585    }
1586}
1587
1588/// Builds a fully-qualified name for an instance or singleton method.
1589///
1590/// Ruby naming conventions:
1591/// - Instance methods: `Class#method`
1592/// - Singleton methods: `Class.method`
1593///
1594/// # Arguments
1595/// * `namespace` - Stack of containing modules/classes (e.g., `["Module", "Class"]`)
1596/// * `method_name` - Base method name
1597/// * `singleton` - Whether this is a singleton (class) method
1598///
1599/// # Returns
1600/// Tuple of (`qualified_name`, `optional_container`)
1601fn method_qualified_name(
1602    namespace: &[String],
1603    method_name: &str,
1604    singleton: bool,
1605) -> (String, Option<String>) {
1606    if namespace.is_empty() {
1607        return (method_name.to_string(), None);
1608    }
1609
1610    let container = namespace.join("::");
1611    let qualified = if singleton {
1612        format!("{container}.{method_name}")
1613    } else {
1614        format!("{container}#{method_name}")
1615    };
1616    (qualified, Some(container))
1617}
1618
1619/// Builds a qualified name for a singleton method definition.
1620///
1621/// Handles both `def self.method` and `def SomeClass.method` patterns.
1622/// Resolves `self` relative to the current namespace.
1623///
1624/// # Arguments
1625/// * `current_namespace` - Current nesting context (modules/classes)
1626/// * `object_text` - The receiver text ("self" or a constant path)
1627/// * `method_name` - Base method name
1628///
1629/// # Returns
1630/// Tuple of (`qualified_name`, `optional_container`)
1631fn singleton_qualified_name(
1632    current_namespace: &[String],
1633    object_text: &str,
1634    method_name: &str,
1635) -> (String, Option<String>) {
1636    if object_text == "self" {
1637        if current_namespace.is_empty() {
1638            (method_name.to_string(), None)
1639        } else {
1640            let container = current_namespace.join("::");
1641            (format!("{container}.{method_name}"), Some(container))
1642        }
1643    } else {
1644        let parts = split_constant_path(object_text);
1645        if parts.is_empty() {
1646            (method_name.to_string(), None)
1647        } else {
1648            let container = parts.join("::");
1649            (format!("{container}.{method_name}"), Some(container))
1650        }
1651    }
1652}
1653
1654/// Splits a Ruby constant path into individual segments.
1655///
1656/// Handles leading `::` for absolute paths and filters empty segments.
1657///
1658/// # Examples
1659/// - `"::Module::Class"` → `["Module", "Class"]`
1660/// - `"Foo::Bar"` → `["Foo", "Bar"]`
1661///
1662/// # Arguments
1663/// * `path` - Constant path string (e.g., "`Module::Class`")
1664///
1665/// # Returns
1666/// Vector of non-empty path segments
1667fn split_constant_path(path: &str) -> Vec<String> {
1668    path.trim()
1669        .trim_start_matches("::")
1670        .split("::")
1671        .filter_map(|seg| {
1672            let trimmed = seg.trim();
1673            if trimmed.is_empty() {
1674                None
1675            } else {
1676                Some(trimmed.to_string())
1677            }
1678        })
1679        .collect()
1680}
1681
1682/// Checks if a string represents a Ruby constant (starts with uppercase).
1683///
1684/// Ruby constants must begin with an uppercase ASCII letter.
1685///
1686/// # Arguments
1687/// * `text` - String to test
1688///
1689/// # Returns
1690/// true if text starts with uppercase letter, false otherwise
1691fn is_constant(text: &str) -> bool {
1692    text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1693}
1694
1695/// Detects visibility modifier commands without arguments.
1696///
1697/// Bare `private`, `public`, or `protected` calls (without method names)
1698/// are visibility scope changes, not method calls to track as edges.
1699///
1700/// # Arguments
1701/// * `method_call` - Extracted method call
1702///
1703/// # Returns
1704/// true if this is a visibility scope change command
1705fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
1706    matches!(
1707        method_call.name.as_str(),
1708        "public" | "private" | "protected"
1709    ) && method_call.receiver.is_none()
1710        && !has_call_arguments(method_call.node)
1711}
1712
1713/// Checks if a call/command node has any arguments.
1714///
1715/// Used to distinguish `private` (scope change) from `private :method_name` (method-level).
1716///
1717/// # Arguments
1718/// * `node` - AST node to check
1719///
1720/// # Returns
1721/// true if node has named argument children
1722fn has_call_arguments(node: Node<'_>) -> bool {
1723    if let Some(arguments) = node.child_by_field_name("arguments") {
1724        let mut cursor = arguments.walk();
1725        for child in arguments.children(&mut cursor) {
1726            if child.is_named() {
1727                return true;
1728            }
1729        }
1730    }
1731    false
1732}
1733
1734fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
1735    let parent = node.parent()?;
1736    let visibility_node = match parent.kind() {
1737        "call" | "command" | "command_call" => parent,
1738        "argument_list" => parent.parent()?,
1739        _ => return None,
1740    };
1741
1742    if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
1743        return None;
1744    }
1745
1746    let keyword_node = visibility_node
1747        .child_by_field_name("name")
1748        .or_else(|| visibility_node.child_by_field_name("method"))?;
1749    let keyword = node_text_raw(keyword_node, content)?;
1750    Visibility::from_keyword(keyword.trim())
1751}
1752
1753/// Extracts UTF-8 text for an AST node with error handling.
1754///
1755/// Trims whitespace and converts UTF-8 errors to `GraphBuilderError`.
1756///
1757/// # Arguments
1758/// * `node` - AST node to extract text from
1759/// * `content` - Source file bytes
1760///
1761/// # Returns
1762/// Trimmed text content or error if UTF-8 decoding fails
1763fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
1764    node.utf8_text(content)
1765        .map(|s| s.trim().to_string())
1766        .map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
1767}
1768
1769/// Raw text extraction without `GraphBuilderError` conversion
1770fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
1771    node.utf8_text(content)
1772        .ok()
1773        .map(std::string::ToString::to_string)
1774}
1775
1776/// Creates a `GraphBuilderError::ParseError` with span information.
1777///
1778/// # Arguments
1779/// * `node` - AST node where error occurred
1780/// * `reason` - Human-readable error description
1781///
1782/// # Returns
1783/// `ParseError` with node's span and reason message
1784fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
1785    GraphBuilderError::ParseError {
1786        span: span_from_node(node),
1787        reason: reason.to_string(),
1788    }
1789}
1790
1791/// Extract parameter signature from `method_parameters` node.
1792///
1793/// Handles all Ruby parameter types:
1794/// - Simple: `x`
1795/// - Optional: `x = 10`
1796/// - Splat: `*args`
1797/// - Keyword: `x:`, `x: 10`
1798/// - Hash splat: `**kwargs`
1799/// - Block: `&block`
1800///
1801/// # Arguments
1802/// * `params_node` - The `method_parameters` AST node
1803/// * `content` - Source file content
1804///
1805/// # Returns
1806/// Comma-separated parameter string, or None if no parameters
1807#[allow(clippy::match_same_arms)]
1808fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
1809    let mut params = Vec::new();
1810    let mut cursor = params_node.walk();
1811
1812    for child in params_node.named_children(&mut cursor) {
1813        match child.kind() {
1814            // Simple parameter: def foo(x)
1815            // Optional parameter: def foo(x = 10)
1816            "identifier" | "optional_parameter" => {
1817                if let Ok(text) = child.utf8_text(content) {
1818                    params.push(text.to_string());
1819                }
1820            }
1821            // Splat: def foo(*args)
1822            "splat_parameter" => {
1823                if let Some(name_node) = child.child_by_field_name("name") {
1824                    if let Ok(name) = name_node.utf8_text(content) {
1825                        params.push(format!("*{name}"));
1826                    }
1827                } else if let Ok(text) = child.utf8_text(content) {
1828                    // Fallback: use full text if no name field
1829                    params.push(text.to_string());
1830                }
1831            }
1832            // Hash splat: def foo(**kwargs)
1833            "hash_splat_parameter" => {
1834                if let Some(name_node) = child.child_by_field_name("name") {
1835                    if let Ok(name) = name_node.utf8_text(content) {
1836                        params.push(format!("**{name}"));
1837                    }
1838                } else if let Ok(text) = child.utf8_text(content) {
1839                    // Fallback: use full text if no name field
1840                    params.push(text.to_string());
1841                }
1842            }
1843            // Block: def foo(&block)
1844            "block_parameter" => {
1845                if let Some(name_node) = child.child_by_field_name("name") {
1846                    if let Ok(name) = name_node.utf8_text(content) {
1847                        params.push(format!("&{name}"));
1848                    }
1849                } else if let Ok(text) = child.utf8_text(content) {
1850                    // Fallback: use full text if no name field
1851                    params.push(text.to_string());
1852                }
1853            }
1854            // Keyword parameter: def foo(x:, y: 10)
1855            "keyword_parameter" => {
1856                if let Ok(text) = child.utf8_text(content) {
1857                    params.push(text.to_string());
1858                }
1859            }
1860            // Destructured parameter: def foo((a, b))
1861            "destructured_parameter" => {
1862                if let Ok(text) = child.utf8_text(content) {
1863                    params.push(text.to_string());
1864                }
1865            }
1866            // Forward parameter: def foo(...)
1867            "forward_parameter" => {
1868                params.push("...".to_string());
1869            }
1870            // Hash splat nil: def foo(**nil)
1871            "hash_splat_nil" => {
1872                params.push("**nil".to_string());
1873            }
1874            _ => {
1875                // Ignore other node types (e.g., punctuation)
1876            }
1877        }
1878    }
1879
1880    if params.is_empty() {
1881        None
1882    } else {
1883        Some(params.join(", "))
1884    }
1885}
1886
1887/// Extract return type from method definition.
1888///
1889/// Attempts to parse return type annotations from:
1890/// 1. Sorbet sig blocks: `sig { returns(Type) }`
1891/// 2. RBS inline comments: `#: (...) -> Type`
1892/// 3. YARD documentation: `@return [Type]`
1893///
1894/// # Arguments
1895/// * `method_node` - The method definition AST node
1896/// * `content` - Source file content
1897///
1898/// # Returns
1899/// Return type string if found, None otherwise
1900fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1901    // Try Sorbet first
1902    if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
1903        return Some(return_type);
1904    }
1905
1906    // Try RBS inline comment
1907    if let Some(return_type) = extract_rbs_return_type(method_node, content) {
1908        return Some(return_type);
1909    }
1910
1911    // Try YARD documentation
1912    if let Some(return_type) = extract_yard_return_type(method_node, content) {
1913        return Some(return_type);
1914    }
1915
1916    None
1917}
1918
1919/// Extract return type from Sorbet sig block.
1920///
1921/// Looks for `sig { returns(Type) }` before method definition.
1922///
1923/// # Arguments
1924/// * `method_node` - The method definition AST node
1925/// * `content` - Source file content
1926///
1927/// # Returns
1928/// Return type if found in sig block
1929fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1930    // Look for previous sibling that is a call to 'sig'
1931    let mut sibling = method_node.prev_sibling()?;
1932
1933    // Skip comments and whitespace
1934    while sibling.kind() == "comment" {
1935        sibling = sibling.prev_sibling()?;
1936    }
1937
1938    // Check if this is a sig call
1939    if sibling.kind() == "call"
1940        && let Some(method_name) = sibling.child_by_field_name("method")
1941        && let Ok(name_text) = method_name.utf8_text(content)
1942        && name_text == "sig"
1943    {
1944        // Look for block with returns call
1945        if let Some(block_node) = sibling.child_by_field_name("block") {
1946            return extract_returns_from_sig_block(block_node, content);
1947        }
1948    }
1949
1950    None
1951}
1952
1953/// Extract return type from sig block's `returns()` call.
1954fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
1955    let mut cursor = block_node.walk();
1956
1957    for child in block_node.named_children(&mut cursor) {
1958        if child.kind() == "call"
1959            && let Some(method_name) = child.child_by_field_name("method")
1960            && let Ok(name_text) = method_name.utf8_text(content)
1961            && name_text == "returns"
1962        {
1963            // Get the argument to returns()
1964            if let Some(args) = child.child_by_field_name("arguments") {
1965                let mut args_cursor = args.walk();
1966                for arg in args.named_children(&mut args_cursor) {
1967                    if arg.kind() != ","
1968                        && let Ok(type_text) = arg.utf8_text(content)
1969                    {
1970                        return Some(type_text.to_string());
1971                    }
1972                }
1973            }
1974        }
1975        // Recursively search in nested structures
1976        if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
1977            return Some(nested_type);
1978        }
1979    }
1980
1981    None
1982}
1983
1984/// Extract return type from RBS inline comment.
1985///
1986/// Looks for `#: (...) -> Type` pattern as a child of method node.
1987///
1988/// # Arguments
1989/// * `method_node` - The method definition AST node
1990/// * `content` - Source file content
1991///
1992/// # Returns
1993/// Return type if found in RBS comment
1994fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
1995    // RBS comments are children of the method node
1996    let mut cursor = method_node.walk();
1997    for child in method_node.children(&mut cursor) {
1998        if child.kind() == "comment"
1999            && let Ok(comment_text) = child.utf8_text(content)
2000        {
2001            // Parse RBS inline comment: #: (...) -> Type
2002            // Require #: prefix to avoid false positives from regular comments
2003            if comment_text.trim_start().starts_with("#:") {
2004                // Find the top-level arrow (not nested inside parens/brackets/braces)
2005                if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
2006                    let return_part = &comment_text[arrow_pos + 2..];
2007                    let return_type = return_part.trim().to_string();
2008                    if !return_type.is_empty() {
2009                        return Some(return_type);
2010                    }
2011                }
2012            }
2013        }
2014    }
2015
2016    None
2017}
2018
2019/// Find the position of the top-level `->` arrow (not nested in parens/brackets/braces).
2020///
2021/// Tracks depth of (), [], and {} to avoid selecting arrows inside nested types like proc types.
2022fn find_top_level_arrow(text: &str) -> Option<usize> {
2023    let chars: Vec<char> = text.chars().collect();
2024    let mut depth: i32 = 0;
2025    let mut i = 0;
2026
2027    while i < chars.len() {
2028        match chars[i] {
2029            '(' | '[' | '{' => depth += 1,
2030            ')' | ']' | '}' => depth = depth.saturating_sub(1),
2031            '-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
2032                return Some(i);
2033            }
2034            _ => {}
2035        }
2036        i += 1;
2037    }
2038
2039    None
2040}
2041
2042/// Extract return type from YARD documentation.
2043///
2044/// Looks for `@return [Type]` in comment block before method.
2045///
2046/// # Arguments
2047/// * `method_node` - The method definition AST node
2048/// * `content` - Source file content
2049///
2050/// # Returns
2051/// Return type if found in YARD comment
2052fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
2053    // Look for comment block before method
2054    let mut sibling_opt = method_node.prev_sibling();
2055    let method_start_row = method_node.start_position().row;
2056
2057    // Collect all preceding comments that are adjacent to the method
2058    let mut comments = Vec::new();
2059    let mut expected_row = method_start_row;
2060
2061    while let Some(sibling) = sibling_opt {
2062        if sibling.kind() == "comment" {
2063            let comment_end_row = sibling.end_position().row;
2064
2065            // Check adjacency: comment should end on the line right before expected row
2066            // Allow at most 1 line gap (expected_row - 1 or expected_row)
2067            if comment_end_row + 1 >= expected_row {
2068                if let Ok(comment_text) = sibling.utf8_text(content) {
2069                    comments.push(comment_text);
2070                }
2071                expected_row = sibling.start_position().row;
2072                sibling_opt = sibling.prev_sibling();
2073            } else {
2074                // Gap too large, stop collecting
2075                break;
2076            }
2077        } else {
2078            break;
2079        }
2080    }
2081
2082    // Search for @return [Type] pattern (reverse order since we collected backwards)
2083    for comment in comments.iter().rev() {
2084        if let Some(return_pos) = comment.find("@return") {
2085            let after_return = &comment[return_pos + 7..];
2086            // Find [Type] pattern
2087            if let Some(start_bracket) = after_return.find('[')
2088                && let Some(end_bracket) = after_return.find(']')
2089                && end_bracket > start_bracket
2090            {
2091                let return_type = &after_return[start_bracket + 1..end_bracket];
2092                return Some(return_type.trim().to_string());
2093            }
2094        }
2095    }
2096
2097    None
2098}
2099
2100/// Converts a tree-sitter Node to a sqry Span.
2101///
2102/// # Arguments
2103/// * `node` - AST node
2104///
2105/// # Returns
2106/// Span with start/end positions
2107fn span_from_node(node: Node<'_>) -> Span {
2108    span_from_points(node.start_position(), node.end_position())
2109}
2110
2111/// Converts tree-sitter Points to a sqry Span.
2112///
2113/// # Arguments
2114/// * `start` - Start position (row, column)
2115/// * `end` - End position (row, column)
2116///
2117/// # Returns
2118/// Span covering the range
2119fn span_from_points(start: Point, end: Point) -> Span {
2120    Span::new(
2121        Position::new(start.row, start.column),
2122        Position::new(end.row, end.column),
2123    )
2124}
2125
2126/// Checks if a node kind is a literal syntax delimiter.
2127///
2128/// Delimiters (parentheses, commas, brackets) are filtered when counting arguments.
2129///
2130/// # Arguments
2131/// * `kind` - Node kind string
2132///
2133/// # Returns
2134/// true if kind is a delimiter
2135fn is_literal_delimiter(kind: &str) -> bool {
2136    matches!(kind, "," | "(" | ")" | "[" | "]")
2137}
2138
2139/// Parse controller DSL arguments using simple heuristics.
2140/// Returns (callbacks, only, except).
2141fn parse_controller_dsl_args(
2142    text: &str,
2143) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
2144    // Split callbacks head from kwargs tail (only:/except:)
2145    let mut head = text;
2146    let mut tail = "";
2147    if let Some(idx) = text.find("only:") {
2148        head = &text[..idx];
2149        tail = &text[idx..];
2150    } else if let Some(idx) = text.find("except:") {
2151        head = &text[..idx];
2152        tail = &text[idx..];
2153    }
2154    let callbacks = extract_symbol_list_from_args(head);
2155    let only = extract_kw_symbol_list(tail, "only:");
2156    let except = extract_kw_symbol_list(tail, "except:");
2157    (callbacks, only, except)
2158}
2159
2160fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
2161    let mut out = Vec::new();
2162    let bytes = text.as_bytes();
2163    let mut i = 0;
2164    while i < bytes.len() {
2165        if bytes[i] == b':' {
2166            let start = i + 1;
2167            let mut j = start;
2168            while j < bytes.len() {
2169                let c = bytes[j] as char;
2170                if c.is_ascii_alphanumeric() || c == '_' {
2171                    j += 1;
2172                } else {
2173                    break;
2174                }
2175            }
2176            if j > start {
2177                out.push(text[start..j].to_string());
2178                i = j;
2179                continue;
2180            }
2181        }
2182        i += 1;
2183    }
2184    out
2185}
2186
2187fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
2188    let pos = text.find(kw)?;
2189    let mut after = &text[pos + kw.len()..];
2190    // trim leading whitespace and commas
2191    after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
2192    if after.starts_with('[')
2193        && let Some(end) = after.find(']')
2194    {
2195        return Some(extract_symbol_list_from_args(&after[..=end]));
2196    }
2197    // single symbol
2198    if let Some(colon) = after.find(':') {
2199        let mut j = colon + 1;
2200        while j < after.len() {
2201            let ch = after.as_bytes()[j] as char;
2202            if ch.is_ascii_alphanumeric() || ch == '_' {
2203                j += 1;
2204            } else {
2205                break;
2206            }
2207        }
2208        if j > colon + 1 {
2209            return Some(vec![after[colon + 1..j].to_string()]);
2210        }
2211    }
2212    None
2213}
2214
2215fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
2216    let mut out = Vec::new();
2217    match node.kind() {
2218        "symbol" | "simple_symbol" => {
2219            if let Ok(t) = node_text(node, content) {
2220                out.push(t.trim_start_matches(':').to_string());
2221            }
2222        }
2223        "array" => {
2224            let mut c = node.walk();
2225            for ch in node.children(&mut c) {
2226                if matches!(ch.kind(), "symbol" | "simple_symbol")
2227                    && let Ok(t) = node_text(ch, content)
2228                {
2229                    out.push(t.trim_start_matches(':').to_string());
2230                }
2231            }
2232        }
2233        _ => {
2234            // For other nodes, fall back to text scan
2235            if let Some(txt) = node_text_raw(node, content) {
2236                out = extract_symbol_list_from_args(&txt);
2237            }
2238        }
2239    }
2240    out
2241}
2242
2243/// Extract the module name from require arguments
2244fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
2245    let mut cursor = arguments.walk();
2246    for child in arguments.children(&mut cursor) {
2247        if !child.is_named() {
2248            continue;
2249        }
2250        if let Some(s) = extract_string_content(child, content) {
2251            return Some(s);
2252        }
2253    }
2254    None
2255}
2256
2257/// Extract string content from a string node (handles quotes)
2258fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
2259    let text = node.utf8_text(content).ok()?;
2260    let trimmed = text.trim();
2261
2262    // Handle quoted strings
2263    if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
2264        || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
2265        && trimmed.len() >= 2
2266    {
2267        return Some(trimmed[1..trimmed.len() - 1].to_string());
2268    }
2269
2270    // For string nodes, look for string_content child
2271    if matches!(node.kind(), "string" | "chained_string") {
2272        let mut cursor = node.walk();
2273        for child in node.children(&mut cursor) {
2274            if child.kind() == "string_content"
2275                && let Ok(s) = child.utf8_text(content)
2276            {
2277                return Some(s.to_string());
2278            }
2279        }
2280    }
2281
2282    None
2283}
2284
2285/// Resolve Ruby require path to canonical identifier
2286///
2287/// For `require_relative`, incorporates the source file's directory to produce
2288/// a unique, stable identifier that won't collide across different directories.
2289///
2290/// # Examples
2291///
2292/// - `a/file.rb` with `require_relative "util"` -> `a::util`
2293/// - `b/file.rb` with `require_relative "util"` -> `b::util`
2294/// - `require "json"` -> `json`
2295pub(crate) fn resolve_ruby_require(
2296    module_name: &str,
2297    is_relative: bool,
2298    source_file: &str,
2299) -> String {
2300    if is_relative {
2301        // For require_relative, resolve relative to the source file's directory.
2302        // This ensures `a/file.rb: require_relative "util"` and `b/file.rb: require_relative "util"`
2303        // produce distinct identifiers `a::util` and `b::util`.
2304        let source_path = std::path::Path::new(source_file);
2305        let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
2306
2307        // Join the source directory with the relative path
2308        let relative_path = std::path::Path::new(module_name);
2309        let resolved = source_dir.join(relative_path);
2310
2311        // Normalize to collapse `.` and `..` components
2312        let normalized = normalize_path(&resolved);
2313
2314        // Convert to canonical identifier using :: separators
2315        // Handle both Unix (/) and Windows (\) path separators
2316        let path_str = normalized.to_string_lossy();
2317        let separators: &[char] = &['/', '\\'];
2318        path_str
2319            .split(separators)
2320            .filter(|s| !s.is_empty())
2321            .collect::<Vec<_>>()
2322            .join("::")
2323    } else {
2324        // require 'json' -> json
2325        // require 'active_support/core_ext' -> active_support::core_ext
2326        module_name.replace('/', "::")
2327    }
2328}
2329
2330/// Normalize a path by resolving `.` and `..` components without filesystem access
2331fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
2332    let mut components = Vec::new();
2333
2334    for component in path.components() {
2335        match component {
2336            std::path::Component::CurDir => {
2337                // Skip `.` components
2338            }
2339            std::path::Component::ParentDir => {
2340                // Pop for `..` if there's something to pop (and it's not another ..)
2341                if components
2342                    .last()
2343                    .is_some_and(|c| *c != std::path::Component::ParentDir)
2344                {
2345                    components.pop();
2346                } else {
2347                    components.push(component);
2348                }
2349            }
2350            _ => {
2351                components.push(component);
2352            }
2353        }
2354    }
2355
2356    components.iter().collect()
2357}
2358
2359// ============================================================================
2360// YARD Annotation Processing - TypeOf and Reference Edges
2361// ============================================================================
2362
2363/// Process YARD annotations for `TypeOf` and Reference edges
2364/// Recursively walks the tree looking for nodes with YARD comments
2365fn process_yard_annotations(
2366    node: Node,
2367    content: &[u8],
2368    helper: &mut GraphBuildHelper,
2369) -> GraphResult<()> {
2370    match node.kind() {
2371        "method" => {
2372            process_method_yard(node, content, helper)?;
2373        }
2374        "singleton_method" => {
2375            process_singleton_method_yard(node, content, helper)?;
2376        }
2377        "call" | "command" | "command_call" => {
2378            // Check if this is an attr_reader/attr_writer/attr_accessor call
2379            if is_attr_call(node, content) {
2380                process_attr_yard(node, content, helper)?;
2381            }
2382        }
2383        "assignment" => {
2384            // Check if this is an instance variable assignment
2385            if is_instance_variable_assignment(node, content) {
2386                process_assignment_yard(node, content, helper)?;
2387            }
2388        }
2389        _ => {}
2390    }
2391
2392    // Recurse into children
2393    let mut cursor = node.walk();
2394    for child in node.children(&mut cursor) {
2395        process_yard_annotations(child, content, helper)?;
2396    }
2397
2398    Ok(())
2399}
2400
2401/// Process YARD for method definitions
2402fn process_method_yard(
2403    method_node: Node,
2404    content: &[u8],
2405    helper: &mut GraphBuildHelper,
2406) -> GraphResult<()> {
2407    // Extract YARD comment
2408    let Some(yard_text) = extract_yard_comment(method_node, content) else {
2409        return Ok(());
2410    };
2411
2412    // Parse YARD tags
2413    let tags = parse_yard_tags(&yard_text);
2414
2415    // Get method name
2416    let Some(name_node) = method_node.child_by_field_name("name") else {
2417        return Ok(());
2418    };
2419
2420    let method_name = name_node
2421        .utf8_text(content)
2422        .map_err(|_| GraphBuilderError::ParseError {
2423            span: span_from_node(method_node),
2424            reason: "failed to read method name".to_string(),
2425        })?
2426        .trim()
2427        .to_string();
2428
2429    if method_name.is_empty() {
2430        return Ok(());
2431    }
2432
2433    // Find the class name by walking up the tree
2434    let class_name = get_enclosing_class_name(method_node, content);
2435
2436    // Create qualified method name
2437    let qualified_name = if let Some(class_name) = class_name {
2438        format!("{class_name}#{method_name}")
2439    } else {
2440        method_name.clone()
2441    };
2442
2443    // Get or create method node
2444    let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
2445
2446    // Process @param tags
2447    for (param_idx, param_tag) in tags.params.iter().enumerate() {
2448        // Create TypeOf edge: method -> parameter type
2449        let canonical_type = canonical_type_string(&param_tag.type_str);
2450        let type_node_id = helper.add_type(&canonical_type, None);
2451        helper.add_typeof_edge_with_context(
2452            method_node_id,
2453            type_node_id,
2454            Some(TypeOfContext::Parameter),
2455            param_idx.try_into().ok(),
2456            Some(&param_tag.name),
2457        );
2458
2459        // Create Reference edges: method -> each referenced type
2460        let type_names = extract_type_names(&param_tag.type_str);
2461        for type_name in type_names {
2462            let ref_type_id = helper.add_type(&type_name, None);
2463            helper.add_reference_edge(method_node_id, ref_type_id);
2464        }
2465    }
2466
2467    // Process @return tag
2468    if let Some(return_type) = &tags.returns {
2469        let canonical_type = canonical_type_string(return_type);
2470        let type_node_id = helper.add_type(&canonical_type, None);
2471        helper.add_typeof_edge_with_context(
2472            method_node_id,
2473            type_node_id,
2474            Some(TypeOfContext::Return),
2475            Some(0),
2476            None,
2477        );
2478
2479        // Create Reference edges for return type
2480        let type_names = extract_type_names(return_type);
2481        for type_name in type_names {
2482            let ref_type_id = helper.add_type(&type_name, None);
2483            helper.add_reference_edge(method_node_id, ref_type_id);
2484        }
2485    }
2486
2487    Ok(())
2488}
2489
2490/// Process YARD for singleton method definitions (class methods)
2491fn process_singleton_method_yard(
2492    method_node: Node,
2493    content: &[u8],
2494    helper: &mut GraphBuildHelper,
2495) -> GraphResult<()> {
2496    // Extract YARD comment
2497    let Some(yard_text) = extract_yard_comment(method_node, content) else {
2498        return Ok(());
2499    };
2500
2501    // Parse YARD tags
2502    let tags = parse_yard_tags(&yard_text);
2503
2504    // Get method name
2505    let Some(name_node) = method_node.child_by_field_name("name") else {
2506        return Ok(());
2507    };
2508
2509    let method_name = name_node
2510        .utf8_text(content)
2511        .map_err(|_| GraphBuilderError::ParseError {
2512            span: span_from_node(method_node),
2513            reason: "failed to read method name".to_string(),
2514        })?
2515        .trim()
2516        .to_string();
2517
2518    if method_name.is_empty() {
2519        return Ok(());
2520    }
2521
2522    // Find the class name
2523    let class_name = get_enclosing_class_name(method_node, content);
2524
2525    // Create qualified method name (singleton methods use . separator)
2526    let qualified_name = if let Some(class_name) = class_name {
2527        format!("{class_name}.{method_name}")
2528    } else {
2529        method_name.clone()
2530    };
2531
2532    // Get or create method node (singleton method)
2533    let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
2534
2535    // Process @param tags
2536    for (param_idx, param_tag) in tags.params.iter().enumerate() {
2537        // Create TypeOf edge: method -> parameter type
2538        let canonical_type = canonical_type_string(&param_tag.type_str);
2539        let type_node_id = helper.add_type(&canonical_type, None);
2540        helper.add_typeof_edge_with_context(
2541            method_node_id,
2542            type_node_id,
2543            Some(TypeOfContext::Parameter),
2544            param_idx.try_into().ok(),
2545            Some(&param_tag.name),
2546        );
2547
2548        // Create Reference edges: method -> each referenced type
2549        let type_names = extract_type_names(&param_tag.type_str);
2550        for type_name in type_names {
2551            let ref_type_id = helper.add_type(&type_name, None);
2552            helper.add_reference_edge(method_node_id, ref_type_id);
2553        }
2554    }
2555
2556    // Process @return tag
2557    if let Some(return_type) = &tags.returns {
2558        let canonical_type = canonical_type_string(return_type);
2559        let type_node_id = helper.add_type(&canonical_type, None);
2560        helper.add_typeof_edge_with_context(
2561            method_node_id,
2562            type_node_id,
2563            Some(TypeOfContext::Return),
2564            Some(0),
2565            None,
2566        );
2567
2568        // Create Reference edges for return type
2569        let type_names = extract_type_names(return_type);
2570        for type_name in type_names {
2571            let ref_type_id = helper.add_type(&type_name, None);
2572            helper.add_reference_edge(method_node_id, ref_type_id);
2573        }
2574    }
2575
2576    Ok(())
2577}
2578
2579/// Process YARD for `attr_reader/attr_writer/attr_accessor` declarations
2580#[allow(clippy::unnecessary_wraps)]
2581fn process_attr_yard(
2582    attr_node: Node,
2583    content: &[u8],
2584    helper: &mut GraphBuildHelper,
2585) -> GraphResult<()> {
2586    // Extract YARD comment
2587    let Some(yard_text) = extract_yard_comment(attr_node, content) else {
2588        return Ok(());
2589    };
2590
2591    // Parse YARD tags
2592    let tags = parse_yard_tags(&yard_text);
2593
2594    // Only process @return tags for attr declarations
2595    let Some(var_type) = &tags.returns else {
2596        return Ok(());
2597    };
2598
2599    // Extract attribute names from the call arguments
2600    let attr_names = extract_attr_names(attr_node, content);
2601
2602    if attr_names.is_empty() {
2603        return Ok(());
2604    }
2605
2606    // Find the class name
2607    let class_name = get_enclosing_class_name(attr_node, content);
2608
2609    // Process each attribute name
2610    for attr_name in attr_names {
2611        // Create qualified name for the attribute
2612        let qualified_name = if let Some(ref class) = class_name {
2613            format!("{class}#{attr_name}")
2614        } else {
2615            attr_name.clone()
2616        };
2617
2618        // Create variable node for the attribute
2619        let attr_node_id = helper.add_variable(&qualified_name, None);
2620
2621        // Create TypeOf edge: variable -> type
2622        let canonical_type = canonical_type_string(var_type);
2623        let type_node_id = helper.add_type(&canonical_type, None);
2624        helper.add_typeof_edge_with_context(
2625            attr_node_id,
2626            type_node_id,
2627            Some(TypeOfContext::Field),
2628            None,
2629            Some(&attr_name),
2630        );
2631
2632        // Create Reference edges: variable -> each referenced type
2633        let type_names = extract_type_names(var_type);
2634        for type_name in type_names {
2635            let ref_type_id = helper.add_type(&type_name, None);
2636            helper.add_reference_edge(attr_node_id, ref_type_id);
2637        }
2638    }
2639
2640    Ok(())
2641}
2642
2643/// Process YARD for instance variable assignments
2644fn process_assignment_yard(
2645    assignment_node: Node,
2646    content: &[u8],
2647    helper: &mut GraphBuildHelper,
2648) -> GraphResult<()> {
2649    // Extract YARD comment
2650    let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
2651        return Ok(());
2652    };
2653
2654    // Parse YARD tags
2655    let tags = parse_yard_tags(&yard_text);
2656
2657    // Only process @type tags for assignments
2658    let Some(var_type) = &tags.type_annotation else {
2659        return Ok(());
2660    };
2661
2662    // Get the variable name (instance variable like @username)
2663    let Some(left_node) = assignment_node.child_by_field_name("left") else {
2664        return Ok(());
2665    };
2666
2667    if left_node.kind() != "instance_variable" {
2668        return Ok(());
2669    }
2670
2671    let var_name = left_node
2672        .utf8_text(content)
2673        .map_err(|_| GraphBuilderError::ParseError {
2674            span: span_from_node(assignment_node),
2675            reason: "failed to read variable name".to_string(),
2676        })?
2677        .trim()
2678        .to_string();
2679
2680    if var_name.is_empty() {
2681        return Ok(());
2682    }
2683
2684    // Find the class name
2685    let class_name = get_enclosing_class_name(assignment_node, content);
2686
2687    // Create qualified name for the instance variable
2688    let qualified_name = if let Some(class) = class_name {
2689        format!("{class}#{var_name}")
2690    } else {
2691        var_name.clone()
2692    };
2693
2694    // Create variable node
2695    let var_node_id = helper.add_variable(&qualified_name, None);
2696
2697    // Create TypeOf edge: variable -> type
2698    let canonical_type = canonical_type_string(var_type);
2699    let type_node_id = helper.add_type(&canonical_type, None);
2700    helper.add_typeof_edge_with_context(
2701        var_node_id,
2702        type_node_id,
2703        Some(TypeOfContext::Variable),
2704        None,
2705        Some(&var_name),
2706    );
2707
2708    // Create Reference edges: variable -> each referenced type
2709    let type_names = extract_type_names(var_type);
2710    for type_name in type_names {
2711        let ref_type_id = helper.add_type(&type_name, None);
2712        helper.add_reference_edge(var_node_id, ref_type_id);
2713    }
2714
2715    Ok(())
2716}
2717
2718/// Check if a node is an `attr_reader/attr_writer/attr_accessor` call
2719fn is_attr_call(node: Node, content: &[u8]) -> bool {
2720    let method_name = match node.kind() {
2721        "command" => node
2722            .child_by_field_name("name")
2723            .and_then(|n| n.utf8_text(content).ok()),
2724        "call" | "command_call" => node
2725            .child_by_field_name("method")
2726            .and_then(|n| n.utf8_text(content).ok()),
2727        _ => None,
2728    };
2729
2730    method_name
2731        .is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
2732}
2733
2734/// Check if an assignment is to an instance variable
2735fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
2736    if let Some(left_node) = node.child_by_field_name("left") {
2737        left_node.kind() == "instance_variable"
2738    } else {
2739        false
2740    }
2741}
2742
2743/// Extract attribute names from `attr_reader/attr_writer/attr_accessor` arguments
2744/// Supports both symbol and string arguments
2745fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
2746    let mut names = Vec::new();
2747
2748    // Get arguments (could be inline for command nodes)
2749    let arguments = attr_node.child_by_field_name("arguments");
2750
2751    if let Some(args) = arguments {
2752        // Process argument list
2753        let mut cursor = args.walk();
2754        for child in args.children(&mut cursor) {
2755            if matches!(child.kind(), "symbol" | "simple_symbol")
2756                && let Ok(text) = child.utf8_text(content)
2757            {
2758                let cleaned = text.trim().trim_start_matches(':');
2759                if !cleaned.is_empty() {
2760                    names.push(cleaned.to_string());
2761                }
2762            } else if child.kind() == "string"
2763                && let Ok(text) = child.utf8_text(content)
2764            {
2765                // Handle string arguments: attr_reader "name"
2766                let cleaned = text
2767                    .trim()
2768                    .trim_start_matches(['\'', '"'])
2769                    .trim_end_matches(['\'', '"']);
2770                if !cleaned.is_empty() {
2771                    names.push(cleaned.to_string());
2772                }
2773            }
2774        }
2775    } else if matches!(attr_node.kind(), "command" | "command_call") {
2776        // For command/command_call nodes, symbols/strings might be direct children
2777        let mut cursor = attr_node.walk();
2778        let mut found_method = false;
2779        for child in attr_node.children(&mut cursor) {
2780            if !child.is_named() {
2781                continue;
2782            }
2783            // Skip the method name itself
2784            if !found_method {
2785                found_method = true;
2786                continue;
2787            }
2788            // Extract symbol arguments
2789            if matches!(child.kind(), "symbol" | "simple_symbol")
2790                && let Ok(text) = child.utf8_text(content)
2791            {
2792                let cleaned = text.trim().trim_start_matches(':');
2793                if !cleaned.is_empty() {
2794                    names.push(cleaned.to_string());
2795                }
2796            } else if child.kind() == "string"
2797                && let Ok(text) = child.utf8_text(content)
2798            {
2799                // Handle string arguments: attr_reader "name"
2800                let cleaned = text
2801                    .trim()
2802                    .trim_start_matches(['\'', '"'])
2803                    .trim_end_matches(['\'', '"']);
2804                if !cleaned.is_empty() {
2805                    names.push(cleaned.to_string());
2806                }
2807            }
2808        }
2809    }
2810
2811    names
2812}
2813
2814/// Get the fully qualified enclosing class/module name for a node
2815/// Returns the full namespace path (e.g., "`MyModule::MyClass`") by walking
2816/// up the parent chain and collecting all enclosing module/class names.
2817/// Handles absolute constants (e.g., `class ::Foo`) by detecting leading `::`.
2818fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
2819    let mut current = node;
2820    let mut namespace_parts = Vec::new();
2821
2822    // Walk up the tree to collect all enclosing class/module names
2823    while let Some(parent) = current.parent() {
2824        if matches!(parent.kind(), "class" | "module") {
2825            // Extract the name of this class/module
2826            if let Some(name_node) = parent.child_by_field_name("name")
2827                && let Ok(name_text) = name_node.utf8_text(content)
2828            {
2829                let trimmed = name_text.trim();
2830                // Check for absolute constant (starts with ::)
2831                if trimmed.starts_with("::") {
2832                    // Absolute constant - stop accumulating parents
2833                    namespace_parts.clear();
2834                    namespace_parts.push(trimmed.trim_start_matches("::").to_string());
2835                    break;
2836                }
2837                // Add to the beginning of the list (outermost first)
2838                namespace_parts.insert(0, trimmed.to_string());
2839            }
2840        }
2841        current = parent;
2842    }
2843
2844    // Join all namespace parts with "::"
2845    if namespace_parts.is_empty() {
2846        None
2847    } else {
2848        Some(namespace_parts.join("::"))
2849    }
2850}