use bonsai_common::{FileId, Span};
use bonsai_lang_api::{
decl_index_with_handler, extract_imports_via,
kit::{
call_arg_from_node_with_handler, collect_kinds, first_named_child_of_kind, language_from_pack,
named_child_call_args_with_handler, node_text, parse_with, pattern_binding_sites_from_arms, span_of,
},
AdapterContext, AdapterError, CallArg, CallKind, CallTargetExtraction, DeclIndex, DeclKind, FlowEvent,
GrammarHandler, ImportIndex, ImportScope, ImportSpec, LanguageAdapter, LanguageCapabilities, LanguageId,
ModulePath, PatternBindingSite, Ref, RefKind, EMPTY_HANDLER,
};
use tree_sitter::{Language, Node, Tree};
pub const LANG_ID: LanguageId = LanguageId::new("ruby");
const PACK_NAME: &str = "ruby";
fn ruby_call_target<'tree>(node: Node<'tree>, src: &[u8]) -> Option<CallTargetExtraction<'tree>> {
if !matches!(node.kind(), "call" | "method_call") {
return None;
}
let target = node.child_by_field_name("method")?;
let member = node_text(&target, src).trim();
if member.is_empty() {
return None;
}
let full_text = node.child_by_field_name("receiver").map_or_else(
|| member.to_string(),
|receiver| format!("{}.{}", node_text(&receiver, src).trim(), member),
);
Some(CallTargetExtraction {
node: target,
full_text,
})
}
fn ruby_named_argument<'tree>(node: Node<'tree>, src: &[u8]) -> Option<(String, Node<'tree>)> {
if node.kind() != "pair" {
return None;
}
let key = node.child_by_field_name("key")?;
let value = node.child_by_field_name("value")?;
let name = node_text(&key, src)
.trim()
.trim_start_matches(':')
.trim_end_matches(':')
.trim();
(!name.is_empty()).then(|| (name.to_string(), value))
}
fn ruby_pattern_bindings(node: Node<'_>) -> Vec<PatternBindingSite<'_>> {
if node.kind() != "case_match" {
return Vec::new();
}
pattern_binding_sites_from_arms(node, &["value"], &["in_clause"], &["pattern"], &[])
}
fn extract_ruby_callable_reference(node: Node<'_>, src: &[u8]) -> Option<String> {
if !matches!(node.kind(), "call" | "method_call") {
return None;
}
let callee = node
.child_by_field_name("method")
.or_else(|| node.child_by_field_name("function"))
.or_else(|| node.child_by_field_name("name"))
.or_else(|| node.child_by_field_name("target"))?;
if node_text(&callee, src).trim() != "method" {
return None;
}
let arguments = node
.child_by_field_name("arguments")
.or_else(|| node.child_by_field_name("argument_list"))?;
if arguments.named_child_count() != 1 {
return None;
}
let symbol = arguments.named_child(0)?;
if !matches!(symbol.kind(), "simple_symbol" | "symbol" | "symbol_literal") {
return None;
}
let name = node_text(&symbol, src).trim().trim_start_matches(':');
(!name.is_empty()
&& name
.chars()
.enumerate()
.all(|(index, ch)| ch == '_' || ch.is_alphanumeric() && (index > 0 || !ch.is_numeric())))
.then(|| name.to_string())
}
fn ruby_inline_closure_uses_yield(call: Node<'_>, block: Node<'_>, _src: &[u8]) -> bool {
call.kind() == "call" && matches!(block.kind(), "block" | "do_block")
}
const BASE_HANDLER: GrammarHandler = GrammarHandler {
expression_value_kind_extractor: None,
literal_value_kinds: &["nil", "integer", "float", "true", "false"],
string_literal_kinds: &["string", "chained_string", "heredoc_body"],
comment_kinds: &["comment"],
parameter_container_kinds: &["method_parameters"],
parameter_kinds: &[
"identifier",
"optional_parameter",
"keyword_parameter",
"splat_parameter",
"hash_splat_parameter",
"block_parameter",
],
variadic_parameter_kinds: &["splat_parameter"],
binding_identifier_kinds: &[
"identifier",
"constant",
"instance_variable",
"class_variable",
"global_variable",
],
non_binding_pattern_kinds: &[
"variable_reference_pattern",
"reference_pattern",
"pin_pattern",
"pin",
],
non_binding_pattern_field_names: &["type", "key", "class", "guard"],
binding_name_extractor: Some(ruby_binding_name),
pattern_binding_extractor: Some(ruby_pattern_bindings),
identifier_kinds: &[
"identifier",
"constant",
"instance_variable",
"class_variable",
"global_variable",
],
aggregate_pattern_kinds: &["left_assignment_list", "array_pattern", "list_pattern"],
named_aggregate_kinds: &["hash"],
positional_aggregate_kinds: &["array"],
aggregate_pair_kinds: &["pair", "keyword_pattern"],
aggregate_key_field_names: &["key"],
aggregate_value_field_names: &["value"],
shorthand_field_kinds: &["keyword_pattern"],
static_field_name_kinds: &["identifier", "constant"],
spread_kinds: &["splat_argument", "hash_splat_argument"],
spread_value_field_names: &["value"],
lambda_value_container_kinds: &["hash", "pair", "array"],
transparent_call_wrapper_kinds: &["call", "parenthesized_statements"],
single_expression_group_kinds: &["expression_list"],
inline_closure_kinds: &["block", "do_block"],
inline_closure_yield_extractor: Some(ruby_inline_closure_uses_yield),
fn_kinds: &["method", "singleton_method"],
class_kinds: &["class", "module"],
class_decl_kinds: &[("class", DeclKind::Class), ("module", DeclKind::Module)],
method_context_kinds: &["class", "module"],
if_kinds: &[
"if",
"if_modifier",
"unless",
"unless_modifier",
"case",
"case_match",
],
branch_then_field_names: &["consequence", "body"],
branch_else_field_names: &["alternative"],
branch_condition_field_names: &["condition", "value"],
loop_body_field_names: &["body"],
loop_body_kinds: &["body_statement", "then"],
branch_arm_kinds: &["then", "else", "body_statement", "when"],
additional_alternative_kinds: &["elsif", "else"],
for_kinds: &[],
foreach_kinds: &["for"],
foreach_binding_extractor: Some(ruby_foreach_binding),
while_kinds: &["while", "until"],
return_kinds: &["return"],
lambda_kinds: &["lambda", "do_block"],
try_kinds: &["begin", "begin_block"],
catch_kinds: &["rescue"],
finally_kinds: &["ensure"],
break_kinds: &["break"],
continue_kinds: &["next", "redo"],
control_label_field_names: &[],
yield_kinds: &["yield"],
yield_value_field_names: &["argument", "arguments"],
try_body_field_names: &["body"],
implicit_receiver_names: &["self", "super"],
implicit_receiver_prefixes: &["@"],
..EMPTY_HANDLER
};
const HANDLER: GrammarHandler = GrammarHandler {
constructor_names: &["initialize", "new"],
tail_expression_returns: true,
void_return_type_names: &[],
assignment_kinds: &["assignment", "operator_assignment"],
assignment_place_extractor: Some(ruby_assignment_place),
compound_assignment_kinds: &["operator_assignment"],
compound_assignment_operators: &["+=", "-=", "*=", "/=", "%=", "**=", "&&=", "||="],
call_kinds: &["call", "method_call"],
call_callee_field_names: &["method"],
call_receiver_field_names: &["receiver"],
call_member_field_names: &["method"],
call_target_extractor: Some(ruby_call_target),
call_argument_field_names: &["arguments"],
call_argument_container_kinds: &["argument_list"],
named_argument_extractor: Some(ruby_named_argument),
lambda_body_field_names: &["body"],
lambda_body_kinds: &["block", "do_block"],
pseudo_call_extractor: Some(extract_ruby_pseudo_call),
syntax_event_extractor: Some(extract_ruby_syntax_event),
argument_passing_mode_extractor: None,
call_ref_kinds: &["call", "method_call"],
subscript_expression_kinds: &["element_reference"],
subscript_base_field_names: &["object"],
subscript_index_field_names: &["index"],
static_subscript_key_extractor: Some(ruby_static_subscript_key),
computed_subscript_extractor: Some(ruby_element_subscript),
global_variable_kinds: &["global_variable"],
reference_name_extractor: Some(ruby_reference_name),
subscript_base_call_refs: true,
callable_reference_extractor: Some(extract_ruby_callable_reference),
special_forms: &[],
..BASE_HANDLER
};
fn extract_ruby_syntax_event(
node: Node<'_>,
file: FileId,
src: &[u8],
handler: &GrammarHandler,
) -> Option<FlowEvent> {
if node.kind() != "binary" {
return None;
}
let left = node.child_by_field_name("left")?;
let right = node.child_by_field_name("right")?;
let operator = std::str::from_utf8(&src[left.end_byte()..right.start_byte()])
.ok()?
.trim();
if operator != "<<" {
return None;
}
let target = call_arg_from_node_with_handler(left, file, src, None, handler)?.place?;
let source = call_arg_from_node_with_handler(right, file, src, None, handler)?;
let mut source_names = source.source_names;
if let Some(place) = source.place.as_ref() {
if !source_names.iter().any(|existing| existing == place) {
source_names.push(place.clone());
}
}
source_names.retain(|name| name != &target);
source_names.push(target.clone());
source_names.sort();
source_names.dedup();
Some(FlowEvent::Assign {
span: span_of(file, &node),
target,
source_name: source.place,
source_call: None,
source_call_args: Vec::new(),
source_names,
declares_new_binding: false,
value_kind: None,
})
}
fn ruby_element_subscript(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
if node.kind() != "element_reference" {
return None;
}
let object = node.child_by_field_name("object")?;
let mut cursor = node.walk();
let key = node
.named_children(&mut cursor)
.find(|child| child.id() != object.id())?;
Some((object, key))
}
fn ruby_static_subscript_key(node: Node<'_>, src: &[u8]) -> Option<String> {
if node.kind() == "string" {
let mut cursor = node.walk();
let parts: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
let [content] = parts.as_slice() else {
return None;
};
if content.kind() != "string_content" {
return None;
}
let key = node_text(content, src).trim();
return (!key.is_empty()).then(|| key.to_string());
}
if matches!(node.kind(), "simple_symbol" | "symbol" | "symbol_literal") {
let key = node_text(&node, src).trim().trim_start_matches(':');
return (!key.is_empty()).then(|| key.to_string());
}
None
}
fn ruby_reference_name(node: Node<'_>, src: &[u8]) -> Option<String> {
let raw = node_text(&node, src).trim();
if raw.is_empty() {
return None;
}
if node.kind() == "instance_variable" {
return Some(normalize_ruby_instance_variable_text(raw));
}
Some(raw.to_string())
}
fn ruby_binding_name(node: Node<'_>, src: &[u8]) -> Option<String> {
let raw = node_text(&node, src).trim();
if raw.is_empty() {
return None;
}
if node.kind() == "instance_variable" {
return Some(normalize_ruby_instance_variable_text(raw));
}
Some(raw.to_string())
}
fn ruby_assignment_place(node: Node<'_>, src: &[u8]) -> Option<String> {
fn base_place(node: Node<'_>, src: &[u8]) -> Option<String> {
if matches!(
node.kind(),
"identifier" | "constant" | "instance_variable" | "class_variable" | "global_variable"
) {
return ruby_reference_name(node, src);
}
ruby_assignment_place(node, src)
}
if !matches!(node.kind(), "call" | "method_call") || node.child_by_field_name("arguments").is_some() {
return None;
}
let receiver = node.child_by_field_name("receiver")?;
let method = node.child_by_field_name("method")?;
if method.kind() != "identifier" {
return None;
}
let receiver = base_place(receiver, src)?;
let method = node_text(&method, src).trim();
(!receiver.is_empty() && !method.is_empty()).then(|| format!("{receiver}.{method}"))
}
fn ruby_foreach_binding(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
if node.kind() != "for" {
return None;
}
let binding = node
.child_by_field_name("pattern")
.or_else(|| node.child_by_field_name("left"))
.or_else(|| node.named_child(0))?;
let iterable = node
.child_by_field_name("value")
.or_else(|| node.child_by_field_name("right"))
.or_else(|| node.named_child(1))?;
Some((binding, iterable))
}
fn extract_ruby_pseudo_call(
node: Node<'_>,
file: FileId,
src: &[u8],
handler: &GrammarHandler,
) -> Option<FlowEvent> {
if node.kind() != "subshell" {
return None;
}
Some(FlowEvent::Call {
span: span_of(file, &node),
receiver: None,
receiver_types: Vec::new(),
name: "`".to_string(),
call_kind: CallKind::Operator,
args: named_child_call_args_with_handler(&node, file, src, handler),
})
}
#[derive(Debug, Default, Copy, Clone)]
pub struct RubyAdapter;
impl RubyAdapter {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl LanguageAdapter for RubyAdapter {
fn language_id(&self) -> LanguageId {
LANG_ID
}
fn display_name(&self) -> &'static str {
"Ruby"
}
fn file_extensions(&self) -> &'static [&'static str] {
&["rb", "erb", "rhtml"]
}
fn tree_sitter_language(&self) -> Result<Language, AdapterError> {
language_from_pack(PACK_NAME)
}
fn capabilities(&self) -> LanguageCapabilities {
LanguageCapabilities {
module_default_export_names: &[],
universal_type_names: &[],
module_path_syntax: bonsai_lang_api::ModulePathSyntax::none(),
constructor_method_names: &["initialize", "new"],
super_receiver_tokens: &["super"],
implicit_receiver_tokens: &["self"],
receiver_type_syntax: bonsai_lang_api::ReceiverTypeSyntax {
wrapper_calls: &[],
class_object_suffixes: &[".class"],
},
callable_reference_syntax: bonsai_lang_api::CallableReferenceSyntax {
prefixes: &[],
numeric_arity_suffix: false,
symbol_wrapper: Some("method"),
trailing_invocation_punctuation: false,
},
workspace_manifest_context_extensions: &["erb", "rhtml", "haml", "slim"],
..LanguageCapabilities::partial_baseline()
}
}
fn extract_declarations(&self, file: FileId, ctx: &AdapterContext<'_>) -> DeclIndex {
let path = ctx.vfs.path(file).ok();
let is_erb = path
.as_ref()
.and_then(|p| p.extension())
.is_some_and(|ext| ext == "erb" || ext == "rhtml");
if !is_erb {
let mut idx = decl_index_with_handler(PACK_NAME, file, ctx, &HANDLER);
if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
idx.refs.extend(extract_ruby_static_element_key_refs(
&tree,
snapshot.text.as_bytes(),
file,
));
apply_ruby_scope_visibility(&mut idx, &tree, snapshot.text.as_bytes(), file);
for decl in &mut idx.defs {
inject_ruby_raise_throw_events(&mut decl.flow_events);
inject_ruby_super_call_events(&mut decl.flow_events, &decl.name);
normalize_ruby_subshell_events(&mut decl.flow_events, snapshot.text.as_bytes());
normalize_ruby_instance_variable_events(decl);
}
}
let mut block_param_names = std::collections::BTreeSet::new();
if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
let src = snapshot.text.as_bytes();
block_param_names = collect_ruby_block_param_names(&tree, src);
let bases_by_span = collect_ruby_class_bases(&tree, file, src);
for decl in &mut idx.defs {
if !is_class_like(decl.kind) {
continue;
}
if let Some(bases) = bases_by_span.iter().find_map(|(span, name, bases)| {
(*span == decl.span || name == &decl.name).then_some(bases)
}) {
decl.bases = bases.clone();
}
}
inject_ruby_hash_field_assigns(&mut idx, &tree, file, src);
inject_ruby_bare_method_arg_calls(&mut idx, &tree, file, src);
}
bonsai_lang_api::apply_file_stem_semantic_identity(&mut idx, ctx);
apply_ruby_class_semantic_identity(&mut idx);
for decl in &mut idx.defs {
rewrite_ruby_bareword_call_result_assigns(decl, &block_param_names);
inject_ruby_bare_tail_return_calls(decl, &block_param_names);
bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
}
if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
inject_ruby_unbound_receiver_calls(
&mut idx,
&tree,
file,
snapshot.text.as_bytes(),
&block_param_names,
);
}
bonsai_lang_api::apply_constructor_result_type_aliases(&mut idx);
bonsai_lang_api::apply_class_field_type_aliases(&mut idx);
return idx;
}
let Some(snapshot) = ctx.vfs.snapshot(file).ok() else {
return DeclIndex {
file,
..Default::default()
};
};
let processed = preprocess_erb(&snapshot.text);
let Ok(lang) = language_from_pack(PACK_NAME) else {
return DeclIndex {
file,
..Default::default()
};
};
let mut parser = tree_sitter::Parser::new();
if parser.set_language(&lang).is_err() {
return DeclIndex {
file,
..Default::default()
};
}
let Some(tree) = parser.parse(&processed, None) else {
return DeclIndex {
file,
..Default::default()
};
};
let src = processed.as_bytes();
let root = tree.root_node();
let mut root_events = bonsai_lang_api::kit::walk_flow_events(root, file, src, &HANDLER, &[]);
inject_ruby_raise_throw_events(&mut root_events);
normalize_ruby_subshell_events(&mut root_events, src);
let erb_implicit_inputs = collect_ruby_erb_implicit_inputs(&tree, src);
let has_actionable_event = root_events.iter().any(|event| {
matches!(
event,
bonsai_lang_api::FlowEvent::Call { .. }
| bonsai_lang_api::FlowEvent::Assign { .. }
| bonsai_lang_api::FlowEvent::Yield { .. }
| bonsai_lang_api::FlowEvent::Await { .. }
)
});
let mut defs = if has_actionable_event {
let module_span = span_of(file, &root);
let param_annotations = vec![Vec::new(); erb_implicit_inputs.len()];
vec![bonsai_lang_api::Decl {
symbol: bonsai_common::SymbolId::new(0),
kind: bonsai_lang_api::DeclKind::Function,
name: bonsai_lang_api::MODULE_DECL_NAME.to_string(),
qualified_name: None,
module_path: bonsai_lang_api::ModulePath::default(),
span: module_span,
name_span: module_span,
visibility: bonsai_lang_api::Visibility::Public,
parent: None,
body_span: Some(module_span),
flow_events: root_events,
has_implicit_returns: true,
params: erb_implicit_inputs,
param_annotations,
param_default_calls: Vec::new(),
type_aliases: Vec::new(),
bases: Vec::new(),
receiver_param_index: None,
receiver_field_writes: Vec::new(),
receiver_field_initializers: Vec::new(),
implicit_receiver_names: Vec::new(),
receiver_state_sources: Vec::new(),
return_type: None,
is_variadic: false,
}]
} else {
Vec::new()
};
let block_param_names = collect_ruby_block_param_names(&tree, src);
for decl in &mut defs {
normalize_ruby_instance_variable_events(decl);
rewrite_ruby_bareword_call_result_assigns(decl, &block_param_names);
inject_ruby_bare_tail_return_calls(decl, &block_param_names);
bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
}
let mut refs = bonsai_lang_api::kit::extract_call_refs(&tree, file, src, &HANDLER);
refs.extend(bonsai_lang_api::kit::extract_decorators(
&tree, file, src, &HANDLER,
));
refs.extend(bonsai_lang_api::kit::extract_read_write_refs(
&tree, file, src, &HANDLER,
));
refs.extend(extract_ruby_static_element_key_refs(&tree, src, file));
let strings = bonsai_lang_api::kit::extract_string_literals(&tree, file, src, &HANDLER);
let comments = bonsai_lang_api::kit::extract_comments(&tree, file, src, &HANDLER);
let assignment_values =
bonsai_lang_api::kit::extract_assignment_value_facts(&tree, file, &HANDLER, src);
let call_receivers = bonsai_lang_api::kit::extract_call_receiver_facts(&tree, file, &HANDLER, src);
let call_argument_values =
bonsai_lang_api::kit::extract_call_argument_value_facts(&tree, file, &defs, src, &HANDLER);
let runtime_type_narrowings =
bonsai_lang_api::kit::extract_runtime_type_narrowing_facts(&tree, file, &HANDLER, src);
let branch_conditions =
bonsai_lang_api::kit::extract_branch_condition_facts(&tree, file, &HANDLER, src);
DeclIndex {
file,
defs,
refs,
assignment_values,
call_receivers,
call_argument_values,
static_string_maps: Vec::new(),
string_compositions: Vec::new(),
finite_literal_selections: Vec::new(),
character_substitutions: Vec::new(),
character_constraints: Vec::new(),
guarded_value_filters: Vec::new(),
same_origin_path_constraints: Vec::new(),
dynamic_key_filters: Vec::new(),
runtime_type_narrowings,
branch_conditions,
compiler_guards: Vec::new(),
aggregate_layouts: Vec::new(),
strings,
comments,
}
}
fn extract_imports(&self, file: FileId, ctx: &AdapterContext<'_>) -> ImportIndex {
extract_imports_via(PACK_NAME, file, ctx, parse_imports)
}
}
fn inject_ruby_raise_throw_events(events: &mut Vec<FlowEvent>) {
for event in events.iter_mut() {
match event {
FlowEvent::Branch {
then_events,
else_events,
..
} => {
inject_ruby_raise_throw_events(then_events);
inject_ruby_raise_throw_events(else_events);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
inject_ruby_raise_throw_events(body);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
inject_ruby_raise_throw_events(body);
inject_ruby_raise_throw_events(catch_events);
inject_ruby_raise_throw_events(finally_events);
}
_ => {}
}
}
let mut rewritten = Vec::with_capacity(events.len());
for event in events.drain(..) {
let synthetic_throw = ruby_raise_throw_event(&event);
rewritten.push(event);
if let Some(throw_event) = synthetic_throw {
rewritten.push(throw_event);
}
}
*events = rewritten;
}
fn inject_ruby_super_call_events(events: &mut Vec<FlowEvent>, method_name: &str) {
for event in events.iter_mut() {
match event {
FlowEvent::Branch {
then_events,
else_events,
..
} => {
inject_ruby_super_call_events(then_events, method_name);
inject_ruby_super_call_events(else_events, method_name);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
inject_ruby_super_call_events(body, method_name);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
inject_ruby_super_call_events(body, method_name);
inject_ruby_super_call_events(catch_events, method_name);
inject_ruby_super_call_events(finally_events, method_name);
}
_ => {}
}
}
if method_name.trim().is_empty() {
return;
}
let mut rewritten = Vec::with_capacity(events.len());
for event in events.drain(..) {
if ruby_return_is_bare_super(&event) {
let span = match &event {
FlowEvent::Return { span, .. } => *span,
_ => unreachable!("guarded by ruby_return_is_bare_super"),
};
rewritten.push(FlowEvent::Call {
span,
name: format!("super.{method_name}"),
receiver: Some("super".to_string()),
receiver_types: Vec::new(),
call_kind: CallKind::Method,
args: Vec::new(),
});
}
rewritten.push(event);
}
*events = rewritten;
}
fn ruby_return_is_bare_super(event: &FlowEvent) -> bool {
let FlowEvent::Return {
value_name,
value_text,
value_flow,
..
} = event
else {
return false;
};
value_flow.place.as_deref() == Some("super")
|| (value_name.as_deref() == Some("super")
&& value_text.as_deref().is_some_and(|text| text.trim() == "super")
&& value_flow.call_sites.is_empty())
}
fn ruby_raise_throw_event(event: &FlowEvent) -> Option<FlowEvent> {
let FlowEvent::Call { name, args, span, .. } = event else {
return None;
};
if name != "raise" {
return None;
}
let thrown_arg = match args.first() {
Some(first) if args.len() >= 2 && ruby_is_exception_class(&first.value_text) => args.get(1),
other => other,
};
Some(FlowEvent::Throw {
span: *span,
value_name: thrown_arg.and_then(|arg| arg.place.clone()),
thrown_type: None,
})
}
fn ruby_is_exception_class(text: &str) -> bool {
let head = text.trim().rsplit("::").next().unwrap_or("").trim();
head.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& head.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn normalize_ruby_subshell_events(events: &mut [FlowEvent], src: &[u8]) {
for event in events {
match event {
FlowEvent::Call {
name,
args,
span,
call_kind,
..
} if name == "`" => {
let source_names = ruby_subshell_arg_source_names(args);
let value_text = ruby_span_text(src, *span)
.filter(|text| !text.trim().is_empty())
.map(|text| text.trim().to_string())
.unwrap_or_else(|| {
args.iter()
.map(|arg| arg.value_text.trim())
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join(" ")
});
*call_kind = CallKind::Function;
*args = vec![CallArg {
passing_mode: Default::default(),
span: *span,
name: None,
value_text,
place: None,
source_names,
}];
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
normalize_ruby_subshell_events(then_events, src);
normalize_ruby_subshell_events(else_events, src);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
normalize_ruby_subshell_events(body, src);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
normalize_ruby_subshell_events(body, src);
normalize_ruby_subshell_events(catch_events, src);
normalize_ruby_subshell_events(finally_events, src);
}
_ => {}
}
}
}
fn ruby_subshell_arg_source_names(args: &[CallArg]) -> Vec<String> {
let mut source_names = Vec::new();
for arg in args {
for name in &arg.source_names {
if name.is_empty() || source_names.iter().any(|seen| seen == name) {
continue;
}
source_names.push(name.clone());
}
}
source_names
}
fn normalize_ruby_instance_variable_events(decl: &mut bonsai_lang_api::Decl) {
for write in &mut decl.receiver_field_writes {
write.target = normalize_ruby_instance_variable_text(&write.target);
}
for source in &mut decl.receiver_state_sources {
*source = normalize_ruby_instance_variable_text(source);
}
normalize_ruby_instance_variable_flow_events(&mut decl.flow_events);
}
fn normalize_ruby_instance_variable_flow_events(events: &mut [FlowEvent]) {
for event in events {
match event {
FlowEvent::Assign {
target,
source_name,
source_call,
source_call_args,
source_names,
..
} => {
*target = normalize_ruby_instance_variable_text(target);
normalize_optional_ruby_instance_variable_text(source_name);
normalize_optional_ruby_instance_variable_text(source_call);
normalize_ruby_instance_variable_texts(source_call_args);
normalize_ruby_instance_variable_texts(source_names);
}
FlowEvent::AggregateAssign {
target, value_flow, ..
} => {
*target = normalize_ruby_instance_variable_text(target);
normalize_ruby_instance_variable_expression_flow(value_flow);
}
FlowEvent::Call {
name, receiver, args, ..
} => {
*name = normalize_ruby_instance_variable_text(name);
normalize_optional_ruby_instance_variable_text(receiver);
for arg in args {
arg.value_text = normalize_ruby_instance_variable_text(&arg.value_text);
normalize_optional_ruby_instance_variable_text(&mut arg.place);
normalize_ruby_instance_variable_texts(&mut arg.source_names);
enrich_ruby_instance_variable_call_arg(arg);
}
}
FlowEvent::Return {
value_name,
value_text,
value_flow,
..
} => {
normalize_optional_ruby_instance_variable_text(value_name);
normalize_optional_ruby_instance_variable_text(value_text);
normalize_ruby_instance_variable_expression_flow(value_flow);
}
FlowEvent::Throw { value_name, .. } => {
normalize_optional_ruby_instance_variable_text(value_name);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
catch_param,
..
} => {
normalize_optional_ruby_instance_variable_text(catch_param);
normalize_ruby_instance_variable_flow_events(body);
normalize_ruby_instance_variable_flow_events(catch_events);
normalize_ruby_instance_variable_flow_events(finally_events);
}
FlowEvent::Branch {
condition,
then_events,
else_events,
..
} => {
normalize_optional_ruby_instance_variable_text(condition);
normalize_ruby_instance_variable_flow_events(then_events);
normalize_ruby_instance_variable_flow_events(else_events);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
normalize_ruby_instance_variable_flow_events(body);
}
FlowEvent::Yield {
value_text,
value_flow,
..
} => {
normalize_optional_ruby_instance_variable_text(value_text);
normalize_ruby_instance_variable_expression_flow(value_flow);
}
FlowEvent::Await { value_name, .. } => {
normalize_optional_ruby_instance_variable_text(value_name);
}
FlowEvent::Lifecycle { name, .. } => {
*name = normalize_ruby_instance_variable_text(name);
}
FlowEvent::Break { .. } | FlowEvent::Continue { .. } => {}
}
}
}
fn normalize_ruby_instance_variable_expression_flow(flow: &mut bonsai_lang_api::ExpressionFlow) {
normalize_optional_ruby_instance_variable_text(&mut flow.place);
normalize_ruby_instance_variable_texts(&mut flow.source_names);
if let Some(projection) = &mut flow.projection {
projection.base = normalize_ruby_instance_variable_text(&projection.base);
normalize_ruby_instance_variable_texts(&mut projection.path);
}
for field in &mut flow.aggregate_fields {
field.name = normalize_ruby_instance_variable_text(&field.name);
normalize_ruby_instance_variable_expression_flow(&mut field.value);
}
for item in &mut flow.tuple_items {
normalize_ruby_instance_variable_expression_flow(item);
}
for spread in &mut flow.spreads {
normalize_ruby_instance_variable_expression_flow(spread);
}
}
fn normalize_optional_ruby_instance_variable_text(value: &mut Option<String>) {
if let Some(text) = value {
*text = normalize_ruby_instance_variable_text(text);
}
}
fn normalize_ruby_instance_variable_texts(values: &mut [String]) {
for value in values {
*value = normalize_ruby_instance_variable_text(value);
}
}
fn enrich_ruby_instance_variable_call_arg(arg: &mut CallArg) {
let Some(place) = ruby_normalized_instance_variable_place(&arg.value_text) else {
return;
};
if arg.place.as_deref().is_none_or(str::is_empty) {
arg.place = Some(place.clone());
}
if !arg.source_names.iter().any(|source| source == &place) {
arg.source_names.push(place);
}
arg.source_names.sort();
arg.source_names.dedup();
}
fn ruby_normalized_instance_variable_place(text: &str) -> Option<String> {
let text = text.trim();
let rest = text.strip_prefix("self.")?;
if rest.is_empty() {
return None;
}
if rest.split('.').all(ruby_identifier_part) {
Some(text.to_string())
} else {
None
}
}
fn ruby_identifier_part(part: &str) -> bool {
let mut chars = part.chars();
chars
.next()
.is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn normalize_ruby_instance_variable_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
let mut quote: Option<char> = None;
let mut escaped = false;
while let Some(ch) = chars.next() {
if let Some(active_quote) = quote {
out.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == active_quote {
quote = None;
}
continue;
}
if matches!(ch, '\'' | '"' | '`') {
quote = Some(ch);
out.push(ch);
continue;
}
if ch != '@' {
out.push(ch);
continue;
}
match chars.peek().copied() {
Some('@') => {
out.push(ch);
out.push('@');
chars.next();
}
Some(next) if next == '_' || next.is_ascii_alphabetic() => {
out.push_str("self.");
while let Some(part) = chars.peek().copied() {
if part == '_' || part.is_ascii_alphanumeric() {
out.push(part);
chars.next();
} else {
break;
}
}
}
_ => out.push(ch),
}
}
out
}
fn inject_ruby_hash_field_assigns(idx: &mut DeclIndex, tree: &Tree, file: FileId, src: &[u8]) {
let mut synthesized = Vec::new();
for assignment in collect_kinds(tree, &["assignment"]) {
let (Some(left), Some(right)) = (
assignment.child_by_field_name("left"),
assignment.child_by_field_name("right"),
) else {
continue;
};
let target = normalize_ruby_instance_variable_text(node_text(&left, src).trim());
if target.is_empty() || !ruby_field_target_base_is_supported(&target) {
continue;
}
collect_ruby_hash_field_assigns_for_target(&target, right, file, src, &mut synthesized);
}
if synthesized.is_empty() {
return;
}
for event in synthesized {
let span = event.span();
let Some(decl) = idx
.defs
.iter_mut()
.filter(|decl| {
matches!(
decl.kind,
DeclKind::Function | DeclKind::Method | DeclKind::Constructor
) && decl_span_contains(decl, span)
})
.min_by_key(|decl| decl.span.end.saturating_sub(decl.span.start))
else {
continue;
};
if !decl.flow_events.iter().any(|existing| existing == &event) {
decl.flow_events.push(event);
decl.flow_events
.sort_by_key(|event| (event.span().start, event.span().end));
}
}
}
fn ruby_field_target_base_is_supported(target: &str) -> bool {
target
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()))
}
fn decl_span_contains(decl: &bonsai_lang_api::Decl, span: Span) -> bool {
let container = decl.body_span.unwrap_or(decl.span);
container.file == span.file && container.start <= span.start && span.end <= container.end
}
fn collect_ruby_hash_field_assigns_for_target(
target: &str,
node: tree_sitter::Node<'_>,
file: FileId,
src: &[u8],
out: &mut Vec<FlowEvent>,
) {
if node.kind() == "hash" {
collect_ruby_hash_pair_assigns(target, node, file, src, out);
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
collect_ruby_hash_field_assigns_for_target(target, child, file, src, out);
}
}
fn collect_ruby_hash_pair_assigns(
target: &str,
hash: tree_sitter::Node<'_>,
file: FileId,
src: &[u8],
out: &mut Vec<FlowEvent>,
) {
let mut cursor = hash.walk();
for child in hash.named_children(&mut cursor) {
if child.kind() != "pair" {
continue;
}
let Some(key) = child
.child_by_field_name("key")
.and_then(|key| ruby_hash_key_name(key, src))
else {
continue;
};
let Some(value) = child.child_by_field_name("value") else {
continue;
};
let mut source_names = ruby_value_source_names(value, src);
if source_names.is_empty() {
continue;
}
source_names.sort();
source_names.dedup();
out.push(FlowEvent::Assign {
span: span_of(file, &child),
target: format!("{target}.{key}"),
source_name: None,
source_call: None,
source_call_args: Vec::new(),
source_names,
declares_new_binding: false,
value_kind: Some(bonsai_lang_api::AssignValueKind::Compound),
});
}
}
fn ruby_hash_key_name(key: tree_sitter::Node<'_>, src: &[u8]) -> Option<String> {
let raw = node_text(&key, src)
.trim()
.trim_start_matches(':')
.trim_matches('"')
.trim_matches('\'')
.trim();
if raw.is_empty() || !raw.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
return None;
}
Some(raw.to_string())
}
fn ruby_value_source_names(node: tree_sitter::Node<'_>, src: &[u8]) -> Vec<String> {
let mut out = Vec::new();
collect_ruby_value_source_names(node, src, &mut out);
out.sort();
out.dedup();
out
}
fn collect_ruby_value_source_names(node: tree_sitter::Node<'_>, src: &[u8], out: &mut Vec<String>) {
match node.kind() {
"identifier" | "constant" | "self" => {
push_ruby_source_name(out, node_text(&node, src));
}
"instance_variable" => {
push_ruby_source_name(out, &normalize_ruby_instance_variable_text(node_text(&node, src)));
}
"call" => {
push_ruby_source_name(
out,
&normalize_ruby_instance_variable_text(node_text(&node, src).trim()),
);
if let Some(receiver) = node.child_by_field_name("receiver") {
collect_ruby_value_source_names(receiver, src, out);
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if child.kind() == "arguments" {
collect_ruby_value_source_names(child, src, out);
}
}
return;
}
"element_reference" => {
if let Some(access) = ruby_element_reference_name(node, src) {
push_ruby_source_name(out, &access);
}
}
"hash_key_symbol" | "simple_symbol" | "integer" | "float" | "string_content" => {}
_ => {}
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
collect_ruby_value_source_names(child, src, out);
}
}
fn ruby_element_reference_name(node: tree_sitter::Node<'_>, src: &[u8]) -> Option<String> {
let object = node
.child_by_field_name("object")
.map(|object| normalize_ruby_instance_variable_text(node_text(&object, src).trim()))?;
let mut cursor = node.walk();
let key = node
.named_children(&mut cursor)
.find(|child| child.kind() == "simple_symbol" || child.kind() == "string")
.and_then(|key| ruby_hash_key_name(key, src))?;
(!object.is_empty()).then(|| format!("{object}.{key}"))
}
fn push_ruby_source_name(out: &mut Vec<String>, value: &str) {
let value = value.trim();
if value.is_empty()
|| value.starts_with(':')
|| value.starts_with('"')
|| value.starts_with('\'')
|| value.chars().all(|ch| ch.is_ascii_digit())
{
return;
}
if !out.iter().any(|existing| existing == value) {
out.push(value.to_string());
}
}
fn inject_ruby_bare_method_arg_calls(idx: &mut DeclIndex, tree: &Tree, file: FileId, src: &[u8]) {
let mut candidates = Vec::new();
for call in collect_kinds(tree, &["call"]) {
let Some(arguments) = call.child_by_field_name("arguments") else {
continue;
};
let mut cursor = arguments.walk();
for arg in arguments.named_children(&mut cursor) {
if arg.kind() != "identifier" {
continue;
}
let name = node_text(&arg, src).trim();
if !ruby_bare_method_candidate(name) {
continue;
}
candidates.push((span_of(file, &arg), name.to_string()));
}
}
if candidates.is_empty() {
return;
}
for (span, name) in candidates {
let Some(decl) = idx
.defs
.iter_mut()
.filter(|decl| {
matches!(
decl.kind,
DeclKind::Function | DeclKind::Method | DeclKind::Constructor
) && decl_span_contains(decl, span)
})
.min_by_key(|decl| decl.span.end.saturating_sub(decl.span.start))
else {
continue;
};
let locals = ruby_local_bindings_for_decl(decl);
if locals.contains(name.as_str()) {
continue;
}
let event = FlowEvent::Call {
span,
name,
receiver: None,
receiver_types: Vec::new(),
call_kind: CallKind::Method,
args: Vec::new(),
};
if !decl.flow_events.iter().any(|existing| existing == &event) {
decl.flow_events.push(event);
decl.flow_events
.sort_by_key(|event| (event.span().start, event.span().end));
}
}
}
fn ruby_bare_method_candidate(name: &str) -> bool {
!name.is_empty()
&& !matches!(
name,
"nil" | "true" | "false" | "self" | "super" | "yield" | "return" | "break" | "next"
)
&& name
.chars()
.all(|ch| ch == '_' || ch == '!' || ch == '?' || ch.is_ascii_alphanumeric())
&& name
.chars()
.next()
.is_some_and(|ch| ch == '_' || ch.is_ascii_lowercase())
}
fn ruby_local_bindings_for_decl(decl: &bonsai_lang_api::Decl) -> std::collections::BTreeSet<String> {
let mut locals: std::collections::BTreeSet<String> = decl
.params
.iter()
.filter_map(|param| ruby_bare_binding_name(param))
.collect();
collect_ruby_local_bindings(&decl.flow_events, &mut locals);
locals
}
fn collect_ruby_local_bindings(events: &[FlowEvent], locals: &mut std::collections::BTreeSet<String>) {
for event in events {
match event {
FlowEvent::Assign { target, .. } => {
if let Some(name) = ruby_bare_binding_name(target) {
locals.insert(name);
}
}
FlowEvent::AggregateAssign { target, .. } => {
if let Some(name) = ruby_bare_binding_name(target) {
locals.insert(name);
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
catch_param,
..
} => {
if let Some(param) = catch_param.as_deref().and_then(ruby_bare_binding_name) {
locals.insert(param);
}
collect_ruby_local_bindings(body, locals);
collect_ruby_local_bindings(catch_events, locals);
collect_ruby_local_bindings(finally_events, locals);
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
collect_ruby_local_bindings(then_events, locals);
collect_ruby_local_bindings(else_events, locals);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
collect_ruby_local_bindings(body, locals);
}
FlowEvent::Call { .. }
| FlowEvent::Return { .. }
| FlowEvent::Throw { .. }
| FlowEvent::Yield { .. }
| FlowEvent::Await { .. }
| FlowEvent::Lifecycle { .. }
| FlowEvent::Break { .. }
| FlowEvent::Continue { .. } => {}
}
}
}
fn ruby_bare_binding_name(value: &str) -> Option<String> {
let name = value.trim();
if name.is_empty() || name.contains('.') || name.contains('[') || name.starts_with('@') {
return None;
}
ruby_bare_method_candidate(name).then(|| name.to_string())
}
fn rewrite_ruby_bareword_call_result_assigns(
decl: &mut bonsai_lang_api::Decl,
block_param_names: &std::collections::BTreeSet<String>,
) {
let mut locals = ruby_local_bindings_for_decl(decl);
locals.extend(block_param_names.iter().cloned());
rewrite_ruby_bareword_assigns_in_events(&mut decl.flow_events, &locals);
}
fn inject_ruby_bare_tail_return_calls(
decl: &mut bonsai_lang_api::Decl,
block_param_names: &std::collections::BTreeSet<String>,
) {
let mut locals = ruby_local_bindings_for_decl(decl);
locals.extend(block_param_names.iter().cloned());
let mut sites = Vec::new();
collect_ruby_bare_tail_call_sites(&decl.flow_events, &locals, &mut sites);
if sites.is_empty() {
return;
}
let mut changed = false;
for (span, name) in sites {
let event = FlowEvent::Call {
span,
name,
receiver: None,
receiver_types: Vec::new(),
call_kind: CallKind::Method,
args: Vec::new(),
};
if !decl.flow_events.iter().any(|existing| existing == &event) {
decl.flow_events.push(event);
changed = true;
}
}
if changed {
decl.flow_events
.sort_by_key(|event| (event.span().start, event.span().end));
}
}
fn collect_ruby_bare_tail_call_sites(
events: &[FlowEvent],
locals: &std::collections::BTreeSet<String>,
out: &mut Vec<(Span, String)>,
) {
for event in events {
match event {
FlowEvent::Return {
span,
value_name: Some(name),
value_flow,
..
} => {
if value_flow.place.as_deref() == Some(name.as_str())
&& ruby_bare_method_candidate(name)
&& !locals.contains(name.as_str())
{
out.push((*span, name.clone()));
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
collect_ruby_bare_tail_call_sites(then_events, locals, out);
collect_ruby_bare_tail_call_sites(else_events, locals, out);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
collect_ruby_bare_tail_call_sites(body, locals, out);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
collect_ruby_bare_tail_call_sites(body, locals, out);
collect_ruby_bare_tail_call_sites(catch_events, locals, out);
collect_ruby_bare_tail_call_sites(finally_events, locals, out);
}
_ => {}
}
}
}
fn collect_ruby_block_param_names(tree: &Tree, src: &[u8]) -> std::collections::BTreeSet<String> {
let mut names = std::collections::BTreeSet::new();
for params in collect_kinds(tree, &["block_parameters", "lambda_parameters"]) {
collect_ruby_param_identifiers(params, src, &mut names);
}
names
}
fn inject_ruby_unbound_receiver_calls(
index: &mut DeclIndex,
tree: &Tree,
file: FileId,
src: &[u8],
block_param_names: &std::collections::BTreeSet<String>,
) {
let mut candidates = Vec::new();
for call in collect_kinds(tree, &["call", "method_call"]) {
let Some(receiver) = call.child_by_field_name("receiver") else {
continue;
};
if receiver.kind() != "identifier" || call.child_by_field_name("method").is_none() {
continue;
}
let name = node_text(&receiver, src).trim();
if ruby_bare_method_candidate(name) {
candidates.push((span_of(file, &receiver), name.to_string()));
}
}
for (span, name) in candidates {
let Some(decl) = index
.defs
.iter_mut()
.filter(|decl| {
matches!(
decl.kind,
DeclKind::Function | DeclKind::Method | DeclKind::Constructor
) && decl_span_contains(decl, span)
})
.min_by_key(|decl| decl.span.end.saturating_sub(decl.span.start))
else {
continue;
};
let mut locals = ruby_local_bindings_for_decl(decl);
locals.extend(block_param_names.iter().cloned());
if locals.contains(name.as_str()) {
continue;
}
let call = FlowEvent::Call {
span,
name: name.clone(),
receiver: None,
receiver_types: Vec::new(),
call_kind: CallKind::Method,
args: Vec::new(),
};
let result = FlowEvent::Assign {
span,
target: name.clone(),
source_name: None,
source_call: Some(name),
source_call_args: Vec::new(),
source_names: Vec::new(),
declares_new_binding: false,
value_kind: Some(bonsai_lang_api::AssignValueKind::CallResult),
};
if !decl.flow_events.iter().any(|event| event == &call) {
decl.flow_events.push(call);
decl.flow_events.push(result);
decl.flow_events
.sort_by_key(|event| (event.span().start, event.span().end));
}
}
}
fn collect_ruby_param_identifiers(
node: tree_sitter::Node<'_>,
src: &[u8],
out: &mut std::collections::BTreeSet<String>,
) {
if node.kind() == "identifier" {
if let Some(name) = ruby_bare_binding_name(node_text(&node, src).trim()) {
out.insert(name);
}
return;
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
collect_ruby_param_identifiers(child, src, out);
}
}
fn rewrite_ruby_bareword_assigns_in_events(
events: &mut Vec<FlowEvent>,
locals: &std::collections::BTreeSet<String>,
) {
for event in events.iter_mut() {
match event {
FlowEvent::Branch {
then_events,
else_events,
..
} => {
rewrite_ruby_bareword_assigns_in_events(then_events, locals);
rewrite_ruby_bareword_assigns_in_events(else_events, locals);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
rewrite_ruby_bareword_assigns_in_events(body, locals);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
rewrite_ruby_bareword_assigns_in_events(body, locals);
rewrite_ruby_bareword_assigns_in_events(catch_events, locals);
rewrite_ruby_bareword_assigns_in_events(finally_events, locals);
}
_ => {}
}
}
if !events
.iter()
.any(|event| ruby_bareword_call_result_name(event, locals).is_some())
{
return;
}
let mut rewritten = Vec::with_capacity(events.len() + 1);
for event in events.drain(..) {
match ruby_bareword_call_result_name(&event, locals) {
Some(call_name) => {
let span = event.span();
rewritten.push(promote_ruby_bareword_assign_to_call_result(event, &call_name));
rewritten.push(FlowEvent::Call {
span,
name: call_name,
receiver: None,
receiver_types: Vec::new(),
call_kind: CallKind::Function,
args: Vec::new(),
});
}
None => rewritten.push(event),
}
}
*events = rewritten;
}
fn ruby_bareword_call_result_name(
event: &FlowEvent,
locals: &std::collections::BTreeSet<String>,
) -> Option<String> {
let FlowEvent::Assign {
source_name: Some(name),
source_call: None,
source_names,
value_kind,
..
} = event
else {
return None;
};
if !source_names.iter().all(|carrier| carrier == name) {
return None;
}
if matches!(
value_kind,
Some(
bonsai_lang_api::AssignValueKind::Literal
| bonsai_lang_api::AssignValueKind::CallResult
| bonsai_lang_api::AssignValueKind::YieldResult
| bonsai_lang_api::AssignValueKind::CallableReference
)
) {
return None;
}
if locals.contains(name.as_str()) {
return None;
}
ruby_bare_method_candidate(name).then(|| name.clone())
}
fn promote_ruby_bareword_assign_to_call_result(event: FlowEvent, call_name: &str) -> FlowEvent {
let FlowEvent::Assign {
span,
target,
declares_new_binding,
..
} = event
else {
return event;
};
FlowEvent::Assign {
span,
target,
source_name: None,
source_call: Some(call_name.to_string()),
source_call_args: Vec::new(),
source_names: Vec::new(),
declares_new_binding,
value_kind: Some(bonsai_lang_api::AssignValueKind::CallResult),
}
}
fn ruby_span_text(src: &[u8], span: Span) -> Option<&str> {
let start = usize::try_from(span.start).ok()?;
let end = usize::try_from(span.end).ok()?;
let bytes = src.get(start..end)?;
std::str::from_utf8(bytes).ok()
}
fn apply_ruby_class_semantic_identity(idx: &mut DeclIndex) {
let owners = idx
.defs
.iter()
.map(|decl| (decl.symbol, (decl.parent, decl.kind, decl.name.clone())))
.collect::<std::collections::HashMap<_, _>>();
for decl in &mut idx.defs {
let mut owner = if decl.kind == DeclKind::Module {
Some(decl.symbol)
} else {
decl.parent
};
let mut module_names = Vec::new();
let mut seen = std::collections::HashSet::new();
while let Some(symbol) = owner {
if !seen.insert(symbol) {
break;
}
let Some((parent, kind, name)) = owners.get(&symbol) else {
break;
};
if *kind == DeclKind::Module {
module_names.push(name.clone());
}
owner = *parent;
}
module_names.reverse();
for module_name in module_names {
if decl.module_path.segments.last() != Some(&module_name) {
decl.module_path.segments.push(module_name);
}
}
}
let mut classes: Vec<(Span, String, bonsai_common::SymbolId)> = idx
.defs
.iter()
.filter(|decl| is_class_like(decl.kind))
.map(|decl| (decl.span, decl.name.clone(), decl.symbol))
.collect();
classes.sort_by_key(|(span, _, _)| span.end.saturating_sub(span.start));
if classes.is_empty() {
return;
}
for decl in &mut idx.defs {
if is_class_like(decl.kind) {
let mut segments = decl.module_path.segments.clone();
segments.push(decl.name.clone());
decl.module_path = ModulePath::from_segments(segments.iter().cloned());
decl.qualified_name = Some(segments.join("."));
continue;
}
if !matches!(
decl.kind,
DeclKind::Function | DeclKind::Method | DeclKind::Constructor
) {
continue;
}
let Some((_, class_name, class_symbol)) = classes
.iter()
.filter(|(span, _, _)| span.start <= decl.span.start && span.end >= decl.span.end)
.min_by_key(|(span, _, _)| span.end.saturating_sub(span.start))
else {
continue;
};
decl.parent = Some(*class_symbol);
let mut segments = decl.module_path.segments.clone();
segments.push(class_name.clone());
decl.module_path = ModulePath::from_segments(segments.iter().cloned());
decl.qualified_name = Some(format!("{}.{}", segments.join("."), decl.name));
}
}
fn apply_ruby_scope_visibility(idx: &mut DeclIndex, tree: &Tree, src: &[u8], file: FileId) {
let mut visibility_overrides: std::collections::HashMap<(u64, u64), bonsai_lang_api::Visibility> =
std::collections::HashMap::new();
walk_class_bodies(
tree.root_node(),
src,
file,
bonsai_lang_api::Visibility::Public,
&mut visibility_overrides,
);
for decl in &mut idx.defs {
if !matches!(
decl.kind,
bonsai_lang_api::DeclKind::Function
| bonsai_lang_api::DeclKind::Method
| bonsai_lang_api::DeclKind::Constructor
) {
continue;
}
if let Some(visibility) = visibility_overrides.get(&(decl.span.start, decl.span.end)) {
decl.visibility = *visibility;
}
}
}
fn walk_class_bodies(
node: tree_sitter::Node<'_>,
src: &[u8],
file: FileId,
inherited_scope: bonsai_lang_api::Visibility,
out: &mut std::collections::HashMap<(u64, u64), bonsai_lang_api::Visibility>,
) {
match node.kind() {
"class" | "module" | "singleton_class" => {
let mut scope = bonsai_lang_api::Visibility::Public;
if let Some(body) = node.child_by_field_name("body") {
walk_body_statements(body, src, file, &mut scope, out);
} else {
let mut child_cursor = node.walk();
for child in node.named_children(&mut child_cursor) {
if child.kind() == "body_statement" {
walk_body_statements(child, src, file, &mut scope, out);
}
}
}
}
_ => {}
}
let mut child_cursor = node.walk();
for child in node.named_children(&mut child_cursor) {
walk_class_bodies(child, src, file, inherited_scope, out);
}
}
fn walk_body_statements(
body: tree_sitter::Node<'_>,
src: &[u8],
file: FileId,
current_scope: &mut bonsai_lang_api::Visibility,
out: &mut std::collections::HashMap<(u64, u64), bonsai_lang_api::Visibility>,
) {
let mut body_cursor = body.walk();
for stmt in body.named_children(&mut body_cursor) {
match stmt.kind() {
"identifier" => {
let text = std::str::from_utf8(&src[stmt.byte_range()]).unwrap_or("");
match text {
"private" => *current_scope = bonsai_lang_api::Visibility::Private,
"protected" => *current_scope = bonsai_lang_api::Visibility::Protected,
"public" => *current_scope = bonsai_lang_api::Visibility::Public,
"module_function" => *current_scope = bonsai_lang_api::Visibility::Public,
_ => {}
}
}
"call" => {
let method_node = stmt.child_by_field_name("method");
let method_text = method_node
.map(|method| std::str::from_utf8(&src[method.byte_range()]).unwrap_or(""))
.unwrap_or("");
let target_visibility = match method_text {
"private" => Some(bonsai_lang_api::Visibility::Private),
"protected" => Some(bonsai_lang_api::Visibility::Protected),
"public" => Some(bonsai_lang_api::Visibility::Public),
"module_function" => Some(bonsai_lang_api::Visibility::Public),
_ => None,
};
if let Some(visibility) = target_visibility {
if let Some(args) = stmt.child_by_field_name("arguments") {
let mut arg_cursor = args.walk();
for arg in args.named_children(&mut arg_cursor) {
if arg.kind() != "simple_symbol" {
continue;
}
let raw_symbol = std::str::from_utf8(&src[arg.byte_range()]).unwrap_or("");
let target_name = raw_symbol.trim_start_matches(':');
if target_name.is_empty() {
continue;
}
let mut sibling_cursor = body.walk();
for sibling in body.named_children(&mut sibling_cursor) {
if sibling.kind() != "method" && sibling.kind() != "singleton_method" {
continue;
}
let name_node = sibling.child_by_field_name("name");
let sibling_name = name_node
.map(|name| std::str::from_utf8(&src[name.byte_range()]).unwrap_or(""))
.unwrap_or("");
if sibling_name == target_name {
let span = span_of(file, &sibling);
out.insert((span.start, span.end), visibility);
}
}
}
}
}
if method_text == "module_function" {
let no_args = match stmt.child_by_field_name("arguments") {
None => true,
Some(args) => args.named_child_count() == 0,
};
if no_args {
*current_scope = bonsai_lang_api::Visibility::Public;
}
}
}
"method" | "singleton_method" => {
let span = span_of(file, &stmt);
out.entry((span.start, span.end)).or_insert(*current_scope);
}
_ => {}
}
}
}
fn is_class_like(kind: DeclKind) -> bool {
matches!(
kind,
DeclKind::Class | DeclKind::Interface | DeclKind::Trait | DeclKind::Struct | DeclKind::Enum
)
}
fn collect_ruby_class_bases(
tree: &Tree,
file: FileId,
src: &[u8],
) -> Vec<(bonsai_common::Span, String, Vec<String>)> {
let mut bases_table = Vec::new();
for class_node in collect_kinds(tree, &["class"]) {
let class_name = class_node
.child_by_field_name("name")
.map(|node| node_text(&node, src).to_string())
.unwrap_or_default();
let mut bases: Vec<String> = Vec::new();
if let Some(superclass_node) = class_node.child_by_field_name("superclass") {
let mut sc_cursor = superclass_node.walk();
for child in superclass_node.named_children(&mut sc_cursor) {
if let Some(name) = canonical_ruby_base_name(node_text(&child, src)) {
if !bases.iter().any(|existing| existing == &name) {
bases.push(name);
}
}
}
}
if !bases.is_empty() {
bases_table.push((span_of(file, &class_node), class_name, bases));
}
}
bases_table
}
fn canonical_ruby_base_name(raw: &str) -> Option<String> {
let trimmed = raw.trim();
let bare = trimmed.rsplit("::").next().unwrap_or(trimmed).trim();
if bare.is_empty() {
return None;
}
Some(bare.to_string())
}
fn extract_ruby_static_element_key_refs(tree: &Tree, src: &[u8], file: FileId) -> Vec<Ref> {
let mut refs = Vec::new();
for element in collect_kinds(tree, &["element_reference"]) {
if ruby_element_reference_is_write(&element) {
continue;
}
let Some(object) = element.child_by_field_name("object") else {
continue;
};
let object_name = node_text(&object, src).trim();
if object_name.is_empty() {
continue;
}
let mut cursor = element.walk();
for argument in element.named_children(&mut cursor) {
if argument.id() == object.id() || argument.kind() != "string" {
continue;
}
let mut string_cursor = argument.walk();
let parts = argument.named_children(&mut string_cursor).collect::<Vec<_>>();
let [content] = parts.as_slice() else {
continue;
};
if content.kind() != "string_content" {
continue;
}
let key = node_text(content, src).trim();
if key.is_empty() {
continue;
}
refs.push(Ref {
span: span_of(file, content),
name: format!("{object_name}.{key}"),
kind: RefKind::Read,
scope: None,
resolved: None,
});
}
}
refs
}
fn ruby_element_reference_is_write(node: &tree_sitter::Node<'_>) -> bool {
let Some(parent) = node.parent() else {
return false;
};
if !matches!(parent.kind(), "assignment" | "operator_assignment") {
return false;
}
parent
.child_by_field_name("left")
.is_some_and(|left| left.id() == node.id())
}
fn collect_ruby_erb_implicit_inputs(tree: &Tree, src: &[u8]) -> Vec<String> {
let mut inputs = collect_kinds(tree, &["instance_variable"])
.into_iter()
.map(|node| normalize_ruby_instance_variable_text(node_text(&node, src).trim()))
.filter(|input| ruby_normalized_instance_variable_place(input).is_some())
.collect::<Vec<_>>();
inputs.sort();
inputs.dedup();
inputs
}
fn preprocess_erb(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = vec![b' '; bytes.len()];
for (byte_index, &byte) in bytes.iter().enumerate() {
if byte == b'\n' || byte == b'\r' {
out[byte_index] = byte;
}
}
let mut cursor = 0;
while cursor + 1 < bytes.len() {
if bytes[cursor] == b'<' && bytes[cursor + 1] == b'%' {
let tag_start = cursor;
let mut content_start = cursor + 2;
while content_start < bytes.len() && matches!(bytes[content_start], b'=' | b'-' | b'#') {
content_start += 1;
}
let mut close_start = content_start;
while close_start + 1 < bytes.len() {
if bytes[close_start] == b'%' && bytes[close_start + 1] == b'>' {
break;
}
close_start += 1;
}
if close_start + 1 >= bytes.len() {
break;
}
let is_comment = bytes.get(tag_start + 2).copied() == Some(b'#');
if !is_comment {
for content_index in content_start..close_start {
if bytes[content_index] != b'\n' && bytes[content_index] != b'\r' {
out[content_index] = bytes[content_index];
}
}
}
cursor = close_start + 2;
if cursor < bytes.len() && bytes[cursor] == b'-' {
cursor += 1;
}
continue;
}
cursor += 1;
}
match String::from_utf8(out) {
Ok(masked) => masked,
Err(invalid) => {
String::from_utf8_lossy(invalid.as_bytes()).into_owned()
}
}
}
fn parse_imports(tree: &Tree, src: &[u8], file: FileId) -> Vec<ImportSpec> {
let mut imports = Vec::new();
for call_node in collect_kinds(tree, &["call"]) {
let Some(method_node) = call_node.child_by_field_name("method") else {
continue;
};
let method = node_text(&method_node, src);
if !matches!(method, "require" | "require_relative" | "load" | "autoload") {
continue;
}
let Some(args) = call_node.child_by_field_name("arguments") else {
continue;
};
let module = first_named_child_of_kind(&args, "string")
.and_then(|string_node| first_named_child_of_kind(&string_node, "string_content"))
.map(|content| node_text(&content, src).to_string())
.unwrap_or_default();
if module.is_empty() {
continue;
}
imports.push(ImportSpec {
span: span_of(file, &call_node),
module: module.clone(),
alias: None,
is_wildcard: false,
original_name: None,
scope: ImportScope::Module,
});
if matches!(method, "require" | "require_relative" | "load") {
imports.push(ImportSpec {
span: span_of(file, &call_node),
module: module.clone(),
alias: None,
is_wildcard: true,
original_name: None,
scope: ImportScope::Local,
});
if let Some(stem) = module.rsplit(['/', '\\']).next() {
let constant = ruby_constant_name_from_snake_case(stem);
if !constant.is_empty() && constant != module {
imports.push(ImportSpec {
span: span_of(file, &call_node),
module,
alias: Some(constant),
is_wildcard: true,
original_name: None,
scope: ImportScope::Local,
});
}
}
}
}
for assignment in collect_kinds(tree, &["assignment"]) {
if inside_ruby_executable_scope(assignment) {
continue;
}
let (Some(left), Some(right)) = (
assignment.child_by_field_name("left"),
assignment.child_by_field_name("right"),
) else {
continue;
};
if left.kind() != "constant" || right.kind() != "constant" {
continue;
}
let alias = node_text(&left, src).trim();
let module = node_text(&right, src).trim();
if alias.is_empty() || module.is_empty() || alias == module {
continue;
}
if imports.iter().any(|import| {
import.alias.as_deref() == Some(alias)
&& import.module == module
&& import.original_name.is_none()
}) {
continue;
}
imports.push(ImportSpec {
span: span_of(file, &assignment),
module: module.to_string(),
alias: Some(alias.to_string()),
is_wildcard: false,
original_name: None,
scope: ImportScope::Module,
});
}
imports
}
fn ruby_constant_name_from_snake_case(stem: &str) -> String {
stem.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut out = String::new();
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
out
})
.collect::<String>()
}
fn inside_ruby_executable_scope(node: tree_sitter::Node<'_>) -> bool {
let mut parent = node.parent();
while let Some(current) = parent {
if matches!(
current.kind(),
"method" | "singleton_method" | "block" | "do_block"
) {
return true;
}
parent = current.parent();
}
false
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;