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