use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
pub const EXCLUDED: [&str; 8] = [
"-c",
"-M",
"-MM",
"-MD",
"-MMD",
"-MP",
"-fcolor-diagnostics",
"-fno-color-diagnostics",
];
pub const EXCLUDED_WITH_VALUE: [&str; 4] = ["-o", "-MF", "-MT", "-MQ"];
#[must_use]
pub fn content_hash(text: &str) -> String {
blake3::hash(text.as_bytes()).to_hex().to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Setting {
pub name: &'static str,
pub shape: Shape,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shape {
Given(String),
Resolved(Option<String>),
Ordered(Vec<String>),
}
impl Shape {
#[must_use]
pub fn values(&self) -> Vec<&str> {
match self {
Self::Given(value) => vec![value.as_str()],
Self::Resolved(value) => value.as_deref().into_iter().collect(),
Self::Ordered(values) => values.iter().map(String::as_str).collect(),
}
}
}
fn given(name: &'static str, value: &str) -> Setting {
Setting {
name,
shape: Shape::Given(value.to_string()),
}
}
fn resolved(name: &'static str, value: Option<&str>) -> Setting {
Setting {
name,
shape: Shape::Resolved(value.map(ToString::to_string)),
}
}
fn ordered(name: &'static str, values: &[String]) -> Setting {
Setting {
name,
shape: Shape::Ordered(values.to_vec()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CppBuild {
pub compiler: String,
pub compiler_version: Option<String>,
pub linker: Option<String>,
pub macros: Vec<String>,
pub include_paths: Vec<String>,
pub flags: Vec<String>,
pub database_hash: Option<String>,
pub post_processing_tools: Vec<String>,
}
impl CppBuild {
#[must_use]
pub fn from_command(arguments: &[String], file: &Path) -> Self {
Self::from_command_in_directory(arguments, file, None)
}
#[must_use]
pub fn from_command_in_directory(
arguments: &[String],
file: &Path,
directory: Option<&Path>,
) -> Self {
let mut build = Self {
compiler: arguments.first().cloned().unwrap_or_default(),
..Self::default()
};
let mut macros = Vec::new();
let mut index = 1;
while index < arguments.len() {
let argument = arguments[index].as_str();
index += 1;
if source_argument_matches(argument, file, directory) {
continue;
}
if EXCLUDED_WITH_VALUE.contains(&argument) {
index += 1;
continue;
}
if EXCLUDED.contains(&argument) || argument.starts_with("-fdiagnostics-color") {
continue;
}
match separated(argument, arguments.get(index).map(String::as_str)) {
Some(Separated::Macro(setting, consumed)) => {
macros.push(setting);
index += usize::from(consumed);
}
Some(Separated::Include(path, consumed)) => {
build.include_paths.push(path);
index += usize::from(consumed);
}
None => build.flags.push(argument.to_string()),
}
}
build.macros = last_mention_wins(macros);
build
}
#[must_use]
pub fn defines(&self) -> Vec<&str> {
self.macros
.iter()
.filter_map(|setting| setting.strip_prefix("-D"))
.collect()
}
#[must_use]
pub fn settings(&self) -> Vec<Setting> {
vec![
given("compiler", &self.compiler),
resolved("compiler_version", self.compiler_version.as_deref()),
resolved("linker", self.linker.as_deref()),
ordered("macros", &self.macros),
ordered("includes", &self.include_paths),
ordered("flags", &self.flags),
resolved("database", self.database_hash.as_deref()),
ordered("post_processing_tools", &self.post_processing_tools),
]
}
}
fn source_argument_matches(argument: &str, file: &Path, directory: Option<&Path>) -> bool {
let argument = Path::new(argument);
let resolved = if argument.is_relative() {
directory.map_or_else(
|| argument.to_path_buf(),
|directory| directory.join(argument),
)
} else {
argument.to_path_buf()
};
normalize_path(&resolved) == normalize_path(file)
}
fn normalize_path(path: &Path) -> std::path::PathBuf {
crate::paths::canonical(path).unwrap_or_else(|_| path.to_path_buf())
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RustBuild {
pub target: String,
pub features: Vec<String>,
pub cfgs: Vec<String>,
pub compiler_version: String,
pub opt_level: String,
pub lto: String,
pub codegen_units: Option<u32>,
pub panic: String,
pub lockfile_hash: Option<String>,
pub build_command_hash: Option<String>,
pub post_processing_tools: Vec<String>,
pub permitted_execution: Vec<String>,
}
impl RustBuild {
#[must_use]
pub fn normalized(mut self) -> Self {
self.features = self
.features
.into_iter()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
self.cfgs = self
.cfgs
.into_iter()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
self
}
#[must_use]
pub fn settings(&self) -> Vec<Setting> {
vec![
given("target", &self.target),
ordered("features", &self.features),
ordered("cfgs", &self.cfgs),
given("compiler_version", &self.compiler_version),
given("opt_level", &self.opt_level),
given("lto", &self.lto),
resolved(
"codegen_units",
self.codegen_units.map(|units| units.to_string()).as_deref(),
),
given("panic", &self.panic),
resolved("lockfile", self.lockfile_hash.as_deref()),
resolved("build_command", self.build_command_hash.as_deref()),
ordered("post_processing_tools", &self.post_processing_tools),
ordered("permitted_execution", &self.permitted_execution),
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildConfiguration {
Rust(Box<RustBuild>),
Cpp(Box<CppBuild>),
}
impl BuildConfiguration {
#[must_use]
pub const fn language(&self) -> &'static str {
match self {
Self::Rust(_) => "rust",
Self::Cpp(_) => "cpp",
}
}
#[must_use]
pub fn settings(&self) -> Vec<Setting> {
match self {
Self::Rust(build) => build.settings(),
Self::Cpp(build) => build.settings(),
}
}
#[must_use]
pub fn canonical(&self) -> String {
let mut out = String::new();
scalar(&mut out, "language", self.language());
for setting in self.settings() {
match &setting.shape {
Shape::Given(value) => scalar(&mut out, setting.name, value),
Shape::Resolved(value) => optional(&mut out, setting.name, value.as_deref()),
Shape::Ordered(values) => list(&mut out, setting.name, values),
}
}
out
}
#[must_use]
pub fn fingerprint(&self) -> String {
blake3::hash(self.canonical().as_bytes())
.to_hex()
.to_string()
}
}
enum Separated {
Macro(String, bool),
Include(String, bool),
}
fn separated(argument: &str, next: Option<&str>) -> Option<Separated> {
for prefix in ["-D", "-U"] {
if let Some(rest) = argument.strip_prefix(prefix) {
return Some(if rest.is_empty() {
Separated::Macro(format!("{prefix}{}", next.unwrap_or_default()), true)
} else {
Separated::Macro(argument.to_string(), false)
});
}
}
if let Some(rest) = argument.strip_prefix("-I") {
return Some(if rest.is_empty() {
Separated::Include(next.unwrap_or_default().to_string(), true)
} else {
Separated::Include(rest.to_string(), false)
});
}
None
}
fn last_mention_wins(settings: Vec<String>) -> Vec<String> {
let mut latest: BTreeMap<String, String> = BTreeMap::new();
for setting in settings {
let name = setting
.trim_start_matches("-D")
.trim_start_matches("-U")
.split('=')
.next()
.unwrap_or_default()
.to_string();
latest.insert(name, setting);
}
latest.into_values().collect()
}
fn scalar(out: &mut String, name: &str, value: &str) {
out.push_str(name);
out.push('=');
push_sized(out, value);
out.push(';');
}
fn optional(out: &mut String, name: &str, value: Option<&str>) {
out.push_str(name);
out.push('=');
match value {
Some(value) => {
out.push_str("some");
push_sized(out, value);
}
None => out.push_str("none"),
}
out.push(';');
}
fn list(out: &mut String, name: &str, values: &[String]) {
out.push_str(name);
out.push('=');
out.push_str(&values.len().to_string());
out.push('[');
for value in values {
push_sized(out, value);
}
out.push_str("];");
}
fn push_sized(out: &mut String, value: &str) {
out.push_str(&value.len().to_string());
out.push(':');
out.push_str(value);
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn command(arguments: &[&str]) -> Vec<String> {
arguments.iter().map(|a| (*a).to_string()).collect()
}
fn cpp(arguments: &[&str], file: &str) -> CppBuild {
CppBuild::from_command(&command(arguments), Path::new(file))
}
#[test]
fn the_compiler_and_what_it_was_told_are_read_off_the_command() {
let build = cpp(
&[
"clang++",
"-std=c++17",
"-DACCUM_WIDTH=64",
"-I/w/include",
"-c",
"-o",
"wide.o",
"/w/src/wide.cpp",
],
"/w/src/wide.cpp",
);
assert_eq!(build.compiler, "clang++");
assert_eq!(build.macros, vec!["-DACCUM_WIDTH=64"]);
assert_eq!(build.include_paths, vec!["/w/include"]);
assert_eq!(build.flags, vec!["-std=c++17"]);
assert_eq!(build.defines(), vec!["ACCUM_WIDTH=64"]);
}
#[test]
fn the_object_path_does_not_become_part_of_the_identity() {
let narrow = cpp(&["cc", "-O2", "-o", "a/narrow.o", "-c", "/w/a.c"], "/w/a.c");
let wide = cpp(&["cc", "-O2", "-o", "b/wide.o", "-c", "/w/a.c"], "/w/a.c");
assert_eq!(narrow, wide);
assert!(narrow.flags.iter().all(|flag| !flag.contains("narrow.o")));
}
#[test]
fn dependency_bookkeeping_and_diagnostic_colour_are_not_identity() {
let plain = cpp(&["cc", "-O2", "/w/a.c"], "/w/a.c");
let noisy = cpp(
&[
"cc",
"-O2",
"-MD",
"-MF",
"a.d",
"-MT",
"a.o",
"-fcolor-diagnostics",
"-fdiagnostics-color=always",
"/w/a.c",
],
"/w/a.c",
);
assert_eq!(plain, noisy);
}
#[test]
fn an_unrecognised_flag_counts_towards_identity() {
let plain = cpp(&["cc", "/w/a.c"], "/w/a.c");
let odd = cpp(&["cc", "-fsomething-nobody-here-knows", "/w/a.c"], "/w/a.c");
assert_ne!(plain, odd);
assert_eq!(odd.flags, vec!["-fsomething-nobody-here-knows"]);
}
#[test]
fn the_separated_spellings_mean_the_same_as_the_joined_ones() {
let joined = cpp(&["cc", "-DWIDTH=64", "-I/w/inc", "/w/a.c"], "/w/a.c");
let separated = cpp(
&["cc", "-D", "WIDTH=64", "-I", "/w/inc", "/w/a.c"],
"/w/a.c",
);
assert_eq!(joined, separated);
}
#[test]
fn macros_are_sorted_but_the_last_mention_still_decides() {
let one = cpp(&["cc", "-DB=2", "-DA=1", "/w/a.c"], "/w/a.c");
let other = cpp(&["cc", "-DA=1", "-DB=2", "/w/a.c"], "/w/a.c");
assert_eq!(one, other);
assert_eq!(one.macros, vec!["-DA=1", "-DB=2"]);
let redefined = cpp(&["cc", "-DA=1", "-DA=2", "/w/a.c"], "/w/a.c");
assert_eq!(redefined.macros, vec!["-DA=2"]);
}
#[test]
fn defining_then_undefining_is_not_the_same_as_the_reverse() {
let defined_last = cpp(&["cc", "-UA", "-DA=1", "/w/a.c"], "/w/a.c");
let undefined_last = cpp(&["cc", "-DA=1", "-UA", "/w/a.c"], "/w/a.c");
assert_ne!(defined_last, undefined_last);
assert_eq!(defined_last.macros, vec!["-DA=1"]);
assert_eq!(undefined_last.macros, vec!["-UA"]);
}
#[test]
fn include_order_is_meaning_and_is_kept() {
let vendor_first = cpp(&["cc", "-I/vendor", "-I/local", "/w/a.c"], "/w/a.c");
let local_first = cpp(&["cc", "-I/local", "-I/vendor", "/w/a.c"], "/w/a.c");
assert_ne!(vendor_first, local_first);
assert_eq!(vendor_first.include_paths, vec!["/vendor", "/local"]);
}
#[test]
fn punctuation_inside_an_argument_cannot_forge_another_argument() {
let one = cpp(&["cc", "-Dpair=a,b", "/w/a.c"], "/w/a.c");
let two = cpp(&["cc", "-Dpair=a", "-Db", "/w/a.c"], "/w/a.c");
let one = BuildConfiguration::Cpp(Box::new(one));
let two = BuildConfiguration::Cpp(Box::new(two));
assert_ne!(one.canonical(), two.canonical());
assert_ne!(one.fingerprint(), two.fingerprint());
}
#[test]
fn an_absent_value_is_not_an_empty_one() {
let absent = BuildConfiguration::Cpp(Box::new(CppBuild {
compiler: "cc".into(),
compiler_version: None,
..CppBuild::default()
}));
let empty = BuildConfiguration::Cpp(Box::new(CppBuild {
compiler: "cc".into(),
compiler_version: Some(String::new()),
..CppBuild::default()
}));
assert_ne!(absent.fingerprint(), empty.fingerprint());
}
#[test]
fn the_fingerprint_is_a_function_of_the_configuration_alone() {
let build = || {
BuildConfiguration::Cpp(Box::new(cpp(
&["clang++", "-std=c++17", "-DA=1", "/w/a.c"],
"/w/a.c",
)))
};
assert_eq!(build().fingerprint(), build().fingerprint());
}
#[test]
fn rust_features_are_a_set_and_are_ordered_like_one() {
let one = RustBuild {
features: vec!["wide".into(), "serde".into(), "wide".into()],
..RustBuild::default()
}
.normalized();
let other = RustBuild {
features: vec!["serde".into(), "wide".into()],
..RustBuild::default()
}
.normalized();
assert_eq!(one, other);
assert_eq!(one.features, vec!["serde", "wide"]);
}
#[test]
fn a_different_lockfile_is_a_different_build() {
let base = RustBuild {
target: "aarch64-apple-darwin".into(),
compiler_version: "rustc 1.85.0".into(),
lockfile_hash: Some(content_hash("one")),
..RustBuild::default()
};
let moved = RustBuild {
lockfile_hash: Some(content_hash("another")),
..base.clone()
};
assert_ne!(
BuildConfiguration::Rust(Box::new(base)).fingerprint(),
BuildConfiguration::Rust(Box::new(moved)).fingerprint()
);
}
#[test]
fn the_two_languages_are_in_different_identity_spaces() {
let rust = BuildConfiguration::Rust(Box::default());
let cpp = BuildConfiguration::Cpp(Box::default());
assert_ne!(rust.fingerprint(), cpp.fingerprint());
}
#[test]
fn the_encoding_of_a_configuration_is_fixed() {
let build = BuildConfiguration::Cpp(Box::new(CppBuild {
compiler: "cc".into(),
macros: vec!["-DA=1".into()],
include_paths: vec!["/inc".into()],
..CppBuild::default()
}));
assert_eq!(
build.canonical(),
"language=3:cpp;compiler=2:cc;compiler_version=none;linker=none;\
macros=1[5:-DA=1];includes=1[4:/inc];flags=0[];database=none;\
post_processing_tools=0[];"
);
let build = BuildConfiguration::Rust(Box::new(RustBuild {
features: vec!["ledger/std".into()],
cfgs: vec!["unix".into()],
compiler_version: "rust-analyzer 0.0.344".into(),
permitted_execution: vec!["build-script".into()],
..RustBuild::default()
}));
assert_eq!(
build.canonical(),
"language=4:rust;target=0:;features=1[10:ledger/std];cfgs=1[4:unix];\
compiler_version=21:rust-analyzer 0.0.344;opt_level=0:;lto=0:;\
codegen_units=none;panic=0:;lockfile=none;build_command=none;\
post_processing_tools=0[];permitted_execution=1[12:build-script];"
);
}
#[test]
fn every_field_that_moves_the_identity_is_one_of_the_settings() {
let cpp = |change: fn(&mut CppBuild)| {
let mut build = CppBuild {
compiler: "cc".into(),
compiler_version: Some("18".into()),
linker: Some("ld".into()),
macros: vec!["-DA=1".into()],
include_paths: vec!["/inc".into()],
flags: vec!["-O2".into()],
database_hash: Some("db".into()),
post_processing_tools: vec!["strip".into()],
};
change(&mut build);
BuildConfiguration::Cpp(Box::new(build))
};
let changes: [fn(&mut CppBuild); 8] = [
|b| b.compiler = "c++".into(),
|b| b.compiler_version = None,
|b| b.linker = Some("lld".into()),
|b| b.macros.push("-DB=2".into()),
|b| b.include_paths.clear(),
|b| b.flags = vec!["-O0".into()],
|b| b.database_hash = None,
|b| b.post_processing_tools.push("objcopy".into()),
];
let base = cpp(|_| {});
for change in changes {
let moved = cpp(change);
assert_ne!(base.fingerprint(), moved.fingerprint());
assert_ne!(base.settings(), moved.settings());
}
let rust = |change: fn(&mut RustBuild)| {
let mut build = RustBuild {
target: "aarch64-apple-darwin".into(),
features: vec!["serde".into()],
cfgs: vec!["unix".into()],
compiler_version: "rustc 1.85.0".into(),
opt_level: "3".into(),
lto: "thin".into(),
codegen_units: Some(16),
panic: "unwind".into(),
lockfile_hash: Some("lock".into()),
build_command_hash: Some("cmd".into()),
post_processing_tools: vec!["strip".into()],
permitted_execution: Vec::new(),
};
change(&mut build);
BuildConfiguration::Rust(Box::new(build))
};
let changes: [fn(&mut RustBuild); 12] = [
|b| b.target = "x86_64-unknown-linux-gnu".into(),
|b| b.features.clear(),
|b| b.cfgs.push("windows".into()),
|b| b.compiler_version = "rustc 1.86.0".into(),
|b| b.opt_level = "0".into(),
|b| b.lto = "fat".into(),
|b| b.codegen_units = None,
|b| b.panic = "abort".into(),
|b| b.lockfile_hash = None,
|b| b.build_command_hash = Some("other".into()),
|b| b.post_processing_tools.push("objcopy".into()),
|b| b.permitted_execution = vec!["build-script".into()],
];
let base = rust(|_| {});
for change in changes {
let moved = rust(change);
assert_ne!(base.fingerprint(), moved.fingerprint());
assert_ne!(base.settings(), moved.settings());
}
}
#[test]
fn an_unresolved_setting_records_nothing_and_an_empty_one_records_a_value() {
assert!(Shape::Resolved(None).values().is_empty());
assert_eq!(Shape::Resolved(Some(String::new())).values(), vec![""]);
assert_eq!(Shape::Given("cc".into()).values(), vec!["cc"]);
assert_eq!(
Shape::Ordered(vec!["/a".into(), "/b".into()]).values(),
vec!["/a", "/b"]
);
}
}