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