Skip to main content

brokk_bifrost_cpp/
reconcile.rs

1//! Resolution-time identity reconciliation for C++ out-of-line member
2//! definitions (#1134).
3//!
4//! A method nested inside a class that is itself inside a namespace --
5//! `log4cxx::Outer::Inner::method` -- is declared in a header (indexed
6//! `log4cxx.Outer$Inner.method`: `$` joins the class-nesting chain, the
7//! enclosing namespace is the package) and defined out-of-line in a `.cpp`
8//! (`int Outer::Inner::method() const {...}`). Per-file extraction cannot see
9//! the header's class layout, so it must guess whether each qualifier segment
10//! (`Outer`, `Inner`) is a namespace or a class. Two shapes guess wrong (#1121
11//! left them documented, not masked): a file-scope definition under a
12//! `using namespace` directive, and the template-specialization twin. Both are
13//! irreducibly class-table-dependent -- the only signal that `Outer` is a class
14//! and not a namespace is the set of classes visible to the `.cpp` through its
15//! `#include` graph.
16//!
17//! This module holds the pure decision: given the ordered owner segments of a
18//! definition's qualifier, the candidate enclosing namespaces (the lexical
19//! package plus any in-scope `using namespace` targets), and a view of the
20//! include-visible class table, decide the one canonical `(package, owner-chain,
21//! member)` a visible class actually confirms -- or refuse when nothing
22//! confirms or the confirmation is ambiguous. The function is analyzer-free and
23//! I/O-free so it can be unit-tested in isolation; the analyzer wiring that
24//! feeds it the real class table lives in the surrounding modules.
25
26/// A minimal, testable view of one class visible to a file through its
27/// `#include` graph: its enclosing namespace (`::`-joined, empty at global
28/// scope) and its class-nesting chain as Bifrost's `$`-joined short name
29/// (`Outer$Inner` for `Outer::Inner`, `Klass` for a non-nested class).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct VisibleClass<'a> {
32    pub package: &'a str,
33    pub nested_short_name: &'a str,
34}
35
36/// The canonical identity a definition should unify under: the enclosing
37/// namespace (`::`-joined), the class-nesting chain (`$`-joined short name), and
38/// the member name.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ReconciledIdentity {
41    pub package: String,
42    pub owner_chain: String,
43    pub member: String,
44}
45
46impl ReconciledIdentity {
47    /// Render this identity as a `CodeUnit`-style `fq_name`
48    /// (`package.owner_chain.member`, package omitted at global scope), so it can
49    /// be compared against and keyed alongside stored identities.
50    pub fn fq_name(&self) -> String {
51        let short = format!("{}.{}", self.owner_chain, self.member);
52        if self.package.is_empty() {
53            short
54        } else {
55            format!("{}.{}", self.package, short)
56        }
57    }
58}
59
60/// Decide the canonical identity of an out-of-line member definition from its
61/// qualifier segments and the include-visible class table.
62///
63/// `owner_segments` are the qualifier segments in source order, excluding the
64/// terminal member -- `["Outer", "Inner"]` for `Outer::Inner::method`.
65/// `namespace_candidates` are the enclosing namespaces to try, most-authoritative
66/// first: the lexical package (the `namespace {}` block the definition sits in,
67/// or empty at file scope) followed by every `using namespace` target in scope
68/// at that point. `class_table` is the include-visible class table.
69///
70/// The function partitions the segments into a namespace prefix and a
71/// class-nesting suffix at every split point, prepending the leading segments
72/// onto each candidate namespace, and keeps a partition only when some visible
73/// class confirms it exactly (same package, same `$`-joined chain). It prefers
74/// the reading with the *longest* confirmed class chain (the deepest real
75/// nesting is the most specific true identity). It returns `None` when nothing
76/// in the table confirms any reading (caller leaves the provisional identity
77/// untouched) and when two distinct identities tie at the deepest confirmed
78/// nesting (genuinely ambiguous -- never guess).
79pub fn reconcile_out_of_line_member_identity(
80    owner_segments: &[&str],
81    member: &str,
82    namespace_candidates: &[&str],
83    class_table: &[VisibleClass<'_>],
84) -> Option<ReconciledIdentity> {
85    if owner_segments.is_empty() || member.is_empty() {
86        return None;
87    }
88
89    // Split points ordered by longest class chain first (smallest `i` reads the
90    // fewest leading segments as namespace, so the most as class nesting). The
91    // class suffix must be non-empty, so `i` never reaches `owner_segments.len()`.
92    for split in 0..owner_segments.len() {
93        let chain = owner_segments[split..].join("$");
94        let namespace_prefix = &owner_segments[..split];
95
96        let mut confirmed: Option<ReconciledIdentity> = None;
97        for namespace in namespace_candidates {
98            let package = join_namespace(namespace, namespace_prefix);
99            let matches = class_table
100                .iter()
101                .any(|visible| visible.package == package && visible.nested_short_name == chain);
102            if !matches {
103                continue;
104            }
105            let candidate = ReconciledIdentity {
106                package,
107                owner_chain: chain.clone(),
108                member: member.to_string(),
109            };
110            match &confirmed {
111                // A second, *distinct* reading at the same (deepest) nesting is
112                // genuinely ambiguous -- two visible classes with the same chain
113                // in different namespaces both match. Refuse rather than guess.
114                Some(existing) if existing != &candidate => return None,
115                Some(_) => {}
116                None => confirmed = Some(candidate),
117            }
118        }
119
120        if let Some(identity) = confirmed {
121            return Some(identity);
122        }
123    }
124
125    None
126}
127
128/// Join an enclosing namespace with the leading owner segments that are being
129/// read as further namespace nesting, in Bifrost's `::`-joined package form.
130fn join_namespace(namespace: &str, extra_segments: &[&str]) -> String {
131    let extra = extra_segments.join("::");
132    match (namespace.is_empty(), extra.is_empty()) {
133        (true, true) => String::new(),
134        (true, false) => extra,
135        (false, true) => namespace.to_string(),
136        (false, false) => format!("{namespace}::{extra}"),
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use brokk_bifrost_test_macros::hardwon;
144
145    fn identity(package: &str, owner_chain: &str, member: &str) -> ReconciledIdentity {
146        ReconciledIdentity {
147            package: package.to_string(),
148            owner_chain: owner_chain.to_string(),
149            member: member.to_string(),
150        }
151    }
152
153    /// File-scope definition under `using namespace log4cxx;`:
154    /// `int Outer::Inner::method()` at file scope. The lexical package is empty;
155    /// the using-directive contributes `log4cxx`. The include-visible class
156    /// table confirms `log4cxx::Outer::Inner`, so the whole qualifier is a class
157    /// chain under `log4cxx`, not a namespace path.
158    #[test]
159    #[hardwon("#1134: out-of-line nested C++ members were assigned the wrong namespace")]
160    fn file_scope_using_directive_shape_recovers_namespace_and_chain() {
161        let table = [
162            VisibleClass {
163                package: "log4cxx",
164                nested_short_name: "Outer",
165            },
166            VisibleClass {
167                package: "log4cxx",
168                nested_short_name: "Outer$Inner",
169            },
170        ];
171        let reconciled = reconcile_out_of_line_member_identity(
172            &["Outer", "Inner"],
173            "method",
174            &["", "log4cxx"],
175            &table,
176        );
177        assert_eq!(
178            reconciled,
179            Some(identity("log4cxx", "Outer$Inner", "method"))
180        );
181    }
182
183    /// Template-specialization twin inside `namespace ns {}`:
184    /// `Outer::Inner<int>::method`. The lexical package is `ns`; the templated
185    /// splitter mis-reads `Outer` as a namespace. The class table confirms
186    /// `ns::Outer::Inner`, folding `Outer` back into the class chain.
187    #[test]
188    #[hardwon("#1134: template-specialized nested C++ members were assigned the wrong owner")]
189    fn template_shape_inside_namespace_block_folds_outer_into_chain() {
190        let table = [VisibleClass {
191            package: "ns",
192            nested_short_name: "Outer$Inner",
193        }];
194        let reconciled =
195            reconcile_out_of_line_member_identity(&["Outer", "Inner"], "method", &["ns"], &table);
196        assert_eq!(reconciled, Some(identity("ns", "Outer$Inner", "method")));
197    }
198
199    /// Genuine namespace chain `ns1::ns2::Klass::method` written out-of-line at
200    /// file scope: the class table contains the real class
201    /// (`package ns1::ns2`, `Klass`) but no nested-class reading of the leading
202    /// segments. Reconciliation confirms the namespace reading unchanged -- it
203    /// must never corrupt the owner into `ns1$ns2$Klass`.
204    #[test]
205    #[hardwon("#1134: real namespace segments were mistaken for class nesting")]
206    fn genuine_namespace_chain_keeps_namespace_reading() {
207        // A lexical namespace and a qualified namespace prefix must join
208        // before the visible class table is consulted.
209        let visible = [VisibleClass {
210            package: "a::b",
211            nested_short_name: "Outer$Inner",
212        }];
213        assert_eq!(
214            reconcile_out_of_line_member_identity(
215                &["b", "Outer", "Inner"],
216                "method",
217                &["a"],
218                &visible
219            ),
220            Some(identity("a::b", "Outer$Inner", "method"))
221        );
222
223        let table = [VisibleClass {
224            package: "ns1::ns2",
225            nested_short_name: "Klass",
226        }];
227        let reconciled = reconcile_out_of_line_member_identity(
228            &["ns1", "ns2", "Klass"],
229            "method",
230            &[""],
231            &table,
232        );
233        assert_eq!(reconciled, Some(identity("ns1::ns2", "Klass", "method")));
234    }
235
236    /// Deepest confirmed nesting wins: when both a shallow and a deep reading are
237    /// visible, the deeper class chain is the more specific true identity.
238    #[test]
239    #[hardwon("#1134: nested owner reconciliation preferred a shallower class chain")]
240    fn prefers_longest_confirmed_class_chain() {
241        let table = [
242            VisibleClass {
243                package: "a::Outer",
244                nested_short_name: "Inner",
245            },
246            VisibleClass {
247                package: "a",
248                nested_short_name: "Outer$Inner",
249            },
250        ];
251        let reconciled =
252            reconcile_out_of_line_member_identity(&["Outer", "Inner"], "method", &["a"], &table);
253        assert_eq!(reconciled, Some(identity("a", "Outer$Inner", "method")));
254    }
255
256    /// Nothing visible confirms any reading: leave the provisional identity
257    /// untouched (caller keeps today's behavior).
258    #[test]
259    #[hardwon("#1134: unconfirmed class identities were fabricated from qualifiers")]
260    fn no_visible_class_returns_none() {
261        let reconciled = reconcile_out_of_line_member_identity(
262            &["Outer", "Inner"],
263            "method",
264            &["", "log4cxx"],
265            &[],
266        );
267        assert_eq!(reconciled, None);
268    }
269
270    /// Two visible classes confirm distinct readings at the same (deepest)
271    /// nesting depth -- genuinely ambiguous, so refuse rather than guess.
272    #[test]
273    #[hardwon("#1134: equally deep visible owner readings were guessed instead of refused")]
274    fn ambiguous_equal_depth_readings_return_none() {
275        let table = [
276            VisibleClass {
277                package: "one",
278                nested_short_name: "Outer$Inner",
279            },
280            VisibleClass {
281                package: "two",
282                nested_short_name: "Outer$Inner",
283            },
284        ];
285        let reconciled = reconcile_out_of_line_member_identity(
286            &["Outer", "Inner"],
287            "method",
288            &["one", "two"],
289            &table,
290        );
291        assert_eq!(reconciled, None);
292    }
293
294    /// Empty owner segments or empty member name cannot name a member.
295    #[test]
296    #[hardwon("#1134: empty owner inputs fabricated an out-of-line member identity")]
297    fn degenerate_inputs_return_none() {
298        assert_eq!(
299            reconcile_out_of_line_member_identity(&[], "method", &["ns"], &[]),
300            None
301        );
302        assert_eq!(
303            reconcile_out_of_line_member_identity(&["Outer"], "", &["ns"], &[]),
304            None
305        );
306    }
307}