use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Component, Path, PathBuf},
process::Command,
};
use ra_ap_syntax::{
AstNode, AstToken, Edition, SourceFile,
ast::{self, HasAttrs, HasModuleItem, HasName},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::{
coverage_report::CoverageManifest, rust_instrumenter::instrument_rust_source,
rust_runtime::render_rust_runtime,
};
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedRustProject {
pub workspace_root: PathBuf,
pub target_directory: PathBuf,
pub source_files: Vec<String>,
pub crate_roots: Vec<String>,
pub runtime_module: String,
pub manifest: CoverageManifest,
}
#[derive(Debug)]
pub enum RustProjectError {
Io { path: PathBuf, reason: String },
MetadataLaunch(String),
MetadataFailed(String),
MetadataJson(String),
UnsafePath(String),
NoWorkspacePackages,
NoSourceFiles,
Instrument { file: String, reason: String },
DuplicateObligation(String),
Runtime(String),
}
impl std::fmt::Display for RustProjectError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, reason } => write!(formatter, "{}: {reason}", path.display()),
Self::MetadataLaunch(reason) => {
write!(formatter, "could not launch cargo metadata: {reason}")
}
Self::MetadataFailed(reason) => write!(formatter, "cargo metadata failed: {reason}"),
Self::MetadataJson(reason) => write!(formatter, "invalid cargo metadata: {reason}"),
Self::UnsafePath(path) => {
write!(formatter, "Cargo reported an unsafe workspace path: {path}")
}
Self::NoWorkspacePackages => {
write!(formatter, "Cargo metadata reported no workspace packages")
}
Self::NoSourceFiles => write!(
formatter,
"Cargo workspace contains no owned Rust source files"
),
Self::Instrument { file, reason } => {
write!(formatter, "could not instrument {file}: {reason}")
}
Self::DuplicateObligation(id) => {
write!(formatter, "duplicate Rust obligation ID: {id}")
}
Self::Runtime(reason) => write!(formatter, "could not generate Rust runtime: {reason}"),
}
}
}
impl std::error::Error for RustProjectError {}
#[derive(Deserialize)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
workspace_members: Vec<String>,
workspace_root: PathBuf,
target_directory: PathBuf,
}
#[derive(Deserialize)]
struct CargoPackage {
id: String,
manifest_path: PathBuf,
targets: Vec<CargoTarget>,
}
#[derive(Deserialize)]
struct CargoTarget {
kind: Vec<String>,
src_path: PathBuf,
}
fn canonical_directory(path: &Path) -> Result<PathBuf, RustProjectError> {
fs::canonicalize(path).map_err(|error| RustProjectError::Io {
path: path.to_owned(),
reason: error.to_string(),
})
}
fn confined_relative(root: &Path, path: &Path) -> Result<String, RustProjectError> {
let relative = path
.strip_prefix(root)
.map_err(|_| RustProjectError::UnsafePath(path.display().to_string()))?;
if relative.as_os_str().is_empty()
|| relative
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(RustProjectError::UnsafePath(path.display().to_string()));
}
Ok(relative.to_string_lossy().replace('\\', "/"))
}
fn cargo_metadata(root: &Path) -> Result<CargoMetadata, RustProjectError> {
let target_directory = root.join(".supercov/rust-target");
let output = Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.current_dir(root)
.env("CARGO_TARGET_DIR", &target_directory)
.output()
.map_err(|error| RustProjectError::MetadataLaunch(error.to_string()))?;
if !output.status.success() {
return Err(RustProjectError::MetadataFailed(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
));
}
serde_json::from_slice(&output.stdout)
.map_err(|error| RustProjectError::MetadataJson(error.to_string()))
}
fn resolve_module_tree(
workspace: &Path,
roots: &BTreeSet<PathBuf>,
files: &mut BTreeSet<PathBuf>,
) -> Result<(), RustProjectError> {
let canonical_workspace = canonical_directory(workspace)?;
let mut pending = roots
.iter()
.map(|root| (root.clone(), owner_directory(root)))
.collect::<Vec<_>>();
while let Some((file, directory)) = pending.pop() {
let file = normalize(&file);
let directory = normalize(&directory);
if !file.starts_with(workspace) {
continue;
}
let Ok(metadata) = fs::symlink_metadata(&file) else {
continue;
};
let file = if metadata.file_type().is_symlink() {
let target = fs::canonicalize(&file).map_err(|error| RustProjectError::Io {
path: file.clone(),
reason: error.to_string(),
})?;
if !target.starts_with(&canonical_workspace) || !target.is_file() {
return Err(RustProjectError::UnsafePath(file.display().to_string()));
}
target
} else if metadata.is_file() {
file.clone()
} else {
continue;
};
if !files.insert(file.clone()) {
continue;
}
let source = fs::read_to_string(&file).map_err(|error| RustProjectError::Io {
path: file.clone(),
reason: error.to_string(),
})?;
let parsed = SourceFile::parse(&source, Edition::CURRENT).tree();
collect_module_declarations(parsed.items(), &file, &directory, false, &mut pending);
}
Ok(())
}
fn normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
normalized.pop();
}
Component::CurDir => {}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
fn owner_directory(file: &Path) -> PathBuf {
file.parent().map_or_else(PathBuf::new, Path::to_path_buf)
}
fn collect_module_declarations(
items: impl Iterator<Item = ast::Item>,
file: &Path,
directory: &Path,
inline: bool,
pending: &mut Vec<(PathBuf, PathBuf)>,
) {
for item in items {
match item {
ast::Item::Module(module) => {
let Some(name) = module.name() else {
continue;
};
let name = name.text().to_string();
let path_attribute = module.attrs().find_map(|attr| {
let is_path = attr
.path()
.is_some_and(|path| path.syntax().text() == "path");
is_path.then(|| string_literal(attr.syntax())).flatten()
});
if let Some(list) = module.item_list() {
let nested = directory.join(&name);
collect_module_declarations(list.items(), file, &nested, true, pending);
} else if let Some(path) = path_attribute {
let base = if inline {
directory.to_path_buf()
} else {
owner_directory(file)
};
let target = base.join(path);
let owner = owner_directory(&target);
pending.push((target, owner));
} else {
let children = directory.join(&name);
pending.push((directory.join(format!("{name}.rs")), children.clone()));
pending.push((children.join("mod.rs"), children));
}
}
ast::Item::MacroCall(call) => {
let is_include = call.path().is_some_and(|path| {
matches!(
path.syntax().text().to_string().as_str(),
"include" | "std::include" | "core::include" | "::std::include"
)
});
if !is_include {
continue;
}
let Some(literal) = string_literal(call.syntax()) else {
continue;
};
if !literal.ends_with(".rs") {
continue;
}
pending.push((owner_directory(file).join(literal), directory.to_path_buf()));
}
_ => {}
}
}
}
fn string_literal(node: &ra_ap_syntax::SyntaxNode) -> Option<String> {
node.descendants_with_tokens().find_map(|element| {
let string = ast::String::cast(element.into_token()?)?;
string.value().ok().map(|value| value.into_owned())
})
}
fn crate_roots(
workspace: &Path,
packages: &[CargoPackage],
) -> Result<BTreeSet<PathBuf>, RustProjectError> {
let mut roots = BTreeSet::new();
for package in packages {
let directory = package.manifest_path.parent().ok_or_else(|| {
RustProjectError::UnsafePath(package.manifest_path.display().to_string())
})?;
let directory = canonical_directory(directory)?;
confined_relative(workspace, &directory).or_else(|error| {
(directory == workspace)
.then_some(String::new())
.ok_or(error)
})?;
for target in &package.targets {
if target.kind.iter().any(|kind| kind == "custom-build") {
continue;
}
let root =
fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
path: target.src_path.clone(),
reason: error.to_string(),
})?;
confined_relative(workspace, &root)?;
roots.insert(root);
}
}
Ok(roots)
}
pub fn discover_rust_source_files(workspace: &Path) -> Result<Vec<String>, RustProjectError> {
let workspace = canonical_directory(workspace)?;
let metadata = cargo_metadata(&workspace)?;
let metadata_root = canonical_directory(&metadata.workspace_root)?;
if metadata_root != workspace {
return Err(RustProjectError::UnsafePath(
metadata.workspace_root.display().to_string(),
));
}
let members = metadata
.workspace_members
.into_iter()
.collect::<BTreeSet<_>>();
let packages = metadata
.packages
.into_iter()
.filter(|package| members.contains(&package.id))
.collect::<Vec<_>>();
if packages.is_empty() {
return Err(RustProjectError::NoWorkspacePackages);
}
let mut files = BTreeSet::new();
resolve_module_tree(&workspace, &crate_roots(&workspace, &packages)?, &mut files)?;
if files.is_empty() {
return Err(RustProjectError::NoSourceFiles);
}
files
.into_iter()
.map(|path| confined_relative(&workspace, &path))
.collect()
}
fn runtime_module_name(sources: &BTreeMap<String, String>) -> String {
let mut suffix = 0_usize;
loop {
let candidate = if suffix == 0 {
"__supercov_runtime_v1".to_owned()
} else {
format!("__supercov_runtime_v1_{suffix}")
};
if sources.values().all(|source| !source.contains(&candidate)) {
return candidate;
}
suffix += 1;
}
}
pub fn manifest_token(manifest: &CoverageManifest) -> String {
let mut ids = manifest
.points
.iter()
.map(|point| point.id.as_str())
.chain(
manifest
.decisions
.iter()
.map(|decision| decision.id.as_str()),
)
.chain(manifest.branches.iter().flat_map(|branch| {
branch
.alternatives
.iter()
.map(|alternative| alternative.id.as_str())
}))
.collect::<Vec<_>>();
ids.sort_unstable();
ids.dedup();
let mut hasher = Sha256::new();
for id in ids {
hasher.update(id.as_bytes());
hasher.update(b"\n");
}
hex(&hasher.finalize()[..6])
}
fn crate_key(token: &str, path: &str) -> String {
format!("{token}{}", hex(&Sha256::digest(path.as_bytes())[..6]))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn merge_manifest(
destination: &mut CoverageManifest,
mut source: CoverageManifest,
) -> Result<(), RustProjectError> {
let mut ids = destination
.points
.iter()
.map(|point| point.id.as_str())
.chain(
destination
.decisions
.iter()
.map(|decision| decision.id.as_str()),
)
.chain(destination.branches.iter().map(|branch| branch.id.as_str()))
.collect::<BTreeSet<_>>();
for id in source
.points
.iter()
.map(|point| point.id.as_str())
.chain(source.decisions.iter().map(|decision| decision.id.as_str()))
.chain(source.branches.iter().map(|branch| branch.id.as_str()))
{
if !ids.insert(id) {
return Err(RustProjectError::DuplicateObligation(id.into()));
}
}
destination.points.append(&mut source.points);
destination.decisions.append(&mut source.decisions);
destination.branches.append(&mut source.branches);
for limitation in source.limitations {
let id = limitation.get("id").and_then(|value| value.as_str());
if !destination
.limitations
.iter()
.any(|existing| existing.get("id").and_then(|value| value.as_str()) == id)
{
destination.limitations.push(limitation);
}
}
Ok(())
}
pub fn prepare_rust_project(workspace: &Path) -> Result<PreparedRustProject, RustProjectError> {
let workspace = canonical_directory(workspace)?;
let metadata = cargo_metadata(&workspace)?;
let metadata_root = canonical_directory(&metadata.workspace_root)?;
if metadata_root != workspace {
return Err(RustProjectError::UnsafePath(
metadata.workspace_root.display().to_string(),
));
}
let members = metadata
.workspace_members
.into_iter()
.collect::<BTreeSet<_>>();
let packages = metadata
.packages
.into_iter()
.filter(|package| members.contains(&package.id))
.collect::<Vec<_>>();
if packages.is_empty() {
return Err(RustProjectError::NoWorkspacePackages);
}
let roots = crate_roots(&workspace, &packages)?;
let mut files = BTreeSet::new();
resolve_module_tree(&workspace, &roots, &mut files)?;
if files.is_empty() {
return Err(RustProjectError::NoSourceFiles);
}
let mut sources = BTreeMap::new();
for path in files {
let relative = confined_relative(&workspace, &path)?;
let source = fs::read_to_string(&path).map_err(|error| RustProjectError::Io {
path: path.clone(),
reason: error.to_string(),
})?;
sources.insert(relative, source);
}
let runtime_module = runtime_module_name(&sources);
let runtime_path = format!("crate::{runtime_module}");
let mut manifest = CoverageManifest {
unmeasured: Vec::new(),
decisions: Vec::new(),
points: Vec::new(),
branches: Vec::new(),
limitations: Vec::new(),
scope: None,
};
for (relative, source) in &sources {
let transformed =
instrument_rust_source(relative, source, &runtime_path).map_err(|error| {
RustProjectError::Instrument {
file: relative.clone(),
reason: error.to_string(),
}
})?;
merge_manifest(&mut manifest, transformed.manifest)?;
fs::write(workspace.join(relative), transformed.code).map_err(|error| {
RustProjectError::Io {
path: workspace.join(relative),
reason: error.to_string(),
}
})?;
}
let token = manifest_token(&manifest);
let mut crate_roots = Vec::new();
for root in roots {
let relative = confined_relative(&workspace, &root)?;
let runtime = render_rust_runtime(&runtime_module, &crate_key(&token, &relative))
.map_err(RustProjectError::Runtime)?;
let mut source = fs::read_to_string(&root).map_err(|error| RustProjectError::Io {
path: root.clone(),
reason: error.to_string(),
})?;
source.push('\n');
source.push_str(&runtime);
fs::write(&root, source).map_err(|error| RustProjectError::Io {
path: root,
reason: error.to_string(),
})?;
crate_roots.push(relative);
}
manifest
.points
.sort_by(|left, right| left.id.cmp(&right.id));
manifest
.decisions
.sort_by(|left, right| left.id.cmp(&right.id));
manifest
.branches
.sort_by(|left, right| left.id.cmp(&right.id));
manifest.limitations.sort_by(|left, right| {
left.get("id")
.and_then(|value| value.as_str())
.cmp(&right.get("id").and_then(|value| value.as_str()))
});
let target_directory = metadata.target_directory;
let target_directory = if target_directory.is_absolute() {
target_directory
} else {
workspace.join(target_directory)
};
if !target_directory.starts_with(&workspace) {
return Err(RustProjectError::UnsafePath(
target_directory.display().to_string(),
));
}
Ok(PreparedRustProject {
workspace_root: workspace,
target_directory,
source_files: sources.into_keys().collect(),
crate_roots,
runtime_module,
manifest,
})
}
#[cfg(test)]
mod tests {
use std::{
process::Command,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use super::*;
fn fixture() -> PathBuf {
static UNIQUE: AtomicU64 = AtomicU64::new(0);
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"supercov-rust-project-{}-{nonce}-{}",
std::process::id(),
UNIQUE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&root).unwrap();
fs::create_dir(root.join("src")).unwrap();
fs::create_dir(root.join("tests")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname='rust-project-fixture'\nversion='0.0.0'\nedition='2024'\n",
)
.unwrap();
fs::write(
root.join("src/lib.rs"),
r#"pub fn choose(first: bool, second: bool) -> i32 {
if first && second { 7 } else { 3 }
}
#[cfg(test)]
mod tests {
#[test]
fn unit_choice() {
assert_eq!(super::choose(true, true), 7);
}
}
"#,
)
.unwrap();
fs::write(
root.join("tests/integration.rs"),
r#"#[test]
fn integration_choice() {
assert_eq!(rust_project_fixture::choose(false, true), 3);
}
"#,
)
.unwrap();
root
}
#[test]
fn only_files_the_module_tree_reaches_are_instrumented() {
let root = fixture();
fs::create_dir_all(root.join("src/nested")).unwrap();
fs::create_dir_all(root.join("src/deep/inner")).unwrap();
fs::create_dir_all(root.join("runtime-assets")).unwrap();
fs::write(
root.join("src/lib.rs"),
concat!(
"mod util;\n",
"mod nested;\n",
"#[path = \"renamed_file.rs\"]\n",
"mod renamed;\n",
"mod deep;\n",
"include!(\"included.rs\");\n",
"pub const EMBEDDED: &str = include_str!(\"../runtime-assets/embedded.rs\");\n",
"pub fn choose(first: bool, second: bool) -> i32 {\n",
" if first && second { util::seven() } else { nested::three() }\n",
"}\n",
),
)
.unwrap();
fs::write(root.join("src/util.rs"), "pub fn seven() -> i32 { 7 }\n").unwrap();
fs::write(
root.join("src/nested/mod.rs"),
"mod leaf;\npub fn three() -> i32 { leaf::three() }\n",
)
.unwrap();
fs::write(
root.join("src/nested/leaf.rs"),
"pub fn three() -> i32 { 3 }\n",
)
.unwrap();
fs::write(
root.join("src/renamed_file.rs"),
"pub fn renamed() -> i32 { 1 }\n",
)
.unwrap();
fs::write(
root.join("src/deep.rs"),
"pub mod inner {\n mod block_child;\n pub fn deep() -> i32 { block_child::v() }\n}\n",
)
.unwrap();
fs::write(
root.join("src/deep/inner/block_child.rs"),
"pub fn v() -> i32 { 9 }\n",
)
.unwrap();
fs::write(
root.join("src/included.rs"),
"pub fn included() -> i32 { 2 }\n",
)
.unwrap();
fs::write(
root.join("tests/integration.rs"),
concat!(
"#[path = \"../src/util.rs\"]\n",
"mod util;\n",
"#[test]\n",
"fn integration_choice() {\n",
" assert_eq!(rust_project_fixture::choose(false, true), 3);\n",
" assert_eq!(util::seven(), 7);\n",
"}\n",
),
)
.unwrap();
let embedded = "pub fn standalone() -> i32 { if true { 1 } else { 0 } }\n";
fs::write(root.join("runtime-assets/embedded.rs"), embedded).unwrap();
fs::write(
root.join("src/orphan.rs"),
"pub fn unreachable_module() {}\n",
)
.unwrap();
let prepared = prepare_rust_project(&root).unwrap();
assert_eq!(
prepared.source_files,
[
"src/deep.rs",
"src/deep/inner/block_child.rs",
"src/included.rs",
"src/lib.rs",
"src/nested/leaf.rs",
"src/nested/mod.rs",
"src/renamed_file.rs",
"src/util.rs",
"tests/integration.rs",
]
);
assert_eq!(
fs::read_to_string(root.join("runtime-assets/embedded.rs")).unwrap(),
embedded
);
assert!(
!fs::read_to_string(root.join("src/orphan.rs"))
.unwrap()
.contains("__supercov")
);
assert!(
fs::read_to_string(root.join("src/deep/inner/block_child.rs"))
.unwrap()
.contains("__supercov")
);
let build = Command::new("cargo")
.args(["test", "--no-run"])
.current_dir(&root)
.env("CARGO_TARGET_DIR", &prepared.target_directory)
.output()
.unwrap();
assert!(
build.status.success(),
"{}",
String::from_utf8_lossy(&build.stderr)
);
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn a_module_shared_through_a_symlink_is_instrumented_once() {
let root = fixture();
fs::write(root.join("src/shared.rs"), "pub fn shared() -> i32 { 5 }\n").unwrap();
std::os::unix::fs::symlink("../src/shared.rs", root.join("tests/shared.rs")).unwrap();
fs::write(
root.join("src/lib.rs"),
concat!(
"pub mod shared;\n",
"pub fn choose(first: bool, second: bool) -> i32 {\n",
" if first && second { 7 } else { shared::shared() }\n",
"}\n",
),
)
.unwrap();
fs::write(
root.join("tests/integration.rs"),
concat!(
"mod shared;\n",
"#[test]\n",
"fn integration_choice() {\n",
" assert_eq!(rust_project_fixture::choose(false, true), 5);\n",
" assert_eq!(shared::shared(), 5);\n",
"}\n",
),
)
.unwrap();
let prepared = prepare_rust_project(&root).unwrap();
let shared = prepared
.source_files
.iter()
.filter(|file| file.ends_with("shared.rs"))
.collect::<Vec<_>>();
assert_eq!(shared, ["src/shared.rs"], "{:?}", prepared.source_files);
let instrumented = fs::read_to_string(root.join("src/shared.rs")).unwrap();
assert_eq!(instrumented.matches("rs:function:").count(), 1);
let build = Command::new("cargo")
.args(["test", "--no-run"])
.current_dir(&root)
.env("CARGO_TARGET_DIR", &prepared.target_directory)
.output()
.unwrap();
assert!(
build.status.success(),
"{}",
String::from_utf8_lossy(&build.stderr)
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn crate_keys_carry_the_manifest_token() {
let root = fixture();
let prepared = prepare_rust_project(&root).unwrap();
let token = manifest_token(&prepared.manifest);
assert_eq!(token.len(), 12);
assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert_eq!(token, manifest_token(&prepared.manifest));
let key = crate_key(&token, "src/lib.rs");
assert_eq!(key.len(), 24);
assert!(key.starts_with(&token));
assert_ne!(key, crate_key(&token, "tests/integration.rs"));
for crate_root in &prepared.crate_roots {
assert!(
fs::read_to_string(root.join(crate_root))
.unwrap()
.contains(&crate_key(&token, crate_root))
);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn prepares_every_workspace_crate_root_and_compiles_without_manifest_changes() {
let root = fixture();
let manifest_before = fs::read(root.join("Cargo.toml")).unwrap();
let prepared = prepare_rust_project(&root).unwrap();
assert_eq!(
prepared.source_files,
["src/lib.rs", "tests/integration.rs"]
);
assert_eq!(prepared.crate_roots, ["src/lib.rs", "tests/integration.rs"]);
assert!(!prepared.manifest.points.is_empty());
assert!(!prepared.manifest.decisions.is_empty());
assert_eq!(fs::read(root.join("Cargo.toml")).unwrap(), manifest_before);
for crate_root in &prepared.crate_roots {
assert!(
fs::read_to_string(root.join(crate_root))
.unwrap()
.contains(&format!("mod {}", prepared.runtime_module))
);
}
let build = Command::new("cargo")
.args(["test", "--no-run"])
.current_dir(&root)
.env("CARGO_TARGET_DIR", &prepared.target_directory)
.output()
.unwrap();
assert!(
build.status.success(),
"{}",
String::from_utf8_lossy(&build.stderr)
);
fs::remove_dir_all(root).unwrap();
}
}