1use crate::declarations::{RubyFieldScope, extract_name_path};
2use crate::graph::RubyGraphSource;
3use crate::graph_support::{RubySemanticFacts, RubySource};
4use crate::mixins::{ruby_forward_mixin_specs, ruby_forward_superclass_targets};
5use brokk_bifrost_core::analyzer::model::RubyMethodDispatchMode;
6use brokk_bifrost_core::analyzer::type_relations::TypeRelationKind;
7use brokk_bifrost_core::analyzer::{BoundedDefinitionLookup, CodeUnit, ProjectFile};
8use brokk_bifrost_core::hash::{HashMap, HashSet};
9use std::cell::RefCell;
10use tree_sitter::Node;
11
12#[derive(Clone, Copy, PartialEq, Eq)]
13pub enum RubyTargetKind {
14 TypeOrConstant,
15 Method,
16 Field(RubyFieldScope),
17}
18
19pub struct RubyTargetSpec {
20 pub target: CodeUnit,
21 pub kind: RubyTargetKind,
22 pub member_name: String,
23 pub field_owner: Option<String>,
24}
25
26pub struct RubyFieldTarget {
27 pub owner: String,
28 pub scope: RubyFieldScope,
29 pub member: String,
30}
31
32impl RubyTargetSpec {
33 pub fn from_target(
34 graph: &RubyGraphSource<'_>,
35 ruby: &dyn RubySource,
36 target: &CodeUnit,
37 ) -> Option<Self> {
38 if target.is_field()
39 && let Some(field) = ruby_field_target(target)
40 {
41 return Some(Self {
42 target: target.clone(),
43 kind: RubyTargetKind::Field(field.scope),
44 member_name: field.member,
45 field_owner: Some(field.owner),
46 });
47 }
48 if target.is_class() || target.is_module() || target.is_field() {
49 return Some(Self {
50 target: target.clone(),
51 kind: RubyTargetKind::TypeOrConstant,
52 member_name: target.identifier().to_string(),
53 field_owner: None,
54 });
55 }
56 if target.is_function() {
57 let class_side_declaration = matches!(
58 ruby.method_dispatch_mode(target),
59 RubyMethodDispatchMode::Singleton | RubyMethodDispatchMode::ModuleFunction
60 );
61 if graph.index.parent_of(target).is_none() && class_side_declaration {
62 return None;
63 }
64 return Some(Self {
65 target: target.clone(),
66 kind: RubyTargetKind::Method,
67 member_name: target.identifier().to_string(),
68 field_owner: None,
69 });
70 }
71 None
72 }
73}
74
75pub fn ruby_field_target(target: &CodeUnit) -> Option<RubyFieldTarget> {
76 let member = target.identifier();
77 let short_name = target.short_name();
83 if member.starts_with("@@") {
84 let owner = short_name.strip_suffix(&format!(".{member}"))?;
85 return (!owner.is_empty()).then(|| RubyFieldTarget {
86 owner: owner.to_string(),
87 scope: RubyFieldScope::ClassVariable,
88 member: member.to_string(),
89 });
90 }
91 if member.starts_with('@') {
92 let singleton_suffix = format!(".$singleton.{member}");
93 if let Some(owner) = short_name.strip_suffix(&singleton_suffix) {
94 return (!owner.is_empty()).then(|| RubyFieldTarget {
95 owner: owner.to_string(),
96 scope: RubyFieldScope::SingletonClass,
97 member: member.to_string(),
98 });
99 }
100 let owner = short_name.strip_suffix(&format!(".{member}"))?;
101 return (!owner.is_empty()).then(|| RubyFieldTarget {
102 owner: owner.to_string(),
103 scope: RubyFieldScope::Instance,
104 member: member.to_string(),
105 });
106 }
107 None
108}
109
110#[derive(Clone, Copy, PartialEq, Eq)]
111pub enum ReceiverMode {
112 Instance,
113 Class,
114 TopLevel,
115}
116
117#[derive(Clone, Copy)]
118pub enum ExplicitReceiverLookup {
119 Bare,
120 ReceiverOnly,
121}
122
123#[derive(Clone)]
124pub struct ReceiverType {
125 pub owner_fq_name: String,
126 pub mode: ReceiverMode,
127}
128
129pub struct RubySemanticIndex<'a> {
130 pub graph: RubyGraphSource<'a>,
131 pub ruby: &'a dyn RubySource,
132 facts: Option<&'a RubySemanticFacts>,
133 target: Option<CodeUnit>,
134 forward_owner_facts: RefCell<HashMap<String, RubyForwardOwnerFacts>>,
135 pub factory_return_cache: RefCell<HashMap<FactoryInferenceKey, Option<String>>>,
136}
137
138#[derive(Clone, Default)]
139struct RubyForwardOwnerFacts {
140 ancestors: Vec<String>,
141 included: Vec<String>,
142 prepended: Vec<String>,
143 extended: Vec<String>,
144}
145
146impl<'a> RubySemanticIndex<'a> {
147 pub fn build(
148 graph: RubyGraphSource<'a>,
149 ruby: &'a dyn RubySource,
150 spec: &RubyTargetSpec,
151 ) -> Self {
152 Self::build_with_target(graph, ruby, Some(spec.target.clone()))
153 }
154
155 pub fn build_for_lookup(graph: RubyGraphSource<'a>, ruby: &'a dyn RubySource) -> Self {
156 Self::build_with_target(graph, ruby, None)
157 }
158
159 fn build_with_target(
160 graph: RubyGraphSource<'a>,
161 ruby: &'a dyn RubySource,
162 target: Option<CodeUnit>,
163 ) -> Self {
164 Self {
165 graph,
166 ruby,
167 facts: target.as_ref().map(|_| ruby.semantic_facts()),
168 target,
169 forward_owner_facts: RefCell::new(HashMap::default()),
170 factory_return_cache: RefCell::new(HashMap::default()),
171 }
172 }
173
174 pub fn visible_files_from(&self, file: &ProjectFile) -> HashSet<ProjectFile> {
175 let mut visible = HashSet::default();
176 visible.insert(file.clone());
177 if let Some(zeitwerk_files) =
178 crate::imports::ruby_zeitwerk_visible_files_for(self.ruby, file)
179 {
180 visible.extend(zeitwerk_files.iter().cloned());
181 }
182 let mut stack = crate::imports::ruby_required_files(self.ruby, file);
183 while let Some(next) = stack.pop() {
184 if !visible.insert(next.clone()) {
185 continue;
186 }
187 stack.extend(crate::imports::ruby_required_files(self.ruby, &next));
188 }
189 visible
190 }
191
192 pub fn visible_files_from_bounded(
199 &self,
200 file: &ProjectFile,
201 max_files: usize,
202 ) -> Option<HashSet<ProjectFile>> {
203 let mut visible = HashSet::default();
204 visible.insert(file.clone());
205 let mut stack = crate::imports::ruby_required_files(self.ruby, file);
206 while let Some(next) = stack.pop() {
207 if !visible.insert(next.clone()) {
208 continue;
209 }
210 if visible.len() > max_files {
211 return None;
212 }
213 stack.extend(crate::imports::ruby_required_files(self.ruby, &next));
214 }
215 Some(visible)
216 }
217
218 pub fn resolve_constant(
219 &self,
220 file: &ProjectFile,
221 visible_files: &HashSet<ProjectFile>,
222 lexical_stack: &[String],
223 node: Node<'_>,
224 source: &str,
225 ) -> Option<CodeUnit> {
226 let path = extract_name_path(node, source);
227 self.resolve_constant_path(
228 file,
229 visible_files,
230 lexical_stack,
231 &path.segments,
232 path.absolute,
233 true,
234 )
235 }
236
237 pub fn resolve_project_local_constant(
241 &self,
242 file: &ProjectFile,
243 visible_files: &HashSet<ProjectFile>,
244 lexical_stack: &[String],
245 node: Node<'_>,
246 source: &str,
247 ) -> Option<CodeUnit> {
248 let path = extract_name_path(node, source);
249 self.resolve_constant_path(
250 file,
251 visible_files,
252 lexical_stack,
253 &path.segments,
254 path.absolute,
255 false,
256 )
257 }
258
259 pub fn resolve_constant_name(
260 &self,
261 file: &ProjectFile,
262 visible_files: &HashSet<ProjectFile>,
263 lexical_stack: &[String],
264 name: &str,
265 ) -> Option<CodeUnit> {
266 self.resolve_constant_path(
267 file,
268 visible_files,
269 lexical_stack,
270 &[name.to_string()],
271 false,
272 true,
273 )
274 }
275
276 fn resolve_constant_path(
277 &self,
278 file: &ProjectFile,
279 visible_files: &HashSet<ProjectFile>,
280 lexical_stack: &[String],
281 segments: &[String],
282 absolute: bool,
283 include_autoload: bool,
284 ) -> Option<CodeUnit> {
285 let candidates = constant_lookup_candidates(lexical_stack, segments, absolute)?;
286
287 candidates.into_iter().find_map(|candidate| {
288 let autoload_files = if include_autoload {
289 crate::imports::ruby_autoload_visible_files_for_constant(self.ruby, &candidate)
290 } else {
291 HashSet::default()
292 };
293 self.graph.index.definitions(&candidate).find(|unit| {
294 visible_files.contains(unit.source())
295 || unit.source() == file
296 || autoload_files.contains(unit.source())
297 })
298 })
299 }
300
301 pub fn target_matches_constant(&self, unit: &CodeUnit) -> bool {
302 self.target
303 .as_ref()
304 .is_some_and(|target| unit == target || unit.fq_name() == target.fq_name())
305 }
306
307 pub fn resolve_method_candidates(
308 &self,
309 support: &dyn BoundedDefinitionLookup,
310 visible_files: &HashSet<ProjectFile>,
311 receiver: &ReceiverType,
312 member: &str,
313 ) -> Vec<CodeUnit> {
314 let visible_files: Vec<ProjectFile> = visible_files.iter().cloned().collect();
315 let mut seen = HashSet::default();
316 let mut push_owner = |owner: &str, mode: RubyMethodLookupMode, out: &mut Vec<CodeUnit>| {
317 for unit in support.fqn_direct_children(owner) {
318 if unit.is_function()
319 && unit.identifier() == member
320 && visible_files.contains(unit.source())
321 && ruby_method_lookup_mode_matches(self.ruby, &unit, mode)
322 && seen.insert(unit.clone())
323 {
324 out.push(unit);
325 }
326 }
327 };
328
329 match receiver.mode {
330 ReceiverMode::TopLevel => {
331 self.resolve_top_level_method_candidates(support, &visible_files, member)
332 }
333 ReceiverMode::Instance => {
334 for owner in self.forward_receiver_owner_lookup_order(
335 support,
336 &visible_files,
337 &receiver.owner_fq_name,
338 ) {
339 let mut prepended = Vec::new();
340 for mixin in self
341 .mixin_owners(
342 support,
343 &visible_files,
344 &owner,
345 TypeRelationKind::MixinPrepend,
346 )
347 .into_iter()
348 .rev()
349 {
350 push_owner(&mixin, RubyMethodLookupMode::InstanceMethod, &mut prepended);
351 if !prepended.is_empty() {
352 break;
353 }
354 }
355 if !prepended.is_empty() {
356 return prepended;
357 }
358
359 let mut direct = Vec::new();
360 push_owner(&owner, RubyMethodLookupMode::InstanceMethod, &mut direct);
361 if !direct.is_empty() {
362 return direct;
363 }
364
365 let mut included = Vec::new();
366 for mixin in self
367 .mixin_owners(
368 support,
369 &visible_files,
370 &owner,
371 TypeRelationKind::MixinInclude,
372 )
373 .into_iter()
374 .rev()
375 {
376 push_owner(&mixin, RubyMethodLookupMode::InstanceMethod, &mut included);
377 if !included.is_empty() {
378 break;
379 }
380 }
381 if !included.is_empty() {
382 return included;
383 }
384 }
385 Vec::new()
386 }
387 ReceiverMode::Class => {
388 for owner in self.forward_receiver_owner_lookup_order(
389 support,
390 &visible_files,
391 &receiver.owner_fq_name,
392 ) {
393 let mut direct = Vec::new();
394 push_owner(&owner, RubyMethodLookupMode::SingletonMethod, &mut direct);
395 if !direct.is_empty() {
396 return direct;
397 }
398
399 let mut extended = Vec::new();
400 for mixin in self
401 .mixin_owners(
402 support,
403 &visible_files,
404 &owner,
405 TypeRelationKind::MixinExtend,
406 )
407 .into_iter()
408 .rev()
409 {
410 push_owner(&mixin, RubyMethodLookupMode::InstanceMethod, &mut extended);
411 if !extended.is_empty() {
412 break;
413 }
414 }
415 if !extended.is_empty() {
416 return extended;
417 }
418 }
419 Vec::new()
420 }
421 }
422 }
423
424 pub fn resolve_bare_method_candidates(
425 &self,
426 support: &dyn BoundedDefinitionLookup,
427 visible_files: &HashSet<ProjectFile>,
428 receiver: &ReceiverType,
429 member: &str,
430 ) -> Vec<CodeUnit> {
431 let candidates = self.resolve_method_candidates(support, visible_files, receiver, member);
432 if !candidates.is_empty() || receiver.mode == ReceiverMode::TopLevel {
433 return candidates;
434 }
435 let visible_files: Vec<ProjectFile> = visible_files.iter().cloned().collect();
436 self.resolve_top_level_method_candidates(support, &visible_files, member)
437 }
438
439 fn resolve_top_level_method_candidates(
440 &self,
441 support: &dyn BoundedDefinitionLookup,
442 visible_files: &[ProjectFile],
443 member: &str,
444 ) -> Vec<CodeUnit> {
445 support
446 .file_identifier_in_files(visible_files, member)
447 .into_iter()
448 .filter(|unit| {
449 unit.is_function()
450 && unit.identifier() == member
451 && self.graph.index.parent_of(unit).is_none()
452 && !ruby_method_lookup_mode_matches(
453 self.ruby,
454 unit,
455 RubyMethodLookupMode::SingletonMethod,
456 )
457 })
458 .collect()
459 }
460
461 fn mixin_owners(
462 &self,
463 support: &dyn BoundedDefinitionLookup,
464 visible_files: &[ProjectFile],
465 owner: &str,
466 kind: TypeRelationKind,
467 ) -> Vec<String> {
468 if let Some(facts) = self.facts {
469 let index = match kind {
470 TypeRelationKind::MixinInclude => &facts.mixin_included_owners,
471 TypeRelationKind::MixinPrepend => &facts.mixin_prepended_owners,
472 TypeRelationKind::MixinExtend => &facts.mixin_class_owners,
473 _ => return Vec::new(),
474 };
475 return index.get(owner).cloned().unwrap_or_default();
476 }
477 let facts = self.forward_owner_facts(support, visible_files, owner);
478 match kind {
479 TypeRelationKind::MixinInclude => facts.included,
480 TypeRelationKind::MixinPrepend => facts.prepended,
481 TypeRelationKind::MixinExtend => facts.extended,
482 _ => Vec::new(),
483 }
484 }
485
486 pub fn ancestor_lookup_order(&self, owner: &str) -> Vec<String> {
487 let Some(facts) = self.facts else {
488 return Vec::new();
489 };
490 let mut out = Vec::new();
491 let mut visited = HashSet::default();
492 let mut stack: Vec<String> = facts
493 .ancestors
494 .get(owner)
495 .map(|items| items.iter().cloned().collect())
496 .unwrap_or_default();
497 while let Some(candidate) = stack.pop() {
498 if !visited.insert(candidate.clone()) {
499 continue;
500 }
501 out.push(candidate.clone());
502 if let Some(next) = facts.ancestors.get(&candidate) {
503 stack.extend(next.iter().cloned());
504 }
505 }
506 out
507 }
508
509 pub fn forward_ancestor_lookup_order(
510 &self,
511 support: &dyn BoundedDefinitionLookup,
512 visible_files: &[ProjectFile],
513 owner: &str,
514 ) -> Vec<String> {
515 if self.facts.is_some() {
516 return self.ancestor_lookup_order(owner);
517 }
518 let mut out = Vec::new();
519 let mut visited = HashSet::default();
520 let mut stack = self
521 .forward_owner_facts(support, visible_files, owner)
522 .ancestors;
523 stack.reverse();
524 while let Some(candidate) = stack.pop() {
525 if !visited.insert(candidate.clone()) {
526 continue;
527 }
528 out.push(candidate.clone());
529 let mut next = self
530 .forward_owner_facts(support, visible_files, &candidate)
531 .ancestors;
532 next.reverse();
533 stack.extend(next);
534 }
535 out
536 }
537
538 fn forward_receiver_owner_lookup_order(
539 &self,
540 support: &dyn BoundedDefinitionLookup,
541 visible_files: &[ProjectFile],
542 owner: &str,
543 ) -> Vec<String> {
544 let mut owners = vec![owner.to_string()];
545 owners.extend(self.forward_ancestor_lookup_order(support, visible_files, owner));
546 owners
547 }
548
549 fn forward_owner_facts(
550 &self,
551 support: &dyn BoundedDefinitionLookup,
552 visible_files: &[ProjectFile],
553 owner: &str,
554 ) -> RubyForwardOwnerFacts {
555 if let Some(cached) = self.forward_owner_facts.borrow().get(owner) {
556 return cached.clone();
557 }
558 let Some(owner_unit) = support.fqn(owner).into_iter().find(|unit| {
559 (unit.is_class() || unit.is_module())
560 && unit.fq_name() == owner
561 && visible_files.contains(unit.source())
562 }) else {
563 self.forward_owner_facts
564 .borrow_mut()
565 .insert(owner.to_string(), RubyForwardOwnerFacts::default());
566 return RubyForwardOwnerFacts::default();
567 };
568
569 let specs = ruby_forward_mixin_specs(self.ruby, &owner_unit);
570 let mixin_names: HashSet<String> =
571 specs.iter().map(|spec| spec.raw_target.clone()).collect();
572 let mut facts = RubyForwardOwnerFacts::default();
573 for spec in specs {
574 let Some(target) =
575 self.resolve_forward_owner_name(support, visible_files, owner, &spec.raw_target)
576 else {
577 continue;
578 };
579 match spec.kind {
580 TypeRelationKind::MixinInclude => facts.included.push(target),
581 TypeRelationKind::MixinPrepend => facts.prepended.push(target),
582 TypeRelationKind::MixinExtend => facts.extended.push(target),
583 _ => {}
584 }
585 }
586 for raw in ruby_forward_superclass_targets(self.ruby, &owner_unit) {
587 if mixin_names.contains(&raw) {
588 continue;
589 }
590 if let Some(target) =
591 self.resolve_forward_owner_name(support, visible_files, owner, &raw)
592 {
593 facts.ancestors.push(target);
594 }
595 }
596 facts.ancestors.dedup();
597 facts.included.dedup();
598 facts.prepended.dedup();
599 facts.extended.dedup();
600 self.forward_owner_facts
601 .borrow_mut()
602 .insert(owner.to_string(), facts.clone());
603 facts
604 }
605
606 fn resolve_forward_owner_name(
607 &self,
608 support: &dyn BoundedDefinitionLookup,
609 visible_files: &[ProjectFile],
610 lexical_owner: &str,
611 raw: &str,
612 ) -> Option<String> {
613 let mut candidate_names = vec![raw.to_string()];
614 let mut prefix = lexical_owner;
615 while let Some((parent, _)) = prefix.rsplit_once('$') {
618 candidate_names.push(format!("{parent}${raw}"));
619 prefix = parent;
620 }
621 for candidate in candidate_names {
622 let mut matches = support.fqn(&candidate);
623 matches.retain(|unit| {
624 (unit.is_class() || unit.is_module()) && visible_files.contains(unit.source())
625 });
626 matches.sort();
627 matches.dedup();
628 if matches.len() == 1 {
629 return Some(matches.remove(0).fq_name());
630 }
631 }
632
633 let identifier = raw.rsplit('$').next().unwrap_or(raw); let mut matches = support.file_identifier_in_files(visible_files, identifier);
635 matches.retain(|unit| {
636 (unit.is_class() || unit.is_module()) && unit.identifier() == identifier
637 });
638 matches.sort();
639 matches.dedup();
640 (matches.len() == 1).then(|| matches.remove(0).fq_name())
641 }
642}
643
644#[derive(Clone, Copy)]
645pub enum RubyMethodLookupMode {
646 InstanceMethod,
647 SingletonMethod,
648}
649
650#[derive(Clone, Eq, Hash, PartialEq)]
651pub struct FactoryInferenceKey {
652 pub method: CodeUnit,
653 pub invocation_owner_fq_name: String,
654}
655
656pub struct FactoryInferenceFrame {
657 pub method: CodeUnit,
658 pub invocation_owner_fq_name: String,
659}
660
661pub enum FactoryMethodOutcome {
662 Owner(String),
663 Chain(Vec<FactoryInferenceFrame>),
664 Unknown,
665}
666
667pub fn ruby_method_lookup_mode_matches(
668 ruby: &dyn RubySource,
669 unit: &CodeUnit,
670 mode: RubyMethodLookupMode,
671) -> bool {
672 matches!(
673 (ruby.method_dispatch_mode(unit), mode),
674 (
675 RubyMethodDispatchMode::Instance,
676 RubyMethodLookupMode::InstanceMethod
677 ) | (
678 RubyMethodDispatchMode::Singleton,
679 RubyMethodLookupMode::SingletonMethod
680 ) | (RubyMethodDispatchMode::ModuleFunction, _)
681 )
682}
683
684fn constant_lookup_candidates(
685 lexical_stack: &[String],
686 segments: &[String],
687 absolute: bool,
688) -> Option<Vec<String>> {
689 if segments.is_empty() {
690 return None;
691 }
692
693 let name = segments.join("$");
694 let mut candidates = Vec::new();
695 if !absolute {
696 for owner in lexical_stack.iter().rev() {
697 candidates.push(format!("{owner}${name}"));
698 }
699 }
700 candidates.push(name);
701
702 let Some((constant_name, owner_segments)) = segments.split_last() else {
703 return Some(candidates);
704 };
705 if owner_segments.is_empty() {
706 if !absolute {
707 for owner in lexical_stack.iter().rev() {
708 candidates.push(format!("{owner}.{constant_name}"));
709 }
710 }
711 return Some(candidates);
712 }
713
714 let owner_name = owner_segments.join("$");
715 if !absolute {
716 for owner in lexical_stack.iter().rev() {
717 candidates.push(format!("{owner}${owner_name}.{constant_name}"));
718 }
719 }
720 candidates.push(format!("{owner_name}.{constant_name}"));
721
722 Some(candidates)
723}