1use crate::imports::parse_ruby_require_call;
2use crate::mixins::{encode_mixin_relation, encode_superclass_relation, raw_mixin_specs_for_type};
3use brokk_bifrost_core::analyzer::fq_name::{FqName, SegmentId, SegmentKind, segment_interner};
4use brokk_bifrost_core::analyzer::model::{
5 CodeUnitType, DispatchExtensibility, RubyMethodDispatchMode, SignatureMetadata,
6};
7use brokk_bifrost_core::analyzer::parsed_file::ParsedFile;
8use brokk_bifrost_core::analyzer::structural::materialization::{
9 GenerationKind, MaterializationRecord,
10};
11use brokk_bifrost_core::analyzer::structural::resolution::DeclaredVisibility;
12use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, node_range, walk_named_tree_preorder};
13use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
14use brokk_bifrost_core::hash::HashSet;
15use tree_sitter::{Node, Parser, Tree};
16
17fn ruby_segment(text: &str, kind: SegmentKind) -> SegmentId {
19 segment_interner().intern(text, kind)
20}
21
22fn ruby_type_chain_fq(segments: &[String]) -> FqName {
34 let mut fq = FqName::new();
35 for segment in segments {
36 fq.push(ruby_segment(segment, SegmentKind::Nested));
37 }
38 fq
39}
40
41fn ruby_member_fq(type_segments: &[String], name: &str) -> FqName {
45 ruby_type_chain_fq(type_segments).with_pushed(ruby_segment(name, SegmentKind::Member))
46}
47
48pub fn parse_ruby_tree(source: &str) -> Option<Tree> {
50 let mut parser = Parser::new();
51 parser
52 .set_language(&tree_sitter_ruby::LANGUAGE.into())
53 .expect("failed to load ruby parser");
54 parser.parse(source, None)
55}
56
57pub fn ruby_node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
59 brokk_bifrost_core::analyzer::common::node_source_text(node, source)
60}
61
62pub struct RubyVisitor<'a> {
70 pub file: &'a ProjectFile,
71 pub source: &'a str,
72 pub parsed: &'a mut ParsedFile,
73}
74
75struct RubyWork<'tree> {
80 node: Node<'tree>,
81 segments: Vec<String>,
82 parent: Option<CodeUnit>,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum RubyFieldScope {
87 Instance,
88 ClassVariable,
89 SingletonClass,
90}
91
92fn push_named_children<'tree>(
95 node: Node<'tree>,
96 segments: &[String],
97 parent: Option<&CodeUnit>,
98 stack: &mut Vec<RubyWork<'tree>>,
99) {
100 let mut cursor = node.walk();
101 let children: Vec<_> = node.named_children(&mut cursor).collect();
102 for child in children.into_iter().rev() {
103 stack.push(RubyWork {
104 node: child,
105 segments: segments.to_vec(),
106 parent: parent.cloned(),
107 });
108 }
109}
110
111impl RubyVisitor<'_> {
112 pub fn visit_program(&mut self, root: Node<'_>) {
113 let mut stack = Vec::new();
114 push_named_children(root, &[], None, &mut stack);
115 while let Some(work) = stack.pop() {
116 self.visit_statement(work.node, &work.segments, work.parent.as_ref(), &mut stack);
117 }
118 }
119
120 fn visit_statement<'tree>(
121 &mut self,
122 node: Node<'tree>,
123 segments: &[String],
124 parent: Option<&CodeUnit>,
125 stack: &mut Vec<RubyWork<'tree>>,
126 ) {
127 match node.kind() {
128 "class" => self.visit_class_like(node, segments, parent, false, stack),
129 "module" => self.visit_class_like(node, segments, parent, true, stack),
130 "singleton_class" => {
131 if let Some(body) = node.child_by_field_name("body") {
133 push_named_children(body, segments, parent, stack);
134 }
135 }
136 "method" | "singleton_method" => self.visit_method(node, segments, parent),
137 "assignment" | "operator_assignment" => {
138 self.visit_assignment(node, segments, parent, None)
139 }
140 "call" => self.visit_call(node, segments, parent),
141 kind if is_descendable_container(kind) => {
142 push_named_children(node, segments, parent, stack);
143 }
144 _ => {}
145 }
146 }
147
148 fn visit_class_like<'tree>(
149 &mut self,
150 node: Node<'tree>,
151 segments: &[String],
152 parent: Option<&CodeUnit>,
153 is_module: bool,
154 stack: &mut Vec<RubyWork<'tree>>,
155 ) {
156 let Some(name_node) = node.child_by_field_name("name") else {
157 return;
158 };
159 let name_segments = extract_name_segments(name_node, self.source);
160 if name_segments.is_empty() {
161 return;
162 }
163
164 let mut new_segments = segments.to_vec();
165 new_segments.extend(name_segments);
166 let short_name = new_segments.join("$");
167
168 let kind = if is_module {
169 CodeUnitType::Module
170 } else {
171 CodeUnitType::Class
172 };
173 let code_unit = CodeUnit::new_fq(
174 self.file.clone(),
175 kind,
176 String::new(),
177 short_name,
178 ruby_type_chain_fq(&new_segments),
179 );
180 self.parsed
181 .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
182 self.parsed
183 .add_signature(code_unit.clone(), first_line(node, self.source));
184
185 let mut owner_relations = extract_ruby_supertypes(node, self.source)
186 .into_iter()
187 .map(|target| {
188 let encoded = encode_superclass_relation(&target);
189 (target, encoded)
190 })
191 .collect::<Vec<_>>();
192 owner_relations.extend(raw_mixin_specs_for_type(node, self.source).into_iter().map(
193 |spec| {
194 let encoded = encode_mixin_relation(&spec);
195 (spec.raw_target, encoded)
196 },
197 ));
198 if !owner_relations.is_empty() {
199 self.parsed.set_raw_supertypes(
200 code_unit.clone(),
201 owner_relations
202 .iter()
203 .map(|(target, _)| target.clone())
204 .collect(),
205 );
206 self.parsed.set_supertype_lookup_paths(
207 code_unit.clone(),
208 owner_relations
209 .into_iter()
210 .map(|(_, encoded)| encoded)
211 .collect(),
212 );
213 }
214
215 self.visit_scope_field_assignments(
216 node,
217 &new_segments,
218 Some(&code_unit),
219 RubyFieldScope::SingletonClass,
220 );
221 if let Some(body) = node.child_by_field_name("body") {
222 push_named_children(body, &new_segments, Some(&code_unit), stack);
223 }
224 }
225
226 fn visit_method(&mut self, node: Node<'_>, segments: &[String], parent: Option<&CodeUnit>) {
227 let Some(name_node) = node.child_by_field_name("name") else {
228 return;
229 };
230 let name = ruby_node_text(name_node, self.source).trim();
231 if name.is_empty() {
232 return;
233 }
234 let short_name = member_short_name(segments, name);
235 let signature = node
236 .child_by_field_name("parameters")
237 .map(|params| ruby_node_text(params, self.source).trim().to_string());
238 let code_unit = CodeUnit::with_signature_and_fq(
239 self.file.clone(),
240 CodeUnitType::Function,
241 String::new(),
242 short_name,
243 signature,
244 false,
245 ruby_member_fq(segments, name),
246 );
247 self.parsed
248 .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
249 self.parsed.set_ruby_method_dispatch_mode(
250 code_unit.clone(),
251 ruby_method_dispatch_mode(node, self.source),
252 );
253 self.parsed.add_signature_with_metadata(
254 code_unit,
255 ruby_signature_metadata(first_line(node, self.source), node, self.source),
256 );
257 self.visit_scope_field_assignments(node, segments, parent, ruby_method_field_scope(node));
261 }
262
263 fn visit_assignment(
264 &mut self,
265 node: Node<'_>,
266 segments: &[String],
267 parent: Option<&CodeUnit>,
268 field_scope: Option<RubyFieldScope>,
269 ) {
270 let Some(left) = node.child_by_field_name("left") else {
271 return;
272 };
273 if let Some(field_scope) = ruby_field_scope_for_assignment_left(left, segments, field_scope)
274 {
275 self.visit_variable_field_assignment(node, left, segments, parent, field_scope);
276 return;
277 }
278 if !matches!(left.kind(), "constant" | "scope_resolution") {
280 return;
281 }
282 let name_path = extract_name_path(left, self.source);
283 if name_path.segments.is_empty() {
284 return;
285 }
286 let short_name = assignment_constant_short_name(segments, &name_path);
287 let code_unit = CodeUnit::new_fq(
288 self.file.clone(),
289 CodeUnitType::Field,
290 String::new(),
291 short_name,
292 assignment_constant_fq(segments, &name_path),
293 );
294 self.parsed
295 .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
296 self.parsed.add_signature(
297 code_unit,
298 ruby_node_text(node, self.source).trim().to_string(),
299 );
300 }
301
302 fn visit_scope_field_assignments(
303 &mut self,
304 node: Node<'_>,
305 segments: &[String],
306 parent: Option<&CodeUnit>,
307 field_scope: RubyFieldScope,
308 ) {
309 let mut stack = vec![node];
310 while let Some(current) = stack.pop() {
311 if current != node
312 && matches!(
313 current.kind(),
314 "class" | "module" | "method" | "singleton_method" | "singleton_class"
315 )
316 {
317 continue;
318 }
319 if matches!(current.kind(), "assignment" | "operator_assignment") {
320 self.visit_assignment(current, segments, parent, Some(field_scope));
321 continue;
322 }
323 for index in (0..current.named_child_count()).rev() {
324 if let Some(child) = current.named_child(index) {
325 stack.push(child);
326 }
327 }
328 }
329 }
330
331 fn visit_variable_field_assignment(
332 &mut self,
333 node: Node<'_>,
334 left: Node<'_>,
335 segments: &[String],
336 parent: Option<&CodeUnit>,
337 field_scope: RubyFieldScope,
338 ) {
339 let Some(short_name) = ruby_field_short_name(segments, left, self.source, field_scope)
340 else {
341 return;
342 };
343 let fq = ruby_field_fq(segments, left, self.source, field_scope).unwrap_or_default();
344 let code_unit = CodeUnit::new_fq(
345 self.file.clone(),
346 CodeUnitType::Field,
347 String::new(),
348 short_name,
349 fq,
350 );
351 if self
352 .parsed
353 .first_range_start(&code_unit)
354 .is_some_and(|start| start <= node.start_byte())
355 {
356 return;
357 }
358 self.parsed
359 .replace_code_unit(code_unit.clone(), node, self.source, parent.cloned(), None);
360 self.parsed.add_signature(
361 code_unit,
362 ruby_node_text(node, self.source).trim().to_string(),
363 );
364 }
365
366 fn visit_call(&mut self, node: Node<'_>, segments: &[String], parent: Option<&CodeUnit>) {
367 let Some(method) = node.child_by_field_name("method") else {
368 return;
369 };
370 let method_name = ruby_node_text(method, self.source).trim();
371 match method_name {
372 "require" | "require_relative" | "load" | "autoload" => {
373 if let Some(info) = parse_ruby_require_call(node, self.source) {
374 self.parsed.imports.push(info);
375 }
376 }
377 "attr_accessor" | "attr_reader" | "attr_writer" => {
378 self.visit_attr_macro(node, method_name, segments, parent);
379 }
380 "alias_method" => {
381 self.visit_alias_method(node, segments, parent);
382 }
383 _ => {}
384 }
385 }
386
387 fn visit_attr_macro(
388 &mut self,
389 node: Node<'_>,
390 method_name: &str,
391 segments: &[String],
392 parent: Option<&CodeUnit>,
393 ) {
394 let Some(parent) = parent else {
396 return;
397 };
398 let Some(arguments) = node.child_by_field_name("arguments") else {
399 return;
400 };
401 let mut cursor = arguments.walk();
402 let mut dynamic_argument_seen = false;
403 for arg in arguments.named_children(&mut cursor) {
404 let Some(name) = literal_symbol_or_string_name(arg, self.source) else {
405 dynamic_argument_seen = true;
409 continue;
410 };
411 let member_name = attr_field_member_name(node, &name);
412 let field_name = format!("@{name}");
413 let code_unit = CodeUnit::new_fq(
414 self.file.clone(),
415 CodeUnitType::Field,
416 String::new(),
417 member_short_name(segments, &member_name),
418 ruby_scoped_field_fq(segments, &field_name, method_is_singleton_context(node)),
419 );
420 self.parsed.replace_code_unit(
421 code_unit.clone(),
422 node,
423 self.source,
424 Some(parent.clone()),
425 None,
426 );
427 self.parsed
428 .record_materialization(MaterializationRecord::GeneratedDeclaration {
429 site: node_range(node),
430 argument: node_range(arg),
431 kind: GenerationKind::AccessorMacro,
432 unit: code_unit.clone(),
433 });
434 self.parsed.add_signature(
435 code_unit,
436 ruby_node_text(node, self.source).trim().to_string(),
437 );
438 if matches!(method_name, "attr_accessor" | "attr_reader") {
439 self.add_member_function(
440 node,
441 arg,
442 segments,
443 parent,
444 &name,
445 GenerationKind::AccessorMacro,
446 );
447 }
448 if matches!(method_name, "attr_accessor" | "attr_writer") {
449 self.add_member_function(
450 node,
451 arg,
452 segments,
453 parent,
454 &format!("{name}="),
455 GenerationKind::AccessorMacro,
456 );
457 }
458 }
459 if dynamic_argument_seen {
460 self.parsed
461 .record_materialization(MaterializationRecord::DynamicGenerationSite {
462 site: node_range(node),
463 kind: GenerationKind::AccessorMacro,
464 });
465 }
466 }
467
468 fn visit_alias_method(
469 &mut self,
470 node: Node<'_>,
471 segments: &[String],
472 parent: Option<&CodeUnit>,
473 ) {
474 let Some(parent) = parent else {
475 return;
476 };
477 let Some(arguments) = node.child_by_field_name("arguments") else {
478 return;
479 };
480 let mut cursor = arguments.walk();
481 let Some(alias_arg) = arguments.named_children(&mut cursor).next() else {
482 return;
483 };
484 let Some(alias_name) = literal_symbol_or_string_name(alias_arg, self.source) else {
485 self.parsed
489 .record_materialization(MaterializationRecord::DynamicGenerationSite {
490 site: node_range(node),
491 kind: GenerationKind::AliasMacro,
492 });
493 return;
494 };
495 self.add_member_function(
496 node,
497 alias_arg,
498 segments,
499 parent,
500 &alias_name,
501 GenerationKind::AliasMacro,
502 );
503 }
504
505 fn add_member_function(
506 &mut self,
507 signature_node: Node<'_>,
508 range_node: Node<'_>,
509 segments: &[String],
510 parent: &CodeUnit,
511 name: &str,
512 generation: GenerationKind,
513 ) {
514 let code_unit = CodeUnit::new_fq(
515 self.file.clone(),
516 CodeUnitType::Function,
517 String::new(),
518 member_short_name(segments, name),
519 ruby_member_fq(segments, name),
520 );
521 self.parsed.replace_code_unit(
522 code_unit.clone(),
523 range_node,
524 self.source,
525 Some(parent.clone()),
526 None,
527 );
528 self.parsed
529 .record_materialization(MaterializationRecord::GeneratedDeclaration {
530 site: node_range(signature_node),
531 argument: node_range(range_node),
532 kind: generation,
533 unit: code_unit.clone(),
534 });
535 self.parsed.set_ruby_method_dispatch_mode(
536 code_unit.clone(),
537 ruby_method_dispatch_mode(signature_node, self.source),
538 );
539 self.parsed.add_signature(
540 code_unit,
541 ruby_node_text(signature_node, self.source)
542 .trim()
543 .to_string(),
544 );
545 }
546}
547
548fn member_short_name(segments: &[String], name: &str) -> String {
550 if segments.is_empty() {
551 name.to_string()
552 } else {
553 format!("{}.{}", segments.join("$"), name)
554 }
555}
556
557fn attr_field_member_name(node: Node<'_>, name: &str) -> String {
558 if method_is_singleton_context(node) {
559 format!("$singleton.@{name}")
560 } else {
561 format!("@{name}")
562 }
563}
564
565pub fn ruby_variable_field_name(node: Node<'_>, source: &str) -> Option<String> {
566 if !matches!(node.kind(), "instance_variable" | "class_variable") {
567 return None;
568 }
569 let name = ruby_node_text(node, source).trim();
570 (!name.is_empty()).then(|| name.to_string())
571}
572
573fn ruby_field_member_name(node: Node<'_>, source: &str, scope: RubyFieldScope) -> Option<String> {
578 let name = ruby_variable_field_name(node, source)?;
579 Some(match scope {
580 RubyFieldScope::Instance | RubyFieldScope::ClassVariable => name,
581 RubyFieldScope::SingletonClass => format!("$singleton.{name}"),
582 })
583}
584
585pub fn ruby_field_short_name(
586 segments: &[String],
587 node: Node<'_>,
588 source: &str,
589 scope: RubyFieldScope,
590) -> Option<String> {
591 if segments.is_empty() {
592 return None;
593 }
594 let member = ruby_field_member_name(node, source, scope)?;
595 Some(member_short_name(segments, &member))
596}
597
598fn ruby_field_fq(
600 segments: &[String],
601 node: Node<'_>,
602 source: &str,
603 scope: RubyFieldScope,
604) -> Option<FqName> {
605 if segments.is_empty() {
606 return None;
607 }
608 let name = ruby_variable_field_name(node, source)?;
609 Some(ruby_scoped_field_fq(
610 segments,
611 &name,
612 scope == RubyFieldScope::SingletonClass,
613 ))
614}
615
616fn ruby_scoped_field_fq(segments: &[String], name: &str, singleton: bool) -> FqName {
619 let mut fq = ruby_type_chain_fq(segments);
620 if singleton {
621 fq.push(ruby_segment("$singleton", SegmentKind::Package));
622 }
623 fq.push(ruby_segment(name, SegmentKind::Member));
624 fq
625}
626
627pub fn ruby_field_scope_for_assignment_left(
628 left: Node<'_>,
629 segments: &[String],
630 current_scope: Option<RubyFieldScope>,
631) -> Option<RubyFieldScope> {
632 if segments.is_empty() {
633 return None;
634 }
635 match left.kind() {
636 "class_variable" => Some(RubyFieldScope::ClassVariable),
637 "instance_variable" => Some(current_scope.unwrap_or(RubyFieldScope::SingletonClass)),
638 _ => None,
639 }
640}
641
642fn ruby_method_field_scope(node: Node<'_>) -> RubyFieldScope {
643 if method_is_singleton_context(node) {
644 RubyFieldScope::SingletonClass
645 } else {
646 RubyFieldScope::Instance
647 }
648}
649
650fn ruby_method_dispatch_mode(node: Node<'_>, source: &str) -> RubyMethodDispatchMode {
651 if module_function_applies_to_method(node, source) {
652 RubyMethodDispatchMode::ModuleFunction
653 } else if method_is_singleton_context(node) {
654 RubyMethodDispatchMode::Singleton
655 } else {
656 RubyMethodDispatchMode::Instance
657 }
658}
659
660fn method_is_singleton_context(node: Node<'_>) -> bool {
661 if node.kind() == "singleton_method" {
662 return true;
663 }
664 let mut parent = node.parent();
665 while let Some(current) = parent {
666 if current.kind() == "singleton_class" {
667 return true;
668 }
669 if matches!(current.kind(), "class" | "module") {
670 break;
671 }
672 parent = current.parent();
673 }
674 false
675}
676
677fn module_function_applies_to_method(node: Node<'_>, source: &str) -> bool {
678 if node.kind() != "method" {
679 return false;
680 }
681 let Some(name_node) = node.child_by_field_name("name") else {
682 return false;
683 };
684 let method_name = ruby_node_text(name_node, source).trim();
685 let Some(module) = enclosing_module_for_module_function(node) else {
686 return false;
687 };
688 let Some(body) = module.child_by_field_name("body") else {
689 return false;
690 };
691
692 let mut bare_module_function_active = false;
693 let mut stack = vec![body];
694 while let Some(current) = stack.pop() {
695 if current != body
696 && matches!(
697 current.kind(),
698 "class" | "module" | "method" | "singleton_method"
699 )
700 {
701 continue;
702 }
703 if current.kind() == "identifier"
704 && current.start_byte() < node.start_byte()
705 && ruby_node_text(current, source).trim() == "module_function"
706 {
707 bare_module_function_active = true;
708 continue;
709 }
710 if current.kind() == "call"
711 && let Some(method) = current.child_by_field_name("method")
712 && ruby_node_text(method, source).trim() == "module_function"
713 {
714 let mut names = module_function_names(current, source);
715 if names.next().is_none() {
716 if current.start_byte() < node.start_byte() {
717 bare_module_function_active = true;
718 }
719 } else if module_function_names(current, source).any(|name| name == method_name) {
720 return true;
721 }
722 continue;
723 }
724 for index in (0..current.named_child_count()).rev() {
725 if let Some(child) = current.named_child(index) {
726 stack.push(child);
727 }
728 }
729 }
730 bare_module_function_active
731}
732
733fn enclosing_module_for_module_function(node: Node<'_>) -> Option<Node<'_>> {
734 let mut parent = node.parent();
735 while let Some(current) = parent {
736 match current.kind() {
737 "module" => return Some(current),
738 "class" => return None,
739 _ => parent = current.parent(),
740 }
741 }
742 None
743}
744
745fn module_function_names<'a>(node: Node<'_>, source: &'a str) -> impl Iterator<Item = String> + 'a {
746 let mut names = Vec::new();
747 if let Some(arguments) = node.child_by_field_name("arguments") {
748 let mut cursor = arguments.walk();
749 for arg in arguments.named_children(&mut cursor) {
750 if let Some(name) = literal_symbol_or_string_name(arg, source) {
751 names.push(name);
752 }
753 }
754 }
755 names.into_iter()
756}
757
758fn assignment_constant_short_name(lexical_segments: &[String], name_path: &RubyNamePath) -> String {
759 let Some((name, owner_segments)) = name_path.segments.split_last() else {
760 return String::new();
761 };
762 if owner_segments.is_empty() {
763 return member_short_name(lexical_segments, name);
764 }
765 if name_path.absolute || owner_segments.len() > 1 || lexical_segments.is_empty() {
766 return member_short_name(owner_segments, name);
767 }
768
769 let mut resolved_owner = Vec::new();
770 resolved_owner.extend_from_slice(lexical_segments);
771 resolved_owner.extend_from_slice(owner_segments);
772 member_short_name(&resolved_owner, name)
773}
774
775fn assignment_constant_fq(lexical_segments: &[String], name_path: &RubyNamePath) -> FqName {
782 let Some((name, owner_segments)) = name_path.segments.split_last() else {
783 return FqName::new();
784 };
785 if owner_segments.is_empty() {
786 return ruby_member_fq(lexical_segments, name);
787 }
788 if name_path.absolute || owner_segments.len() > 1 || lexical_segments.is_empty() {
789 return ruby_member_fq(owner_segments, name);
790 }
791
792 let mut resolved_owner = Vec::new();
793 resolved_owner.extend_from_slice(lexical_segments);
794 resolved_owner.extend_from_slice(owner_segments);
795 ruby_member_fq(&resolved_owner, name)
796}
797
798pub struct RubyNamePath {
799 pub segments: Vec<String>,
800 pub absolute: bool,
801}
802
803pub fn extract_name_segments(name_node: Node<'_>, source: &str) -> Vec<String> {
808 extract_name_path(name_node, source).segments
809}
810
811pub fn extract_name_path(name_node: Node<'_>, source: &str) -> RubyNamePath {
812 match name_node.kind() {
813 "scope_resolution" => {
814 let mut path = name_node
815 .child_by_field_name("scope")
816 .map(|scope| extract_name_path(scope, source))
817 .unwrap_or_else(|| RubyNamePath {
818 segments: Vec::new(),
819 absolute: true,
820 });
821 if let Some(name) = name_node.child_by_field_name("name") {
822 path.segments.extend(extract_name_segments(name, source));
823 }
824 path
825 }
826 _ => {
827 let text = ruby_node_text(name_node, source).trim();
828 let segments = if text.is_empty() {
829 Vec::new()
830 } else {
831 vec![text.to_string()]
832 };
833 RubyNamePath {
834 segments,
835 absolute: false,
836 }
837 }
838 }
839}
840
841pub fn qualified_internal_name(node: Node<'_>, source: &str) -> Option<String> {
844 let segments = extract_name_segments(node, source);
845 (!segments.is_empty()).then(|| segments.join("$"))
846}
847
848fn extract_ruby_supertypes(node: Node<'_>, source: &str) -> Vec<String> {
851 let mut supertypes = Vec::new();
852
853 if let Some(superclass) = node.child_by_field_name("superclass") {
854 let mut cursor = superclass.walk();
855 if let Some(expr) = superclass.named_children(&mut cursor).next()
856 && let Some(name) = qualified_internal_name(expr, source)
857 {
858 supertypes.push(name);
859 }
860 }
861
862 supertypes
863}
864
865fn literal_symbol_or_string_name(node: Node<'_>, source: &str) -> Option<String> {
868 if !matches!(node.kind(), "simple_symbol" | "string") {
869 return None;
870 }
871 let text = ruby_node_text(node, source).trim();
872 let stripped = text
873 .strip_prefix(':')
874 .unwrap_or(text)
875 .trim_matches(['"', '\'']);
876 (!stripped.is_empty()).then(|| stripped.to_string())
877}
878
879fn first_line(node: Node<'_>, source: &str) -> String {
881 ruby_node_text(node, source)
882 .lines()
883 .map(str::trim)
884 .find(|line| !line.is_empty())
885 .unwrap_or_default()
886 .to_string()
887}
888
889fn ruby_signature_metadata(signature: String, node: Node<'_>, source: &str) -> SignatureMetadata {
905 let labels = match node.child_by_field_name("parameters") {
906 Some(parameters_node) => {
907 let mut cursor = parameters_node.walk();
908 parameters_node
909 .named_children(&mut cursor)
910 .filter_map(|child| ruby_parameter_label_node(child))
911 .map(|label_node| ruby_node_text(label_node, source).trim().to_string())
912 .filter(|label| !label.is_empty())
913 .collect()
914 }
915 None => Vec::new(),
916 };
917 SignatureMetadata::with_parameter_labels(signature, labels)
918 .with_dispatch_extensibility(DispatchExtensibility::Open)
919 .with_callable_modifiers(
920 method_is_singleton_context(node),
921 false,
922 DeclaredVisibility::Unknown,
923 )
924}
925
926fn ruby_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
927 match node.kind() {
928 "identifier" => Some(node),
929 "optional_parameter"
930 | "keyword_parameter"
931 | "splat_parameter"
932 | "hash_splat_parameter"
933 | "block_parameter" => node
934 .child_by_field_name("name")
935 .or_else(|| first_identifier_descendant(node)),
936 _ => None,
937 }
938}
939
940fn first_identifier_descendant(node: Node<'_>) -> Option<Node<'_>> {
941 let mut stack = vec![node];
942 while let Some(current) = stack.pop() {
943 if current.kind() == "identifier" {
944 return Some(current);
945 }
946 for index in (0..current.named_child_count()).rev() {
947 if let Some(child) = current.named_child(index) {
948 stack.push(child);
949 }
950 }
951 }
952 None
953}
954
955pub fn is_descendable_container(kind: &str) -> bool {
959 matches!(
960 kind,
961 "if" | "unless"
962 | "elsif"
963 | "else"
964 | "while"
965 | "until"
966 | "for"
967 | "case"
968 | "case_match"
969 | "when"
970 | "in_clause"
971 | "begin"
972 | "body_statement"
973 | "do"
974 | "do_block"
975 | "block"
976 | "then"
977 | "ensure"
978 | "rescue"
979 | "parenthesized_statements"
980 | "begin_block"
981 | "end_block"
982 )
983}
984
985pub fn collect_ruby_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
986 walk_named_tree_preorder(node, true, |node| {
987 if matches!(node.kind(), "identifier" | "constant") {
988 let text = ruby_node_text(node, source).trim();
989 if !text.is_empty() {
990 identifiers.insert(text.to_string());
991 }
992 }
993 WalkControl::Continue
994 });
995}
996
997#[cfg(test)]
998mod callable_modifier_tests {
999 use super::*;
1000 use crate::adapter::parse_ruby_file;
1001
1002 #[test]
1008 fn callable_metadata_records_ruby_singleton_structurally() {
1009 let source = "def top_level(value)\n value\nend\n\nclass Widget\n def initialize(spec)\n @spec = spec\n end\n\n def self.build(spec)\n new(spec)\n end\n\n def render(target)\n target\n end\n\n class << self\n def measure(target)\n target\n end\n end\nend\n\nmodule Helpers\n def helper\n end\nend\n";
1010 let file = ProjectFile::new(std::env::temp_dir(), "widget.rb");
1011 let tree = parse_ruby_tree(source).expect("parse Ruby fixture");
1012 let parsed = parse_ruby_file(&file, source, &tree);
1013
1014 let modifiers = |fq_name: &str| {
1015 let metadata = parsed
1016 .signature_metadata
1017 .iter()
1018 .find(|(unit, _)| unit.fq_name() == fq_name)
1019 .and_then(|(_, metadata)| metadata.first())
1020 .unwrap_or_else(|| {
1021 panic!(
1022 "missing Ruby callable {fq_name}; recorded {:?}",
1023 parsed
1024 .signature_metadata
1025 .keys()
1026 .map(CodeUnit::fq_name)
1027 .collect::<Vec<_>>()
1028 )
1029 });
1030 assert!(
1031 metadata.callable_modifiers_recorded(),
1032 "{fq_name} must record that the walk read its declaration shape"
1033 );
1034 (
1035 metadata.callable_is_static(),
1036 metadata.callable_is_constructor(),
1037 metadata.parameters().len(),
1038 )
1039 };
1040
1041 assert_eq!(modifiers("top_level"), (false, false, 1));
1042 assert_eq!(modifiers("Widget.initialize"), (false, false, 1));
1043 assert_eq!(modifiers("Widget.build"), (true, false, 1));
1044 assert_eq!(modifiers("Widget.render"), (false, false, 1));
1045 assert_eq!(modifiers("Widget.measure"), (true, false, 1));
1046 assert_eq!(modifiers("Helpers.helper"), (false, false, 0));
1047 }
1048}