Skip to main content

brokk_bifrost_ruby/
mixins.rs

1//! Ruby's mixin facts: the parser-side extraction of `include`/`prepend`/
2//! `extend` arguments out of a class or module body, and the encode/decode pair
3//! that round-trips them (together with the true superclass) through the
4//! analyzer's persisted `supertype_lookup_paths` column.
5//!
6//! The *read* of that persisted state stays in `analyzer/ruby/mixins.rs`:
7//! `RubyAnalyzer::forward_owner_relation_facts` calls
8//! `TreeSitterAnalyzer::fetch_file_state`, whose `Arc<FileState>` is
9//! crate-private to `brokk-bifrost-analysis`. It decodes with
10//! [`decode_owner_relation`] and hands the resulting [`RubyOwnerRelationFact`]s
11//! back across the crate line, which is why this module owns the fact type and
12//! the decoder but not the accessor.
13
14use crate::declarations::{is_descendable_container, qualified_internal_name, ruby_node_text};
15use crate::graph_support::RubySource;
16use brokk_bifrost_core::analyzer::type_relations::{TypeRelation, TypeRelationKind};
17use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
18use brokk_bifrost_core::hash::HashSet;
19use tree_sitter::Node;
20
21#[derive(Clone)]
22pub struct RubyForwardMixinSpec {
23    pub kind: TypeRelationKind,
24    pub raw_target: String,
25}
26
27pub fn raw_mixin_specs_for_type(node: Node<'_>, source: &str) -> Vec<RubyForwardMixinSpec> {
28    let Some(body) = node.child_by_field_name("body") else {
29        return Vec::new();
30    };
31    let mut specs = Vec::new();
32    let mut stack = vec![body];
33    while let Some(current) = stack.pop() {
34        let mut cursor = current.walk();
35        for child in current.named_children(&mut cursor) {
36            match child.kind() {
37                "call" => {
38                    let Some(kind) = mixin_call_kind(child, source) else {
39                        continue;
40                    };
41                    let Some(arguments) = child.child_by_field_name("arguments") else {
42                        continue;
43                    };
44                    let mut arg_cursor = arguments.walk();
45                    let mut call_specs = Vec::new();
46                    for argument in arguments.named_children(&mut arg_cursor) {
47                        if matches!(argument.kind(), "constant" | "scope_resolution")
48                            && let Some(raw_target) = qualified_internal_name(argument, source)
49                        {
50                            call_specs.push(RubyForwardMixinSpec { kind, raw_target });
51                        }
52                    }
53                    specs.extend(call_specs.into_iter().rev());
54                }
55                kind if is_descendable_container(kind) => stack.push(child),
56                _ => {}
57            }
58        }
59    }
60    specs
61}
62
63pub fn encode_superclass_relation(raw_target: &str) -> String {
64    encode_owner_relation("superclass", raw_target)
65}
66
67pub fn encode_mixin_relation(spec: &RubyForwardMixinSpec) -> String {
68    let kind = match spec.kind {
69        TypeRelationKind::MixinInclude => "include",
70        TypeRelationKind::MixinPrepend => "prepend",
71        TypeRelationKind::MixinExtend => "extend",
72        _ => unreachable!("Ruby mixin extractor only emits mixin relations"),
73    };
74    encode_owner_relation(kind, &spec.raw_target)
75}
76
77pub struct RubyOwnerRelationFact {
78    pub kind: Option<TypeRelationKind>,
79    pub raw_target: String,
80}
81
82fn encode_owner_relation(kind: &str, raw_target: &str) -> String {
83    serde_json::json!({ "kind": kind, "target": raw_target }).to_string()
84}
85
86pub fn decode_owner_relation(
87    encoded: &str,
88    expected_target: &str,
89) -> Option<RubyOwnerRelationFact> {
90    let value: serde_json::Value = serde_json::from_str(encoded).ok()?;
91    let raw_target = value.get("target")?.as_str()?.to_string();
92    if raw_target != expected_target {
93        return None;
94    }
95    let kind = match value.get("kind")?.as_str()? {
96        "superclass" => None,
97        "include" => Some(TypeRelationKind::MixinInclude),
98        "prepend" => Some(TypeRelationKind::MixinPrepend),
99        "extend" => Some(TypeRelationKind::MixinExtend),
100        _ => return None,
101    };
102    Some(RubyOwnerRelationFact { kind, raw_target })
103}
104
105pub fn ruby_collect_mixin_relations(ruby: &dyn RubySource) -> Vec<TypeRelation> {
106    let mut relations = Vec::new();
107    for file in ruby.get_analyzed_files() {
108        for owner in ruby
109            .declarations(&file)
110            .into_iter()
111            .filter(|unit| unit.is_class() || unit.is_module())
112        {
113            for spec in ruby_forward_mixin_specs(ruby, &owner) {
114                if let Some(target) = ruby_resolve_mixin_target(ruby, &file, &spec.raw_target) {
115                    relations.push(TypeRelation {
116                        from: owner.clone(),
117                        to: target,
118                        kind: spec.kind,
119                    });
120                }
121            }
122        }
123    }
124    relations
125}
126
127/// Reads parser-derived mixin facts for exactly one owner file. Forward
128/// definition lookup therefore never reparses Ruby source or constructs the
129/// global mixin graph.
130pub fn ruby_forward_mixin_specs(
131    ruby: &dyn RubySource,
132    owner: &CodeUnit,
133) -> Vec<RubyForwardMixinSpec> {
134    ruby.forward_owner_relation_facts(owner)
135        .into_iter()
136        .filter_map(|fact| {
137            fact.kind.map(|kind| RubyForwardMixinSpec {
138                kind,
139                raw_target: fact.raw_target,
140            })
141        })
142        .collect()
143}
144
145pub fn ruby_forward_superclass_targets(ruby: &dyn RubySource, owner: &CodeUnit) -> Vec<String> {
146    ruby.forward_owner_relation_facts(owner)
147        .into_iter()
148        .filter(|fact| fact.kind.is_none())
149        .map(|fact| fact.raw_target)
150        .collect()
151}
152
153fn ruby_resolve_mixin_target(
154    ruby: &dyn RubySource,
155    file: &ProjectFile,
156    raw: &str,
157) -> Option<CodeUnit> {
158    let visible_files = ruby_visible_mixin_files(ruby, file);
159    ruby.declarations(file)
160        .into_iter()
161        .find(|unit| ruby_type_matches(unit, raw))
162        .or_else(|| {
163            ruby.imported_code_units_of(file)
164                .iter()
165                .find(|unit| ruby_type_matches(unit, raw))
166                .cloned()
167        })
168        .or_else(|| {
169            ruby.definitions(raw).find(|unit| {
170                (unit.is_class() || unit.is_module()) && visible_files.contains(unit.source())
171            })
172        })
173        .or_else(|| {
174            ruby.all_declarations()
175                .filter(|unit| visible_files.contains(unit.source()))
176                .find(|unit| ruby_type_matches(unit, raw))
177        })
178}
179
180fn ruby_visible_mixin_files(ruby: &dyn RubySource, file: &ProjectFile) -> HashSet<ProjectFile> {
181    let mut files = HashSet::default();
182    files.insert(file.clone());
183    files.extend(
184        ruby.imported_code_units_of(file)
185            .iter()
186            .map(|unit| unit.source().clone()),
187    );
188    files
189}
190
191fn ruby_type_matches(unit: &CodeUnit, raw: &str) -> bool {
192    (unit.is_class() || unit.is_module())
193        && (unit.fq_name() == raw || unit.short_name() == raw || unit.identifier() == raw)
194}
195
196fn mixin_call_kind(node: Node<'_>, source: &str) -> Option<TypeRelationKind> {
197    if node.child_by_field_name("receiver").is_some() {
198        return None;
199    }
200    let method = node.child_by_field_name("method")?;
201    match ruby_node_text(method, source).trim() {
202        "include" => Some(TypeRelationKind::MixinInclude),
203        "prepend" => Some(TypeRelationKind::MixinPrepend),
204        "extend" => Some(TypeRelationKind::MixinExtend),
205        _ => None,
206    }
207}