1use std::collections::{BTreeMap, BTreeSet};
55use std::path::Path;
56
57pub const EXCLUDED: [&str; 8] = [
62 "-c",
63 "-M",
64 "-MM",
65 "-MD",
66 "-MMD",
67 "-MP",
68 "-fcolor-diagnostics",
69 "-fno-color-diagnostics",
70];
71
72pub const EXCLUDED_WITH_VALUE: [&str; 4] = ["-o", "-MF", "-MT", "-MQ"];
78
79#[must_use]
82pub fn content_hash(text: &str) -> String {
83 blake3::hash(text.as_bytes()).to_hex().to_string()
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Setting {
92 pub name: &'static str,
94 pub shape: Shape,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Shape {
105 Given(String),
107 Resolved(Option<String>),
109 Ordered(Vec<String>),
111}
112
113impl Shape {
114 #[must_use]
119 pub fn values(&self) -> Vec<&str> {
120 match self {
121 Self::Given(value) => vec![value.as_str()],
122 Self::Resolved(value) => value.as_deref().into_iter().collect(),
123 Self::Ordered(values) => values.iter().map(String::as_str).collect(),
124 }
125 }
126}
127
128fn given(name: &'static str, value: &str) -> Setting {
129 Setting {
130 name,
131 shape: Shape::Given(value.to_string()),
132 }
133}
134
135fn resolved(name: &'static str, value: Option<&str>) -> Setting {
136 Setting {
137 name,
138 shape: Shape::Resolved(value.map(ToString::to_string)),
139 }
140}
141
142fn ordered(name: &'static str, values: &[String]) -> Setting {
143 Setting {
144 name,
145 shape: Shape::Ordered(values.to_vec()),
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Default)]
151pub struct CppBuild {
152 pub compiler: String,
154 pub compiler_version: Option<String>,
156 pub linker: Option<String>,
158 pub macros: Vec<String>,
161 pub include_paths: Vec<String>,
163 pub flags: Vec<String>,
165 pub database_hash: Option<String>,
167 pub post_processing_tools: Vec<String>,
172}
173
174impl CppBuild {
175 #[must_use]
180 pub fn from_command(arguments: &[String], file: &Path) -> Self {
181 Self::from_command_in_directory(arguments, file, None)
182 }
183
184 #[must_use]
191 pub fn from_command_in_directory(
192 arguments: &[String],
193 file: &Path,
194 directory: Option<&Path>,
195 ) -> Self {
196 let mut build = Self {
197 compiler: arguments.first().cloned().unwrap_or_default(),
198 ..Self::default()
199 };
200 let mut macros = Vec::new();
201 let mut index = 1;
202 while index < arguments.len() {
203 let argument = arguments[index].as_str();
204 index += 1;
205 if source_argument_matches(argument, file, directory) {
206 continue;
207 }
208 if EXCLUDED_WITH_VALUE.contains(&argument) {
209 index += 1;
210 continue;
211 }
212 if EXCLUDED.contains(&argument) || argument.starts_with("-fdiagnostics-color") {
213 continue;
214 }
215 match separated(argument, arguments.get(index).map(String::as_str)) {
216 Some(Separated::Macro(setting, consumed)) => {
217 macros.push(setting);
218 index += usize::from(consumed);
219 }
220 Some(Separated::Include(path, consumed)) => {
221 build.include_paths.push(path);
222 index += usize::from(consumed);
223 }
224 None => build.flags.push(argument.to_string()),
225 }
226 }
227 build.macros = last_mention_wins(macros);
228 build
229 }
230
231 #[must_use]
233 pub fn defines(&self) -> Vec<&str> {
234 self.macros
235 .iter()
236 .filter_map(|setting| setting.strip_prefix("-D"))
237 .collect()
238 }
239
240 #[must_use]
242 pub fn settings(&self) -> Vec<Setting> {
243 vec![
244 given("compiler", &self.compiler),
245 resolved("compiler_version", self.compiler_version.as_deref()),
246 resolved("linker", self.linker.as_deref()),
247 ordered("macros", &self.macros),
248 ordered("includes", &self.include_paths),
249 ordered("flags", &self.flags),
250 resolved("database", self.database_hash.as_deref()),
251 ordered("post_processing_tools", &self.post_processing_tools),
252 ]
253 }
254}
255
256fn source_argument_matches(argument: &str, file: &Path, directory: Option<&Path>) -> bool {
257 let argument = Path::new(argument);
258 let resolved = if argument.is_relative() {
259 directory.map_or_else(
260 || argument.to_path_buf(),
261 |directory| directory.join(argument),
262 )
263 } else {
264 argument.to_path_buf()
265 };
266 normalize_path(&resolved) == normalize_path(file)
267}
268
269fn normalize_path(path: &Path) -> std::path::PathBuf {
270 crate::paths::canonical(path).unwrap_or_else(|_| path.to_path_buf())
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Default)]
279pub struct RustBuild {
280 pub target: String,
282 pub features: Vec<String>,
289 pub cfgs: Vec<String>,
291 pub compiler_version: String,
293 pub opt_level: String,
295 pub lto: String,
297 pub codegen_units: Option<u32>,
299 pub panic: String,
301 pub lockfile_hash: Option<String>,
304 pub build_command_hash: Option<String>,
306 pub post_processing_tools: Vec<String>,
311 pub permitted_execution: Vec<String>,
322}
323
324impl RustBuild {
325 #[must_use]
327 pub fn normalized(mut self) -> Self {
328 self.features = self
329 .features
330 .into_iter()
331 .collect::<BTreeSet<_>>()
332 .into_iter()
333 .collect();
334 self.cfgs = self
335 .cfgs
336 .into_iter()
337 .collect::<BTreeSet<_>>()
338 .into_iter()
339 .collect();
340 self
341 }
342
343 #[must_use]
345 pub fn settings(&self) -> Vec<Setting> {
346 vec![
347 given("target", &self.target),
348 ordered("features", &self.features),
349 ordered("cfgs", &self.cfgs),
350 given("compiler_version", &self.compiler_version),
351 given("opt_level", &self.opt_level),
352 given("lto", &self.lto),
353 resolved(
354 "codegen_units",
355 self.codegen_units.map(|units| units.to_string()).as_deref(),
356 ),
357 given("panic", &self.panic),
358 resolved("lockfile", self.lockfile_hash.as_deref()),
359 resolved("build_command", self.build_command_hash.as_deref()),
360 ordered("post_processing_tools", &self.post_processing_tools),
361 ordered("permitted_execution", &self.permitted_execution),
362 ]
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
368pub enum BuildConfiguration {
369 Rust(Box<RustBuild>),
371 Cpp(Box<CppBuild>),
373}
374
375impl BuildConfiguration {
376 #[must_use]
382 pub const fn language(&self) -> &'static str {
383 match self {
384 Self::Rust(_) => "rust",
385 Self::Cpp(_) => "cpp",
386 }
387 }
388
389 #[must_use]
391 pub fn settings(&self) -> Vec<Setting> {
392 match self {
393 Self::Rust(build) => build.settings(),
394 Self::Cpp(build) => build.settings(),
395 }
396 }
397
398 #[must_use]
403 pub fn canonical(&self) -> String {
404 let mut out = String::new();
405 scalar(&mut out, "language", self.language());
406 for setting in self.settings() {
407 match &setting.shape {
408 Shape::Given(value) => scalar(&mut out, setting.name, value),
409 Shape::Resolved(value) => optional(&mut out, setting.name, value.as_deref()),
410 Shape::Ordered(values) => list(&mut out, setting.name, values),
411 }
412 }
413 out
414 }
415
416 #[must_use]
418 pub fn fingerprint(&self) -> String {
419 blake3::hash(self.canonical().as_bytes())
420 .to_hex()
421 .to_string()
422 }
423}
424
425enum Separated {
427 Macro(String, bool),
429 Include(String, bool),
431}
432
433fn separated(argument: &str, next: Option<&str>) -> Option<Separated> {
436 for prefix in ["-D", "-U"] {
437 if let Some(rest) = argument.strip_prefix(prefix) {
438 return Some(if rest.is_empty() {
439 Separated::Macro(format!("{prefix}{}", next.unwrap_or_default()), true)
440 } else {
441 Separated::Macro(argument.to_string(), false)
442 });
443 }
444 }
445 if let Some(rest) = argument.strip_prefix("-I") {
446 return Some(if rest.is_empty() {
447 Separated::Include(next.unwrap_or_default().to_string(), true)
448 } else {
449 Separated::Include(rest.to_string(), false)
450 });
451 }
452 None
453}
454
455fn last_mention_wins(settings: Vec<String>) -> Vec<String> {
461 let mut latest: BTreeMap<String, String> = BTreeMap::new();
462 for setting in settings {
463 let name = setting
464 .trim_start_matches("-D")
465 .trim_start_matches("-U")
466 .split('=')
467 .next()
468 .unwrap_or_default()
469 .to_string();
470 latest.insert(name, setting);
471 }
472 latest.into_values().collect()
473}
474
475fn scalar(out: &mut String, name: &str, value: &str) {
476 out.push_str(name);
477 out.push('=');
478 push_sized(out, value);
479 out.push(';');
480}
481
482fn optional(out: &mut String, name: &str, value: Option<&str>) {
483 out.push_str(name);
484 out.push('=');
485 match value {
486 Some(value) => {
487 out.push_str("some");
488 push_sized(out, value);
489 }
490 None => out.push_str("none"),
492 }
493 out.push(';');
494}
495
496fn list(out: &mut String, name: &str, values: &[String]) {
497 out.push_str(name);
498 out.push('=');
499 out.push_str(&values.len().to_string());
500 out.push('[');
501 for value in values {
502 push_sized(out, value);
503 }
504 out.push_str("];");
505}
506
507fn push_sized(out: &mut String, value: &str) {
508 out.push_str(&value.len().to_string());
509 out.push(':');
510 out.push_str(value);
511}
512
513#[cfg(test)]
514#[allow(clippy::unwrap_used, clippy::expect_used)]
515mod tests {
516 use super::*;
517
518 fn command(arguments: &[&str]) -> Vec<String> {
519 arguments.iter().map(|a| (*a).to_string()).collect()
520 }
521
522 fn cpp(arguments: &[&str], file: &str) -> CppBuild {
523 CppBuild::from_command(&command(arguments), Path::new(file))
524 }
525
526 #[test]
527 fn the_compiler_and_what_it_was_told_are_read_off_the_command() {
528 let build = cpp(
529 &[
530 "clang++",
531 "-std=c++17",
532 "-DACCUM_WIDTH=64",
533 "-I/w/include",
534 "-c",
535 "-o",
536 "wide.o",
537 "/w/src/wide.cpp",
538 ],
539 "/w/src/wide.cpp",
540 );
541 assert_eq!(build.compiler, "clang++");
542 assert_eq!(build.macros, vec!["-DACCUM_WIDTH=64"]);
543 assert_eq!(build.include_paths, vec!["/w/include"]);
544 assert_eq!(build.flags, vec!["-std=c++17"]);
545 assert_eq!(build.defines(), vec!["ACCUM_WIDTH=64"]);
546 }
547
548 #[test]
551 fn the_object_path_does_not_become_part_of_the_identity() {
552 let narrow = cpp(&["cc", "-O2", "-o", "a/narrow.o", "-c", "/w/a.c"], "/w/a.c");
553 let wide = cpp(&["cc", "-O2", "-o", "b/wide.o", "-c", "/w/a.c"], "/w/a.c");
554 assert_eq!(narrow, wide);
555 assert!(narrow.flags.iter().all(|flag| !flag.contains("narrow.o")));
556 }
557
558 #[test]
559 fn dependency_bookkeeping_and_diagnostic_colour_are_not_identity() {
560 let plain = cpp(&["cc", "-O2", "/w/a.c"], "/w/a.c");
561 let noisy = cpp(
562 &[
563 "cc",
564 "-O2",
565 "-MD",
566 "-MF",
567 "a.d",
568 "-MT",
569 "a.o",
570 "-fcolor-diagnostics",
571 "-fdiagnostics-color=always",
572 "/w/a.c",
573 ],
574 "/w/a.c",
575 );
576 assert_eq!(plain, noisy);
577 }
578
579 #[test]
583 fn an_unrecognised_flag_counts_towards_identity() {
584 let plain = cpp(&["cc", "/w/a.c"], "/w/a.c");
585 let odd = cpp(&["cc", "-fsomething-nobody-here-knows", "/w/a.c"], "/w/a.c");
586 assert_ne!(plain, odd);
587 assert_eq!(odd.flags, vec!["-fsomething-nobody-here-knows"]);
588 }
589
590 #[test]
591 fn the_separated_spellings_mean_the_same_as_the_joined_ones() {
592 let joined = cpp(&["cc", "-DWIDTH=64", "-I/w/inc", "/w/a.c"], "/w/a.c");
593 let separated = cpp(
594 &["cc", "-D", "WIDTH=64", "-I", "/w/inc", "/w/a.c"],
595 "/w/a.c",
596 );
597 assert_eq!(joined, separated);
598 }
599
600 #[test]
603 fn macros_are_sorted_but_the_last_mention_still_decides() {
604 let one = cpp(&["cc", "-DB=2", "-DA=1", "/w/a.c"], "/w/a.c");
605 let other = cpp(&["cc", "-DA=1", "-DB=2", "/w/a.c"], "/w/a.c");
606 assert_eq!(one, other);
607 assert_eq!(one.macros, vec!["-DA=1", "-DB=2"]);
608
609 let redefined = cpp(&["cc", "-DA=1", "-DA=2", "/w/a.c"], "/w/a.c");
610 assert_eq!(redefined.macros, vec!["-DA=2"]);
611 }
612
613 #[test]
616 fn defining_then_undefining_is_not_the_same_as_the_reverse() {
617 let defined_last = cpp(&["cc", "-UA", "-DA=1", "/w/a.c"], "/w/a.c");
618 let undefined_last = cpp(&["cc", "-DA=1", "-UA", "/w/a.c"], "/w/a.c");
619 assert_ne!(defined_last, undefined_last);
620 assert_eq!(defined_last.macros, vec!["-DA=1"]);
621 assert_eq!(undefined_last.macros, vec!["-UA"]);
622 }
623
624 #[test]
627 fn include_order_is_meaning_and_is_kept() {
628 let vendor_first = cpp(&["cc", "-I/vendor", "-I/local", "/w/a.c"], "/w/a.c");
629 let local_first = cpp(&["cc", "-I/local", "-I/vendor", "/w/a.c"], "/w/a.c");
630 assert_ne!(vendor_first, local_first);
631 assert_eq!(vendor_first.include_paths, vec!["/vendor", "/local"]);
632 }
633
634 #[test]
637 fn punctuation_inside_an_argument_cannot_forge_another_argument() {
638 let one = cpp(&["cc", "-Dpair=a,b", "/w/a.c"], "/w/a.c");
639 let two = cpp(&["cc", "-Dpair=a", "-Db", "/w/a.c"], "/w/a.c");
640 let one = BuildConfiguration::Cpp(Box::new(one));
641 let two = BuildConfiguration::Cpp(Box::new(two));
642 assert_ne!(one.canonical(), two.canonical());
643 assert_ne!(one.fingerprint(), two.fingerprint());
644 }
645
646 #[test]
647 fn an_absent_value_is_not_an_empty_one() {
648 let absent = BuildConfiguration::Cpp(Box::new(CppBuild {
649 compiler: "cc".into(),
650 compiler_version: None,
651 ..CppBuild::default()
652 }));
653 let empty = BuildConfiguration::Cpp(Box::new(CppBuild {
654 compiler: "cc".into(),
655 compiler_version: Some(String::new()),
656 ..CppBuild::default()
657 }));
658 assert_ne!(absent.fingerprint(), empty.fingerprint());
659 }
660
661 #[test]
662 fn the_fingerprint_is_a_function_of_the_configuration_alone() {
663 let build = || {
664 BuildConfiguration::Cpp(Box::new(cpp(
665 &["clang++", "-std=c++17", "-DA=1", "/w/a.c"],
666 "/w/a.c",
667 )))
668 };
669 assert_eq!(build().fingerprint(), build().fingerprint());
670 }
671
672 #[test]
673 fn rust_features_are_a_set_and_are_ordered_like_one() {
674 let one = RustBuild {
675 features: vec!["wide".into(), "serde".into(), "wide".into()],
676 ..RustBuild::default()
677 }
678 .normalized();
679 let other = RustBuild {
680 features: vec!["serde".into(), "wide".into()],
681 ..RustBuild::default()
682 }
683 .normalized();
684 assert_eq!(one, other);
685 assert_eq!(one.features, vec!["serde", "wide"]);
686 }
687
688 #[test]
691 fn a_different_lockfile_is_a_different_build() {
692 let base = RustBuild {
693 target: "aarch64-apple-darwin".into(),
694 compiler_version: "rustc 1.85.0".into(),
695 lockfile_hash: Some(content_hash("one")),
696 ..RustBuild::default()
697 };
698 let moved = RustBuild {
699 lockfile_hash: Some(content_hash("another")),
700 ..base.clone()
701 };
702 assert_ne!(
703 BuildConfiguration::Rust(Box::new(base)).fingerprint(),
704 BuildConfiguration::Rust(Box::new(moved)).fingerprint()
705 );
706 }
707
708 #[test]
711 fn the_two_languages_are_in_different_identity_spaces() {
712 let rust = BuildConfiguration::Rust(Box::default());
713 let cpp = BuildConfiguration::Cpp(Box::default());
714 assert_ne!(rust.fingerprint(), cpp.fingerprint());
715 }
716
717 #[test]
722 fn the_encoding_of_a_configuration_is_fixed() {
723 let build = BuildConfiguration::Cpp(Box::new(CppBuild {
724 compiler: "cc".into(),
725 macros: vec!["-DA=1".into()],
726 include_paths: vec!["/inc".into()],
727 ..CppBuild::default()
728 }));
729 assert_eq!(
730 build.canonical(),
731 "language=3:cpp;compiler=2:cc;compiler_version=none;linker=none;\
732 macros=1[5:-DA=1];includes=1[4:/inc];flags=0[];database=none;\
733 post_processing_tools=0[];"
734 );
735 let build = BuildConfiguration::Rust(Box::new(RustBuild {
736 features: vec!["ledger/std".into()],
737 cfgs: vec!["unix".into()],
738 compiler_version: "rust-analyzer 0.0.344".into(),
739 permitted_execution: vec!["build-script".into()],
740 ..RustBuild::default()
741 }));
742 assert_eq!(
743 build.canonical(),
744 "language=4:rust;target=0:;features=1[10:ledger/std];cfgs=1[4:unix];\
745 compiler_version=21:rust-analyzer 0.0.344;opt_level=0:;lto=0:;\
746 codegen_units=none;panic=0:;lockfile=none;build_command=none;\
747 post_processing_tools=0[];permitted_execution=1[12:build-script];"
748 );
749 }
750
751 #[test]
756 fn every_field_that_moves_the_identity_is_one_of_the_settings() {
757 let cpp = |change: fn(&mut CppBuild)| {
758 let mut build = CppBuild {
759 compiler: "cc".into(),
760 compiler_version: Some("18".into()),
761 linker: Some("ld".into()),
762 macros: vec!["-DA=1".into()],
763 include_paths: vec!["/inc".into()],
764 flags: vec!["-O2".into()],
765 database_hash: Some("db".into()),
766 post_processing_tools: vec!["strip".into()],
767 };
768 change(&mut build);
769 BuildConfiguration::Cpp(Box::new(build))
770 };
771 let changes: [fn(&mut CppBuild); 8] = [
772 |b| b.compiler = "c++".into(),
773 |b| b.compiler_version = None,
774 |b| b.linker = Some("lld".into()),
775 |b| b.macros.push("-DB=2".into()),
776 |b| b.include_paths.clear(),
777 |b| b.flags = vec!["-O0".into()],
778 |b| b.database_hash = None,
779 |b| b.post_processing_tools.push("objcopy".into()),
780 ];
781 let base = cpp(|_| {});
782 for change in changes {
783 let moved = cpp(change);
784 assert_ne!(base.fingerprint(), moved.fingerprint());
785 assert_ne!(base.settings(), moved.settings());
786 }
787
788 let rust = |change: fn(&mut RustBuild)| {
789 let mut build = RustBuild {
790 target: "aarch64-apple-darwin".into(),
791 features: vec!["serde".into()],
792 cfgs: vec!["unix".into()],
793 compiler_version: "rustc 1.85.0".into(),
794 opt_level: "3".into(),
795 lto: "thin".into(),
796 codegen_units: Some(16),
797 panic: "unwind".into(),
798 lockfile_hash: Some("lock".into()),
799 build_command_hash: Some("cmd".into()),
800 post_processing_tools: vec!["strip".into()],
801 permitted_execution: Vec::new(),
802 };
803 change(&mut build);
804 BuildConfiguration::Rust(Box::new(build))
805 };
806 let changes: [fn(&mut RustBuild); 12] = [
807 |b| b.target = "x86_64-unknown-linux-gnu".into(),
808 |b| b.features.clear(),
809 |b| b.cfgs.push("windows".into()),
810 |b| b.compiler_version = "rustc 1.86.0".into(),
811 |b| b.opt_level = "0".into(),
812 |b| b.lto = "fat".into(),
813 |b| b.codegen_units = None,
814 |b| b.panic = "abort".into(),
815 |b| b.lockfile_hash = None,
816 |b| b.build_command_hash = Some("other".into()),
817 |b| b.post_processing_tools.push("objcopy".into()),
818 |b| b.permitted_execution = vec!["build-script".into()],
819 ];
820 let base = rust(|_| {});
821 for change in changes {
822 let moved = rust(change);
823 assert_ne!(base.fingerprint(), moved.fingerprint());
824 assert_ne!(base.settings(), moved.settings());
825 }
826 }
827
828 #[test]
831 fn an_unresolved_setting_records_nothing_and_an_empty_one_records_a_value() {
832 assert!(Shape::Resolved(None).values().is_empty());
833 assert_eq!(Shape::Resolved(Some(String::new())).values(), vec![""]);
834 assert_eq!(Shape::Given("cc".into()).values(), vec!["cc"]);
835 assert_eq!(
836 Shape::Ordered(vec!["/a".into(), "/b".into()]).values(),
837 vec!["/a", "/b"]
838 );
839 }
840}