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