Skip to main content

sqry_lang_ruby/relations/
graph_builder.rs

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