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    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        // fqname-M4: walks the `$`-joined lexical-owner *string* (not a CodeUnit) to enumerate
616        // enclosing-scope candidate names; fq not threaded to this string-keyed support probe
617        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); // fqname-M4: leaf of a `$`-joined reference string (no CodeUnit here)
634        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}