use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use super::{BuildConfiguration, CppBuild};
#[derive(Debug, thiserror::Error)]
pub enum CompileCommandsError {
#[error("compile_commands.json is {actual_bytes} bytes, exceeding the {max_bytes}-byte limit")]
TooLarge {
actual_bytes: u64,
max_bytes: u64,
},
#[error("reading compile_commands.json: {0}")]
Read(#[source] std::io::Error),
#[error("parsing compile_commands.json: {0}")]
Parse(#[source] serde_json::Error),
}
#[derive(Debug, Deserialize)]
struct RawEntry {
file: String,
directory: Option<String>,
#[serde(default)]
arguments: Option<Vec<String>>,
#[serde(default)]
command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileEntry {
pub file: PathBuf,
pub directory: Option<PathBuf>,
pub arguments: Vec<String>,
}
impl CompileEntry {
#[must_use]
pub fn build(&self) -> CppBuild {
CppBuild::from_command_in_directory(&self.arguments, &self.file, self.directory.as_deref())
}
#[must_use]
pub fn selector_fields(&self) -> (String, Option<String>, Vec<String>) {
let normalize = |path: &Path| {
crate::paths::canonical(path)
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string()
};
(
normalize(&self.file),
self.directory.as_deref().map(normalize),
self.arguments.clone(),
)
}
}
#[derive(Debug, Clone, Default)]
pub struct CompileCommands {
pub entries: Vec<CompileEntry>,
pub content_hash: Option<String>,
}
impl CompileCommands {
pub fn read(path: &Path) -> Result<Self, CompileCommandsError> {
let text = std::fs::read_to_string(path).map_err(CompileCommandsError::Read)?;
Self::parse(&text)
}
pub fn read_with_limit(path: &Path, max_bytes: u64) -> Result<Self, CompileCommandsError> {
let metadata = std::fs::metadata(path).map_err(CompileCommandsError::Read)?;
if metadata.len() > max_bytes {
return Err(CompileCommandsError::TooLarge {
actual_bytes: metadata.len(),
max_bytes,
});
}
let text = std::fs::read_to_string(path).map_err(CompileCommandsError::Read)?;
if text.len() as u64 > max_bytes {
return Err(CompileCommandsError::TooLarge {
actual_bytes: text.len() as u64,
max_bytes,
});
}
Self::parse(&text)
}
fn parse(text: &str) -> Result<Self, CompileCommandsError> {
let raw: Vec<RawEntry> = serde_json::from_str(text).map_err(CompileCommandsError::Parse)?;
let mut seen = BTreeSet::new();
let mut entries = Vec::new();
for entry in raw {
let directory = entry.directory.map(PathBuf::from);
let file_path = PathBuf::from(&entry.file);
let resolved = match (&directory, file_path.is_relative()) {
(Some(dir), true) => dir.join(&file_path),
_ => file_path,
};
let arguments = entry.arguments.unwrap_or_else(|| {
entry
.command
.as_deref()
.map(split_command)
.unwrap_or_default()
});
if seen.insert((resolved.clone(), arguments.clone())) {
entries.push(CompileEntry {
file: resolved,
directory,
arguments,
});
}
}
Ok(Self {
entries,
content_hash: Some(super::build_config::content_hash(text)),
})
}
#[must_use]
pub fn translation_unit_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn source_file_count(&self) -> usize {
self.entries
.iter()
.map(|entry| &entry.file)
.collect::<BTreeSet<_>>()
.len()
}
#[must_use]
pub fn build_partitions(&self) -> std::collections::BTreeMap<String, Vec<&CompileEntry>> {
let mut partitions = std::collections::BTreeMap::new();
for entry in &self.entries {
let build = BuildConfiguration::Cpp(Box::new(entry.build()));
partitions
.entry(build.fingerprint())
.or_insert_with(Vec::new)
.push(entry);
}
partitions
}
}
fn split_command(command: &str) -> Vec<String> {
let mut arguments = Vec::new();
let mut current = String::new();
let mut started = false;
let mut quote: Option<char> = None;
let mut characters = command.chars();
while let Some(character) = characters.next() {
match (character, quote) {
('\\', Some('\'')) => current.push('\\'),
('\\', _) => {
if let Some(escaped) = characters.next() {
current.push(escaped);
}
}
('\'' | '"', None) => {
quote = Some(character);
started = true;
}
(c, Some(open)) if c == open => quote = None,
(c, None) if c.is_whitespace() => {
if started || !current.is_empty() {
arguments.push(std::mem::take(&mut current));
started = false;
}
}
(c, _) => current.push(c),
}
}
if started || !current.is_empty() {
arguments.push(current);
}
arguments
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn reads_entries_and_resolves_relative_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compile_commands.json");
std::fs::write(
&path,
r#"[
{"directory": "/work/build", "file": "../src/a.c", "command": "cc a.c"},
{"directory": "/work/build", "file": "/abs/b.c", "command": "cc b.c"}
]"#,
)
.unwrap();
let db = CompileCommands::read(&path).unwrap();
assert_eq!(db.translation_unit_count(), 2);
assert_eq!(db.entries[0].file, PathBuf::from("/work/build/../src/a.c"));
assert_eq!(db.entries[1].file, PathBuf::from("/abs/b.c"));
}
#[test]
fn duplicate_translation_units_are_registered_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compile_commands.json");
std::fs::write(
&path,
r#"[
{"directory": "/w", "file": "/w/a.c"},
{"directory": "/w", "file": "/w/a.c"}
]"#,
)
.unwrap();
let db = CompileCommands::read(&path).unwrap();
assert_eq!(db.translation_unit_count(), 1);
}
#[test]
fn one_file_compiled_two_ways_is_two_translation_units() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compile_commands.json");
std::fs::write(
&path,
r#"[
{"directory": "/w", "file": "/w/a.c", "arguments": ["cc", "-c", "/w/a.c"]},
{"directory": "/w", "file": "/w/a.c",
"arguments": ["cc", "-DWIDE=1", "-c", "/w/a.c"]}
]"#,
)
.unwrap();
let db = CompileCommands::read(&path).unwrap();
assert_eq!(db.translation_unit_count(), 2);
assert_eq!(db.source_file_count(), 1);
}
#[test]
fn commands_partition_by_build_settings_not_source_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compile_commands.json");
std::fs::write(
&path,
r#"[
{"directory": "/w", "file": "/w/a.cpp", "arguments": ["clang++", "-DNARROW", "-c", "/w/a.cpp"]},
{"directory": "/w", "file": "/w/b.cpp", "arguments": ["clang++", "-DNARROW", "-c", "/w/b.cpp"]},
{"directory": "/w", "file": "/w/a.cpp", "arguments": ["clang++", "-DWIDE", "-c", "/w/a.cpp"]}
]"#,
)
.unwrap();
let db = CompileCommands::read(&path).unwrap();
let partitions = db.build_partitions();
assert_eq!(partitions.len(), 2);
let mut sizes: Vec<usize> = partitions.values().map(Vec::len).collect();
sizes.sort_unstable();
assert_eq!(sizes, [1, 2]);
assert!(partitions.values().any(|entries| {
entries
.iter()
.all(|entry| entry.build().defines() == ["NARROW"])
}));
assert!(partitions.values().any(|entries| {
entries
.iter()
.all(|entry| entry.build().defines() == ["WIDE"])
}));
}
#[test]
fn relative_source_arguments_do_not_split_an_otherwise_shared_build() {
let dir = tempfile::tempdir().unwrap();
let source_dir = dir.path().join("src");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::write(source_dir.join("first.cpp"), "int first() { return 1; }\n").unwrap();
std::fs::write(
source_dir.join("second.cpp"),
"int second() { return 2; }\n",
)
.unwrap();
let path = dir.path().join("compile_commands.json");
let directory = serde_json::to_string(&source_dir.display().to_string()).unwrap();
std::fs::write(
&path,
format!(
r#"[
{{"directory": {directory}, "file": "first.cpp", "arguments": ["clang++", "-std=c++20", "-c", "first.cpp"]}},
{{"directory": {directory}, "file": "second.cpp", "arguments": ["clang++", "-std=c++20", "-c", "second.cpp"]}}
]"#
),
)
.unwrap();
let db = CompileCommands::read(&path).unwrap();
let partitions = db.build_partitions();
assert_eq!(partitions.len(), 1);
assert_eq!(partitions.values().next().map(Vec::len), Some(2));
}
#[test]
fn a_recorded_command_line_is_split_the_way_a_shell_would_split_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compile_commands.json");
std::fs::write(
&path,
r#"[{"directory": "/w", "file": "/w/a.c",
"command": "cc -I\"/w/inc dir\" -DTEXT='a b' -c /w/a.c"}]"#,
)
.unwrap();
let db = CompileCommands::read(&path).unwrap();
assert_eq!(
db.entries[0].arguments,
vec!["cc", "-I/w/inc dir", "-DTEXT=a b", "-c", "/w/a.c"]
);
}
#[test]
fn the_database_is_identified_by_what_it_says() {
let dir = tempfile::tempdir().unwrap();
let one = dir.path().join("one.json");
let other = dir.path().join("other.json");
std::fs::write(&one, r#"[{"directory": "/w", "file": "/w/a.c"}]"#).unwrap();
std::fs::write(&other, r#"[{"directory": "/w", "file": "/w/b.c"}]"#).unwrap();
let one = CompileCommands::read(&one).unwrap();
let other = CompileCommands::read(&other).unwrap();
assert!(one.content_hash.is_some());
assert_ne!(one.content_hash, other.content_hash);
}
#[test]
fn unrelated_database_entries_do_not_change_an_existing_partition_identity() {
let one = CompileCommands::parse(
r#"[{"directory":"/w","file":"/w/a.c","arguments":["cc","-DVALUE=1","-c","/w/a.c"]}]"#,
)
.unwrap();
let expanded = CompileCommands::parse(
r#"[
{"directory":"/w","file":"/w/a.c","arguments":["cc","-DVALUE=1","-c","/w/a.c"]},
{"directory":"/w","file":"/w/unrelated.c","arguments":["cc","-DVALUE=2","-c","/w/unrelated.c"]}
]"#,
)
.unwrap();
let original = BuildConfiguration::Cpp(Box::new(one.entries[0].build())).fingerprint();
let unchanged =
BuildConfiguration::Cpp(Box::new(expanded.entries[0].build())).fingerprint();
assert_eq!(original, unchanged);
assert_ne!(one.content_hash, expanded.content_hash);
}
#[test]
fn malformed_json_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compile_commands.json");
std::fs::write(&path, "not json").unwrap();
assert!(matches!(
CompileCommands::read(&path),
Err(CompileCommandsError::Parse(_))
));
}
#[test]
fn a_database_over_the_size_limit_is_rejected_before_parsing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compile_commands.json");
std::fs::write(&path, "[{}]").unwrap();
assert!(matches!(
CompileCommands::read_with_limit(&path, 2),
Err(CompileCommandsError::TooLarge {
actual_bytes: 4,
max_bytes: 2,
})
));
}
}