Skip to main content

sqry_lang_ruby/relations/
graph_builder.rs

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