Skip to main content

brokk_bifrost_ruby/graph/
resolver.rs

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    // fqname-M4: `owner` below is compared against a package-less class-name
78    // reference-text `owner` parsed at a field-reference site (see
79    // `field_reference_matches_target`); `fq.parent()`/`default_parent_fq_name`
80    // would render the package-qualified owner, a different string that would
81    // never match there.
82    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    /// Follows only explicit project-local `require` edges and fails closed
193    /// when the dependency closure is too broad for a latency-sensitive caller.
194    ///
195    /// Diagnostics use this instead of the navigation-oriented visibility
196    /// closure. Callers that want convention-derived Zeitwerk visibility must
197    /// continue using [`Self::visible_files_from`].
198    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    /// Resolves only indexed declarations in the supplied project-local
238    /// visibility closure. This avoids initializing the workspace-wide
239    /// `autoload` index for conservative, latency-sensitive diagnostics.
240    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    /// Whether the workspace declares the constant path `node` spells anywhere
260    /// at all, ignoring which files the referencing file can reach.
261    ///
262    /// [`Self::resolve_constant`] answers the navigation question -- can this
263    /// file reach the declaration -- and a cross-workspace boundary claim must
264    /// not be built on it. A project file that declares the constant without
265    /// being required is still a workspace declaration, and reporting the
266    /// reference as leaving the workspace would be false. This is the weaker,
267    /// visibility-blind question such a claim has to ask first.
268    pub fn declares_constant_anywhere(
269        &self,
270        lexical_stack: &[String],
271        node: Node<'_>,
272        source: &str,
273    ) -> bool {
274        let path = extract_name_path(node, source);
275        let Some(candidates) =
276            constant_lookup_candidates(lexical_stack, &path.segments, path.absolute)
277        else {
278            return false;
279        };
280        candidates
281            .iter()
282            .any(|candidate| self.graph.index.definitions(candidate).next().is_some())
283    }
284
285    pub fn resolve_constant_name(
286        &self,
287        file: &ProjectFile,
288        visible_files: &HashSet<ProjectFile>,
289        lexical_stack: &[String],
290        name: &str,
291    ) -> Option<CodeUnit> {
292        self.resolve_constant_path(
293            file,
294            visible_files,
295            lexical_stack,
296            &[name.to_string()],
297            false,
298            true,
299        )
300    }
301
302    fn resolve_constant_path(
303        &self,
304        file: &ProjectFile,
305        visible_files: &HashSet<ProjectFile>,
306        lexical_stack: &[String],
307        segments: &[String],
308        absolute: bool,
309        include_autoload: bool,
310    ) -> Option<CodeUnit> {
311        let candidates = constant_lookup_candidates(lexical_stack, segments, absolute)?;
312
313        candidates.into_iter().find_map(|candidate| {
314            let autoload_files = if include_autoload {
315                crate::imports::ruby_autoload_visible_files_for_constant(self.ruby, &candidate)
316            } else {
317                HashSet::default()
318            };
319            self.graph.index.definitions(&candidate).find(|unit| {
320                visible_files.contains(unit.source())
321                    || unit.source() == file
322                    || autoload_files.contains(unit.source())
323            })
324        })
325    }
326
327    pub fn target_matches_constant(&self, unit: &CodeUnit) -> bool {
328        self.target
329            .as_ref()
330            .is_some_and(|target| unit == target || unit.fq_name() == target.fq_name())
331    }
332
333    pub fn resolve_method_candidates(
334        &self,
335        support: &dyn BoundedDefinitionLookup,
336        visible_files: &HashSet<ProjectFile>,
337        receiver: &ReceiverType,
338        member: &str,
339    ) -> Vec<CodeUnit> {
340        self.method_candidates(support, visible_files, receiver, member, None)
341    }
342
343    /// [`Self::resolve_method_candidates`], reporting where the group it
344    /// returns was found (#1477).
345    ///
346    /// The lookup returns the first non-empty group it reaches, so one owner
347    /// and one edge describe every candidate in that group. A caller that does
348    /// not ask keeps the plain entry point and pays nothing.
349    pub fn resolve_method_candidates_traced(
350        &self,
351        support: &dyn BoundedDefinitionLookup,
352        visible_files: &HashSet<ProjectFile>,
353        receiver: &ReceiverType,
354        member: &str,
355        find: &mut Option<RubyMethodFind>,
356    ) -> Vec<CodeUnit> {
357        self.method_candidates(support, visible_files, receiver, member, Some(find))
358    }
359
360    fn method_candidates(
361        &self,
362        support: &dyn BoundedDefinitionLookup,
363        visible_files: &HashSet<ProjectFile>,
364        receiver: &ReceiverType,
365        member: &str,
366        mut find: Option<&mut Option<RubyMethodFind>>,
367    ) -> Vec<CodeUnit> {
368        let visible_files: Vec<ProjectFile> = visible_files.iter().cloned().collect();
369        let mut seen = HashSet::default();
370        let mut push_owner = |owner: &str, mode: RubyMethodLookupMode, out: &mut Vec<CodeUnit>| {
371            for unit in support.fqn_direct_children(owner) {
372                if unit.is_function()
373                    && unit.identifier() == member
374                    && visible_files.contains(unit.source())
375                    && ruby_method_lookup_mode_matches(self.ruby, &unit, mode)
376                    && seen.insert(unit.clone())
377                {
378                    out.push(unit);
379                }
380            }
381        };
382
383        match receiver.mode {
384            ReceiverMode::TopLevel => {
385                self.resolve_top_level_method_candidates(support, &visible_files, member)
386            }
387            ReceiverMode::Instance => {
388                for owner in self.forward_receiver_owner_lookup_order(
389                    support,
390                    &visible_files,
391                    &receiver.owner_fq_name,
392                ) {
393                    let mut prepended = Vec::new();
394                    let mut prepended_from = None;
395                    for mixin in self
396                        .mixin_owners(
397                            support,
398                            &visible_files,
399                            &owner,
400                            TypeRelationKind::MixinPrepend,
401                        )
402                        .into_iter()
403                        .rev()
404                    {
405                        push_owner(&mixin, RubyMethodLookupMode::InstanceMethod, &mut prepended);
406                        if !prepended.is_empty() {
407                            prepended_from = Some(mixin);
408                            break;
409                        }
410                    }
411                    if !prepended.is_empty() {
412                        record_find(
413                            &mut find,
414                            &owner,
415                            prepended_from,
416                            Some(TypeRelationKind::MixinPrepend),
417                            false,
418                        );
419                        return prepended;
420                    }
421
422                    let mut direct = Vec::new();
423                    push_owner(&owner, RubyMethodLookupMode::InstanceMethod, &mut direct);
424                    if !direct.is_empty() {
425                        record_find(&mut find, &owner, None, None, false);
426                        return direct;
427                    }
428
429                    let mut included = Vec::new();
430                    let mut included_from = None;
431                    for mixin in self
432                        .mixin_owners(
433                            support,
434                            &visible_files,
435                            &owner,
436                            TypeRelationKind::MixinInclude,
437                        )
438                        .into_iter()
439                        .rev()
440                    {
441                        push_owner(&mixin, RubyMethodLookupMode::InstanceMethod, &mut included);
442                        if !included.is_empty() {
443                            included_from = Some(mixin);
444                            break;
445                        }
446                    }
447                    if !included.is_empty() {
448                        record_find(
449                            &mut find,
450                            &owner,
451                            included_from,
452                            Some(TypeRelationKind::MixinInclude),
453                            false,
454                        );
455                        return included;
456                    }
457                }
458                Vec::new()
459            }
460            ReceiverMode::Class => {
461                for owner in self.forward_receiver_owner_lookup_order(
462                    support,
463                    &visible_files,
464                    &receiver.owner_fq_name,
465                ) {
466                    let mut direct = Vec::new();
467                    push_owner(&owner, RubyMethodLookupMode::SingletonMethod, &mut direct);
468                    if !direct.is_empty() {
469                        record_find(&mut find, &owner, None, None, true);
470                        return direct;
471                    }
472
473                    let mut extended = Vec::new();
474                    let mut extended_from = None;
475                    for mixin in self
476                        .mixin_owners(
477                            support,
478                            &visible_files,
479                            &owner,
480                            TypeRelationKind::MixinExtend,
481                        )
482                        .into_iter()
483                        .rev()
484                    {
485                        push_owner(&mixin, RubyMethodLookupMode::InstanceMethod, &mut extended);
486                        if !extended.is_empty() {
487                            extended_from = Some(mixin);
488                            break;
489                        }
490                    }
491                    if !extended.is_empty() {
492                        record_find(
493                            &mut find,
494                            &owner,
495                            extended_from,
496                            Some(TypeRelationKind::MixinExtend),
497                            true,
498                        );
499                        return extended;
500                    }
501                }
502                Vec::new()
503            }
504        }
505    }
506
507    pub fn resolve_bare_method_candidates(
508        &self,
509        support: &dyn BoundedDefinitionLookup,
510        visible_files: &HashSet<ProjectFile>,
511        receiver: &ReceiverType,
512        member: &str,
513    ) -> Vec<CodeUnit> {
514        self.bare_method_candidates(support, visible_files, receiver, member, None)
515    }
516
517    /// [`Self::resolve_bare_method_candidates`], reporting where the group it
518    /// returns was found (#1477).
519    ///
520    /// A bare name that falls through to the top-level scope reports no find:
521    /// a top-level method belongs to no owner, so there is nothing to
522    /// attribute it to.
523    pub fn resolve_bare_method_candidates_traced(
524        &self,
525        support: &dyn BoundedDefinitionLookup,
526        visible_files: &HashSet<ProjectFile>,
527        receiver: &ReceiverType,
528        member: &str,
529        find: &mut Option<RubyMethodFind>,
530    ) -> Vec<CodeUnit> {
531        self.bare_method_candidates(support, visible_files, receiver, member, Some(find))
532    }
533
534    fn bare_method_candidates(
535        &self,
536        support: &dyn BoundedDefinitionLookup,
537        visible_files: &HashSet<ProjectFile>,
538        receiver: &ReceiverType,
539        member: &str,
540        find: Option<&mut Option<RubyMethodFind>>,
541    ) -> Vec<CodeUnit> {
542        let candidates = self.method_candidates(support, visible_files, receiver, member, find);
543        if !candidates.is_empty() || receiver.mode == ReceiverMode::TopLevel {
544            return candidates;
545        }
546        let visible_files: Vec<ProjectFile> = visible_files.iter().cloned().collect();
547        self.resolve_top_level_method_candidates(support, &visible_files, member)
548    }
549
550    /// The direct ancestors of `owner`: the exact edges
551    /// [`Self::forward_ancestor_lookup_order`] walks, before it flattens them
552    /// into a lookup order that no longer says which owner reached which.
553    pub fn direct_ancestor_owners(
554        &self,
555        support: &dyn BoundedDefinitionLookup,
556        visible_files: &[ProjectFile],
557        owner: &str,
558    ) -> Vec<String> {
559        if let Some(facts) = self.facts {
560            let mut direct: Vec<String> = facts
561                .ancestors
562                .get(owner)
563                .map(|items| items.iter().cloned().collect())
564                .unwrap_or_default();
565            direct.sort();
566            return direct;
567        }
568        self.forward_owner_facts(support, visible_files, owner)
569            .ancestors
570    }
571
572    /// The mixins `owner` composes in through `kind`, in the order the method
573    /// lookup considers them.
574    pub fn mixin_owners_of(
575        &self,
576        support: &dyn BoundedDefinitionLookup,
577        visible_files: &[ProjectFile],
578        owner: &str,
579        kind: TypeRelationKind,
580    ) -> Vec<String> {
581        self.mixin_owners(support, visible_files, owner, kind)
582    }
583
584    /// The class or module declaration `owner` names, as the lookup resolves
585    /// it: an indexed class or module of that exact fq name, visible from the
586    /// referencing file.
587    pub fn owner_unit(
588        &self,
589        support: &dyn BoundedDefinitionLookup,
590        visible_files: &[ProjectFile],
591        owner: &str,
592    ) -> Option<CodeUnit> {
593        support.fqn(owner).into_iter().find(|unit| {
594            (unit.is_class() || unit.is_module())
595                && unit.fq_name() == owner
596                && visible_files.contains(unit.source())
597        })
598    }
599
600    fn resolve_top_level_method_candidates(
601        &self,
602        support: &dyn BoundedDefinitionLookup,
603        visible_files: &[ProjectFile],
604        member: &str,
605    ) -> Vec<CodeUnit> {
606        support
607            .file_identifier_in_files(visible_files, member)
608            .into_iter()
609            .filter(|unit| {
610                unit.is_function()
611                    && unit.identifier() == member
612                    && self.graph.index.parent_of(unit).is_none()
613                    && !ruby_method_lookup_mode_matches(
614                        self.ruby,
615                        unit,
616                        RubyMethodLookupMode::SingletonMethod,
617                    )
618            })
619            .collect()
620    }
621
622    fn mixin_owners(
623        &self,
624        support: &dyn BoundedDefinitionLookup,
625        visible_files: &[ProjectFile],
626        owner: &str,
627        kind: TypeRelationKind,
628    ) -> Vec<String> {
629        if let Some(facts) = self.facts {
630            let index = match kind {
631                TypeRelationKind::MixinInclude => &facts.mixin_included_owners,
632                TypeRelationKind::MixinPrepend => &facts.mixin_prepended_owners,
633                TypeRelationKind::MixinExtend => &facts.mixin_class_owners,
634                _ => return Vec::new(),
635            };
636            return index.get(owner).cloned().unwrap_or_default();
637        }
638        let facts = self.forward_owner_facts(support, visible_files, owner);
639        match kind {
640            TypeRelationKind::MixinInclude => facts.included,
641            TypeRelationKind::MixinPrepend => facts.prepended,
642            TypeRelationKind::MixinExtend => facts.extended,
643            _ => Vec::new(),
644        }
645    }
646
647    pub fn ancestor_lookup_order(&self, owner: &str) -> Vec<String> {
648        let Some(facts) = self.facts else {
649            return Vec::new();
650        };
651        let mut out = Vec::new();
652        let mut visited = HashSet::default();
653        let mut stack: Vec<String> = facts
654            .ancestors
655            .get(owner)
656            .map(|items| items.iter().cloned().collect())
657            .unwrap_or_default();
658        while let Some(candidate) = stack.pop() {
659            if !visited.insert(candidate.clone()) {
660                continue;
661            }
662            out.push(candidate.clone());
663            if let Some(next) = facts.ancestors.get(&candidate) {
664                stack.extend(next.iter().cloned());
665            }
666        }
667        out
668    }
669
670    pub fn forward_ancestor_lookup_order(
671        &self,
672        support: &dyn BoundedDefinitionLookup,
673        visible_files: &[ProjectFile],
674        owner: &str,
675    ) -> Vec<String> {
676        if self.facts.is_some() {
677            return self.ancestor_lookup_order(owner);
678        }
679        let mut out = Vec::new();
680        let mut visited = HashSet::default();
681        let mut stack = self
682            .forward_owner_facts(support, visible_files, owner)
683            .ancestors;
684        stack.reverse();
685        while let Some(candidate) = stack.pop() {
686            if !visited.insert(candidate.clone()) {
687                continue;
688            }
689            out.push(candidate.clone());
690            let mut next = self
691                .forward_owner_facts(support, visible_files, &candidate)
692                .ancestors;
693            next.reverse();
694            stack.extend(next);
695        }
696        out
697    }
698
699    fn forward_receiver_owner_lookup_order(
700        &self,
701        support: &dyn BoundedDefinitionLookup,
702        visible_files: &[ProjectFile],
703        owner: &str,
704    ) -> Vec<String> {
705        let mut owners = vec![owner.to_string()];
706        owners.extend(self.forward_ancestor_lookup_order(support, visible_files, owner));
707        owners
708    }
709
710    fn forward_owner_facts(
711        &self,
712        support: &dyn BoundedDefinitionLookup,
713        visible_files: &[ProjectFile],
714        owner: &str,
715    ) -> RubyForwardOwnerFacts {
716        if let Some(cached) = self.forward_owner_facts.borrow().get(owner) {
717            return cached.clone();
718        }
719        let Some(owner_unit) = self.owner_unit(support, visible_files, owner) else {
720            self.forward_owner_facts
721                .borrow_mut()
722                .insert(owner.to_string(), RubyForwardOwnerFacts::default());
723            return RubyForwardOwnerFacts::default();
724        };
725
726        let specs = ruby_forward_mixin_specs(self.ruby, &owner_unit);
727        let mixin_names: HashSet<String> =
728            specs.iter().map(|spec| spec.raw_target.clone()).collect();
729        let mut facts = RubyForwardOwnerFacts::default();
730        for spec in specs {
731            let Some(target) =
732                self.resolve_forward_owner_name(support, visible_files, owner, &spec.raw_target)
733            else {
734                continue;
735            };
736            match spec.kind {
737                TypeRelationKind::MixinInclude => facts.included.push(target),
738                TypeRelationKind::MixinPrepend => facts.prepended.push(target),
739                TypeRelationKind::MixinExtend => facts.extended.push(target),
740                _ => {}
741            }
742        }
743        for raw in ruby_forward_superclass_targets(self.ruby, &owner_unit) {
744            if mixin_names.contains(&raw) {
745                continue;
746            }
747            if let Some(target) =
748                self.resolve_forward_owner_name(support, visible_files, owner, &raw)
749            {
750                facts.ancestors.push(target);
751            }
752        }
753        facts.ancestors.dedup();
754        facts.included.dedup();
755        facts.prepended.dedup();
756        facts.extended.dedup();
757        self.forward_owner_facts
758            .borrow_mut()
759            .insert(owner.to_string(), facts.clone());
760        facts
761    }
762
763    fn resolve_forward_owner_name(
764        &self,
765        support: &dyn BoundedDefinitionLookup,
766        visible_files: &[ProjectFile],
767        lexical_owner: &str,
768        raw: &str,
769    ) -> Option<String> {
770        let mut candidate_names = vec![raw.to_string()];
771        let mut prefix = lexical_owner;
772        // fqname-M4: walks the `$`-joined lexical-owner *string* (not a CodeUnit) to enumerate
773        // enclosing-scope candidate names; fq not threaded to this string-keyed support probe
774        while let Some((parent, _)) = prefix.rsplit_once('$') {
775            candidate_names.push(format!("{parent}${raw}"));
776            prefix = parent;
777        }
778        for candidate in candidate_names {
779            let mut matches = support.fqn(&candidate);
780            matches.retain(|unit| {
781                (unit.is_class() || unit.is_module()) && visible_files.contains(unit.source())
782            });
783            matches.sort();
784            matches.dedup();
785            if matches.len() == 1 {
786                return Some(matches.remove(0).fq_name());
787            }
788        }
789
790        let identifier = raw.rsplit('$').next().unwrap_or(raw); // fqname-M4: leaf of a `$`-joined reference string (no CodeUnit here)
791        let mut matches = support.file_identifier_in_files(visible_files, identifier);
792        matches.retain(|unit| {
793            (unit.is_class() || unit.is_module()) && unit.identifier() == identifier
794        });
795        matches.sort();
796        matches.dedup();
797        (matches.len() == 1).then(|| matches.remove(0).fq_name())
798    }
799}
800
801/// Where a method lookup found the group of candidates it returned (#1477).
802///
803/// The lookup walks the receiver's ancestor order and returns the first
804/// non-empty group it reaches, so exactly one owner and one edge describe
805/// every candidate in that group.
806#[derive(Debug, Clone, PartialEq, Eq)]
807pub struct RubyMethodFind {
808    /// The owner in the receiver's ancestor lookup order the group was
809    /// reached from.
810    pub reached_from: String,
811    /// The owner that declares the group: `reached_from` itself, or the module
812    /// `mixin` names.
813    pub owner: String,
814    /// The mixin edge from `reached_from` to `owner`, absent when the group
815    /// was declared by `reached_from` itself.
816    pub mixin: Option<TypeRelationKind>,
817    /// Whether the lookup was on the owner's class side.
818    pub class_side: bool,
819}
820
821/// Record one find, when the caller asked for one. Every argument is a fact
822/// the branch that calls this has just decided; nothing here re-derives one.
823fn record_find(
824    find: &mut Option<&mut Option<RubyMethodFind>>,
825    reached_from: &str,
826    mixin_owner: Option<String>,
827    mixin: Option<TypeRelationKind>,
828    class_side: bool,
829) {
830    let Some(slot) = find.as_deref_mut() else {
831        return;
832    };
833    debug_assert_eq!(
834        mixin_owner.is_some(),
835        mixin.is_some(),
836        "a mixin edge and the module it reaches are recorded together"
837    );
838    *slot = Some(RubyMethodFind {
839        owner: mixin_owner.unwrap_or_else(|| reached_from.to_owned()),
840        reached_from: reached_from.to_owned(),
841        mixin,
842        class_side,
843    });
844}
845
846#[derive(Clone, Copy)]
847pub enum RubyMethodLookupMode {
848    InstanceMethod,
849    SingletonMethod,
850}
851
852#[derive(Clone, Eq, Hash, PartialEq)]
853pub struct FactoryInferenceKey {
854    pub method: CodeUnit,
855    pub invocation_owner_fq_name: String,
856}
857
858pub struct FactoryInferenceFrame {
859    pub method: CodeUnit,
860    pub invocation_owner_fq_name: String,
861}
862
863pub enum FactoryMethodOutcome {
864    Owner(String),
865    Chain(Vec<FactoryInferenceFrame>),
866    Unknown,
867}
868
869pub fn ruby_method_lookup_mode_matches(
870    ruby: &dyn RubySource,
871    unit: &CodeUnit,
872    mode: RubyMethodLookupMode,
873) -> bool {
874    matches!(
875        (ruby.method_dispatch_mode(unit), mode),
876        (
877            RubyMethodDispatchMode::Instance,
878            RubyMethodLookupMode::InstanceMethod
879        ) | (
880            RubyMethodDispatchMode::Singleton,
881            RubyMethodLookupMode::SingletonMethod
882        ) | (RubyMethodDispatchMode::ModuleFunction, _)
883    )
884}
885
886fn constant_lookup_candidates(
887    lexical_stack: &[String],
888    segments: &[String],
889    absolute: bool,
890) -> Option<Vec<String>> {
891    if segments.is_empty() {
892        return None;
893    }
894
895    let name = segments.join("$");
896    let mut candidates = Vec::new();
897    if !absolute {
898        for owner in lexical_stack.iter().rev() {
899            candidates.push(format!("{owner}${name}"));
900        }
901    }
902    candidates.push(name);
903
904    let Some((constant_name, owner_segments)) = segments.split_last() else {
905        return Some(candidates);
906    };
907    if owner_segments.is_empty() {
908        if !absolute {
909            for owner in lexical_stack.iter().rev() {
910                candidates.push(format!("{owner}.{constant_name}"));
911            }
912        }
913        return Some(candidates);
914    }
915
916    let owner_name = owner_segments.join("$");
917    if !absolute {
918        for owner in lexical_stack.iter().rev() {
919            candidates.push(format!("{owner}${owner_name}.{constant_name}"));
920        }
921    }
922    candidates.push(format!("{owner_name}.{constant_name}"));
923
924    Some(candidates)
925}