use std::path::{Path, PathBuf};
use crate::coverage_report::CoverageManifest;
use crate::go_instrumenter::GoProbe;
use crate::integrity::ExplicitIntegrityInputs;
use crate::jvm_instrumenter::{JvmLanguage, build_jvm_obligations};
const EXCLUDED_DIRECTORIES: &[&str] = &[
".git",
".gradle",
".idea",
".mvn",
".vscode",
"build",
"node_modules",
"out",
"target",
];
const DEPENDENCY_FILES: &[&str] = &[
"build.gradle",
"build.gradle.kts",
"gradle.properties",
"libs.versions.toml",
"pom.xml",
"settings.gradle",
"settings.gradle.kts",
"gradle-wrapper.properties",
];
const CONFIGURATION_FILES: &[&str] = &[".java-version", ".sdkmanrc", "junit-platform.properties"];
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct JvmFiles {
pub sources: Vec<(String, JvmLanguage)>,
pub tests: Vec<(String, JvmLanguage)>,
pub dependency_files: Vec<PathBuf>,
pub configuration_files: Vec<PathBuf>,
pub excluded: Vec<(String, &'static str)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedJvmProject {
pub root: PathBuf,
pub files: JvmFiles,
pub manifest: CoverageManifest,
pub probes: std::collections::BTreeMap<u64, GoProbe>,
pub decision_widths: Vec<u8>,
pub instrumented: Vec<(String, String)>,
pub unparseable: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JvmBuild {
Maven,
Gradle,
Plain,
}
pub fn detect_build(root: &Path) -> JvmBuild {
if root.join("pom.xml").is_file() {
return JvmBuild::Maven;
}
for name in [
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
] {
if root.join(name).is_file() {
return JvmBuild::Gradle;
}
}
JvmBuild::Plain
}
fn language_of(name: &str) -> Option<JvmLanguage> {
if name.ends_with(".java") {
return Some(JvmLanguage::Java);
}
if name.ends_with(".kt") {
return Some(JvmLanguage::Kotlin);
}
None
}
fn role(relative: &str) -> Option<&'static str> {
let mut parts = relative.split('/');
while let Some(part) = parts.next() {
if part != "src" {
continue;
}
return match parts.next() {
Some("test") | Some("integrationTest") | Some("testFixtures") => Some("test"),
Some("main") => Some("main"),
Some("jvmMain") => Some("main"),
Some("commonMain") => Some("common"),
Some(set) if set.ends_with("Test") => Some("test"),
Some(_) => Some("other"),
None => None,
};
}
None
}
fn targets_only_the_jvm(root: &Path) -> bool {
let mut text = String::new();
for name in ["build.gradle.kts", "build.gradle", "pom.xml"] {
if let Ok(own) = std::fs::read_to_string(root.join(name)) {
text.push_str(&own);
text.push('\n');
}
}
![
"js(",
"wasmJs(",
"wasmWasi(",
"linuxX64(",
"macosX64(",
"macosArm64(",
"mingwX64(",
"iosArm64(",
"iosX64(",
"iosSimulatorArm64(",
"watchos",
"tvos",
"androidNativeArm64(",
]
.iter()
.any(|target| text.contains(target))
}
fn walk(root: &Path, directory: &Path, files: &mut JvmFiles) -> Result<(), String> {
let jvm_only = targets_only_the_jvm(root);
let entries = std::fs::read_dir(directory)
.map_err(|error| format!("could not read {}: {error}", directory.display()))?;
let mut sorted = entries
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("could not read {}: {error}", directory.display()))?;
sorted.sort_by_key(std::fs::DirEntry::path);
for entry in sorted {
let path = entry.path();
let Ok(relative) = path.strip_prefix(root) else {
continue;
};
let relative = relative.to_string_lossy().replace('\\', "/");
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let file_type = entry
.file_type()
.map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
if file_type.is_dir() {
if EXCLUDED_DIRECTORIES.contains(&name.as_str()) || name.starts_with('.') {
files
.excluded
.push((relative, "build output or tooling directory"));
continue;
}
walk(root, &path, files)?;
continue;
}
if !file_type.is_file() {
continue;
}
if DEPENDENCY_FILES.contains(&name.as_str()) {
files.dependency_files.push(PathBuf::from(&relative));
continue;
}
if CONFIGURATION_FILES.contains(&name.as_str()) {
files.configuration_files.push(PathBuf::from(&relative));
continue;
}
let Some(language) = language_of(&name) else {
continue;
};
match role(&relative) {
Some("main") => files.sources.push((relative, language)),
Some("test") => files.tests.push((relative, language)),
Some("common") if jvm_only => files.sources.push((relative, language)),
Some("common") => files.excluded.push((
relative,
"shared with a target that has no Supercov runtime",
)),
Some(other) => files.excluded.push((
relative,
if other == "other" {
"a source set that is neither main nor test"
} else {
other
},
)),
None => files.excluded.push((relative, "outside a src source set")),
}
}
Ok(())
}
pub fn discover_jvm_files(root: &Path) -> Result<JvmFiles, String> {
let mut files = JvmFiles::default();
walk(root, root, &mut files)?;
files.sources.sort();
files.tests.sort();
files.dependency_files.sort();
files.configuration_files.sort();
files.excluded.sort();
Ok(files)
}
const LANGUAGE: &str = "jvm";
fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
serde_json::json!({
"id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
"kind": "file-does-not-parse",
"file": file,
"source": file,
"line": 1,
"column": 1,
"reason": format!(
"{reason}; the file carries no obligations and nothing in it counts towards this run"
),
})
}
pub fn prepare_jvm_project(root: &Path) -> Result<PreparedJvmProject, String> {
let files = discover_jvm_files(root)?;
if files.sources.is_empty() && files.tests.is_empty() {
return Err(
"no Java or Kotlin sources were found under src/main or src/test; Supercov measures the source sets Maven and Gradle define"
.into(),
);
}
let mut manifest = CoverageManifest {
decisions: Vec::new(),
points: Vec::new(),
branches: Vec::new(),
limitations: Vec::new(),
unmeasured: Vec::new(),
scope: None,
};
let mut probes = std::collections::BTreeMap::new();
let mut widths = Vec::new();
let mut instrumented = Vec::new();
let mut unparseable = Vec::new();
let mut next_probe = 0_u64;
let mut next_decision = 0_u32;
for (relative, language) in &files.sources {
let path = root.join(relative);
let Ok(source) = std::fs::read_to_string(&path) else {
let reason = "file could not be read as UTF-8";
manifest
.limitations
.push(unparseable_limitation(relative, reason));
unparseable.push((relative.clone(), reason.to_owned()));
continue;
};
match build_jvm_obligations(
relative,
&source,
*language,
&mut next_probe,
&mut next_decision,
) {
Ok(obligations) => {
manifest.decisions.extend(obligations.manifest.decisions);
manifest.points.extend(obligations.manifest.points);
manifest.branches.extend(obligations.manifest.branches);
manifest
.limitations
.extend(obligations.manifest.limitations);
probes.extend(obligations.probes);
widths.extend(obligations.decision_widths);
instrumented.push((
relative.clone(),
crate::jvm_instrumenter::rewrite(&source, &obligations.edits),
));
}
Err(error) => {
manifest
.limitations
.push(unparseable_limitation(relative, &error.to_string()));
unparseable.push((relative.clone(), error.to_string()));
}
}
}
Ok(PreparedJvmProject {
root: root.to_owned(),
files,
manifest,
probes,
decision_widths: widths,
instrumented,
unparseable,
})
}
pub fn jvm_integrity_inputs(files: &JvmFiles, command: &[String]) -> ExplicitIntegrityInputs {
ExplicitIntegrityInputs {
source_files: files
.sources
.iter()
.map(|(path, _)| PathBuf::from(path))
.collect(),
test_files: files
.tests
.iter()
.map(|(path, _)| PathBuf::from(path))
.collect(),
dependency_files: files.dependency_files.clone(),
configuration_files: files.configuration_files.clone(),
execution_configuration: command.join("\0").into_bytes(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn fixture(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"supercov-jvm-project-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
root
}
fn write(root: &Path, relative: &str, contents: &str) {
let path = root.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
#[test]
fn the_source_set_decides_what_a_file_is_not_its_name() {
let root = fixture("layout");
write(&root, "pom.xml", "<project/>");
write(
&root,
"src/main/java/app/TestHelper.java",
"class TestHelper {}",
);
write(&root, "src/main/kotlin/app/Api.kt", "class Api");
write(
&root,
"src/test/java/app/Calculator.java",
"class Calculator {}",
);
write(&root, "src/test/kotlin/app/ApiTest.kt", "class ApiTest");
let files = discover_jvm_files(&root).unwrap();
assert_eq!(
files.sources,
[
(
"src/main/java/app/TestHelper.java".to_owned(),
JvmLanguage::Java
),
("src/main/kotlin/app/Api.kt".to_owned(), JvmLanguage::Kotlin),
]
);
assert_eq!(
files.tests,
[
(
"src/test/java/app/Calculator.java".to_owned(),
JvmLanguage::Java
),
(
"src/test/kotlin/app/ApiTest.kt".to_owned(),
JvmLanguage::Kotlin
),
]
);
assert_eq!(detect_build(&root), JvmBuild::Maven);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn build_output_and_loose_files_are_out_of_scope_rather_than_guessed_at() {
let root = fixture("scope");
write(&root, "build.gradle.kts", "plugins { java }");
write(&root, "gradle/libs.versions.toml", "[versions]");
write(&root, ".java-version", "21");
write(&root, "src/main/java/app/Api.java", "class Api {}");
write(&root, "target/classes/app/Api.java", "class Api {}");
write(&root, "build/generated/app/Gen.java", "class Gen {}");
write(&root, "Scratch.java", "class Scratch {}");
let files = discover_jvm_files(&root).unwrap();
assert_eq!(files.sources.len(), 1);
assert_eq!(files.sources[0].0, "src/main/java/app/Api.java");
assert!(
files
.excluded
.iter()
.any(|(path, why)| path == "Scratch.java" && *why == "outside a src source set")
);
assert_eq!(detect_build(&root), JvmBuild::Gradle);
assert!(
files
.dependency_files
.contains(&PathBuf::from("gradle/libs.versions.toml"))
);
assert_eq!(files.configuration_files, [PathBuf::from(".java-version")]);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
let root = fixture("unparseable");
write(&root, "pom.xml", "<project/>");
write(
&root,
"src/main/java/app/Good.java",
"class Good { boolean f(int a, boolean b) { if (a > 1 && b) { return true; } return false; } }",
);
write(
&root,
"src/main/java/app/Broken.java",
"class Broken { void f( {",
);
let project = prepare_jvm_project(&root).unwrap();
assert_eq!(project.unparseable.len(), 1);
assert!(project.unparseable[0].0.ends_with("Broken.java"));
let declared = project
.manifest
.limitations
.iter()
.filter(|limitation| limitation["kind"] == "file-does-not-parse")
.collect::<Vec<_>>();
assert_eq!(declared.len(), 1, "{declared:?}");
assert!(
declared[0]["file"]
.as_str()
.unwrap()
.ends_with("Broken.java"),
"{declared:?}"
);
assert!(
declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
"the declaration needs an id to reference: {declared:?}"
);
assert_eq!(project.manifest.decisions.len(), 1);
assert_eq!(project.decision_widths, [2]);
assert!(!project.probes.is_empty());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_project_with_nothing_to_measure_is_refused() {
let root = fixture("empty");
write(&root, "pom.xml", "<project/>");
assert!(prepare_jvm_project(&root).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_multiplatform_layout_is_measured_where_the_jvm_is_the_only_target() {
let root = fixture("kmp-jvm");
fs::write(root.join("build.gradle.kts"), "kotlin {\n jvm()\n}\n").unwrap();
for (path, body) in [
("src/commonMain/kotlin/Shared.kt", "fun shared() {}"),
("src/jvmMain/kotlin/Jvm.kt", "fun onlyJvm() {}"),
("src/commonTest/kotlin/SharedTest.kt", "fun sharedTest() {}"),
("src/jvmTest/kotlin/JvmTest.kt", "fun jvmTest() {}"),
] {
let full = root.join(path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(full, body).unwrap();
}
let files = discover_jvm_files(&root).expect("discovery");
assert_eq!(
files
.sources
.iter()
.map(|(p, _)| p.as_str())
.collect::<Vec<_>>(),
[
"src/commonMain/kotlin/Shared.kt",
"src/jvmMain/kotlin/Jvm.kt"
]
);
assert_eq!(
files
.tests
.iter()
.map(|(p, _)| p.as_str())
.collect::<Vec<_>>(),
[
"src/commonTest/kotlin/SharedTest.kt",
"src/jvmTest/kotlin/JvmTest.kt"
]
);
fs::write(
root.join("build.gradle.kts"),
"kotlin {\n jvm()\n js(IR) { nodejs() }\n}\n",
)
.unwrap();
let files = discover_jvm_files(&root).expect("discovery");
assert_eq!(
files
.sources
.iter()
.map(|(p, _)| p.as_str())
.collect::<Vec<_>>(),
["src/jvmMain/kotlin/Jvm.kt"]
);
assert!(
files
.excluded
.iter()
.any(|(path, reason)| path == "src/commonMain/kotlin/Shared.kt"
&& *reason == "shared with a target that has no Supercov runtime"),
"{:?}",
files.excluded
);
fs::remove_dir_all(root).unwrap();
}
}