Skip to main content

codehelion_core/discovery/
build_config.rs

1//! What a compiler was told, in the form an identity is decided on.
2//!
3//! Two compilations of the same text are the same program only if the compiler
4//! was told the same things. A define changes which branch of a header exists,
5//! a feature changes which type a name resolves to, an optimization level
6//! changes what the artifact contains. So the arguments are part of the variant
7//! rather than context beside it, and a run that resolved them carries a
8//! [`BuildConfiguration`].
9//!
10//! # What counts towards identity
11//!
12//! Everything, minus a short explicit exclusion list. The rule is deliberately
13//! that way round: a flag wrongly included splits one variant into two, which
14//! costs recall and is visible; a flag wrongly excluded merges two programs
15//! into one identity, which produces confident findings about code that was
16//! never compiled the same way and is not visible at all. The exclusion list
17//! ([`EXCLUDED`], [`EXCLUDED_WITH_VALUE`]) is therefore short, and grows only
18//! for arguments that provably cannot change what a compiler resolves —
19//! diagnostic presentation, dependency-file bookkeeping, and the output path,
20//! which would otherwise give every translation unit a variant of its own and
21//! leave nothing to partition.
22//!
23//! # Where order is normalized, and where it is not
24//!
25//! Only where order provably does not matter. Macro settings are reduced to one
26//! entry per macro — the state its last mention left it in, so that `-DX -UX`
27//! and `-UX -DX` stay different — and then sorted by name, which makes the
28//! order they were written in irrelevant. Include directories are a search
29//! path, so their order is meaning and is preserved; sorting them would merge
30//! two builds that find different headers under the same name. Remaining flags
31//! keep their order too, because last-one-wins options like `-O1 -O2` are
32//! common and nothing here can tell which flags those are.
33//!
34//! # Paths
35//!
36//! A path an argument names is resolved against the directory the command was
37//! recorded to run in before it counts towards anything. A build generator
38//! writes `-Iinclude` from each of its build directories, and those are as many
39//! include directories as there are directories: taken as the word it is
40//! written as, one identity would cover compilations that read different
41//! headers under one name, which is the merge this file exists to prevent.
42//!
43//! # Encoding
44//!
45//! The canonical form is length-prefixed rather than delimiter-separated.
46//! Compiler arguments are arbitrary text and routinely contain the punctuation
47//! a delimiter scheme would use: `-Dpair=a,b` and `-Dpair=a -Db` are different
48//! builds that any comma-joined encoding reports as the same one. Prefixing
49//! each value with its length makes the encoding injective whatever the values
50//! contain.
51//!
52//! # One list, read two ways
53//!
54//! A configuration says what it was told once, as [`Setting`]s, and the
55//! canonical form is a fold over that list. Anything that wants the fields
56//! themselves — an audit database recording what a stored variant was built
57//! with — reads the same list. Keeping the identity and the record derived from
58//! one enumeration is what stops them drifting: a field added to the encoding
59//! but forgotten in the record would leave two variants that differ in the
60//! database by nothing but a hash, which is precisely a difference nobody can
61//! act on.
62
63use std::collections::{BTreeMap, BTreeSet};
64use std::path::Path;
65
66use codehelion_helper_protocol::compile_commands::resolve_in_directory;
67
68/// Arguments dropped from a compilation before it becomes an identity.
69///
70/// Each is either a statement about how to present diagnostics or about where
71/// to write bookkeeping files. None can change what the compiler resolves.
72pub const EXCLUDED: [&str; 8] = [
73    "-c",
74    "-M",
75    "-MM",
76    "-MD",
77    "-MMD",
78    "-MP",
79    "-fcolor-diagnostics",
80    "-fno-color-diagnostics",
81];
82
83/// Arguments dropped together with the value that follows them.
84///
85/// `-o` is here for a second reason: an object path is unique per translation
86/// unit, so keeping it would give every unit its own variant and leave the
87/// partition with one member each.
88pub const EXCLUDED_WITH_VALUE: [&str; 4] = ["-o", "-MF", "-MT", "-MQ"];
89
90/// A stable hex hash of a file's contents, for the build inputs that are
91/// identified by what they say rather than by where they are.
92#[must_use]
93pub fn content_hash(text: &str) -> String {
94    blake3::hash(text.as_bytes()).to_hex().to_string()
95}
96
97/// One thing a compiler was told, under the name it is recorded by.
98///
99/// The name is part of the record and outlives the release that wrote it, so it
100/// is chosen once and not renamed with the field it comes from.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Setting {
103    /// What it is called wherever it is stored.
104    pub name: &'static str,
105    /// Its value, in the shape the setting has.
106    pub shape: Shape,
107}
108
109/// The three shapes a build setting comes in.
110///
111/// They are distinguished because they encode differently, and they encode
112/// differently because they mean different things: a value nobody resolved is
113/// not an empty value, and a sequence of one is not a scalar.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum Shape {
116    /// A value every build has.
117    Given(String),
118    /// A value only something that looked it up can supply.
119    Resolved(Option<String>),
120    /// A sequence, in the order it was given.
121    Ordered(Vec<String>),
122}
123
124impl Shape {
125    /// The values worth recording, in order.
126    ///
127    /// An unresolved setting yields nothing: what was never looked up is
128    /// absent from the record rather than present and empty.
129    #[must_use]
130    pub fn values(&self) -> Vec<&str> {
131        match self {
132            Self::Given(value) => vec![value.as_str()],
133            Self::Resolved(value) => value.as_deref().into_iter().collect(),
134            Self::Ordered(values) => values.iter().map(String::as_str).collect(),
135        }
136    }
137}
138
139fn given(name: &'static str, value: &str) -> Setting {
140    Setting {
141        name,
142        shape: Shape::Given(value.to_string()),
143    }
144}
145
146fn resolved(name: &'static str, value: Option<&str>) -> Setting {
147    Setting {
148        name,
149        shape: Shape::Resolved(value.map(ToString::to_string)),
150    }
151}
152
153fn ordered(name: &'static str, values: &[String]) -> Setting {
154    Setting {
155        name,
156        shape: Shape::Ordered(values.to_vec()),
157    }
158}
159
160/// What a C or C++ translation unit was compiled with.
161#[derive(Debug, Clone, PartialEq, Eq, Default)]
162pub struct CppBuild {
163    /// The compiler as the database spells it.
164    pub compiler: String,
165    /// Its version, which only something that ran it can know.
166    pub compiler_version: Option<String>,
167    /// The linker, for the variants that reach a link step.
168    pub linker: Option<String>,
169    /// One entry per macro, in the flag form its last mention left it in
170    /// (`-DNAME=value` or `-UNAME`), sorted by macro name.
171    pub macros: Vec<String>,
172    /// Include directories in search order, without the `-I`.
173    pub include_paths: Vec<String>,
174    /// Everything else that was passed, in the order it was passed.
175    pub flags: Vec<String>,
176    /// A hash of the compilation database this came from.
177    pub database_hash: Option<String>,
178    /// Post-processing tools declared for this build, in invocation order.
179    ///
180    /// This is deliberately declarative. Source analysis never runs a tool in
181    /// this list; artifact analysis may consume the recorded fact later.
182    pub post_processing_tools: Vec<String>,
183}
184
185impl CppBuild {
186    /// The identity of one entry of a compilation database.
187    ///
188    /// `file` is the translation unit's own source, which is dropped: it says
189    /// which unit this is, not which variant it belongs to.
190    #[must_use]
191    pub fn from_command(arguments: &[String], file: &Path) -> Self {
192        Self::from_command_in_directory(arguments, file, None)
193    }
194
195    /// The identity of one entry of a compilation database whose command ran
196    /// from `directory`.
197    ///
198    /// Every relative path in the command is resolved from that directory: the
199    /// input paths before they are compared with `file`, so translation-unit
200    /// paths do not accidentally become variant settings, and the paths the
201    /// compiler reads before they become part of the identity, so two commands
202    /// spelled alike from two directories stay the two builds they are.
203    #[must_use]
204    pub fn from_command_in_directory(
205        arguments: &[String],
206        file: &Path,
207        directory: Option<&Path>,
208    ) -> Self {
209        let mut build = Self {
210            compiler: arguments.first().cloned().unwrap_or_default(),
211            ..Self::default()
212        };
213        let mut macros = Vec::new();
214        let mut index = 1;
215        while index < arguments.len() {
216            let argument = arguments[index].as_str();
217            index += 1;
218            if source_argument_matches(argument, file, directory) {
219                continue;
220            }
221            if EXCLUDED_WITH_VALUE.contains(&argument) {
222                index += 1;
223                continue;
224            }
225            if EXCLUDED.contains(&argument) || argument.starts_with("-fdiagnostics-color") {
226                continue;
227            }
228            match separated(argument, arguments.get(index).map(String::as_str)) {
229                Some(Separated::Macro(setting, consumed)) => {
230                    macros.push(setting);
231                    index += usize::from(consumed);
232                }
233                Some(Separated::Include(path, consumed)) => {
234                    build.include_paths.push(resolved_path(&path, directory));
235                    index += usize::from(consumed);
236                }
237                Some(Separated::Reads(option, path, consumed)) => {
238                    build.flags.push(option.to_string());
239                    build.flags.push(resolved_path(&path, directory));
240                    index += usize::from(consumed);
241                }
242                None => build.flags.push(argument.to_string()),
243            }
244        }
245        build.macros = last_mention_wins(macros);
246        build
247    }
248
249    /// The macros left defined, without the `-D`.
250    #[must_use]
251    pub fn defines(&self) -> Vec<&str> {
252        self.macros
253            .iter()
254            .filter_map(|setting| setting.strip_prefix("-D"))
255            .collect()
256    }
257
258    /// Everything this build was told, in the order the identity encodes it.
259    #[must_use]
260    pub fn settings(&self) -> Vec<Setting> {
261        vec![
262            given("compiler", &self.compiler),
263            resolved("compiler_version", self.compiler_version.as_deref()),
264            resolved("linker", self.linker.as_deref()),
265            ordered("macros", &self.macros),
266            ordered("includes", &self.include_paths),
267            ordered("flags", &self.flags),
268            resolved("database", self.database_hash.as_deref()),
269            ordered("post_processing_tools", &self.post_processing_tools),
270        ]
271    }
272}
273
274fn source_argument_matches(argument: &str, file: &Path, directory: Option<&Path>) -> bool {
275    let resolved = resolve_in_directory(directory, Path::new(argument));
276    normalize_path(&resolved) == normalize_path(file)
277}
278
279/// A path an argument names, in the form it contributes to the identity.
280///
281/// Resolved against the directory the command ran in, then reduced to the one
282/// spelling the filesystem gives it so that two commands reaching one directory
283/// by two routes are one build. A path that names nothing on this machine keeps
284/// the spelling it was resolved to: two spellings of one absent directory then
285/// count as two builds, which costs recall where a merge would have cost the
286/// truth of the finding.
287fn resolved_path(path: &str, directory: Option<&Path>) -> String {
288    let path = Path::new(path);
289    // An option written with nothing after it names no path, and inventing the
290    // command's own directory for it would be an include directory the compiler
291    // was never given.
292    if path.as_os_str().is_empty() {
293        return String::new();
294    }
295    if path.is_relative() && directory.is_none() {
296        // Nothing here says what this is relative to, and this process's own
297        // working directory is not it.
298        return path.display().to_string();
299    }
300    normalize_path(&resolve_in_directory(directory, path))
301        .display()
302        .to_string()
303}
304
305fn normalize_path(path: &Path) -> std::path::PathBuf {
306    crate::paths::canonical(path).unwrap_or_else(|_| path.to_path_buf())
307}
308
309/// What a Rust crate was built with.
310///
311/// Recorded rather than assumed: a helper analyses with the compiler it holds,
312/// which need not be the one the project builds with, so the version here is
313/// the one that produced the answers.
314#[derive(Debug, Clone, PartialEq, Eq, Default)]
315pub struct RustBuild {
316    /// The target triple.
317    pub target: String,
318    /// Enabled cargo features, deduplicated and sorted: they are a set, and
319    /// nothing about the order they were requested in reaches the compiler.
320    ///
321    /// Each names the package it belongs to, because a feature is declared per
322    /// package: one package's `derive` and another's are unrelated, and a bare
323    /// list would let either stand for both.
324    pub features: Vec<String>,
325    /// `--cfg` settings, deduplicated and sorted for the same reason.
326    pub cfgs: Vec<String>,
327    /// The compiler that produced the answers.
328    pub compiler_version: String,
329    /// Optimization level, as cargo spells it.
330    pub opt_level: String,
331    /// Link-time optimization setting.
332    pub lto: String,
333    /// Codegen units, when pinned.
334    pub codegen_units: Option<u32>,
335    /// Panic strategy.
336    pub panic: String,
337    /// A hash of `Cargo.lock`: the dependency versions are part of what the
338    /// source means, and the lockfile is the only place that records them all.
339    pub lockfile_hash: Option<String>,
340    /// A hash of the command the build was requested with.
341    pub build_command_hash: Option<String>,
342    /// Post-processing tools declared for this build, in invocation order.
343    ///
344    /// This records build context only. The source scanner never executes a
345    /// declared tool.
346    pub post_processing_tools: Vec<String>,
347    /// The classes of execution the run was permitted, sorted and named as a
348    /// person types them.
349    ///
350    /// Part of the identity because a run allowed to run build scripts reads a
351    /// program the refused run cannot see: types that only exist after a script
352    /// has written them resolve in one and not the other. What was *permitted*
353    /// rather than what turned out to run — the second is a prediction this
354    /// side would have to make before doing the work, and a prediction that
355    /// came out wrong would file the answers under conditions that did not
356    /// hold.
357    pub permitted_execution: Vec<String>,
358}
359
360impl RustBuild {
361    /// The same build with its features and cfgs reduced to sets.
362    #[must_use]
363    pub fn normalized(mut self) -> Self {
364        self.features = self
365            .features
366            .into_iter()
367            .collect::<BTreeSet<_>>()
368            .into_iter()
369            .collect();
370        self.cfgs = self
371            .cfgs
372            .into_iter()
373            .collect::<BTreeSet<_>>()
374            .into_iter()
375            .collect();
376        self
377    }
378
379    /// Everything this build was told, in the order the identity encodes it.
380    #[must_use]
381    pub fn settings(&self) -> Vec<Setting> {
382        vec![
383            given("target", &self.target),
384            ordered("features", &self.features),
385            ordered("cfgs", &self.cfgs),
386            given("compiler_version", &self.compiler_version),
387            given("opt_level", &self.opt_level),
388            given("lto", &self.lto),
389            resolved(
390                "codegen_units",
391                self.codegen_units.map(|units| units.to_string()).as_deref(),
392            ),
393            given("panic", &self.panic),
394            resolved("lockfile", self.lockfile_hash.as_deref()),
395            resolved("build_command", self.build_command_hash.as_deref()),
396            ordered("post_processing_tools", &self.post_processing_tools),
397            ordered("permitted_execution", &self.permitted_execution),
398        ]
399    }
400}
401
402/// The build configuration a variant was resolved under.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum BuildConfiguration {
405    /// A Rust crate.
406    Rust(Box<RustBuild>),
407    /// A C or C++ translation unit.
408    Cpp(Box<CppBuild>),
409}
410
411impl BuildConfiguration {
412    /// Which language's build this is.
413    ///
414    /// Part of the identity in its own right: the two languages' settings are
415    /// named differently, but nothing stops them lining up field for field, and
416    /// two builds that share an encoding are not the same program.
417    #[must_use]
418    pub const fn language(&self) -> &'static str {
419        match self {
420            Self::Rust(_) => "rust",
421            Self::Cpp(_) => "cpp",
422        }
423    }
424
425    /// Everything this build was told, in the order the identity encodes it.
426    #[must_use]
427    pub fn settings(&self) -> Vec<Setting> {
428        match self {
429            Self::Rust(build) => build.settings(),
430            Self::Cpp(build) => build.settings(),
431        }
432    }
433
434    /// The canonical, injective encoding of this configuration.
435    ///
436    /// Two configurations produce the same string exactly when they are equal,
437    /// whatever punctuation their arguments contain.
438    #[must_use]
439    pub fn canonical(&self) -> String {
440        let mut out = String::new();
441        scalar(&mut out, "language", self.language());
442        for setting in self.settings() {
443            match &setting.shape {
444                Shape::Given(value) => scalar(&mut out, setting.name, value),
445                Shape::Resolved(value) => optional(&mut out, setting.name, value.as_deref()),
446                Shape::Ordered(values) => list(&mut out, setting.name, values),
447            }
448        }
449        out
450    }
451
452    /// A stable hex fingerprint of the canonical form.
453    #[must_use]
454    pub fn fingerprint(&self) -> String {
455        blake3::hash(self.canonical().as_bytes())
456            .to_hex()
457            .to_string()
458    }
459}
460
461/// Options whose value names a place the compiler reads, in the spelling that
462/// puts the value in the following word.
463///
464/// Here so that the value is resolved rather than kept as the word it was
465/// written as. `-I` is absent because an include directory has a field of its
466/// own, where its place in the search order is meaning.
467const READS_PATH: [&str; 9] = [
468    "--sysroot",
469    "-F",
470    "-idirafter",
471    "-iframework",
472    "-imacros",
473    "-include",
474    "-iquote",
475    "-isysroot",
476    "-isystem",
477];
478
479/// An argument that may carry its value in the next position.
480enum Separated {
481    /// A macro setting, and whether the next argument was consumed.
482    Macro(String, bool),
483    /// An include directory, and whether the next argument was consumed.
484    Include(String, bool),
485    /// An option naming a place the compiler reads: the option, the path it
486    /// names, and whether the next argument was consumed.
487    Reads(&'static str, String, bool),
488}
489
490/// Classifies `argument`, reading `next` only for the separated spellings
491/// (`-D NAME` beside `-DNAME`), which both compilers accept.
492///
493/// An option and its value come back apart however they were written, so the
494/// joined and separated spellings of one option are one identity — they are one
495/// compilation, and a build that differs from another only in where its
496/// generator put a space is not a second build.
497fn separated(argument: &str, next: Option<&str>) -> Option<Separated> {
498    for prefix in ["-D", "-U"] {
499        if let Some(rest) = argument.strip_prefix(prefix) {
500            return Some(if rest.is_empty() {
501                Separated::Macro(format!("{prefix}{}", next.unwrap_or_default()), true)
502            } else {
503                Separated::Macro(argument.to_string(), false)
504            });
505        }
506    }
507    if let Some(rest) = argument.strip_prefix("-I") {
508        return Some(if rest.is_empty() {
509            Separated::Include(next.unwrap_or_default().to_string(), true)
510        } else {
511            Separated::Include(rest.to_string(), false)
512        });
513    }
514    if let Some(rest) = argument.strip_prefix("--sysroot=") {
515        return Some(Separated::Reads("--sysroot", rest.to_string(), false));
516    }
517    if let Some(rest) = argument.strip_prefix("-F")
518        && !rest.is_empty()
519    {
520        return Some(Separated::Reads("-F", rest.to_string(), false));
521    }
522    READS_PATH
523        .into_iter()
524        .find(|option| *option == argument)
525        .map(|option| Separated::Reads(option, next.unwrap_or_default().to_string(), true))
526}
527
528/// One entry per macro, keeping the last mention and sorting by name.
529///
530/// Last mention rather than first because that is what the preprocessor does,
531/// and keeping `-D` and `-U` in the same reduction is what makes `-DX -UX`
532/// and `-UX -DX` two identities rather than one.
533fn last_mention_wins(settings: Vec<String>) -> Vec<String> {
534    let mut latest: BTreeMap<String, String> = BTreeMap::new();
535    for setting in settings {
536        let name = setting
537            .trim_start_matches("-D")
538            .trim_start_matches("-U")
539            .split('=')
540            .next()
541            .unwrap_or_default()
542            .to_string();
543        latest.insert(name, setting);
544    }
545    latest.into_values().collect()
546}
547
548fn scalar(out: &mut String, name: &str, value: &str) {
549    out.push_str(name);
550    out.push('=');
551    push_sized(out, value);
552    out.push(';');
553}
554
555fn optional(out: &mut String, name: &str, value: Option<&str>) {
556    out.push_str(name);
557    out.push('=');
558    match value {
559        Some(value) => {
560            out.push_str("some");
561            push_sized(out, value);
562        }
563        // Distinct from a present empty value, which is a different claim.
564        None => out.push_str("none"),
565    }
566    out.push(';');
567}
568
569fn list(out: &mut String, name: &str, values: &[String]) {
570    out.push_str(name);
571    out.push('=');
572    out.push_str(&values.len().to_string());
573    out.push('[');
574    for value in values {
575        push_sized(out, value);
576    }
577    out.push_str("];");
578}
579
580fn push_sized(out: &mut String, value: &str) {
581    out.push_str(&value.len().to_string());
582    out.push(':');
583    out.push_str(value);
584}
585
586#[cfg(test)]
587#[allow(clippy::unwrap_used, clippy::expect_used)]
588mod tests {
589    use super::*;
590
591    fn command(arguments: &[&str]) -> Vec<String> {
592        arguments.iter().map(|a| (*a).to_string()).collect()
593    }
594
595    fn cpp(arguments: &[&str], file: &str) -> CppBuild {
596        CppBuild::from_command(&command(arguments), Path::new(file))
597    }
598
599    fn cpp_in(arguments: &[&str], file: &str, directory: &str) -> CppBuild {
600        CppBuild::from_command_in_directory(
601            &command(arguments),
602            Path::new(file),
603            Some(Path::new(directory)),
604        )
605    }
606
607    fn spelled(directory: &str, path: &str) -> String {
608        Path::new(directory).join(path).display().to_string()
609    }
610
611    #[test]
612    fn the_compiler_and_what_it_was_told_are_read_off_the_command() {
613        let build = cpp(
614            &[
615                "clang++",
616                "-std=c++17",
617                "-DACCUM_WIDTH=64",
618                "-I/w/include",
619                "-c",
620                "-o",
621                "wide.o",
622                "/w/src/wide.cpp",
623            ],
624            "/w/src/wide.cpp",
625        );
626        assert_eq!(build.compiler, "clang++");
627        assert_eq!(build.macros, vec!["-DACCUM_WIDTH=64"]);
628        assert_eq!(build.include_paths, vec!["/w/include"]);
629        assert_eq!(build.flags, vec!["-std=c++17"]);
630        assert_eq!(build.defines(), vec!["ACCUM_WIDTH=64"]);
631    }
632
633    /// The output path is unique per unit. Keeping it would give every
634    /// translation unit its own variant, which is the same as having none.
635    #[test]
636    fn the_object_path_does_not_become_part_of_the_identity() {
637        let narrow = cpp(&["cc", "-O2", "-o", "a/narrow.o", "-c", "/w/a.c"], "/w/a.c");
638        let wide = cpp(&["cc", "-O2", "-o", "b/wide.o", "-c", "/w/a.c"], "/w/a.c");
639        assert_eq!(narrow, wide);
640        assert!(narrow.flags.iter().all(|flag| !flag.contains("narrow.o")));
641    }
642
643    #[test]
644    fn dependency_bookkeeping_and_diagnostic_colour_are_not_identity() {
645        let plain = cpp(&["cc", "-O2", "/w/a.c"], "/w/a.c");
646        let noisy = cpp(
647            &[
648                "cc",
649                "-O2",
650                "-MD",
651                "-MF",
652                "a.d",
653                "-MT",
654                "a.o",
655                "-fcolor-diagnostics",
656                "-fdiagnostics-color=always",
657                "/w/a.c",
658            ],
659            "/w/a.c",
660        );
661        assert_eq!(plain, noisy);
662    }
663
664    /// An unrecognised flag is kept. The exclusion list is the whole of what is
665    /// dropped, because a flag wrongly dropped merges two programs into one
666    /// identity and nothing downstream can notice.
667    #[test]
668    fn an_unrecognised_flag_counts_towards_identity() {
669        let plain = cpp(&["cc", "/w/a.c"], "/w/a.c");
670        let odd = cpp(&["cc", "-fsomething-nobody-here-knows", "/w/a.c"], "/w/a.c");
671        assert_ne!(plain, odd);
672        assert_eq!(odd.flags, vec!["-fsomething-nobody-here-knows"]);
673    }
674
675    #[test]
676    fn the_separated_spellings_mean_the_same_as_the_joined_ones() {
677        let joined = cpp(&["cc", "-DWIDTH=64", "-I/w/inc", "/w/a.c"], "/w/a.c");
678        let separated = cpp(
679            &["cc", "-D", "WIDTH=64", "-I", "/w/inc", "/w/a.c"],
680            "/w/a.c",
681        );
682        assert_eq!(joined, separated);
683    }
684
685    /// Macro order is not meaning, so it is normalized away — but only after
686    /// the last mention has won, which is what the preprocessor does.
687    #[test]
688    fn macros_are_sorted_but_the_last_mention_still_decides() {
689        let one = cpp(&["cc", "-DB=2", "-DA=1", "/w/a.c"], "/w/a.c");
690        let other = cpp(&["cc", "-DA=1", "-DB=2", "/w/a.c"], "/w/a.c");
691        assert_eq!(one, other);
692        assert_eq!(one.macros, vec!["-DA=1", "-DB=2"]);
693
694        let redefined = cpp(&["cc", "-DA=1", "-DA=2", "/w/a.c"], "/w/a.c");
695        assert_eq!(redefined.macros, vec!["-DA=2"]);
696    }
697
698    /// Sorting a define beside an undefine of the same macro would lose which
699    /// one the compiler saw last, and those are two different programs.
700    #[test]
701    fn defining_then_undefining_is_not_the_same_as_the_reverse() {
702        let defined_last = cpp(&["cc", "-UA", "-DA=1", "/w/a.c"], "/w/a.c");
703        let undefined_last = cpp(&["cc", "-DA=1", "-UA", "/w/a.c"], "/w/a.c");
704        assert_ne!(defined_last, undefined_last);
705        assert_eq!(defined_last.macros, vec!["-DA=1"]);
706        assert_eq!(undefined_last.macros, vec!["-UA"]);
707    }
708
709    /// Include directories are a search order. Two builds that reach different
710    /// headers under the same name are not one variant.
711    #[test]
712    fn include_order_is_meaning_and_is_kept() {
713        let vendor_first = cpp(&["cc", "-I/vendor", "-I/local", "/w/a.c"], "/w/a.c");
714        let local_first = cpp(&["cc", "-I/local", "-I/vendor", "/w/a.c"], "/w/a.c");
715        assert_ne!(vendor_first, local_first);
716        assert_eq!(vendor_first.include_paths, vec!["/vendor", "/local"]);
717    }
718
719    /// A generator writes one command per build directory, and spells the
720    /// include path relative to the directory it runs in. Counted as the word
721    /// it is written as, two compilations that reach different headers under
722    /// one name become one identity, and every clone reported from it is a
723    /// claim about code that was not compiled the same way.
724    #[test]
725    fn one_relative_include_from_two_directories_is_two_builds() {
726        let unit = ["clang++", "-Iinclude", "-c", "a.cpp"];
727        let one = cpp_in(&unit, "/w/one/a.cpp", "/w/one");
728        let other = cpp_in(&unit, "/w/two/a.cpp", "/w/two");
729        assert_eq!(one.include_paths, [spelled("/w/one", "include")]);
730        assert_eq!(other.include_paths, [spelled("/w/two", "include")]);
731        assert_ne!(
732            BuildConfiguration::Cpp(Box::new(one)).fingerprint(),
733            BuildConfiguration::Cpp(Box::new(other)).fingerprint()
734        );
735
736        // And the directory splits nothing on its own: one command run twice
737        // from one place is one build.
738        assert_eq!(
739            cpp_in(&unit, "/w/one/a.cpp", "/w/one"),
740            cpp_in(&unit, "/w/one/a.cpp", "/w/one")
741        );
742    }
743
744    /// The rest of the options that name somewhere to read from. A sysroot or a
745    /// system include directory reached from two build directories is two sets
746    /// of headers, exactly as an `-I` is.
747    #[test]
748    fn a_relative_path_in_any_reading_option_is_resolved_where_the_command_ran() {
749        for option in ["--sysroot", "-isystem", "-iquote", "-idirafter", "-include"] {
750            let unit = ["cc", option, "sdk/thing", "-c", "a.c"];
751            let one = cpp_in(&unit, "/w/one/a.c", "/w/one");
752            let other = cpp_in(&unit, "/w/two/a.c", "/w/two");
753            assert_eq!(
754                one.flags,
755                [option.to_string(), spelled("/w/one", "sdk/thing")],
756                "{option}"
757            );
758            assert_ne!(one, other, "{option}");
759        }
760    }
761
762    /// One compilation written two ways. A build that differs from another only
763    /// in where its generator put a space is not a second build.
764    #[test]
765    fn the_joined_and_separated_spellings_of_a_path_option_are_one_build() {
766        let joined = cpp_in(&["cc", "--sysroot=/sdk", "-F/sdk/f", "a.c"], "/w/a.c", "/w");
767        let apart = cpp_in(
768            &["cc", "--sysroot", "/sdk", "-F", "/sdk/f", "a.c"],
769            "/w/a.c",
770            "/w",
771        );
772        assert_eq!(joined, apart);
773    }
774
775    /// Two routes to one directory are one include path, which is what asking
776    /// the filesystem is for: the build reads the same headers either way.
777    #[test]
778    fn two_routes_to_one_include_directory_are_one_build() {
779        let project = tempfile::tempdir().unwrap();
780        let include = project.path().join("include");
781        std::fs::create_dir(&include).unwrap();
782        let build_directory = project.path().join("build");
783        std::fs::create_dir(&build_directory).unwrap();
784        let unit = project.path().join("a.c");
785
786        let relative = CppBuild::from_command_in_directory(
787            &command(&["cc", "-I../include"]),
788            &unit,
789            Some(&build_directory),
790        );
791        let joined = format!("-I{}", include.display());
792        let absolute = CppBuild::from_command_in_directory(
793            &command(&["cc", &joined]),
794            &unit,
795            Some(&build_directory),
796        );
797        assert_eq!(relative, absolute);
798    }
799
800    /// Nothing says what this is relative to, and this process's own directory
801    /// is not it: resolving it there would file the build under a path no
802    /// compiler read.
803    #[test]
804    fn a_relative_path_with_no_directory_stands_as_it_was_written() {
805        let build = cpp(&["cc", "-Iinclude", "-isystem", "sdk", "/w/a.c"], "/w/a.c");
806        assert_eq!(build.include_paths, ["include"]);
807        assert_eq!(build.flags, ["-isystem", "sdk"]);
808    }
809
810    /// A delimiter-joined encoding reports these two as the same build. The
811    /// length prefix is what keeps them apart.
812    #[test]
813    fn punctuation_inside_an_argument_cannot_forge_another_argument() {
814        let one = cpp(&["cc", "-Dpair=a,b", "/w/a.c"], "/w/a.c");
815        let two = cpp(&["cc", "-Dpair=a", "-Db", "/w/a.c"], "/w/a.c");
816        let one = BuildConfiguration::Cpp(Box::new(one));
817        let two = BuildConfiguration::Cpp(Box::new(two));
818        assert_ne!(one.canonical(), two.canonical());
819        assert_ne!(one.fingerprint(), two.fingerprint());
820    }
821
822    #[test]
823    fn an_absent_value_is_not_an_empty_one() {
824        let absent = BuildConfiguration::Cpp(Box::new(CppBuild {
825            compiler: "cc".into(),
826            compiler_version: None,
827            ..CppBuild::default()
828        }));
829        let empty = BuildConfiguration::Cpp(Box::new(CppBuild {
830            compiler: "cc".into(),
831            compiler_version: Some(String::new()),
832            ..CppBuild::default()
833        }));
834        assert_ne!(absent.fingerprint(), empty.fingerprint());
835    }
836
837    #[test]
838    fn the_fingerprint_is_a_function_of_the_configuration_alone() {
839        let build = || {
840            BuildConfiguration::Cpp(Box::new(cpp(
841                &["clang++", "-std=c++17", "-DA=1", "/w/a.c"],
842                "/w/a.c",
843            )))
844        };
845        assert_eq!(build().fingerprint(), build().fingerprint());
846    }
847
848    #[test]
849    fn rust_features_are_a_set_and_are_ordered_like_one() {
850        let one = RustBuild {
851            features: vec!["wide".into(), "serde".into(), "wide".into()],
852            ..RustBuild::default()
853        }
854        .normalized();
855        let other = RustBuild {
856            features: vec!["serde".into(), "wide".into()],
857            ..RustBuild::default()
858        }
859        .normalized();
860        assert_eq!(one, other);
861        assert_eq!(one.features, vec!["serde", "wide"]);
862    }
863
864    /// Two runs of the same source under different dependency versions are not
865    /// comparable, and the lockfile is the only record of what those were.
866    #[test]
867    fn a_different_lockfile_is_a_different_build() {
868        let base = RustBuild {
869            target: "aarch64-apple-darwin".into(),
870            compiler_version: "rustc 1.85.0".into(),
871            lockfile_hash: Some(content_hash("one")),
872            ..RustBuild::default()
873        };
874        let moved = RustBuild {
875            lockfile_hash: Some(content_hash("another")),
876            ..base.clone()
877        };
878        assert_ne!(
879            BuildConfiguration::Rust(Box::new(base)).fingerprint(),
880            BuildConfiguration::Rust(Box::new(moved)).fingerprint()
881        );
882    }
883
884    /// A Rust build and a C++ build cannot collide however their fields line
885    /// up, because the language is part of what is hashed.
886    #[test]
887    fn the_two_languages_are_in_different_identity_spaces() {
888        let rust = BuildConfiguration::Rust(Box::default());
889        let cpp = BuildConfiguration::Cpp(Box::default());
890        assert_ne!(rust.fingerprint(), cpp.fingerprint());
891    }
892
893    /// The canonical form is what stored variants are identified by, so it is
894    /// pinned here in full: a refactor that reorders or renames a setting would
895    /// otherwise silently stop an audit database from lining up with the runs
896    /// that follow it.
897    #[test]
898    fn the_encoding_of_a_configuration_is_fixed() {
899        let build = BuildConfiguration::Cpp(Box::new(CppBuild {
900            compiler: "cc".into(),
901            macros: vec!["-DA=1".into()],
902            include_paths: vec!["/inc".into()],
903            ..CppBuild::default()
904        }));
905        assert_eq!(
906            build.canonical(),
907            "language=3:cpp;compiler=2:cc;compiler_version=none;linker=none;\
908             macros=1[5:-DA=1];includes=1[4:/inc];flags=0[];database=none;\
909             post_processing_tools=0[];"
910        );
911        let build = BuildConfiguration::Rust(Box::new(RustBuild {
912            features: vec!["ledger/std".into()],
913            cfgs: vec!["unix".into()],
914            compiler_version: "rust-analyzer 0.0.344".into(),
915            permitted_execution: vec!["build-script".into()],
916            ..RustBuild::default()
917        }));
918        assert_eq!(
919            build.canonical(),
920            "language=4:rust;target=0:;features=1[10:ledger/std];cfgs=1[4:unix];\
921             compiler_version=21:rust-analyzer 0.0.344;opt_level=0:;lto=0:;\
922             codegen_units=none;panic=0:;lockfile=none;build_command=none;\
923             post_processing_tools=0[];permitted_execution=1[12:build-script];"
924        );
925    }
926
927    /// Whatever a field is worth to the identity, it is worth the same to the
928    /// record: a field that moved the fingerprint but not the settings would
929    /// leave two stored variants differing by a hash and nothing a reader could
930    /// name.
931    #[test]
932    fn every_field_that_moves_the_identity_is_one_of_the_settings() {
933        let cpp = |change: fn(&mut CppBuild)| {
934            let mut build = CppBuild {
935                compiler: "cc".into(),
936                compiler_version: Some("18".into()),
937                linker: Some("ld".into()),
938                macros: vec!["-DA=1".into()],
939                include_paths: vec!["/inc".into()],
940                flags: vec!["-O2".into()],
941                database_hash: Some("db".into()),
942                post_processing_tools: vec!["strip".into()],
943            };
944            change(&mut build);
945            BuildConfiguration::Cpp(Box::new(build))
946        };
947        let changes: [fn(&mut CppBuild); 8] = [
948            |b| b.compiler = "c++".into(),
949            |b| b.compiler_version = None,
950            |b| b.linker = Some("lld".into()),
951            |b| b.macros.push("-DB=2".into()),
952            |b| b.include_paths.clear(),
953            |b| b.flags = vec!["-O0".into()],
954            |b| b.database_hash = None,
955            |b| b.post_processing_tools.push("objcopy".into()),
956        ];
957        let base = cpp(|_| {});
958        for change in changes {
959            let moved = cpp(change);
960            assert_ne!(base.fingerprint(), moved.fingerprint());
961            assert_ne!(base.settings(), moved.settings());
962        }
963
964        let rust = |change: fn(&mut RustBuild)| {
965            let mut build = RustBuild {
966                target: "aarch64-apple-darwin".into(),
967                features: vec!["serde".into()],
968                cfgs: vec!["unix".into()],
969                compiler_version: "rustc 1.85.0".into(),
970                opt_level: "3".into(),
971                lto: "thin".into(),
972                codegen_units: Some(16),
973                panic: "unwind".into(),
974                lockfile_hash: Some("lock".into()),
975                build_command_hash: Some("cmd".into()),
976                post_processing_tools: vec!["strip".into()],
977                permitted_execution: Vec::new(),
978            };
979            change(&mut build);
980            BuildConfiguration::Rust(Box::new(build))
981        };
982        let changes: [fn(&mut RustBuild); 12] = [
983            |b| b.target = "x86_64-unknown-linux-gnu".into(),
984            |b| b.features.clear(),
985            |b| b.cfgs.push("windows".into()),
986            |b| b.compiler_version = "rustc 1.86.0".into(),
987            |b| b.opt_level = "0".into(),
988            |b| b.lto = "fat".into(),
989            |b| b.codegen_units = None,
990            |b| b.panic = "abort".into(),
991            |b| b.lockfile_hash = None,
992            |b| b.build_command_hash = Some("other".into()),
993            |b| b.post_processing_tools.push("objcopy".into()),
994            |b| b.permitted_execution = vec!["build-script".into()],
995        ];
996        let base = rust(|_| {});
997        for change in changes {
998            let moved = rust(change);
999            assert_ne!(base.fingerprint(), moved.fingerprint());
1000            assert_ne!(base.settings(), moved.settings());
1001        }
1002    }
1003
1004    /// A value nobody looked up is left out of the record, rather than written
1005    /// down as an empty one — the same distinction the encoding makes.
1006    #[test]
1007    fn an_unresolved_setting_records_nothing_and_an_empty_one_records_a_value() {
1008        assert!(
1009            Shape::Resolved(None).values().is_empty(),
1010            "{:?}",
1011            Shape::Resolved(None).values()
1012        );
1013        assert_eq!(Shape::Resolved(Some(String::new())).values(), vec![""]);
1014        assert_eq!(Shape::Given("cc".into()).values(), vec!["cc"]);
1015        assert_eq!(
1016            Shape::Ordered(vec!["/a".into(), "/b".into()]).values(),
1017            vec!["/a", "/b"]
1018        );
1019    }
1020}