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}