1use 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
37fn 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 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 constructor_names: &["initialize", "new"],
196 tail_expression_returns: true,
197 void_return_type_names: &[],
198 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
328fn 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 &["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 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_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 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 rewrite_ruby_bareword_call_result_assigns(decl, &block_param_names);
501 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 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 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 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 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 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 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 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 Some(FlowEvent::Throw {
807 span: *span,
808 value_name: thrown_arg.and_then(|arg| arg.place.clone()),
809 thrown_type: None,
810 })
811}
812
813fn 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
1450fn rewrite_ruby_bareword_call_result_assigns(
1483 decl: &mut bonsai_lang_api::Decl,
1484 block_param_names: &std::collections::BTreeSet<String>,
1485) {
1486 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
1500fn 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 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
1591fn 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
1605fn 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 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 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
1760fn 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 if !source_names.iter().all(|carrier| carrier == name) {
1783 return None;
1784 }
1785 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 if locals.contains(name.as_str()) {
1800 return None;
1801 }
1802 ruby_bare_method_candidate(name).then(|| name.clone())
1804}
1805
1806fn 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 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
1918fn apply_ruby_scope_visibility(idx: &mut DeclIndex, tree: &Tree, src: &[u8], file: FileId) {
1929 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
1955fn 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 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 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 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
1993fn 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 "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" => *current_scope = bonsai_lang_api::Visibility::Public,
2022 _ => {}
2023 }
2024 }
2025 "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" => 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 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 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 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 out.entry((span.start, span.end)).or_insert(*current_scope);
2107 }
2108 _ => {}
2109 }
2110 }
2111}
2112
2113fn is_class_like(kind: DeclKind) -> bool {
2117 matches!(
2118 kind,
2119 DeclKind::Class | DeclKind::Interface | DeclKind::Trait | DeclKind::Struct | DeclKind::Enum
2120 )
2121}
2122
2123fn 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 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
2164fn 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
2175fn 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
2245fn preprocess_erb(input: &str) -> String {
2255 let bytes = input.as_bytes();
2256 let mut out = vec![b' '; bytes.len()];
2257 for (byte_index, &byte) in bytes.iter().enumerate() {
2260 if byte == b'\n' || byte == b'\r' {
2261 out[byte_index] = byte;
2262 }
2263 }
2264 let mut cursor = 0;
2268 while cursor + 1 < bytes.len() {
2269 if bytes[cursor] == b'<' && bytes[cursor + 1] == b'%' {
2270 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 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 break;
2287 }
2288 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 cursor = close_start + 2;
2301 if cursor < bytes.len() && bytes[cursor] == b'-' {
2302 cursor += 1;
2303 }
2304 continue;
2305 }
2306 cursor += 1;
2307 }
2308 match String::from_utf8(out) {
2311 Ok(masked) => masked,
2312 Err(invalid) => {
2313 String::from_utf8_lossy(invalid.as_bytes()).into_owned()
2317 }
2318 }
2319}
2320
2321fn parse_imports(tree: &Tree, src: &[u8], file: FileId) -> Vec<ImportSpec> {
2325 let mut imports = Vec::new();
2326 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 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;