Skip to main content

codehelion_core/discovery/
build_variant.rs

1//! Build variants: the first-class context every result is attributed to.
2//!
3//! A [`BuildVariant`] records the analysis mode, the enabled languages, the
4//! grammar bare `.h` headers were read with and the normalization ruleset
5//! version. Results produced under different variants must never be compared
6//! or merged, so the variant is attached to discovery output from the start
7//! rather than bolted on later. In Fast mode no build configuration is
8//! resolved, so a single implicit variant covers the whole run.
9
10use std::collections::BTreeMap;
11
12use super::build_config::BuildConfiguration;
13use super::language::{Language, LanguageSelection};
14
15/// Version of the lexing/normalization ruleset.
16///
17/// Bump this on any change that alters how sources are tokenised or normalised,
18/// so that fingerprints and cached results from an older ruleset are not
19/// silently treated as compatible.
20pub const NORMALIZATION_VERSION: u32 = 1;
21
22/// The analysis mode a run was performed under.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AnalysisMode {
25    /// Lexical analysis only; the target code is never executed.
26    Fast,
27    /// Structural (AST-level) analysis; the target code is never executed.
28    Structural,
29    /// Semantic analysis, using out-of-process compiler helpers.
30    Semantic,
31}
32
33impl AnalysisMode {
34    /// Stable lowercase identifier used in reports and fingerprints.
35    #[must_use]
36    pub const fn name(self) -> &'static str {
37        match self {
38            Self::Fast => "fast",
39            Self::Structural => "structural",
40            Self::Semantic => "semantic",
41        }
42    }
43}
44
45/// The context a set of results belongs to.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct BuildVariant {
48    /// Analysis mode.
49    pub mode: AnalysisMode,
50    /// Languages enabled for the run.
51    pub languages: LanguageSelection,
52    /// The language bare `.h` headers were read as, when C or C++ is enabled
53    /// at all. Two runs that read the same header with different grammars see
54    /// different code in it, so this belongs to the variant rather than
55    /// alongside it.
56    pub headers: Option<Language>,
57    /// Normalization ruleset version.
58    pub normalization_version: u32,
59    /// What each compiler was told, for the runs that resolved it.
60    ///
61    /// Empty in Fast and Structural mode, which read source and ask no
62    /// compiler anything: there is no build configuration to differ over, so a
63    /// single implicit variant covers the run.
64    ///
65    /// A list because a tree is answered by one compiler per language, and a
66    /// run of a tree holding both is one run: what either compiler was told is
67    /// part of what its results mean, and a variant that named only one of them
68    /// would give two differently built trees the same identity. Sorted by
69    /// fingerprint when the variant is made, so that the order helpers happened
70    /// to be reached in — an accident of what is installed — cannot move it.
71    pub builds: Vec<BuildConfiguration>,
72}
73
74impl BuildVariant {
75    /// The implicit single variant used by a Fast-mode run over `languages`,
76    /// reading bare `.h` headers as `headers`.
77    #[must_use]
78    pub const fn fast(languages: LanguageSelection, headers: Language) -> Self {
79        Self {
80            mode: AnalysisMode::Fast,
81            languages,
82            headers: Self::headers_of(languages, headers),
83            normalization_version: NORMALIZATION_VERSION,
84            builds: Vec::new(),
85        }
86    }
87
88    /// The implicit single variant used by a Structural-mode run over
89    /// `languages`. Like Fast mode, Structural resolves no build configuration,
90    /// so one implicit variant covers the run; only the mode differs, which is
91    /// enough to keep Fast and Structural fingerprints in separate spaces.
92    #[must_use]
93    pub const fn structural(languages: LanguageSelection, headers: Language) -> Self {
94        Self {
95            mode: AnalysisMode::Structural,
96            languages,
97            headers: Self::headers_of(languages, headers),
98            normalization_version: NORMALIZATION_VERSION,
99            builds: Vec::new(),
100        }
101    }
102
103    /// The variant a semantic run analyses one unit under.
104    ///
105    /// Unlike Fast and Structural, semantic mode has as many variants as the
106    /// project has build configurations: a crate under two feature sets and a
107    /// header under two sets of defines are different programs that happen to
108    /// share their text.
109    #[must_use]
110    pub fn semantic(
111        languages: LanguageSelection,
112        headers: Language,
113        mut builds: Vec<BuildConfiguration>,
114    ) -> Self {
115        builds.sort_by_cached_key(BuildConfiguration::fingerprint);
116        Self {
117            mode: AnalysisMode::Semantic,
118            languages,
119            headers: Self::headers_of(languages, headers),
120            normalization_version: NORMALIZATION_VERSION,
121            builds,
122        }
123    }
124
125    /// The header language worth recording: none when the run enumerates
126    /// neither C nor C++, so that a Rust-only scan keeps one variant whatever
127    /// C or C++ files happen to sit beside it.
128    const fn headers_of(languages: LanguageSelection, headers: Language) -> Option<Language> {
129        if languages.includes(Language::C) || languages.includes(Language::Cpp) {
130            Some(headers)
131        } else {
132            None
133        }
134    }
135
136    /// A canonical, order-stable string describing this variant.
137    ///
138    /// Two variants are equal exactly when their canonical strings match, which
139    /// makes this string safe to use as a grouping key or fingerprint input.
140    ///
141    /// A resolved build configuration is appended as its fingerprint rather
142    /// than its own canonical form: compiler arguments are arbitrary text and
143    /// would otherwise be free to contain this string's own separators. Several
144    /// are appended comma-separated, which is safe for the same reason it is
145    /// not safe for the arguments themselves — a fingerprint is fixed-width hex
146    /// and holds no punctuation to be mistaken for a separator.
147    ///
148    /// Appended only when the run resolved something, so that the modes which
149    /// resolve nothing keep the identity they had before the field existed —
150    /// an audit database written by an earlier build still lines up with one
151    /// written by this one. A run that resolved exactly one configuration keeps
152    /// its identity too, which is what stops the field growing a list from
153    /// re-identifying every Rust-only tree already recorded.
154    #[must_use]
155    pub fn canonical(&self) -> String {
156        let langs = self
157            .languages
158            .enabled()
159            .into_iter()
160            .map(Language::name)
161            .collect::<Vec<_>>()
162            .join(",");
163        let mut canonical = format!(
164            "mode={};languages={};headers={};normalization={}",
165            self.mode.name(),
166            langs,
167            self.headers.map_or("none", Language::name),
168            self.normalization_version,
169        );
170        if !self.builds.is_empty() {
171            canonical.push_str(";build=");
172            canonical.push_str(
173                &self
174                    .builds
175                    .iter()
176                    .map(BuildConfiguration::fingerprint)
177                    .collect::<Vec<_>>()
178                    .join(","),
179            );
180        }
181        canonical
182    }
183
184    /// A stable hex fingerprint of this variant.
185    #[must_use]
186    pub fn fingerprint(&self) -> String {
187        blake3::hash(self.canonical().as_bytes())
188            .to_hex()
189            .to_string()
190    }
191}
192
193/// One variant and everything analysed under it.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct Partition<T> {
196    /// The variant these were analysed under.
197    pub variant: BuildVariant,
198    /// The units, in the order they were given.
199    pub units: Vec<T>,
200}
201
202/// Groups units by the variant they were analysed under.
203///
204/// Clone comparison runs inside one partition by default. Two units compiled
205/// differently are not two spellings of one thing: the text they share may
206/// resolve to different types, take different branches of a header, or exist in
207/// only one of the two builds, and a duplication reported across them cannot be
208/// removed by editing either one.
209///
210/// Keyed by variant fingerprint so that the grouping is stable across runs and
211/// independent of the order units were discovered in.
212#[must_use]
213pub fn partition<T>(
214    units: impl IntoIterator<Item = (BuildVariant, T)>,
215) -> BTreeMap<String, Partition<T>> {
216    let mut partitions: BTreeMap<String, Partition<T>> = BTreeMap::new();
217    for (variant, unit) in units {
218        partitions
219            .entry(variant.fingerprint())
220            .or_insert_with(|| Partition {
221                variant,
222                units: Vec::new(),
223            })
224            .units
225            .push(unit);
226    }
227    partitions
228}
229
230#[cfg(test)]
231mod tests {
232    use super::super::build_config::{CppBuild, RustBuild};
233    use super::*;
234
235    #[test]
236    fn fast_variant_carries_mode_and_normalization_version() {
237        let variant = BuildVariant::fast(LanguageSelection::default(), Language::C);
238        assert_eq!(variant.mode, AnalysisMode::Fast);
239        assert_eq!(variant.normalization_version, NORMALIZATION_VERSION);
240    }
241
242    #[test]
243    fn structural_variant_differs_from_fast_only_in_mode() {
244        let languages = LanguageSelection::default();
245        let fast = BuildVariant::fast(languages, Language::C);
246        let structural = BuildVariant::structural(languages, Language::C);
247        assert_eq!(structural.mode, AnalysisMode::Structural);
248        assert_eq!(structural.languages, fast.languages);
249        assert_eq!(structural.headers, fast.headers);
250        assert_eq!(structural.normalization_version, fast.normalization_version);
251        // Distinct modes must land in distinct fingerprint spaces.
252        assert_ne!(fast.fingerprint(), structural.fingerprint());
253    }
254
255    #[test]
256    fn canonical_reflects_enabled_languages_in_fixed_order() {
257        let variant = BuildVariant::fast(
258            LanguageSelection {
259                rust: true,
260                c: false,
261                cpp: true,
262            },
263            Language::Cpp,
264        );
265        assert_eq!(
266            variant.canonical(),
267            "mode=fast;languages=rust,cpp;headers=cpp;normalization=1"
268        );
269    }
270
271    #[test]
272    fn distinct_variants_have_distinct_fingerprints() {
273        let all = BuildVariant::fast(LanguageSelection::default(), Language::C);
274        let rust_only = BuildVariant::fast(
275            LanguageSelection {
276                rust: true,
277                c: false,
278                cpp: false,
279            },
280            Language::C,
281        );
282        assert_ne!(all.fingerprint(), rust_only.fingerprint());
283        // Fingerprint is a pure function of the canonical form.
284        assert_eq!(
285            all.fingerprint(),
286            BuildVariant::fast(LanguageSelection::default(), Language::C).fingerprint()
287        );
288    }
289
290    #[test]
291    fn reading_headers_with_a_different_grammar_is_a_different_variant() {
292        // The two runs see different code in the same header, so their
293        // findings are not comparable and must not share a fingerprint space.
294        let languages = LanguageSelection::default();
295        let as_c = BuildVariant::fast(languages, Language::C);
296        let as_cpp = BuildVariant::fast(languages, Language::Cpp);
297        assert_ne!(as_c, as_cpp);
298        assert_ne!(as_c.fingerprint(), as_cpp.fingerprint());
299    }
300
301    #[test]
302    fn a_run_that_enumerates_no_c_records_no_header_grammar() {
303        // A Rust-only scan reads no headers, so its variant must not move
304        // because the tree happens to hold more `.cpp` than `.c` files.
305        let rust_only = LanguageSelection {
306            rust: true,
307            c: false,
308            cpp: false,
309        };
310        let with_c = BuildVariant::fast(rust_only, Language::C);
311        let with_cpp = BuildVariant::fast(rust_only, Language::Cpp);
312        assert_eq!(with_c.headers, None);
313        assert_eq!(with_c, with_cpp);
314        assert_eq!(
315            with_c.canonical(),
316            "mode=fast;languages=rust;headers=none;normalization=1"
317        );
318    }
319
320    /// A mode that resolves no build configuration must keep the identity it
321    /// had before variants could carry one, or every stored run stops lining up
322    /// with the runs that follow it.
323    #[test]
324    fn a_run_that_resolved_no_build_configuration_is_identified_as_it_always_was() {
325        let variant = BuildVariant::fast(LanguageSelection::default(), Language::C);
326        assert!(variant.builds.is_empty());
327        assert!(
328            !variant.canonical().contains("build="),
329            "{}",
330            variant.canonical()
331        );
332    }
333
334    #[test]
335    fn two_builds_of_one_source_tree_are_two_variants() {
336        let languages = LanguageSelection::default();
337        let narrow = BuildVariant::semantic(
338            languages,
339            Language::Cpp,
340            vec![BuildConfiguration::Cpp(Box::new(CppBuild {
341                compiler: "clang++".into(),
342                ..CppBuild::default()
343            }))],
344        );
345        let wide = BuildVariant::semantic(
346            languages,
347            Language::Cpp,
348            vec![BuildConfiguration::Cpp(Box::new(CppBuild {
349                compiler: "clang++".into(),
350                macros: vec!["-DACCUM_WIDTH=64".into()],
351                ..CppBuild::default()
352            }))],
353        );
354        assert_ne!(narrow, wide);
355        assert_ne!(narrow.fingerprint(), wide.fingerprint());
356    }
357
358    /// A tree holding both languages is answered by both helpers, and the run
359    /// is one run: the variant names what each was told, because results that
360    /// came out of two compilers mean what both of them were told.
361    #[test]
362    fn a_tree_answered_by_two_compilers_is_one_variant_naming_both() {
363        let languages = LanguageSelection::default();
364        let rust = BuildConfiguration::Rust(Box::new(RustBuild {
365            compiler_version: "rustc 1.85.0".into(),
366            ..RustBuild::default()
367        }));
368        let cpp = BuildConfiguration::Cpp(Box::new(CppBuild {
369            compiler: "clang++".into(),
370            ..CppBuild::default()
371        }));
372        let both =
373            BuildVariant::semantic(languages, Language::Cpp, vec![rust.clone(), cpp.clone()]);
374        let rust_only = BuildVariant::semantic(languages, Language::Cpp, vec![rust]);
375        assert_eq!(both.builds.len(), 2);
376        assert_ne!(both.fingerprint(), rust_only.fingerprint());
377        assert!(
378            both.canonical().contains(&cpp.fingerprint()),
379            "{}",
380            both.canonical()
381        );
382    }
383
384    /// Which helper was reached first is a fact about the machine, not about
385    /// the tree. A variant that moved with it would give one project two
386    /// identities across two installations and compare neither with the other.
387    #[test]
388    fn the_order_the_compilers_were_reached_in_is_not_part_of_the_identity() {
389        let languages = LanguageSelection::default();
390        let rust = || BuildConfiguration::Rust(Box::default());
391        let cpp = || BuildConfiguration::Cpp(Box::default());
392        let one = BuildVariant::semantic(languages, Language::Cpp, vec![rust(), cpp()]);
393        let other = BuildVariant::semantic(languages, Language::Cpp, vec![cpp(), rust()]);
394        assert_eq!(one, other);
395        assert_eq!(one.fingerprint(), other.fingerprint());
396    }
397
398    /// Half a tree built differently is a differently built tree. The Rust side
399    /// resolving the same way says nothing about whether the C++ results can be
400    /// compared with the ones recorded before.
401    #[test]
402    fn one_language_building_differently_moves_the_whole_run() {
403        let languages = LanguageSelection::default();
404        let variant = |macros: Vec<String>| {
405            BuildVariant::semantic(
406                languages,
407                Language::Cpp,
408                vec![
409                    BuildConfiguration::Rust(Box::default()),
410                    BuildConfiguration::Cpp(Box::new(CppBuild {
411                        compiler: "clang++".into(),
412                        macros,
413                        ..CppBuild::default()
414                    })),
415                ],
416            )
417        };
418        assert_ne!(
419            variant(Vec::new()).fingerprint(),
420            variant(vec!["-DACCUM_WIDTH=64".into()]).fingerprint()
421        );
422    }
423
424    /// A run that resolved exactly one configuration keeps the identity it had
425    /// before a variant could hold several, or every semantic run already
426    /// recorded stops lining up with the runs that follow it.
427    #[test]
428    fn resolving_one_configuration_identifies_a_run_as_it_always_did() {
429        let build = BuildConfiguration::Rust(Box::default());
430        let variant = BuildVariant::semantic(
431            LanguageSelection::default(),
432            Language::Cpp,
433            vec![build.clone()],
434        );
435        assert!(
436            variant
437                .canonical()
438                .ends_with(&format!(";build={}", build.fingerprint())),
439            "{}",
440            variant.canonical()
441        );
442    }
443
444    /// The languages are separate identity spaces one level down as well, so a
445    /// Rust variant and a C++ variant cannot collide by having equally empty
446    /// build configurations.
447    #[test]
448    fn a_rust_variant_and_a_cpp_variant_are_never_the_same_variant() {
449        let languages = LanguageSelection::default();
450        let rust = BuildVariant::semantic(
451            languages,
452            Language::Cpp,
453            vec![BuildConfiguration::Rust(Box::default())],
454        );
455        let cpp = BuildVariant::semantic(
456            languages,
457            Language::Cpp,
458            vec![BuildConfiguration::Cpp(Box::default())],
459        );
460        assert_ne!(rust.fingerprint(), cpp.fingerprint());
461    }
462
463    #[test]
464    fn units_are_grouped_by_the_variant_they_were_analysed_under() {
465        let languages = LanguageSelection::default();
466        let variant = |macros: Vec<String>| {
467            BuildVariant::semantic(
468                languages,
469                Language::Cpp,
470                vec![BuildConfiguration::Cpp(Box::new(CppBuild {
471                    compiler: "clang++".into(),
472                    macros,
473                    ..CppBuild::default()
474                }))],
475            )
476        };
477        let narrow = variant(Vec::new());
478        let wide = variant(vec!["-DACCUM_WIDTH=64".into()]);
479        let partitions = partition([
480            (narrow.clone(), "narrow.cpp"),
481            (wide.clone(), "wide.cpp"),
482            (narrow.clone(), "also-narrow.cpp"),
483        ]);
484        assert_eq!(partitions.len(), 2);
485        assert_eq!(
486            partitions[&narrow.fingerprint()].units,
487            vec!["narrow.cpp", "also-narrow.cpp"]
488        );
489        assert_eq!(partitions[&wide.fingerprint()].units, vec!["wide.cpp"]);
490    }
491
492    /// The grouping is a property of the variants, not of the order units
493    /// happened to be discovered in.
494    #[test]
495    fn the_grouping_does_not_depend_on_the_order_units_arrive_in() {
496        let languages = LanguageSelection::default();
497        let fast = BuildVariant::fast(languages, Language::C);
498        let structural = BuildVariant::structural(languages, Language::C);
499        let forwards = partition([(fast.clone(), 1), (structural.clone(), 2)]);
500        let backwards = partition([(structural, 2), (fast, 1)]);
501        assert_eq!(
502            forwards.keys().collect::<Vec<_>>(),
503            backwards.keys().collect::<Vec<_>>()
504        );
505    }
506}