Skip to main content

bonsai_lang_ruby/
lib.rs

1//! Ruby language adapter.
2use bonsai_common::{FileId, Span};
3use bonsai_lang_api::{
4    decl_index_with_handler, extract_imports_via,
5    kit::{
6        call_arg_from_node_with_handler, collect_kinds, first_named_child_of_kind, language_from_pack,
7        named_child_call_args_with_handler, node_text, parse_with, pattern_binding_sites_from_arms, span_of,
8    },
9    AdapterContext, AdapterError, CallArg, CallKind, CallTargetExtraction, DeclIndex, DeclKind, FlowEvent,
10    GrammarHandler, ImportIndex, ImportScope, ImportSpec, LanguageAdapter, LanguageCapabilities, LanguageId,
11    ModulePath, PatternBindingSite, Ref, RefKind, EMPTY_HANDLER,
12};
13use tree_sitter::{Language, Node, Tree};
14
15pub const LANG_ID: LanguageId = LanguageId::new("ruby");
16const PACK_NAME: &str = "ruby";
17
18fn ruby_call_target<'tree>(node: Node<'tree>, src: &[u8]) -> Option<CallTargetExtraction<'tree>> {
19    if !matches!(node.kind(), "call" | "method_call") {
20        return None;
21    }
22    let target = node.child_by_field_name("method")?;
23    let member = node_text(&target, src).trim();
24    if member.is_empty() {
25        return None;
26    }
27    let full_text = node.child_by_field_name("receiver").map_or_else(
28        || member.to_string(),
29        |receiver| format!("{}.{}", node_text(&receiver, src).trim(), member),
30    );
31    Some(CallTargetExtraction {
32        node: target,
33        full_text,
34    })
35}
36
37/// Ruby keyword arguments are `pair` nodes in the argument list. Preserve
38/// their grammar-declared key/value roles so generic constraints can address
39/// the named argument without parsing Ruby source text.
40fn ruby_named_argument<'tree>(node: Node<'tree>, src: &[u8]) -> Option<(String, Node<'tree>)> {
41    if node.kind() != "pair" {
42        return None;
43    }
44    let key = node.child_by_field_name("key")?;
45    let value = node.child_by_field_name("value")?;
46    let name = node_text(&key, src)
47        .trim()
48        .trim_start_matches(':')
49        .trim_end_matches(':')
50        .trim();
51    (!name.is_empty()).then(|| (name.to_string(), value))
52}
53
54fn ruby_pattern_bindings(node: Node<'_>) -> Vec<PatternBindingSite<'_>> {
55    if node.kind() != "case_match" {
56        return Vec::new();
57    }
58    pattern_binding_sites_from_arms(node, &["value"], &["in_clause"], &["pattern"], &[])
59}
60
61fn extract_ruby_callable_reference(node: Node<'_>, src: &[u8]) -> Option<String> {
62    if !matches!(node.kind(), "call" | "method_call") {
63        return None;
64    }
65    let callee = node
66        .child_by_field_name("method")
67        .or_else(|| node.child_by_field_name("function"))
68        .or_else(|| node.child_by_field_name("name"))
69        .or_else(|| node.child_by_field_name("target"))?;
70    if node_text(&callee, src).trim() != "method" {
71        return None;
72    }
73    let arguments = node
74        .child_by_field_name("arguments")
75        .or_else(|| node.child_by_field_name("argument_list"))?;
76    if arguments.named_child_count() != 1 {
77        return None;
78    }
79    let symbol = arguments.named_child(0)?;
80    if !matches!(symbol.kind(), "simple_symbol" | "symbol" | "symbol_literal") {
81        return None;
82    }
83    let name = node_text(&symbol, src).trim().trim_start_matches(':');
84    (!name.is_empty()
85        && name
86            .chars()
87            .enumerate()
88            .all(|(index, ch)| ch == '_' || ch.is_alphanumeric() && (index > 0 || !ch.is_numeric())))
89    .then(|| name.to_string())
90}
91
92fn ruby_inline_closure_uses_yield(call: Node<'_>, block: Node<'_>, _src: &[u8]) -> bool {
93    call.kind() == "call" && matches!(block.kind(), "block" | "do_block")
94}
95const BASE_HANDLER: GrammarHandler = GrammarHandler {
96    expression_value_kind_extractor: None,
97    literal_value_kinds: &["nil", "integer", "float", "true", "false"],
98    string_literal_kinds: &["string", "chained_string", "heredoc_body"],
99    comment_kinds: &["comment"],
100    parameter_container_kinds: &["method_parameters"],
101    parameter_kinds: &[
102        "identifier",
103        "optional_parameter",
104        "keyword_parameter",
105        "splat_parameter",
106        "hash_splat_parameter",
107        "block_parameter",
108    ],
109    variadic_parameter_kinds: &["splat_parameter"],
110    binding_identifier_kinds: &[
111        "identifier",
112        "constant",
113        "instance_variable",
114        "class_variable",
115        "global_variable",
116    ],
117    non_binding_pattern_kinds: &[
118        "variable_reference_pattern",
119        "reference_pattern",
120        "pin_pattern",
121        "pin",
122    ],
123    non_binding_pattern_field_names: &["type", "key", "class", "guard"],
124    binding_name_extractor: Some(ruby_binding_name),
125    pattern_binding_extractor: Some(ruby_pattern_bindings),
126    identifier_kinds: &[
127        "identifier",
128        "constant",
129        "instance_variable",
130        "class_variable",
131        "global_variable",
132    ],
133    aggregate_pattern_kinds: &["left_assignment_list", "array_pattern", "list_pattern"],
134    named_aggregate_kinds: &["hash"],
135    positional_aggregate_kinds: &["array"],
136    aggregate_pair_kinds: &["pair", "keyword_pattern"],
137    aggregate_key_field_names: &["key"],
138    aggregate_value_field_names: &["value"],
139    shorthand_field_kinds: &["keyword_pattern"],
140    static_field_name_kinds: &["identifier", "constant"],
141    spread_kinds: &["splat_argument", "hash_splat_argument"],
142    spread_value_field_names: &["value"],
143    lambda_value_container_kinds: &["hash", "pair", "array"],
144    transparent_call_wrapper_kinds: &["call", "parenthesized_statements"],
145    single_expression_group_kinds: &["expression_list"],
146    inline_closure_kinds: &["block", "do_block"],
147    inline_closure_yield_extractor: Some(ruby_inline_closure_uses_yield),
148    fn_kinds: &["method", "singleton_method"],
149    class_kinds: &["class", "module"],
150    class_decl_kinds: &[("class", DeclKind::Class), ("module", DeclKind::Module)],
151    method_context_kinds: &["class", "module"],
152    if_kinds: &[
153        "if",
154        "if_modifier",
155        "unless",
156        "unless_modifier",
157        "case",
158        "case_match",
159    ],
160    branch_then_field_names: &["consequence", "body"],
161    branch_else_field_names: &["alternative"],
162    branch_condition_field_names: &["condition", "value"],
163    loop_body_field_names: &["body"],
164    loop_body_kinds: &["body_statement", "then"],
165    branch_arm_kinds: &["then", "else", "body_statement", "when"],
166    additional_alternative_kinds: &["elsif", "else"],
167    for_kinds: &[],
168    foreach_kinds: &["for"],
169    foreach_binding_extractor: Some(ruby_foreach_binding),
170    while_kinds: &["while", "until"],
171    return_kinds: &["return"],
172    lambda_kinds: &["lambda", "do_block"],
173    try_kinds: &["begin", "begin_block"],
174    catch_kinds: &["rescue"],
175    finally_kinds: &["ensure"],
176    break_kinds: &["break"],
177    // `next` advances to the next iteration; `redo` restarts the current
178    // iteration without reevaluating its condition. Both map to the shared
179    // loop-continue edge. Ruby's rescue-only `retry` has no equivalent in the
180    // current neutral IR and is intentionally not mislabeled as loop control.
181    continue_kinds: &["next", "redo"],
182    control_label_field_names: &[],
183    yield_kinds: &["yield"],
184    yield_value_field_names: &["argument", "arguments"],
185    try_body_field_names: &["body"],
186    implicit_receiver_names: &["self", "super"],
187    implicit_receiver_prefixes: &["@"],
188    ..EMPTY_HANDLER
189};
190const HANDLER: GrammarHandler = GrammarHandler {
191    // Ruby methods return their final expression when there is no
192    // explicit `return`. Surface that terminal expression as a normal
193    // Return event so the shared semantic taint summaries can model
194    // wrapper methods such as `def wrap(data); new(data); end`.
195    constructor_names: &["initialize", "new"],
196    tail_expression_returns: true,
197    void_return_type_names: &[],
198    // tree-sitter-ruby parses `buf += data`, `x ||= y`, `arr <<= e`
199    // as `operator_assignment`. Both assignment forms are declared here;
200    // shared lowering has no cross-language fallback. The compound arm in
201    // the kit then re-adds the LHS as a source operand (read-modify-write).
202    assignment_kinds: &["assignment", "operator_assignment"],
203    assignment_place_extractor: Some(ruby_assignment_place),
204    compound_assignment_kinds: &["operator_assignment"],
205    compound_assignment_operators: &["+=", "-=", "*=", "/=", "%=", "**=", "&&=", "||="],
206    call_kinds: &["call", "method_call"],
207    call_callee_field_names: &["method"],
208    call_receiver_field_names: &["receiver"],
209    call_member_field_names: &["method"],
210    call_target_extractor: Some(ruby_call_target),
211    call_argument_field_names: &["arguments"],
212    call_argument_container_kinds: &["argument_list"],
213    named_argument_extractor: Some(ruby_named_argument),
214    lambda_body_field_names: &["body"],
215    lambda_body_kinds: &["block", "do_block"],
216    pseudo_call_extractor: Some(extract_ruby_pseudo_call),
217    syntax_event_extractor: Some(extract_ruby_syntax_event),
218    argument_passing_mode_extractor: None,
219    call_ref_kinds: &["call", "method_call"],
220    subscript_expression_kinds: &["element_reference"],
221    subscript_base_field_names: &["object"],
222    subscript_index_field_names: &["index"],
223    static_subscript_key_extractor: Some(ruby_static_subscript_key),
224    computed_subscript_extractor: Some(ruby_element_subscript),
225    global_variable_kinds: &["global_variable"],
226    reference_name_extractor: Some(ruby_reference_name),
227    subscript_base_call_refs: true,
228    callable_reference_extractor: Some(extract_ruby_callable_reference),
229    special_forms: &[],
230    ..BASE_HANDLER
231};
232
233fn extract_ruby_syntax_event(
234    node: Node<'_>,
235    file: FileId,
236    src: &[u8],
237    handler: &GrammarHandler,
238) -> Option<FlowEvent> {
239    if node.kind() != "binary" {
240        return None;
241    }
242    let left = node.child_by_field_name("left")?;
243    let right = node.child_by_field_name("right")?;
244    let operator = std::str::from_utf8(&src[left.end_byte()..right.start_byte()])
245        .ok()?
246        .trim();
247    if operator != "<<" {
248        return None;
249    }
250    let target = call_arg_from_node_with_handler(left, file, src, None, handler)?.place?;
251    let source = call_arg_from_node_with_handler(right, file, src, None, handler)?;
252    let mut source_names = source.source_names;
253    if let Some(place) = source.place.as_ref() {
254        if !source_names.iter().any(|existing| existing == place) {
255            source_names.push(place.clone());
256        }
257    }
258    source_names.retain(|name| name != &target);
259    source_names.push(target.clone());
260    source_names.sort();
261    source_names.dedup();
262    Some(FlowEvent::Assign {
263        span: span_of(file, &node),
264        target,
265        source_name: source.place,
266        source_call: None,
267        source_call_args: Vec::new(),
268        source_names,
269        declares_new_binding: false,
270        value_kind: None,
271    })
272}
273
274fn ruby_element_subscript(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
275    if node.kind() != "element_reference" {
276        return None;
277    }
278    let object = node.child_by_field_name("object")?;
279    let mut cursor = node.walk();
280    let key = node
281        .named_children(&mut cursor)
282        .find(|child| child.id() != object.id())?;
283    Some((object, key))
284}
285
286fn ruby_static_subscript_key(node: Node<'_>, src: &[u8]) -> Option<String> {
287    if node.kind() == "string" {
288        let mut cursor = node.walk();
289        let parts: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
290        let [content] = parts.as_slice() else {
291            return None;
292        };
293        if content.kind() != "string_content" {
294            return None;
295        }
296        let key = node_text(content, src).trim();
297        return (!key.is_empty()).then(|| key.to_string());
298    }
299    if matches!(node.kind(), "simple_symbol" | "symbol" | "symbol_literal") {
300        let key = node_text(&node, src).trim().trim_start_matches(':');
301        return (!key.is_empty()).then(|| key.to_string());
302    }
303    None
304}
305
306fn ruby_reference_name(node: Node<'_>, src: &[u8]) -> Option<String> {
307    let raw = node_text(&node, src).trim();
308    if raw.is_empty() {
309        return None;
310    }
311    if node.kind() == "instance_variable" {
312        return Some(normalize_ruby_instance_variable_text(raw));
313    }
314    Some(raw.to_string())
315}
316
317fn ruby_binding_name(node: Node<'_>, src: &[u8]) -> Option<String> {
318    let raw = node_text(&node, src).trim();
319    if raw.is_empty() {
320        return None;
321    }
322    if node.kind() == "instance_variable" {
323        return Some(normalize_ruby_instance_variable_text(raw));
324    }
325    Some(raw.to_string())
326}
327
328/// Ruby represents a writable property target (`object.property = value`)
329/// with the same `call` node used for a zero-argument reader. The enclosing
330/// assignment is the syntax proof that this particular node denotes a place,
331/// so expose it only through the assignment-scoped adapter capability.
332fn ruby_assignment_place(node: Node<'_>, src: &[u8]) -> Option<String> {
333    fn base_place(node: Node<'_>, src: &[u8]) -> Option<String> {
334        if matches!(
335            node.kind(),
336            "identifier" | "constant" | "instance_variable" | "class_variable" | "global_variable"
337        ) {
338            return ruby_reference_name(node, src);
339        }
340        ruby_assignment_place(node, src)
341    }
342
343    if !matches!(node.kind(), "call" | "method_call") || node.child_by_field_name("arguments").is_some() {
344        return None;
345    }
346    let receiver = node.child_by_field_name("receiver")?;
347    let method = node.child_by_field_name("method")?;
348    if method.kind() != "identifier" {
349        return None;
350    }
351    let receiver = base_place(receiver, src)?;
352    let method = node_text(&method, src).trim();
353    (!receiver.is_empty() && !method.is_empty()).then(|| format!("{receiver}.{method}"))
354}
355
356fn ruby_foreach_binding(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
357    if node.kind() != "for" {
358        return None;
359    }
360    let binding = node
361        .child_by_field_name("pattern")
362        .or_else(|| node.child_by_field_name("left"))
363        .or_else(|| node.named_child(0))?;
364    let iterable = node
365        .child_by_field_name("value")
366        .or_else(|| node.child_by_field_name("right"))
367        .or_else(|| node.named_child(1))?;
368    Some((binding, iterable))
369}
370
371fn extract_ruby_pseudo_call(
372    node: Node<'_>,
373    file: FileId,
374    src: &[u8],
375    handler: &GrammarHandler,
376) -> Option<FlowEvent> {
377    if node.kind() != "subshell" {
378        return None;
379    }
380    Some(FlowEvent::Call {
381        span: span_of(file, &node),
382        receiver: None,
383        receiver_types: Vec::new(),
384        name: "`".to_string(),
385        call_kind: CallKind::Operator,
386        args: named_child_call_args_with_handler(&node, file, src, handler),
387    })
388}
389
390#[derive(Debug, Default, Copy, Clone)]
391pub struct RubyAdapter;
392
393impl RubyAdapter {
394    #[must_use]
395    pub fn new() -> Self {
396        Self
397    }
398}
399
400impl LanguageAdapter for RubyAdapter {
401    fn language_id(&self) -> LanguageId {
402        LANG_ID
403    }
404    fn display_name(&self) -> &'static str {
405        "Ruby"
406    }
407    fn file_extensions(&self) -> &'static [&'static str] {
408        // `.erb` (HTML/Ruby template blend) and `.rhtml` (legacy
409        // Rails) are claimed alongside `.rb`. tree-sitter-ruby cannot
410        // parse the HTML wrapper as Ruby — extract_declarations
411        // pre-processes ERB files to mask HTML with whitespace
412        // (preserving line numbers) and expose only the embedded
413        // `<%= expr %>` / `<% stmt %>` Ruby blocks to the parser.
414        &["rb", "erb", "rhtml"]
415    }
416    fn tree_sitter_language(&self) -> Result<Language, AdapterError> {
417        language_from_pack(PACK_NAME)
418    }
419    fn capabilities(&self) -> LanguageCapabilities {
420        LanguageCapabilities {
421            module_default_export_names: &[],
422            universal_type_names: &[],
423            module_path_syntax: bonsai_lang_api::ModulePathSyntax::none(),
424            constructor_method_names: &["initialize", "new"],
425            super_receiver_tokens: &["super"],
426            implicit_receiver_tokens: &["self"],
427            receiver_type_syntax: bonsai_lang_api::ReceiverTypeSyntax {
428                wrapper_calls: &[],
429                class_object_suffixes: &[".class"],
430            },
431            callable_reference_syntax: bonsai_lang_api::CallableReferenceSyntax {
432                prefixes: &[],
433                numeric_arity_suffix: false,
434                symbol_wrapper: Some("method"),
435                trailing_invocation_punctuation: false,
436            },
437            workspace_manifest_context_extensions: &["erb", "rhtml", "haml", "slim"],
438            ..LanguageCapabilities::partial_baseline()
439        }
440    }
441    fn extract_declarations(&self, file: FileId, ctx: &AdapterContext<'_>) -> DeclIndex {
442        // Pure Ruby files take the standard pipeline.
443        let path = ctx.vfs.path(file).ok();
444        let is_erb = path
445            .as_ref()
446            .and_then(|p| p.extension())
447            .is_some_and(|ext| ext == "erb" || ext == "rhtml");
448        if !is_erb {
449            let mut idx = decl_index_with_handler(PACK_NAME, file, ctx, &HANDLER);
450            if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
451                idx.refs.extend(extract_ruby_static_element_key_refs(
452                    &tree,
453                    snapshot.text.as_bytes(),
454                    file,
455                ));
456                // Apply Ruby's scope-marker visibility: `private`,
457                // `protected`, `public` keywords inside a class body
458                // change the default visibility of subsequent method
459                // definitions. See `apply_ruby_scope_visibility` for
460                // the exact contract this implements.
461                apply_ruby_scope_visibility(&mut idx, &tree, snapshot.text.as_bytes(), file);
462                for decl in &mut idx.defs {
463                    inject_ruby_raise_throw_events(&mut decl.flow_events);
464                    inject_ruby_super_call_events(&mut decl.flow_events, &decl.name);
465                    normalize_ruby_subshell_events(&mut decl.flow_events, snapshot.text.as_bytes());
466                    normalize_ruby_instance_variable_events(decl);
467                }
468            }
469            // Per-class `bases`: `class Echo < Base` → ["Base"].
470            // Ruby has only single-inheritance; mixins via `include`
471            // are call statements (handled by the matcher's existing
472            // include path), not parent-clauses.
473            let mut block_param_names = std::collections::BTreeSet::new();
474            if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
475                let src = snapshot.text.as_bytes();
476                block_param_names = collect_ruby_block_param_names(&tree, src);
477                let bases_by_span = collect_ruby_class_bases(&tree, file, src);
478                for decl in &mut idx.defs {
479                    if !is_class_like(decl.kind) {
480                        continue;
481                    }
482                    if let Some(bases) = bases_by_span.iter().find_map(|(span, name, bases)| {
483                        (*span == decl.span || name == &decl.name).then_some(bases)
484                    }) {
485                        decl.bases = bases.clone();
486                    }
487                }
488                inject_ruby_hash_field_assigns(&mut idx, &tree, file, src);
489                inject_ruby_bare_method_arg_calls(&mut idx, &tree, file, src);
490            }
491            bonsai_lang_api::apply_file_stem_semantic_identity(&mut idx, ctx);
492            apply_ruby_class_semantic_identity(&mut idx);
493            for decl in &mut idx.defs {
494                // Paren-less method calls in value position (`cmd =
495                // get_input`, `v = gets`) parse as bare identifier
496                // reads; promote the ones that name a method (not a
497                // local or block variable) into the call-result shape so
498                // taint crosses the call edge. Runs before the call-result
499                // normalizer so the promoted assign is normalized too.
500                rewrite_ruby_bareword_call_result_assigns(decl, &block_param_names);
501                // Same promotion for tail position: `def get_input; gets;
502                // end` is a call to `gets`, not an identifier read.
503                inject_ruby_bare_tail_return_calls(decl, &block_param_names);
504                bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
505            }
506            if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
507                inject_ruby_unbound_receiver_calls(
508                    &mut idx,
509                    &tree,
510                    file,
511                    snapshot.text.as_bytes(),
512                    &block_param_names,
513                );
514            }
515            // Lift `@field = ParamType` writes captured during decl
516            // collection into per-method `type_aliases`, so the
517            // resolver's `type_alias_for_receiver(method, "self.field")`
518            // returns the constructor-supplied type without re-walking
519            // sibling decls per call site.
520            // Local constructor-result receiver typing (`c = Foo.new` →
521            // `c: Foo`) so `c.method(...)` carries a resolved receiver
522            // type for `receiver_type_in` / `[Type, method]` rules. Ruby
523            // class names are CamelCase and the constructor is the `.new`
524            // method, which `constructor_call_type_name` resolves to the
525            // class qualifier.
526            bonsai_lang_api::apply_constructor_result_type_aliases(&mut idx);
527            bonsai_lang_api::apply_class_field_type_aliases(&mut idx);
528            return idx;
529        }
530        // ERB files: pre-process the source so the HTML wrapper
531        // becomes whitespace and only `<%= expr %>` / `<% stmt %>`
532        // Ruby code remains, then run the standard pipeline against
533        // the synthetic source. Whitespace masking preserves line
534        // numbers so diagnostics stay accurate.
535        let Some(snapshot) = ctx.vfs.snapshot(file).ok() else {
536            return DeclIndex {
537                file,
538                ..Default::default()
539            };
540        };
541        let processed = preprocess_erb(&snapshot.text);
542        // Parse the processed source manually with tree-sitter-ruby.
543        let Ok(lang) = language_from_pack(PACK_NAME) else {
544            return DeclIndex {
545                file,
546                ..Default::default()
547            };
548        };
549        let mut parser = tree_sitter::Parser::new();
550        if parser.set_language(&lang).is_err() {
551            return DeclIndex {
552                file,
553                ..Default::default()
554            };
555        }
556        let Some(tree) = parser.parse(&processed, None) else {
557            return DeclIndex {
558                file,
559                ..Default::default()
560            };
561        };
562        // Build a DeclIndex by hand over the pre-processed source.
563        // ERB expressions are module-scope Ruby snippets, so wrap
564        // actionable flow events in a synthetic `__module__` decl.
565        // That preserves rule constraints that depend on call args
566        // (`raw @value`) instead of falling back to arg-less refs.
567        let src = processed.as_bytes();
568        let root = tree.root_node();
569        let mut root_events = bonsai_lang_api::kit::walk_flow_events(root, file, src, &HANDLER, &[]);
570        inject_ruby_raise_throw_events(&mut root_events);
571        normalize_ruby_subshell_events(&mut root_events, src);
572        // Rails/ERB instance variables are values supplied to the template's
573        // execution context. Model the exact Tree-sitter instance-variable
574        // nodes as implicit inputs of the synthetic module declaration so
575        // ordinary compiler dataflow can prove `@value -> helper(@value)`.
576        // Assignments inside the template remain normal FlowEvents and can
577        // still overwrite an input before a sink.
578        let erb_implicit_inputs = collect_ruby_erb_implicit_inputs(&tree, src);
579        let has_actionable_event = root_events.iter().any(|event| {
580            matches!(
581                event,
582                bonsai_lang_api::FlowEvent::Call { .. }
583                    | bonsai_lang_api::FlowEvent::Assign { .. }
584                    | bonsai_lang_api::FlowEvent::Yield { .. }
585                    | bonsai_lang_api::FlowEvent::Await { .. }
586            )
587        });
588        let mut defs = if has_actionable_event {
589            let module_span = span_of(file, &root);
590            let param_annotations = vec![Vec::new(); erb_implicit_inputs.len()];
591            // Synthesized container for ERB module-level code (the
592            // body of `<% %>` / `<%= %>` blocks). Module-level Ruby
593            // code is implicitly public — the template renderer
594            // executes it when the file is processed. Marking the
595            // container `Public` means the resolver's visibility
596            // filter doesn't accidentally hide its FlowEvents when
597            // a future caller reaches in by name.
598            vec![bonsai_lang_api::Decl {
599                symbol: bonsai_common::SymbolId::new(0),
600                kind: bonsai_lang_api::DeclKind::Function,
601                name: bonsai_lang_api::MODULE_DECL_NAME.to_string(),
602                qualified_name: None,
603                module_path: bonsai_lang_api::ModulePath::default(),
604                span: module_span,
605                name_span: module_span,
606                visibility: bonsai_lang_api::Visibility::Public,
607                parent: None,
608                body_span: Some(module_span),
609                flow_events: root_events,
610                has_implicit_returns: true,
611                params: erb_implicit_inputs,
612                param_annotations,
613                param_default_calls: Vec::new(),
614                type_aliases: Vec::new(),
615                bases: Vec::new(),
616                receiver_param_index: None,
617                receiver_field_writes: Vec::new(),
618                receiver_field_initializers: Vec::new(),
619                implicit_receiver_names: Vec::new(),
620                receiver_state_sources: Vec::new(),
621                return_type: None,
622                is_variadic: false,
623            }]
624        } else {
625            Vec::new()
626        };
627        let block_param_names = collect_ruby_block_param_names(&tree, src);
628        for decl in &mut defs {
629            normalize_ruby_instance_variable_events(decl);
630            rewrite_ruby_bareword_call_result_assigns(decl, &block_param_names);
631            inject_ruby_bare_tail_return_calls(decl, &block_param_names);
632            bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
633        }
634        let mut refs = bonsai_lang_api::kit::extract_call_refs(&tree, file, src, &HANDLER);
635        refs.extend(bonsai_lang_api::kit::extract_decorators(
636            &tree, file, src, &HANDLER,
637        ));
638        refs.extend(bonsai_lang_api::kit::extract_read_write_refs(
639            &tree, file, src, &HANDLER,
640        ));
641        refs.extend(extract_ruby_static_element_key_refs(&tree, src, file));
642        let strings = bonsai_lang_api::kit::extract_string_literals(&tree, file, src, &HANDLER);
643        let comments = bonsai_lang_api::kit::extract_comments(&tree, file, src, &HANDLER);
644        let assignment_values =
645            bonsai_lang_api::kit::extract_assignment_value_facts(&tree, file, &HANDLER, src);
646        let call_receivers = bonsai_lang_api::kit::extract_call_receiver_facts(&tree, file, &HANDLER, src);
647        let call_argument_values =
648            bonsai_lang_api::kit::extract_call_argument_value_facts(&tree, file, &defs, src, &HANDLER);
649        let runtime_type_narrowings =
650            bonsai_lang_api::kit::extract_runtime_type_narrowing_facts(&tree, file, &HANDLER, src);
651        let branch_conditions =
652            bonsai_lang_api::kit::extract_branch_condition_facts(&tree, file, &HANDLER, src);
653        DeclIndex {
654            file,
655            defs,
656            refs,
657            assignment_values,
658            call_receivers,
659            call_argument_values,
660            static_string_maps: Vec::new(),
661            string_compositions: Vec::new(),
662            finite_literal_selections: Vec::new(),
663            character_substitutions: Vec::new(),
664            character_constraints: Vec::new(),
665            guarded_value_filters: Vec::new(),
666            same_origin_path_constraints: Vec::new(),
667            dynamic_key_filters: Vec::new(),
668            runtime_type_narrowings,
669            branch_conditions,
670            compiler_guards: Vec::new(),
671            aggregate_layouts: Vec::new(),
672            strings,
673            comments,
674        }
675    }
676    fn extract_imports(&self, file: FileId, ctx: &AdapterContext<'_>) -> ImportIndex {
677        extract_imports_via(PACK_NAME, file, ctx, parse_imports)
678    }
679}
680
681fn inject_ruby_raise_throw_events(events: &mut Vec<FlowEvent>) {
682    for event in events.iter_mut() {
683        match event {
684            FlowEvent::Branch {
685                then_events,
686                else_events,
687                ..
688            } => {
689                inject_ruby_raise_throw_events(then_events);
690                inject_ruby_raise_throw_events(else_events);
691            }
692            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
693                inject_ruby_raise_throw_events(body);
694            }
695            FlowEvent::Try {
696                body,
697                catch_events,
698                finally_events,
699                ..
700            } => {
701                inject_ruby_raise_throw_events(body);
702                inject_ruby_raise_throw_events(catch_events);
703                inject_ruby_raise_throw_events(finally_events);
704            }
705            _ => {}
706        }
707    }
708
709    let mut rewritten = Vec::with_capacity(events.len());
710    for event in events.drain(..) {
711        let synthetic_throw = ruby_raise_throw_event(&event);
712        rewritten.push(event);
713        if let Some(throw_event) = synthetic_throw {
714            rewritten.push(throw_event);
715        }
716    }
717    *events = rewritten;
718}
719
720fn inject_ruby_super_call_events(events: &mut Vec<FlowEvent>, method_name: &str) {
721    for event in events.iter_mut() {
722        match event {
723            FlowEvent::Branch {
724                then_events,
725                else_events,
726                ..
727            } => {
728                inject_ruby_super_call_events(then_events, method_name);
729                inject_ruby_super_call_events(else_events, method_name);
730            }
731            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
732                inject_ruby_super_call_events(body, method_name);
733            }
734            FlowEvent::Try {
735                body,
736                catch_events,
737                finally_events,
738                ..
739            } => {
740                inject_ruby_super_call_events(body, method_name);
741                inject_ruby_super_call_events(catch_events, method_name);
742                inject_ruby_super_call_events(finally_events, method_name);
743            }
744            _ => {}
745        }
746    }
747
748    if method_name.trim().is_empty() {
749        return;
750    }
751    let mut rewritten = Vec::with_capacity(events.len());
752    for event in events.drain(..) {
753        if ruby_return_is_bare_super(&event) {
754            let span = match &event {
755                FlowEvent::Return { span, .. } => *span,
756                _ => unreachable!("guarded by ruby_return_is_bare_super"),
757            };
758            rewritten.push(FlowEvent::Call {
759                span,
760                name: format!("super.{method_name}"),
761                receiver: Some("super".to_string()),
762                receiver_types: Vec::new(),
763                call_kind: CallKind::Method,
764                args: Vec::new(),
765            });
766        }
767        rewritten.push(event);
768    }
769    *events = rewritten;
770}
771
772fn ruby_return_is_bare_super(event: &FlowEvent) -> bool {
773    let FlowEvent::Return {
774        value_name,
775        value_text,
776        value_flow,
777        ..
778    } = event
779    else {
780        return false;
781    };
782    value_flow.place.as_deref() == Some("super")
783        || (value_name.as_deref() == Some("super")
784            && value_text.as_deref().is_some_and(|text| text.trim() == "super")
785            && value_flow.call_sites.is_empty())
786}
787
788fn ruby_raise_throw_event(event: &FlowEvent) -> Option<FlowEvent> {
789    let FlowEvent::Call { name, args, span, .. } = event else {
790        return None;
791    };
792    if name != "raise" {
793        return None;
794    }
795    // `raise ExceptionClass, message` (M17): arg0 is the exception
796    // class, so the thrown *value* is the message in arg1. Recognize
797    // the class form by a Capitalized constant or `Foo::Bar` scope.
798    let thrown_arg = match args.first() {
799        Some(first) if args.len() >= 2 && ruby_is_exception_class(&first.value_text) => args.get(1),
800        other => other,
801    };
802    // value_name is contractually a bare identifier (M18): take it
803    // only from `place`, leaving compound throws such as
804    // `StandardError.new(msg)` as None so the engine routes them
805    // through its conservative inter-procedural branch.
806    Some(FlowEvent::Throw {
807        span: *span,
808        value_name: thrown_arg.and_then(|arg| arg.place.clone()),
809        thrown_type: None,
810    })
811}
812
813/// True when an argument's text names a Ruby exception class -- a
814/// Capitalized constant (`ArgumentError`) or a scope-resolved constant
815/// (`Net::HTTPError`). Used to detect the two-argument
816/// `raise ExceptionClass, message` form (audit M17).
817fn ruby_is_exception_class(text: &str) -> bool {
818    let head = text.trim().rsplit("::").next().unwrap_or("").trim();
819    head.chars().next().is_some_and(|c| c.is_ascii_uppercase())
820        && head.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
821}
822
823fn normalize_ruby_subshell_events(events: &mut [FlowEvent], src: &[u8]) {
824    for event in events {
825        match event {
826            FlowEvent::Call {
827                name,
828                args,
829                span,
830                call_kind,
831                ..
832            } if name == "`" => {
833                let source_names = ruby_subshell_arg_source_names(args);
834                let value_text = ruby_span_text(src, *span)
835                    .filter(|text| !text.trim().is_empty())
836                    .map(|text| text.trim().to_string())
837                    .unwrap_or_else(|| {
838                        args.iter()
839                            .map(|arg| arg.value_text.trim())
840                            .filter(|text| !text.is_empty())
841                            .collect::<Vec<_>>()
842                            .join(" ")
843                    });
844                *call_kind = CallKind::Function;
845                *args = vec![CallArg {
846                    passing_mode: Default::default(),
847                    span: *span,
848                    name: None,
849                    value_text,
850                    place: None,
851                    source_names,
852                }];
853            }
854            FlowEvent::Branch {
855                then_events,
856                else_events,
857                ..
858            } => {
859                normalize_ruby_subshell_events(then_events, src);
860                normalize_ruby_subshell_events(else_events, src);
861            }
862            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
863                normalize_ruby_subshell_events(body, src);
864            }
865            FlowEvent::Try {
866                body,
867                catch_events,
868                finally_events,
869                ..
870            } => {
871                normalize_ruby_subshell_events(body, src);
872                normalize_ruby_subshell_events(catch_events, src);
873                normalize_ruby_subshell_events(finally_events, src);
874            }
875            _ => {}
876        }
877    }
878}
879
880fn ruby_subshell_arg_source_names(args: &[CallArg]) -> Vec<String> {
881    let mut source_names = Vec::new();
882    for arg in args {
883        for name in &arg.source_names {
884            if name.is_empty() || source_names.iter().any(|seen| seen == name) {
885                continue;
886            }
887            source_names.push(name.clone());
888        }
889    }
890    source_names
891}
892
893fn normalize_ruby_instance_variable_events(decl: &mut bonsai_lang_api::Decl) {
894    for write in &mut decl.receiver_field_writes {
895        write.target = normalize_ruby_instance_variable_text(&write.target);
896    }
897    for source in &mut decl.receiver_state_sources {
898        *source = normalize_ruby_instance_variable_text(source);
899    }
900    normalize_ruby_instance_variable_flow_events(&mut decl.flow_events);
901}
902
903fn normalize_ruby_instance_variable_flow_events(events: &mut [FlowEvent]) {
904    for event in events {
905        match event {
906            FlowEvent::Assign {
907                target,
908                source_name,
909                source_call,
910                source_call_args,
911                source_names,
912                ..
913            } => {
914                *target = normalize_ruby_instance_variable_text(target);
915                normalize_optional_ruby_instance_variable_text(source_name);
916                normalize_optional_ruby_instance_variable_text(source_call);
917                normalize_ruby_instance_variable_texts(source_call_args);
918                normalize_ruby_instance_variable_texts(source_names);
919            }
920            FlowEvent::AggregateAssign {
921                target, value_flow, ..
922            } => {
923                *target = normalize_ruby_instance_variable_text(target);
924                normalize_ruby_instance_variable_expression_flow(value_flow);
925            }
926            FlowEvent::Call {
927                name, receiver, args, ..
928            } => {
929                *name = normalize_ruby_instance_variable_text(name);
930                normalize_optional_ruby_instance_variable_text(receiver);
931                for arg in args {
932                    arg.value_text = normalize_ruby_instance_variable_text(&arg.value_text);
933                    normalize_optional_ruby_instance_variable_text(&mut arg.place);
934                    normalize_ruby_instance_variable_texts(&mut arg.source_names);
935                    enrich_ruby_instance_variable_call_arg(arg);
936                }
937            }
938            FlowEvent::Return {
939                value_name,
940                value_text,
941                value_flow,
942                ..
943            } => {
944                normalize_optional_ruby_instance_variable_text(value_name);
945                normalize_optional_ruby_instance_variable_text(value_text);
946                normalize_ruby_instance_variable_expression_flow(value_flow);
947            }
948            FlowEvent::Throw { value_name, .. } => {
949                normalize_optional_ruby_instance_variable_text(value_name);
950            }
951            FlowEvent::Try {
952                body,
953                catch_events,
954                finally_events,
955                catch_param,
956                ..
957            } => {
958                normalize_optional_ruby_instance_variable_text(catch_param);
959                normalize_ruby_instance_variable_flow_events(body);
960                normalize_ruby_instance_variable_flow_events(catch_events);
961                normalize_ruby_instance_variable_flow_events(finally_events);
962            }
963            FlowEvent::Branch {
964                condition,
965                then_events,
966                else_events,
967                ..
968            } => {
969                normalize_optional_ruby_instance_variable_text(condition);
970                normalize_ruby_instance_variable_flow_events(then_events);
971                normalize_ruby_instance_variable_flow_events(else_events);
972            }
973            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
974                normalize_ruby_instance_variable_flow_events(body);
975            }
976            FlowEvent::Yield {
977                value_text,
978                value_flow,
979                ..
980            } => {
981                normalize_optional_ruby_instance_variable_text(value_text);
982                normalize_ruby_instance_variable_expression_flow(value_flow);
983            }
984            FlowEvent::Await { value_name, .. } => {
985                normalize_optional_ruby_instance_variable_text(value_name);
986            }
987            FlowEvent::Lifecycle { name, .. } => {
988                *name = normalize_ruby_instance_variable_text(name);
989            }
990            FlowEvent::Break { .. } | FlowEvent::Continue { .. } => {}
991        }
992    }
993}
994
995fn normalize_ruby_instance_variable_expression_flow(flow: &mut bonsai_lang_api::ExpressionFlow) {
996    normalize_optional_ruby_instance_variable_text(&mut flow.place);
997    normalize_ruby_instance_variable_texts(&mut flow.source_names);
998    if let Some(projection) = &mut flow.projection {
999        projection.base = normalize_ruby_instance_variable_text(&projection.base);
1000        normalize_ruby_instance_variable_texts(&mut projection.path);
1001    }
1002    for field in &mut flow.aggregate_fields {
1003        field.name = normalize_ruby_instance_variable_text(&field.name);
1004        normalize_ruby_instance_variable_expression_flow(&mut field.value);
1005    }
1006    for item in &mut flow.tuple_items {
1007        normalize_ruby_instance_variable_expression_flow(item);
1008    }
1009    for spread in &mut flow.spreads {
1010        normalize_ruby_instance_variable_expression_flow(spread);
1011    }
1012}
1013
1014fn normalize_optional_ruby_instance_variable_text(value: &mut Option<String>) {
1015    if let Some(text) = value {
1016        *text = normalize_ruby_instance_variable_text(text);
1017    }
1018}
1019
1020fn normalize_ruby_instance_variable_texts(values: &mut [String]) {
1021    for value in values {
1022        *value = normalize_ruby_instance_variable_text(value);
1023    }
1024}
1025
1026fn enrich_ruby_instance_variable_call_arg(arg: &mut CallArg) {
1027    let Some(place) = ruby_normalized_instance_variable_place(&arg.value_text) else {
1028        return;
1029    };
1030    if arg.place.as_deref().is_none_or(str::is_empty) {
1031        arg.place = Some(place.clone());
1032    }
1033    if !arg.source_names.iter().any(|source| source == &place) {
1034        arg.source_names.push(place);
1035    }
1036    arg.source_names.sort();
1037    arg.source_names.dedup();
1038}
1039
1040fn ruby_normalized_instance_variable_place(text: &str) -> Option<String> {
1041    let text = text.trim();
1042    let rest = text.strip_prefix("self.")?;
1043    if rest.is_empty() {
1044        return None;
1045    }
1046    if rest.split('.').all(ruby_identifier_part) {
1047        Some(text.to_string())
1048    } else {
1049        None
1050    }
1051}
1052
1053fn ruby_identifier_part(part: &str) -> bool {
1054    let mut chars = part.chars();
1055    chars
1056        .next()
1057        .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1058        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1059}
1060
1061fn normalize_ruby_instance_variable_text(text: &str) -> String {
1062    let mut out = String::with_capacity(text.len());
1063    let mut chars = text.chars().peekable();
1064    let mut quote: Option<char> = None;
1065    let mut escaped = false;
1066
1067    while let Some(ch) = chars.next() {
1068        if let Some(active_quote) = quote {
1069            out.push(ch);
1070            if escaped {
1071                escaped = false;
1072            } else if ch == '\\' {
1073                escaped = true;
1074            } else if ch == active_quote {
1075                quote = None;
1076            }
1077            continue;
1078        }
1079
1080        if matches!(ch, '\'' | '"' | '`') {
1081            quote = Some(ch);
1082            out.push(ch);
1083            continue;
1084        }
1085
1086        if ch != '@' {
1087            out.push(ch);
1088            continue;
1089        }
1090
1091        match chars.peek().copied() {
1092            Some('@') => {
1093                out.push(ch);
1094                out.push('@');
1095                chars.next();
1096            }
1097            Some(next) if next == '_' || next.is_ascii_alphabetic() => {
1098                out.push_str("self.");
1099                while let Some(part) = chars.peek().copied() {
1100                    if part == '_' || part.is_ascii_alphanumeric() {
1101                        out.push(part);
1102                        chars.next();
1103                    } else {
1104                        break;
1105                    }
1106                }
1107            }
1108            _ => out.push(ch),
1109        }
1110    }
1111
1112    out
1113}
1114
1115fn inject_ruby_hash_field_assigns(idx: &mut DeclIndex, tree: &Tree, file: FileId, src: &[u8]) {
1116    let mut synthesized = Vec::new();
1117    for assignment in collect_kinds(tree, &["assignment"]) {
1118        let (Some(left), Some(right)) = (
1119            assignment.child_by_field_name("left"),
1120            assignment.child_by_field_name("right"),
1121        ) else {
1122            continue;
1123        };
1124        let target = normalize_ruby_instance_variable_text(node_text(&left, src).trim());
1125        if target.is_empty() || !ruby_field_target_base_is_supported(&target) {
1126            continue;
1127        }
1128        collect_ruby_hash_field_assigns_for_target(&target, right, file, src, &mut synthesized);
1129    }
1130    if synthesized.is_empty() {
1131        return;
1132    }
1133
1134    for event in synthesized {
1135        let span = event.span();
1136        let Some(decl) = idx
1137            .defs
1138            .iter_mut()
1139            .filter(|decl| {
1140                matches!(
1141                    decl.kind,
1142                    DeclKind::Function | DeclKind::Method | DeclKind::Constructor
1143                ) && decl_span_contains(decl, span)
1144            })
1145            .min_by_key(|decl| decl.span.end.saturating_sub(decl.span.start))
1146        else {
1147            continue;
1148        };
1149        if !decl.flow_events.iter().any(|existing| existing == &event) {
1150            decl.flow_events.push(event);
1151            decl.flow_events
1152                .sort_by_key(|event| (event.span().start, event.span().end));
1153        }
1154    }
1155}
1156
1157fn ruby_field_target_base_is_supported(target: &str) -> bool {
1158    target
1159        .split('.')
1160        .all(|part| !part.is_empty() && part.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()))
1161}
1162
1163fn decl_span_contains(decl: &bonsai_lang_api::Decl, span: Span) -> bool {
1164    let container = decl.body_span.unwrap_or(decl.span);
1165    container.file == span.file && container.start <= span.start && span.end <= container.end
1166}
1167
1168fn collect_ruby_hash_field_assigns_for_target(
1169    target: &str,
1170    node: tree_sitter::Node<'_>,
1171    file: FileId,
1172    src: &[u8],
1173    out: &mut Vec<FlowEvent>,
1174) {
1175    if node.kind() == "hash" {
1176        collect_ruby_hash_pair_assigns(target, node, file, src, out);
1177    }
1178    let mut cursor = node.walk();
1179    for child in node.named_children(&mut cursor) {
1180        collect_ruby_hash_field_assigns_for_target(target, child, file, src, out);
1181    }
1182}
1183
1184fn collect_ruby_hash_pair_assigns(
1185    target: &str,
1186    hash: tree_sitter::Node<'_>,
1187    file: FileId,
1188    src: &[u8],
1189    out: &mut Vec<FlowEvent>,
1190) {
1191    let mut cursor = hash.walk();
1192    for child in hash.named_children(&mut cursor) {
1193        if child.kind() != "pair" {
1194            continue;
1195        }
1196        let Some(key) = child
1197            .child_by_field_name("key")
1198            .and_then(|key| ruby_hash_key_name(key, src))
1199        else {
1200            continue;
1201        };
1202        let Some(value) = child.child_by_field_name("value") else {
1203            continue;
1204        };
1205        let mut source_names = ruby_value_source_names(value, src);
1206        if source_names.is_empty() {
1207            continue;
1208        }
1209        source_names.sort();
1210        source_names.dedup();
1211        out.push(FlowEvent::Assign {
1212            span: span_of(file, &child),
1213            target: format!("{target}.{key}"),
1214            source_name: None,
1215            source_call: None,
1216            source_call_args: Vec::new(),
1217            source_names,
1218            declares_new_binding: false,
1219            value_kind: Some(bonsai_lang_api::AssignValueKind::Compound),
1220        });
1221    }
1222}
1223
1224fn ruby_hash_key_name(key: tree_sitter::Node<'_>, src: &[u8]) -> Option<String> {
1225    let raw = node_text(&key, src)
1226        .trim()
1227        .trim_start_matches(':')
1228        .trim_matches('"')
1229        .trim_matches('\'')
1230        .trim();
1231    if raw.is_empty() || !raw.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
1232        return None;
1233    }
1234    Some(raw.to_string())
1235}
1236
1237fn ruby_value_source_names(node: tree_sitter::Node<'_>, src: &[u8]) -> Vec<String> {
1238    let mut out = Vec::new();
1239    collect_ruby_value_source_names(node, src, &mut out);
1240    out.sort();
1241    out.dedup();
1242    out
1243}
1244
1245fn collect_ruby_value_source_names(node: tree_sitter::Node<'_>, src: &[u8], out: &mut Vec<String>) {
1246    match node.kind() {
1247        "identifier" | "constant" | "self" => {
1248            push_ruby_source_name(out, node_text(&node, src));
1249        }
1250        "instance_variable" => {
1251            push_ruby_source_name(out, &normalize_ruby_instance_variable_text(node_text(&node, src)));
1252        }
1253        "call" => {
1254            push_ruby_source_name(
1255                out,
1256                &normalize_ruby_instance_variable_text(node_text(&node, src).trim()),
1257            );
1258            if let Some(receiver) = node.child_by_field_name("receiver") {
1259                collect_ruby_value_source_names(receiver, src, out);
1260            }
1261            let mut cursor = node.walk();
1262            for child in node.named_children(&mut cursor) {
1263                if child.kind() == "arguments" {
1264                    collect_ruby_value_source_names(child, src, out);
1265                }
1266            }
1267            return;
1268        }
1269        "element_reference" => {
1270            if let Some(access) = ruby_element_reference_name(node, src) {
1271                push_ruby_source_name(out, &access);
1272            }
1273        }
1274        "hash_key_symbol" | "simple_symbol" | "integer" | "float" | "string_content" => {}
1275        _ => {}
1276    }
1277
1278    let mut cursor = node.walk();
1279    for child in node.named_children(&mut cursor) {
1280        collect_ruby_value_source_names(child, src, out);
1281    }
1282}
1283
1284fn ruby_element_reference_name(node: tree_sitter::Node<'_>, src: &[u8]) -> Option<String> {
1285    let object = node
1286        .child_by_field_name("object")
1287        .map(|object| normalize_ruby_instance_variable_text(node_text(&object, src).trim()))?;
1288    let mut cursor = node.walk();
1289    let key = node
1290        .named_children(&mut cursor)
1291        .find(|child| child.kind() == "simple_symbol" || child.kind() == "string")
1292        .and_then(|key| ruby_hash_key_name(key, src))?;
1293    (!object.is_empty()).then(|| format!("{object}.{key}"))
1294}
1295
1296fn push_ruby_source_name(out: &mut Vec<String>, value: &str) {
1297    let value = value.trim();
1298    if value.is_empty()
1299        || value.starts_with(':')
1300        || value.starts_with('"')
1301        || value.starts_with('\'')
1302        || value.chars().all(|ch| ch.is_ascii_digit())
1303    {
1304        return;
1305    }
1306    if !out.iter().any(|existing| existing == value) {
1307        out.push(value.to_string());
1308    }
1309}
1310
1311fn inject_ruby_bare_method_arg_calls(idx: &mut DeclIndex, tree: &Tree, file: FileId, src: &[u8]) {
1312    let mut candidates = Vec::new();
1313    for call in collect_kinds(tree, &["call"]) {
1314        let Some(arguments) = call.child_by_field_name("arguments") else {
1315            continue;
1316        };
1317        let mut cursor = arguments.walk();
1318        for arg in arguments.named_children(&mut cursor) {
1319            if arg.kind() != "identifier" {
1320                continue;
1321            }
1322            let name = node_text(&arg, src).trim();
1323            if !ruby_bare_method_candidate(name) {
1324                continue;
1325            }
1326            candidates.push((span_of(file, &arg), name.to_string()));
1327        }
1328    }
1329    if candidates.is_empty() {
1330        return;
1331    }
1332
1333    for (span, name) in candidates {
1334        let Some(decl) = idx
1335            .defs
1336            .iter_mut()
1337            .filter(|decl| {
1338                matches!(
1339                    decl.kind,
1340                    DeclKind::Function | DeclKind::Method | DeclKind::Constructor
1341                ) && decl_span_contains(decl, span)
1342            })
1343            .min_by_key(|decl| decl.span.end.saturating_sub(decl.span.start))
1344        else {
1345            continue;
1346        };
1347        let locals = ruby_local_bindings_for_decl(decl);
1348        if locals.contains(name.as_str()) {
1349            continue;
1350        }
1351        let event = FlowEvent::Call {
1352            span,
1353            name,
1354            receiver: None,
1355            receiver_types: Vec::new(),
1356            call_kind: CallKind::Method,
1357            args: Vec::new(),
1358        };
1359        if !decl.flow_events.iter().any(|existing| existing == &event) {
1360            decl.flow_events.push(event);
1361            decl.flow_events
1362                .sort_by_key(|event| (event.span().start, event.span().end));
1363        }
1364    }
1365}
1366
1367fn ruby_bare_method_candidate(name: &str) -> bool {
1368    !name.is_empty()
1369        && !matches!(
1370            name,
1371            "nil" | "true" | "false" | "self" | "super" | "yield" | "return" | "break" | "next"
1372        )
1373        && name
1374            .chars()
1375            .all(|ch| ch == '_' || ch == '!' || ch == '?' || ch.is_ascii_alphanumeric())
1376        && name
1377            .chars()
1378            .next()
1379            .is_some_and(|ch| ch == '_' || ch.is_ascii_lowercase())
1380}
1381
1382fn ruby_local_bindings_for_decl(decl: &bonsai_lang_api::Decl) -> std::collections::BTreeSet<String> {
1383    let mut locals: std::collections::BTreeSet<String> = decl
1384        .params
1385        .iter()
1386        .filter_map(|param| ruby_bare_binding_name(param))
1387        .collect();
1388    collect_ruby_local_bindings(&decl.flow_events, &mut locals);
1389    locals
1390}
1391
1392fn collect_ruby_local_bindings(events: &[FlowEvent], locals: &mut std::collections::BTreeSet<String>) {
1393    for event in events {
1394        match event {
1395            FlowEvent::Assign { target, .. } => {
1396                if let Some(name) = ruby_bare_binding_name(target) {
1397                    locals.insert(name);
1398                }
1399            }
1400            FlowEvent::AggregateAssign { target, .. } => {
1401                if let Some(name) = ruby_bare_binding_name(target) {
1402                    locals.insert(name);
1403                }
1404            }
1405            FlowEvent::Try {
1406                body,
1407                catch_events,
1408                finally_events,
1409                catch_param,
1410                ..
1411            } => {
1412                if let Some(param) = catch_param.as_deref().and_then(ruby_bare_binding_name) {
1413                    locals.insert(param);
1414                }
1415                collect_ruby_local_bindings(body, locals);
1416                collect_ruby_local_bindings(catch_events, locals);
1417                collect_ruby_local_bindings(finally_events, locals);
1418            }
1419            FlowEvent::Branch {
1420                then_events,
1421                else_events,
1422                ..
1423            } => {
1424                collect_ruby_local_bindings(then_events, locals);
1425                collect_ruby_local_bindings(else_events, locals);
1426            }
1427            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
1428                collect_ruby_local_bindings(body, locals);
1429            }
1430            FlowEvent::Call { .. }
1431            | FlowEvent::Return { .. }
1432            | FlowEvent::Throw { .. }
1433            | FlowEvent::Yield { .. }
1434            | FlowEvent::Await { .. }
1435            | FlowEvent::Lifecycle { .. }
1436            | FlowEvent::Break { .. }
1437            | FlowEvent::Continue { .. } => {}
1438        }
1439    }
1440}
1441
1442fn ruby_bare_binding_name(value: &str) -> Option<String> {
1443    let name = value.trim();
1444    if name.is_empty() || name.contains('.') || name.contains('[') || name.starts_with('@') {
1445        return None;
1446    }
1447    ruby_bare_method_candidate(name).then(|| name.to_string())
1448}
1449
1450/// Promote paren-less method calls that sit in an assignment's value
1451/// position into call-result assignments.
1452///
1453/// tree-sitter-ruby parses a receiver-less, argument-less method call
1454/// in value position (`cmd = get_input`, `v = gets`) as a bare
1455/// `identifier`, syntactically identical to a local-variable read. The
1456/// flow walker therefore emits `cmd = get_input` as a simple-rename
1457/// `Assign { source_name: Some("get_input"), value_kind: Compound }`
1458/// with no `Call` sibling — so the IDG stitches no call edge and the
1459/// callee's tainted return never reaches the target. The paren form
1460/// (`get_input()`) instead yields `Assign { source_call: "get_input",
1461/// value_kind: CallResult }` plus a sibling `Call`, which taints
1462/// correctly.
1463///
1464/// Ruby's own disambiguation rule (the same one its parser uses): a
1465/// bareword is a local-variable reference iff a local of that name is
1466/// bound in the enclosing scope; otherwise a receiver-less bareword
1467/// naming a method is a method call. We apply exactly that rule —
1468/// rewriting a simple-rename `Assign` into the call-result shape only
1469/// when the RHS bareword is NOT bound as a local/param anywhere in the
1470/// method — and emit the sibling arg-less `Call` so the paren-less
1471/// form produces the identical shape to the working paren form.
1472///
1473/// FP-safety: taint reaches the target via the pre-existing
1474/// simple-rename path only when the RHS names a *tainted local*, which
1475/// must have been bound earlier and is therefore excluded by the local
1476/// guard (locals/params always stay reads). When the bareword is never
1477/// bound in the method it can carry no variable-level taint today, so
1478/// the rewrite is purely additive: it can introduce the (correct)
1479/// callee-return edge but never remove a working one. An unresolved
1480/// callee yields no return summary, so the engine leaves the target
1481/// clean — the same behaviour the paren form already exhibits.
1482fn rewrite_ruby_bareword_call_result_assigns(
1483    decl: &mut bonsai_lang_api::Decl,
1484    block_param_names: &std::collections::BTreeSet<String>,
1485) {
1486    // Method-wide local set (params + every assignment target). Block and
1487    // lambda parameters (`each do |x|`, `->(x){}`) do NOT surface as Assign
1488    // targets — a block variable is bound by the loop, not written — so they
1489    // are folded in from `block_param_names` (collected from the tree by the
1490    // caller). Without this, a block variable that shares a method name
1491    // (`each do |line| ...`, with a `def line`) is wrongly promoted to a call
1492    // (a false positive). Collecting method-wide rather than
1493    // lexically-before-use is strictly conservative: it can only keep more
1494    // barewords as reads, never fewer.
1495    let mut locals = ruby_local_bindings_for_decl(decl);
1496    locals.extend(block_param_names.iter().cloned());
1497    rewrite_ruby_bareword_assigns_in_events(&mut decl.flow_events, &locals);
1498}
1499
1500/// Paren-less method calls in TAIL position: `def get_input; gets; end`
1501/// parses the bare `gets` as an identifier, so the tail-return synthesis
1502/// records `Return { value_name: "gets" }` and NO Call event exists — the
1503/// source matcher never sees a `gets` call and the wrapper's taint is
1504/// silently lost. Mirror of [`inject_ruby_bare_method_arg_calls`] for the
1505/// return position: when a Return's value is a bare word that is not a
1506/// local, param, or block variable, it IS a method call under Ruby
1507/// semantics — synthesize the Call event at the tail span so matching and
1508/// the call-ret→Return stitch both see it.
1509fn inject_ruby_bare_tail_return_calls(
1510    decl: &mut bonsai_lang_api::Decl,
1511    block_param_names: &std::collections::BTreeSet<String>,
1512) {
1513    let mut locals = ruby_local_bindings_for_decl(decl);
1514    locals.extend(block_param_names.iter().cloned());
1515    let mut sites = Vec::new();
1516    collect_ruby_bare_tail_call_sites(&decl.flow_events, &locals, &mut sites);
1517    if sites.is_empty() {
1518        return;
1519    }
1520    let mut changed = false;
1521    for (span, name) in sites {
1522        let event = FlowEvent::Call {
1523            span,
1524            name,
1525            receiver: None,
1526            receiver_types: Vec::new(),
1527            call_kind: CallKind::Method,
1528            args: Vec::new(),
1529        };
1530        if !decl.flow_events.iter().any(|existing| existing == &event) {
1531            decl.flow_events.push(event);
1532            changed = true;
1533        }
1534    }
1535    if changed {
1536        decl.flow_events
1537            .sort_by_key(|event| (event.span().start, event.span().end));
1538    }
1539}
1540
1541fn collect_ruby_bare_tail_call_sites(
1542    events: &[FlowEvent],
1543    locals: &std::collections::BTreeSet<String>,
1544    out: &mut Vec<(Span, String)>,
1545) {
1546    for event in events {
1547        match event {
1548            FlowEvent::Return {
1549                span,
1550                value_name: Some(name),
1551                value_flow,
1552                ..
1553            } => {
1554                // Only the exact bare-word shape: the whole return value
1555                // is the identifier itself. Compound returns
1556                // (`gets.chomp`, `a + b`) already carry real Call events
1557                // or operand reads.
1558                if value_flow.place.as_deref() == Some(name.as_str())
1559                    && ruby_bare_method_candidate(name)
1560                    && !locals.contains(name.as_str())
1561                {
1562                    out.push((*span, name.clone()));
1563                }
1564            }
1565            FlowEvent::Branch {
1566                then_events,
1567                else_events,
1568                ..
1569            } => {
1570                collect_ruby_bare_tail_call_sites(then_events, locals, out);
1571                collect_ruby_bare_tail_call_sites(else_events, locals, out);
1572            }
1573            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
1574                collect_ruby_bare_tail_call_sites(body, locals, out);
1575            }
1576            FlowEvent::Try {
1577                body,
1578                catch_events,
1579                finally_events,
1580                ..
1581            } => {
1582                collect_ruby_bare_tail_call_sites(body, locals, out);
1583                collect_ruby_bare_tail_call_sites(catch_events, locals, out);
1584                collect_ruby_bare_tail_call_sites(finally_events, locals, out);
1585            }
1586            _ => {}
1587        }
1588    }
1589}
1590
1591/// Names bound as block / lambda parameters anywhere in the file
1592/// (`xs.each do |item|`, `xs.map { |x| }`, `->(y){}`). These shadow method
1593/// names inside their block, so a value-position bareword naming one must
1594/// stay a variable read, never be promoted to a method call. Collected
1595/// file-wide (a conservative over-set) so the promotion never mistakes a
1596/// block variable for a paren-less call.
1597fn collect_ruby_block_param_names(tree: &Tree, src: &[u8]) -> std::collections::BTreeSet<String> {
1598    let mut names = std::collections::BTreeSet::new();
1599    for params in collect_kinds(tree, &["block_parameters", "lambda_parameters"]) {
1600        collect_ruby_param_identifiers(params, src, &mut names);
1601    }
1602    names
1603}
1604
1605/// Ruby's parser represents `read_input.to_s` with `read_input` as an
1606/// identifier receiver. Ruby itself resolves that identifier as a local only
1607/// when the enclosing scope binds it; otherwise it is an implicit-receiver,
1608/// zero-argument method call whose result becomes the receiver of `to_s`.
1609/// Materialize that compiler relation as `CallRet(read_input) -> read_input`
1610/// so nested source calls and ordinary user methods are visible without
1611/// guessing names in shared analysis.
1612fn inject_ruby_unbound_receiver_calls(
1613    index: &mut DeclIndex,
1614    tree: &Tree,
1615    file: FileId,
1616    src: &[u8],
1617    block_param_names: &std::collections::BTreeSet<String>,
1618) {
1619    let mut candidates = Vec::new();
1620    for call in collect_kinds(tree, &["call", "method_call"]) {
1621        let Some(receiver) = call.child_by_field_name("receiver") else {
1622            continue;
1623        };
1624        if receiver.kind() != "identifier" || call.child_by_field_name("method").is_none() {
1625            continue;
1626        }
1627        let name = node_text(&receiver, src).trim();
1628        if ruby_bare_method_candidate(name) {
1629            candidates.push((span_of(file, &receiver), name.to_string()));
1630        }
1631    }
1632
1633    for (span, name) in candidates {
1634        let Some(decl) = index
1635            .defs
1636            .iter_mut()
1637            .filter(|decl| {
1638                matches!(
1639                    decl.kind,
1640                    DeclKind::Function | DeclKind::Method | DeclKind::Constructor
1641                ) && decl_span_contains(decl, span)
1642            })
1643            .min_by_key(|decl| decl.span.end.saturating_sub(decl.span.start))
1644        else {
1645            continue;
1646        };
1647        let mut locals = ruby_local_bindings_for_decl(decl);
1648        locals.extend(block_param_names.iter().cloned());
1649        if locals.contains(name.as_str()) {
1650            continue;
1651        }
1652
1653        let call = FlowEvent::Call {
1654            span,
1655            name: name.clone(),
1656            receiver: None,
1657            receiver_types: Vec::new(),
1658            call_kind: CallKind::Method,
1659            args: Vec::new(),
1660        };
1661        let result = FlowEvent::Assign {
1662            span,
1663            target: name.clone(),
1664            source_name: None,
1665            source_call: Some(name),
1666            source_call_args: Vec::new(),
1667            source_names: Vec::new(),
1668            declares_new_binding: false,
1669            value_kind: Some(bonsai_lang_api::AssignValueKind::CallResult),
1670        };
1671        if !decl.flow_events.iter().any(|event| event == &call) {
1672            decl.flow_events.push(call);
1673            decl.flow_events.push(result);
1674            decl.flow_events
1675                .sort_by_key(|event| (event.span().start, event.span().end));
1676        }
1677    }
1678}
1679
1680fn collect_ruby_param_identifiers(
1681    node: tree_sitter::Node<'_>,
1682    src: &[u8],
1683    out: &mut std::collections::BTreeSet<String>,
1684) {
1685    if node.kind() == "identifier" {
1686        if let Some(name) = ruby_bare_binding_name(node_text(&node, src).trim()) {
1687            out.insert(name);
1688        }
1689        return;
1690    }
1691    let mut cursor = node.walk();
1692    for child in node.named_children(&mut cursor) {
1693        collect_ruby_param_identifiers(child, src, out);
1694    }
1695}
1696
1697fn rewrite_ruby_bareword_assigns_in_events(
1698    events: &mut Vec<FlowEvent>,
1699    locals: &std::collections::BTreeSet<String>,
1700) {
1701    // Recurse into nested regions with the same method-wide local set.
1702    for event in events.iter_mut() {
1703        match event {
1704            FlowEvent::Branch {
1705                then_events,
1706                else_events,
1707                ..
1708            } => {
1709                rewrite_ruby_bareword_assigns_in_events(then_events, locals);
1710                rewrite_ruby_bareword_assigns_in_events(else_events, locals);
1711            }
1712            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
1713                rewrite_ruby_bareword_assigns_in_events(body, locals);
1714            }
1715            FlowEvent::Try {
1716                body,
1717                catch_events,
1718                finally_events,
1719                ..
1720            } => {
1721                rewrite_ruby_bareword_assigns_in_events(body, locals);
1722                rewrite_ruby_bareword_assigns_in_events(catch_events, locals);
1723                rewrite_ruby_bareword_assigns_in_events(finally_events, locals);
1724            }
1725            _ => {}
1726        }
1727    }
1728
1729    if !events
1730        .iter()
1731        .any(|event| ruby_bareword_call_result_name(event, locals).is_some())
1732    {
1733        return;
1734    }
1735    // Preserve source order and insert each synthetic arg-less Call
1736    // immediately after the promoted Assign, mirroring the paren form's
1737    // `Assign{CallResult}` + `Call` pairing. Order-preserving, so the
1738    // pass is deterministic.
1739    let mut rewritten = Vec::with_capacity(events.len() + 1);
1740    for event in events.drain(..) {
1741        match ruby_bareword_call_result_name(&event, locals) {
1742            Some(call_name) => {
1743                let span = event.span();
1744                rewritten.push(promote_ruby_bareword_assign_to_call_result(event, &call_name));
1745                rewritten.push(FlowEvent::Call {
1746                    span,
1747                    name: call_name,
1748                    receiver: None,
1749                    receiver_types: Vec::new(),
1750                    call_kind: CallKind::Function,
1751                    args: Vec::new(),
1752                });
1753            }
1754            None => rewritten.push(event),
1755        }
1756    }
1757    *events = rewritten;
1758}
1759
1760/// If `event` is a simple-rename assignment (`target = bareword`) whose
1761/// RHS bareword names a method rather than a bound local, return the
1762/// method name. `None` for anything else — the guard that keeps locals,
1763/// params, literals, compound RHS, and already-a-call assigns as reads.
1764fn ruby_bareword_call_result_name(
1765    event: &FlowEvent,
1766    locals: &std::collections::BTreeSet<String>,
1767) -> Option<String> {
1768    let FlowEvent::Assign {
1769        source_name: Some(name),
1770        source_call: None,
1771        source_names,
1772        value_kind,
1773        ..
1774    } = event
1775    else {
1776        return None;
1777    };
1778    // Simple-name RHS only: `source_name` is contractually a single bare
1779    // identifier and `source_names` must carry nothing beyond it (a
1780    // compound RHS leaves `source_name` empty). Anything else is not a
1781    // bare paren-less-call candidate.
1782    if !source_names.iter().all(|carrier| carrier == name) {
1783        return None;
1784    }
1785    // Never reclassify a literal, an already-resolved call, or a yield
1786    // RHS. A bare identifier read is Compound / Unknown / unset.
1787    if matches!(
1788        value_kind,
1789        Some(
1790            bonsai_lang_api::AssignValueKind::Literal
1791                | bonsai_lang_api::AssignValueKind::CallResult
1792                | bonsai_lang_api::AssignValueKind::YieldResult
1793                | bonsai_lang_api::AssignValueKind::CallableReference
1794        )
1795    ) {
1796        return None;
1797    }
1798    // Ruby's rule: a bareword bound as a local/param is a variable read.
1799    if locals.contains(name.as_str()) {
1800        return None;
1801    }
1802    // The bareword must have the lexical shape of a Ruby method name.
1803    ruby_bare_method_candidate(name).then(|| name.clone())
1804}
1805
1806/// Rewrite a qualifying simple-rename `Assign` into the call-result
1807/// shape: drop the read-style `source_name` / `source_names`, set
1808/// `source_call` to the callee, and mark the RHS `CallResult`.
1809fn promote_ruby_bareword_assign_to_call_result(event: FlowEvent, call_name: &str) -> FlowEvent {
1810    let FlowEvent::Assign {
1811        span,
1812        target,
1813        declares_new_binding,
1814        ..
1815    } = event
1816    else {
1817        return event;
1818    };
1819    FlowEvent::Assign {
1820        span,
1821        target,
1822        source_name: None,
1823        source_call: Some(call_name.to_string()),
1824        source_call_args: Vec::new(),
1825        source_names: Vec::new(),
1826        declares_new_binding,
1827        value_kind: Some(bonsai_lang_api::AssignValueKind::CallResult),
1828    }
1829}
1830
1831fn ruby_span_text(src: &[u8], span: Span) -> Option<&str> {
1832    let start = usize::try_from(span.start).ok()?;
1833    let end = usize::try_from(span.end).ok()?;
1834    let bytes = src.get(start..end)?;
1835    std::str::from_utf8(bytes).ok()
1836}
1837
1838fn apply_ruby_class_semantic_identity(idx: &mut DeclIndex) {
1839    // Ruby modules are lexical namespace owners, not just display wrappers.
1840    // The generic declaration pass records that ownership in `Decl.parent`;
1841    // materialize it into `module_path` so a constant-qualified call such as
1842    // `Tokenizer.each_token` resolves against the exact AST-declared module.
1843    // Without this step every module method retained only the file module
1844    // (`pipeline`) even though its qualified name correctly included the
1845    // lexical owner (`pipeline::Tokenizer::each_token`).
1846    let owners = idx
1847        .defs
1848        .iter()
1849        .map(|decl| (decl.symbol, (decl.parent, decl.kind, decl.name.clone())))
1850        .collect::<std::collections::HashMap<_, _>>();
1851    for decl in &mut idx.defs {
1852        let mut owner = if decl.kind == DeclKind::Module {
1853            Some(decl.symbol)
1854        } else {
1855            decl.parent
1856        };
1857        let mut module_names = Vec::new();
1858        let mut seen = std::collections::HashSet::new();
1859        while let Some(symbol) = owner {
1860            if !seen.insert(symbol) {
1861                break;
1862            }
1863            let Some((parent, kind, name)) = owners.get(&symbol) else {
1864                break;
1865            };
1866            if *kind == DeclKind::Module {
1867                module_names.push(name.clone());
1868            }
1869            owner = *parent;
1870        }
1871        module_names.reverse();
1872        for module_name in module_names {
1873            if decl.module_path.segments.last() != Some(&module_name) {
1874                decl.module_path.segments.push(module_name);
1875            }
1876        }
1877    }
1878
1879    let mut classes: Vec<(Span, String, bonsai_common::SymbolId)> = idx
1880        .defs
1881        .iter()
1882        .filter(|decl| is_class_like(decl.kind))
1883        .map(|decl| (decl.span, decl.name.clone(), decl.symbol))
1884        .collect();
1885    classes.sort_by_key(|(span, _, _)| span.end.saturating_sub(span.start));
1886    if classes.is_empty() {
1887        return;
1888    }
1889    for decl in &mut idx.defs {
1890        if is_class_like(decl.kind) {
1891            let mut segments = decl.module_path.segments.clone();
1892            segments.push(decl.name.clone());
1893            decl.module_path = ModulePath::from_segments(segments.iter().cloned());
1894            decl.qualified_name = Some(segments.join("."));
1895            continue;
1896        }
1897        if !matches!(
1898            decl.kind,
1899            DeclKind::Function | DeclKind::Method | DeclKind::Constructor
1900        ) {
1901            continue;
1902        }
1903        let Some((_, class_name, class_symbol)) = classes
1904            .iter()
1905            .filter(|(span, _, _)| span.start <= decl.span.start && span.end >= decl.span.end)
1906            .min_by_key(|(span, _, _)| span.end.saturating_sub(span.start))
1907        else {
1908            continue;
1909        };
1910        decl.parent = Some(*class_symbol);
1911        let mut segments = decl.module_path.segments.clone();
1912        segments.push(class_name.clone());
1913        decl.module_path = ModulePath::from_segments(segments.iter().cloned());
1914        decl.qualified_name = Some(format!("{}.{}", segments.join("."), decl.name));
1915    }
1916}
1917
1918/// Apply Ruby's scope-marker visibility to method decls. Ruby's
1919/// `private` / `protected` / `public` keywords (used as bare
1920/// statements inside a class / module body) change the default
1921/// visibility of subsequent `def` definitions until another marker
1922/// flips the scope. The keyword form `private :foo, :bar` flips a
1923/// specific list of names rather than a scope.
1924///
1925/// Visibility comes from real syntax. The kit's modifier-vocabulary
1926/// path doesn't model line-scoped visibility, so this adapter walks
1927/// the parsed tree directly.
1928fn apply_ruby_scope_visibility(idx: &mut DeclIndex, tree: &Tree, src: &[u8], file: FileId) {
1929    // Map (start_byte, end_byte) of each method decl in the index to
1930    // the span we'll patch when we find the matching tree node.
1931    let mut visibility_overrides: std::collections::HashMap<(u64, u64), bonsai_lang_api::Visibility> =
1932        std::collections::HashMap::new();
1933    walk_class_bodies(
1934        tree.root_node(),
1935        src,
1936        file,
1937        bonsai_lang_api::Visibility::Public,
1938        &mut visibility_overrides,
1939    );
1940    for decl in &mut idx.defs {
1941        if !matches!(
1942            decl.kind,
1943            bonsai_lang_api::DeclKind::Function
1944                | bonsai_lang_api::DeclKind::Method
1945                | bonsai_lang_api::DeclKind::Constructor
1946        ) {
1947            continue;
1948        }
1949        if let Some(visibility) = visibility_overrides.get(&(decl.span.start, decl.span.end)) {
1950            decl.visibility = *visibility;
1951        }
1952    }
1953}
1954
1955/// Walk the tree, find each `class` / `module` / `singleton_class`,
1956/// and run the scope-tracking pass over its body. Each body resets to
1957/// `Public` — Ruby scope markers don't bleed between sibling classes.
1958fn walk_class_bodies(
1959    node: tree_sitter::Node<'_>,
1960    src: &[u8],
1961    file: FileId,
1962    inherited_scope: bonsai_lang_api::Visibility,
1963    out: &mut std::collections::HashMap<(u64, u64), bonsai_lang_api::Visibility>,
1964) {
1965    // Ruby tree-sitter exposes class / module / singleton bodies as
1966    // `body_statement` children of `class` / `module` / `singleton_class`.
1967    // Inside those bodies we track the current scope marker.
1968    match node.kind() {
1969        "class" | "module" | "singleton_class" => {
1970            let mut scope = bonsai_lang_api::Visibility::Public;
1971            if let Some(body) = node.child_by_field_name("body") {
1972                walk_body_statements(body, src, file, &mut scope, out);
1973            } else {
1974                // Fall back to scanning all named children — ts-ruby
1975                // grammar uses `body_statement` as a named child.
1976                let mut child_cursor = node.walk();
1977                for child in node.named_children(&mut child_cursor) {
1978                    if child.kind() == "body_statement" {
1979                        walk_body_statements(child, src, file, &mut scope, out);
1980                    }
1981                }
1982            }
1983        }
1984        _ => {}
1985    }
1986    // Recurse: nested classes and modules need their own scope tracking.
1987    let mut child_cursor = node.walk();
1988    for child in node.named_children(&mut child_cursor) {
1989        walk_class_bodies(child, src, file, inherited_scope, out);
1990    }
1991}
1992
1993/// Iterate one class / module body in source order, mutating
1994/// `current_scope` as scope markers appear and tagging each `def` /
1995/// `singleton_method` with the active scope. The scope is line-relative
1996/// — markers only affect defs that follow them inside the same body.
1997fn walk_body_statements(
1998    body: tree_sitter::Node<'_>,
1999    src: &[u8],
2000    file: FileId,
2001    current_scope: &mut bonsai_lang_api::Visibility,
2002    out: &mut std::collections::HashMap<(u64, u64), bonsai_lang_api::Visibility>,
2003) {
2004    let mut body_cursor = body.walk();
2005    for stmt in body.named_children(&mut body_cursor) {
2006        match stmt.kind() {
2007            // Bare scope marker: `private` / `protected` / `public` /
2008            // `module_function` alone on a line flips the default for
2009            // subsequent defs. tree-sitter-ruby parses arg-less calls
2010            // to those methods as bare identifiers, not `call` nodes.
2011            "identifier" => {
2012                let text = std::str::from_utf8(&src[stmt.byte_range()]).unwrap_or("");
2013                match text {
2014                    "private" => *current_scope = bonsai_lang_api::Visibility::Private,
2015                    "protected" => *current_scope = bonsai_lang_api::Visibility::Protected,
2016                    "public" => *current_scope = bonsai_lang_api::Visibility::Public,
2017                    // `module_function` flips the dual-mode (private
2018                    // instance, public module-level). The
2019                    // resolver-relevant half is the public surface;
2020                    // model as Public.
2021                    "module_function" => *current_scope = bonsai_lang_api::Visibility::Public,
2022                    _ => {}
2023                }
2024            }
2025            // Modifier with arg list: `private :foo, :bar`. Tags the
2026            // listed methods only — does NOT flip the scope. Also
2027            // covers `module_function :name` (Ruby's "make `name` both
2028            // private instance and public module-level"), which we
2029            // model as a Public override on the named method, plus the
2030            // `attr_reader` / `attr_writer` / `attr_accessor` forms
2031            // which declare *new* synthetic methods that don't exist
2032            // as `def` decls — but if the declaration sits inside a
2033            // public scope region, we keep the default scope, and if
2034            // it sits inside a private region the kit's scope-marker
2035            // path already handles the surrounding visibility.
2036            "call" => {
2037                let method_node = stmt.child_by_field_name("method");
2038                let method_text = method_node
2039                    .map(|method| std::str::from_utf8(&src[method.byte_range()]).unwrap_or(""))
2040                    .unwrap_or("");
2041                let target_visibility = match method_text {
2042                    "private" => Some(bonsai_lang_api::Visibility::Private),
2043                    "protected" => Some(bonsai_lang_api::Visibility::Protected),
2044                    "public" => Some(bonsai_lang_api::Visibility::Public),
2045                    // `module_function :name` — exposes `name` as a
2046                    // public module-level method while keeping it
2047                    // private as an instance method. We tag the
2048                    // matching `def` Public so cross-module callers
2049                    // can resolve it.
2050                    "module_function" => Some(bonsai_lang_api::Visibility::Public),
2051                    _ => None,
2052                };
2053                if let Some(visibility) = target_visibility {
2054                    if let Some(args) = stmt.child_by_field_name("arguments") {
2055                        let mut arg_cursor = args.walk();
2056                        for arg in args.named_children(&mut arg_cursor) {
2057                            // Only `:name` symbols name a target; bare
2058                            // identifiers in the arg list are treated
2059                            // as locals by Ruby and ignored here.
2060                            if arg.kind() != "simple_symbol" {
2061                                continue;
2062                            }
2063                            let raw_symbol = std::str::from_utf8(&src[arg.byte_range()]).unwrap_or("");
2064                            let target_name = raw_symbol.trim_start_matches(':');
2065                            if target_name.is_empty() {
2066                                continue;
2067                            }
2068                            // Find a method def in this body with the
2069                            // same name and tag it.
2070                            let mut sibling_cursor = body.walk();
2071                            for sibling in body.named_children(&mut sibling_cursor) {
2072                                if sibling.kind() != "method" && sibling.kind() != "singleton_method" {
2073                                    continue;
2074                                }
2075                                let name_node = sibling.child_by_field_name("name");
2076                                let sibling_name = name_node
2077                                    .map(|name| std::str::from_utf8(&src[name.byte_range()]).unwrap_or(""))
2078                                    .unwrap_or("");
2079                                if sibling_name == target_name {
2080                                    let span = span_of(file, &sibling);
2081                                    out.insert((span.start, span.end), visibility);
2082                                }
2083                            }
2084                        }
2085                    }
2086                }
2087                // `module_function` with no args (bare keyword form)
2088                // flips the scope for subsequent defs to the dual
2089                // private-instance/public-module mode. From the
2090                // resolver's perspective the public module-level half
2091                // is what matters, so we treat it as a Public scope
2092                // flip for the rest of the body.
2093                if method_text == "module_function" {
2094                    let no_args = match stmt.child_by_field_name("arguments") {
2095                        None => true,
2096                        Some(args) => args.named_child_count() == 0,
2097                    };
2098                    if no_args {
2099                        *current_scope = bonsai_lang_api::Visibility::Public;
2100                    }
2101                }
2102            }
2103            "method" | "singleton_method" => {
2104                let span = span_of(file, &stmt);
2105                // Don't overwrite an explicit `private :foo` tag.
2106                out.entry((span.start, span.end)).or_insert(*current_scope);
2107            }
2108            _ => {}
2109        }
2110    }
2111}
2112
2113/// True for any `DeclKind` that can carry a `bases` list. Ruby only
2114/// has `Class` itself, but the predicate is shared with the
2115/// post-processing loop and matches the shape used by the other adapters.
2116fn is_class_like(kind: DeclKind) -> bool {
2117    matches!(
2118        kind,
2119        DeclKind::Class | DeclKind::Interface | DeclKind::Trait | DeclKind::Struct | DeclKind::Enum
2120    )
2121}
2122
2123/// Walk Ruby `class` declarations and collect the single optional
2124/// superclass from the `superclass:` field. Grammar shape (verified):
2125///
2126///   `class Echo < Base; … end` →
2127///     (class name: (constant) superclass: (superclass (constant)) body: …)
2128///
2129/// Ruby has no interfaces and no multiple inheritance. The `include
2130/// SomeMixin` form is a call statement inside the body, not a parent
2131/// clause — the matcher's existing import / include resolution path
2132/// handles it elsewhere.
2133fn collect_ruby_class_bases(
2134    tree: &Tree,
2135    file: FileId,
2136    src: &[u8],
2137) -> Vec<(bonsai_common::Span, String, Vec<String>)> {
2138    let mut bases_table = Vec::new();
2139    for class_node in collect_kinds(tree, &["class"]) {
2140        let class_name = class_node
2141            .child_by_field_name("name")
2142            .map(|node| node_text(&node, src).to_string())
2143            .unwrap_or_default();
2144        let mut bases: Vec<String> = Vec::new();
2145        if let Some(superclass_node) = class_node.child_by_field_name("superclass") {
2146            // `superclass` wrapper has one named child — the constant
2147            // / scope_resolution naming the parent.
2148            let mut sc_cursor = superclass_node.walk();
2149            for child in superclass_node.named_children(&mut sc_cursor) {
2150                if let Some(name) = canonical_ruby_base_name(node_text(&child, src)) {
2151                    if !bases.iter().any(|existing| existing == &name) {
2152                        bases.push(name);
2153                    }
2154                }
2155            }
2156        }
2157        if !bases.is_empty() {
2158            bases_table.push((span_of(file, &class_node), class_name, bases));
2159        }
2160    }
2161    bases_table
2162}
2163
2164/// Strip `Foo::Bar::Baz` down to the bare tail (`Baz`). Resolver
2165/// lookups for inherited methods key on the unqualified class name.
2166fn canonical_ruby_base_name(raw: &str) -> Option<String> {
2167    let trimmed = raw.trim();
2168    let bare = trimmed.rsplit("::").next().unwrap_or(trimmed).trim();
2169    if bare.is_empty() {
2170        return None;
2171    }
2172    Some(bare.to_string())
2173}
2174
2175/// Lower Ruby's static string-key element reads into field-like compiler
2176/// facts. `env["QUERY_STRING"]` becomes `env.QUERY_STRING`; comments,
2177/// unrelated string literals, interpolated keys, and element writes do not
2178/// create reads. Security policy remains in the rulepack rather than in this
2179/// adapter.
2180fn extract_ruby_static_element_key_refs(tree: &Tree, src: &[u8], file: FileId) -> Vec<Ref> {
2181    let mut refs = Vec::new();
2182    for element in collect_kinds(tree, &["element_reference"]) {
2183        if ruby_element_reference_is_write(&element) {
2184            continue;
2185        }
2186        let Some(object) = element.child_by_field_name("object") else {
2187            continue;
2188        };
2189        let object_name = node_text(&object, src).trim();
2190        if object_name.is_empty() {
2191            continue;
2192        }
2193        let mut cursor = element.walk();
2194        for argument in element.named_children(&mut cursor) {
2195            if argument.id() == object.id() || argument.kind() != "string" {
2196                continue;
2197            }
2198            let mut string_cursor = argument.walk();
2199            let parts = argument.named_children(&mut string_cursor).collect::<Vec<_>>();
2200            let [content] = parts.as_slice() else {
2201                continue;
2202            };
2203            if content.kind() != "string_content" {
2204                continue;
2205            }
2206            let key = node_text(content, src).trim();
2207            if key.is_empty() {
2208                continue;
2209            }
2210            refs.push(Ref {
2211                span: span_of(file, content),
2212                name: format!("{object_name}.{key}"),
2213                kind: RefKind::Read,
2214                scope: None,
2215                resolved: None,
2216            });
2217        }
2218    }
2219    refs
2220}
2221
2222fn ruby_element_reference_is_write(node: &tree_sitter::Node<'_>) -> bool {
2223    let Some(parent) = node.parent() else {
2224        return false;
2225    };
2226    if !matches!(parent.kind(), "assignment" | "operator_assignment") {
2227        return false;
2228    }
2229    parent
2230        .child_by_field_name("left")
2231        .is_some_and(|left| left.id() == node.id())
2232}
2233
2234fn collect_ruby_erb_implicit_inputs(tree: &Tree, src: &[u8]) -> Vec<String> {
2235    let mut inputs = collect_kinds(tree, &["instance_variable"])
2236        .into_iter()
2237        .map(|node| normalize_ruby_instance_variable_text(node_text(&node, src).trim()))
2238        .filter(|input| ruby_normalized_instance_variable_place(input).is_some())
2239        .collect::<Vec<_>>();
2240    inputs.sort();
2241    inputs.dedup();
2242    inputs
2243}
2244
2245/// Pre-process an ERB / RHTML template source: replace HTML wrapping
2246/// with spaces while preserving the embedded `<%= expr %>` and
2247/// `<% stmt %>` Ruby code blocks. The ERB tags themselves (`<%=`,
2248/// `<%`, `%>`) are also masked so tree-sitter-ruby sees only Ruby
2249/// code surrounded by whitespace.
2250///
2251/// Line breaks are preserved — every replacement is whitespace of
2252/// the same byte length, so column / line positions in the resulting
2253/// tree-sitter node match the original `.erb` source.
2254fn preprocess_erb(input: &str) -> String {
2255    let bytes = input.as_bytes();
2256    let mut out = vec![b' '; bytes.len()];
2257    // Preserve all newlines first so blank-string regions still
2258    // carry line breaks into the masked output.
2259    for (byte_index, &byte) in bytes.iter().enumerate() {
2260        if byte == b'\n' || byte == b'\r' {
2261            out[byte_index] = byte;
2262        }
2263    }
2264    // Find each `<%` ... `%>` block and copy the Ruby chunk into the
2265    // masked output. Tag boundaries themselves stay as spaces so
2266    // tree-sitter-ruby sees only Ruby tokens.
2267    let mut cursor = 0;
2268    while cursor + 1 < bytes.len() {
2269        if bytes[cursor] == b'<' && bytes[cursor + 1] == b'%' {
2270            // Skip optional `=` / `-` / `#` (ERB comment / silent / value).
2271            let tag_start = cursor;
2272            let mut content_start = cursor + 2;
2273            while content_start < bytes.len() && matches!(bytes[content_start], b'=' | b'-' | b'#') {
2274                content_start += 1;
2275            }
2276            // Find closing `%>`.
2277            let mut close_start = content_start;
2278            while close_start + 1 < bytes.len() {
2279                if bytes[close_start] == b'%' && bytes[close_start + 1] == b'>' {
2280                    break;
2281                }
2282                close_start += 1;
2283            }
2284            if close_start + 1 >= bytes.len() {
2285                // Unclosed tag — treat the rest as masked.
2286                break;
2287            }
2288            // Copy Ruby content [content_start..close_start] into the output.
2289            // ERB comments (`<%# ... %>`) — masked, not surfaced as
2290            // Ruby. Silent and value forms are surfaced.
2291            let is_comment = bytes.get(tag_start + 2).copied() == Some(b'#');
2292            if !is_comment {
2293                for content_index in content_start..close_start {
2294                    if bytes[content_index] != b'\n' && bytes[content_index] != b'\r' {
2295                        out[content_index] = bytes[content_index];
2296                    }
2297                }
2298            }
2299            // Skip past `%>` (and an optional trailing `-` for trim form).
2300            cursor = close_start + 2;
2301            if cursor < bytes.len() && bytes[cursor] == b'-' {
2302                cursor += 1;
2303            }
2304            continue;
2305        }
2306        cursor += 1;
2307    }
2308    // Safety: only valid-UTF8 bytes were copied (Ruby code from a
2309    // UTF-8 input is UTF-8). The rest is ASCII whitespace.
2310    match String::from_utf8(out) {
2311        Ok(masked) => masked,
2312        Err(invalid) => {
2313            // Fall back to lossy if a multi-byte char straddled a
2314            // tag boundary in a way that produced an invalid byte
2315            // sequence (very unlikely with the byte-level copy).
2316            String::from_utf8_lossy(invalid.as_bytes()).into_owned()
2317        }
2318    }
2319}
2320
2321/// Lift every `require` / `require_relative` / `load` / `autoload`
2322/// call into an `ImportSpec`. Ruby has no native import keyword; these
2323/// method calls are the convention and the only handle the resolver has.
2324fn parse_imports(tree: &Tree, src: &[u8], file: FileId) -> Vec<ImportSpec> {
2325    let mut imports = Vec::new();
2326    // tree-sitter-ruby parses each loader call as a `call` node whose
2327    // `method:` is the function name and whose `arguments:` carries
2328    // the module string.
2329    for call_node in collect_kinds(tree, &["call"]) {
2330        let Some(method_node) = call_node.child_by_field_name("method") else {
2331            continue;
2332        };
2333        let method = node_text(&method_node, src);
2334        if !matches!(method, "require" | "require_relative" | "load" | "autoload") {
2335            continue;
2336        }
2337        let Some(args) = call_node.child_by_field_name("arguments") else {
2338            continue;
2339        };
2340        let module = first_named_child_of_kind(&args, "string")
2341            .and_then(|string_node| first_named_child_of_kind(&string_node, "string_content"))
2342            .map(|content| node_text(&content, src).to_string())
2343            .unwrap_or_default();
2344        if module.is_empty() {
2345            continue;
2346        }
2347        imports.push(ImportSpec {
2348            span: span_of(file, &call_node),
2349            module: module.clone(),
2350            alias: None,
2351            is_wildcard: false,
2352            original_name: None,
2353            scope: ImportScope::Module,
2354        });
2355        if matches!(method, "require" | "require_relative" | "load") {
2356            imports.push(ImportSpec {
2357                span: span_of(file, &call_node),
2358                module: module.clone(),
2359                alias: None,
2360                is_wildcard: true,
2361                original_name: None,
2362                scope: ImportScope::Local,
2363            });
2364            if let Some(stem) = module.rsplit(['/', '\\']).next() {
2365                let constant = ruby_constant_name_from_snake_case(stem);
2366                if !constant.is_empty() && constant != module {
2367                    imports.push(ImportSpec {
2368                        span: span_of(file, &call_node),
2369                        module,
2370                        alias: Some(constant),
2371                        is_wildcard: true,
2372                        original_name: None,
2373                        // Resolver-only constant binding inferred
2374                        // from the loader target (`user_service` ->
2375                        // `UserService`). The visible import row is
2376                        // still the require/load statement itself.
2377                        scope: ImportScope::Local,
2378                    });
2379                }
2380            }
2381        }
2382    }
2383    for assignment in collect_kinds(tree, &["assignment"]) {
2384        if inside_ruby_executable_scope(assignment) {
2385            continue;
2386        }
2387        let (Some(left), Some(right)) = (
2388            assignment.child_by_field_name("left"),
2389            assignment.child_by_field_name("right"),
2390        ) else {
2391            continue;
2392        };
2393        if left.kind() != "constant" || right.kind() != "constant" {
2394            continue;
2395        }
2396        let alias = node_text(&left, src).trim();
2397        let module = node_text(&right, src).trim();
2398        if alias.is_empty() || module.is_empty() || alias == module {
2399            continue;
2400        }
2401        if imports.iter().any(|import| {
2402            import.alias.as_deref() == Some(alias)
2403                && import.module == module
2404                && import.original_name.is_none()
2405        }) {
2406            continue;
2407        }
2408        imports.push(ImportSpec {
2409            span: span_of(file, &assignment),
2410            module: module.to_string(),
2411            alias: Some(alias.to_string()),
2412            is_wildcard: false,
2413            original_name: None,
2414            scope: ImportScope::Module,
2415        });
2416    }
2417    imports
2418}
2419
2420fn ruby_constant_name_from_snake_case(stem: &str) -> String {
2421    stem.split('_')
2422        .filter(|part| !part.is_empty())
2423        .map(|part| {
2424            let mut chars = part.chars();
2425            let Some(first) = chars.next() else {
2426                return String::new();
2427            };
2428            let mut out = String::new();
2429            out.extend(first.to_uppercase());
2430            out.push_str(chars.as_str());
2431            out
2432        })
2433        .collect::<String>()
2434}
2435
2436fn inside_ruby_executable_scope(node: tree_sitter::Node<'_>) -> bool {
2437    let mut parent = node.parent();
2438    while let Some(current) = parent {
2439        if matches!(
2440            current.kind(),
2441            "method" | "singleton_method" | "block" | "do_block"
2442        ) {
2443            return true;
2444        }
2445        parent = current.parent();
2446    }
2447    false
2448}
2449
2450#[cfg(test)]
2451#[path = "tests.rs"]
2452mod tests;