1use std::path::{Path, PathBuf};
14
15use crate::coverage_report::CoverageManifest;
16use crate::go_instrumenter::GoProbe;
17use crate::integrity::ExplicitIntegrityInputs;
18use crate::jvm_instrumenter::{JvmLanguage, build_jvm_obligations};
19
20const EXCLUDED_DIRECTORIES: &[&str] = &[
22 ".git",
23 ".gradle",
24 ".idea",
25 ".mvn",
26 ".vscode",
27 "build",
28 "node_modules",
29 "out",
30 "target",
31];
32
33const DEPENDENCY_FILES: &[&str] = &[
35 "build.gradle",
36 "build.gradle.kts",
37 "gradle.properties",
38 "libs.versions.toml",
39 "pom.xml",
40 "settings.gradle",
41 "settings.gradle.kts",
42 "gradle-wrapper.properties",
43];
44
45const CONFIGURATION_FILES: &[&str] = &[".java-version", ".sdkmanrc", "junit-platform.properties"];
47
48#[derive(Debug, Clone, PartialEq, Eq, Default)]
49pub struct JvmFiles {
50 pub sources: Vec<(String, JvmLanguage)>,
53 pub tests: Vec<(String, JvmLanguage)>,
54 pub dependency_files: Vec<PathBuf>,
55 pub configuration_files: Vec<PathBuf>,
56 pub excluded: Vec<(String, &'static str)>,
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub struct PreparedJvmProject {
61 pub root: PathBuf,
62 pub files: JvmFiles,
63 pub manifest: CoverageManifest,
64 pub probes: std::collections::BTreeMap<u64, GoProbe>,
65 pub decision_widths: Vec<u8>,
66 pub instrumented: Vec<(String, String)>,
70 pub unparseable: Vec<(String, String)>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum JvmBuild {
78 Maven,
79 Gradle,
80 Plain,
82}
83
84pub fn detect_build(root: &Path) -> JvmBuild {
85 if root.join("pom.xml").is_file() {
86 return JvmBuild::Maven;
87 }
88 for name in [
89 "build.gradle",
90 "build.gradle.kts",
91 "settings.gradle",
92 "settings.gradle.kts",
93 ] {
94 if root.join(name).is_file() {
95 return JvmBuild::Gradle;
96 }
97 }
98 JvmBuild::Plain
99}
100
101fn language_of(name: &str) -> Option<JvmLanguage> {
102 if name.ends_with(".java") {
103 return Some(JvmLanguage::Java);
104 }
105 if name.ends_with(".kt") {
106 return Some(JvmLanguage::Kotlin);
107 }
108 None
109}
110
111fn role(relative: &str) -> Option<&'static str> {
114 let mut parts = relative.split('/');
115 while let Some(part) = parts.next() {
116 if part != "src" {
117 continue;
118 }
119 return match parts.next() {
122 Some("test") | Some("integrationTest") | Some("testFixtures") => Some("test"),
123 Some("main") => Some("main"),
124 Some("jvmMain") => Some("main"),
131 Some("commonMain") => Some("common"),
132 Some(set) if set.ends_with("Test") => Some("test"),
136 Some(_) => Some("other"),
137 None => None,
138 };
139 }
140 None
141}
142
143fn targets_only_the_jvm(root: &Path) -> bool {
151 let mut text = String::new();
152 for name in ["build.gradle.kts", "build.gradle", "pom.xml"] {
153 if let Ok(own) = std::fs::read_to_string(root.join(name)) {
154 text.push_str(&own);
155 text.push('\n');
156 }
157 }
158 ![
159 "js(",
160 "wasmJs(",
161 "wasmWasi(",
162 "linuxX64(",
163 "macosX64(",
164 "macosArm64(",
165 "mingwX64(",
166 "iosArm64(",
167 "iosX64(",
168 "iosSimulatorArm64(",
169 "watchos",
170 "tvos",
171 "androidNativeArm64(",
172 ]
173 .iter()
174 .any(|target| text.contains(target))
175}
176
177fn walk(root: &Path, directory: &Path, files: &mut JvmFiles) -> Result<(), String> {
178 let jvm_only = targets_only_the_jvm(root);
179 let entries = std::fs::read_dir(directory)
180 .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
181 let mut sorted = entries
182 .collect::<Result<Vec<_>, _>>()
183 .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
184 sorted.sort_by_key(std::fs::DirEntry::path);
185 for entry in sorted {
186 let path = entry.path();
187 let Ok(relative) = path.strip_prefix(root) else {
188 continue;
189 };
190 let relative = relative.to_string_lossy().replace('\\', "/");
191 let name = path
192 .file_name()
193 .map(|name| name.to_string_lossy().into_owned())
194 .unwrap_or_default();
195 let file_type = entry
196 .file_type()
197 .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
198 if file_type.is_dir() {
199 if EXCLUDED_DIRECTORIES.contains(&name.as_str()) || name.starts_with('.') {
200 files
201 .excluded
202 .push((relative, "build output or tooling directory"));
203 continue;
204 }
205 walk(root, &path, files)?;
206 continue;
207 }
208 if !file_type.is_file() {
209 continue;
210 }
211 if DEPENDENCY_FILES.contains(&name.as_str()) {
212 files.dependency_files.push(PathBuf::from(&relative));
213 continue;
214 }
215 if CONFIGURATION_FILES.contains(&name.as_str()) {
216 files.configuration_files.push(PathBuf::from(&relative));
217 continue;
218 }
219 let Some(language) = language_of(&name) else {
220 continue;
221 };
222 match role(&relative) {
223 Some("main") => files.sources.push((relative, language)),
224 Some("test") => files.tests.push((relative, language)),
225 Some("common") if jvm_only => files.sources.push((relative, language)),
226 Some("common") => files.excluded.push((
227 relative,
228 "shared with a target that has no Supercov runtime",
229 )),
230 Some(other) => files.excluded.push((
231 relative,
232 if other == "other" {
233 "a source set that is neither main nor test"
234 } else {
235 other
236 },
237 )),
238 None => files.excluded.push((relative, "outside a src source set")),
242 }
243 }
244 Ok(())
245}
246
247pub fn discover_jvm_files(root: &Path) -> Result<JvmFiles, String> {
248 let mut files = JvmFiles::default();
249 walk(root, root, &mut files)?;
250 files.sources.sort();
251 files.tests.sort();
252 files.dependency_files.sort();
253 files.configuration_files.sort();
254 files.excluded.sort();
255 Ok(files)
256}
257
258const LANGUAGE: &str = "jvm";
259
260fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
266 serde_json::json!({
267 "id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
268 "kind": "file-does-not-parse",
269 "file": file,
270 "source": file,
273 "line": 1,
274 "column": 1,
275 "reason": format!(
276 "{reason}; the file carries no obligations and nothing in it counts towards this run"
277 ),
278 })
279}
280
281pub fn prepare_jvm_project(
282 root: &Path,
283 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
284) -> Result<PreparedJvmProject, String> {
285 let mut files = discover_jvm_files(root)?;
286 if let Some(roots) = roots {
287 roots.narrow(
288 &mut files.sources,
289 &mut files.excluded,
290 |(file, _): &(String, JvmLanguage)| file.as_str(),
291 );
292 roots.refuse_if_empty(files.sources.len(), "Java or Kotlin")?;
293 }
294 if files.sources.is_empty() && files.tests.is_empty() {
295 return Err(
296 "no Java or Kotlin sources were found under src/main or src/test; Supercov measures the source sets Maven and Gradle define"
297 .into(),
298 );
299 }
300 let mut manifest = CoverageManifest {
301 decisions: Vec::new(),
302 points: Vec::new(),
303 branches: Vec::new(),
304 limitations: Vec::new(),
305 unmeasured: Vec::new(),
306 scope: None,
307 assertion_sites: Vec::new(),
308 };
309 let mut probes = std::collections::BTreeMap::new();
310 let mut widths = Vec::new();
311 let mut instrumented = Vec::new();
312 let mut unparseable = Vec::new();
313 let mut next_probe = 0_u64;
314 let mut next_decision = 0_u32;
315 for (relative, language) in &files.sources {
316 let path = root.join(relative);
317 let Ok(source) = std::fs::read_to_string(&path) else {
318 let reason = "file could not be read as UTF-8";
319 manifest
320 .limitations
321 .push(unparseable_limitation(relative, reason));
322 unparseable.push((relative.clone(), reason.to_owned()));
323 continue;
324 };
325 match build_jvm_obligations(
326 relative,
327 &source,
328 *language,
329 &mut next_probe,
330 &mut next_decision,
331 ) {
332 Ok(obligations) => {
333 manifest.decisions.extend(obligations.manifest.decisions);
334 manifest.points.extend(obligations.manifest.points);
335 manifest.branches.extend(obligations.manifest.branches);
336 manifest
337 .limitations
338 .extend(obligations.manifest.limitations);
339 probes.extend(obligations.probes);
340 widths.extend(obligations.decision_widths);
341 instrumented.push((
342 relative.clone(),
343 crate::jvm_instrumenter::rewrite(&source, &obligations.edits),
344 ));
345 }
346 Err(error) => {
347 manifest
348 .limitations
349 .push(unparseable_limitation(relative, &error.to_string()));
350 unparseable.push((relative.clone(), error.to_string()));
351 }
352 }
353 }
354 if let Some(roots) = roots {
357 let kept = files
358 .sources
359 .iter()
360 .map(|(file, _)| file.clone())
361 .collect::<Vec<_>>();
362 manifest.scope = Some(
363 serde_json::to_value(
364 roots
365 .scope(&kept, &files.excluded)
366 .map_err(|error| error.to_string())?,
367 )
368 .map_err(|error| error.to_string())?,
369 );
370 }
371 Ok(PreparedJvmProject {
372 root: root.to_owned(),
373 files,
374 manifest,
375 probes,
376 decision_widths: widths,
377 instrumented,
378 unparseable,
379 })
380}
381
382pub fn jvm_integrity_inputs(files: &JvmFiles, command: &[String]) -> ExplicitIntegrityInputs {
384 ExplicitIntegrityInputs {
385 source_files: files
386 .sources
387 .iter()
388 .map(|(path, _)| PathBuf::from(path))
389 .collect(),
390 test_files: files
391 .tests
392 .iter()
393 .map(|(path, _)| PathBuf::from(path))
394 .collect(),
395 dependency_files: files.dependency_files.clone(),
396 configuration_files: files.configuration_files.clone(),
397 execution_configuration: command.join("\0").into_bytes(),
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use std::fs;
405
406 fn fixture(label: &str) -> PathBuf {
407 let root = std::env::temp_dir().join(format!(
408 "supercov-jvm-project-{label}-{}-{}",
409 std::process::id(),
410 std::time::SystemTime::now()
411 .duration_since(std::time::UNIX_EPOCH)
412 .unwrap()
413 .as_nanos()
414 ));
415 fs::create_dir_all(&root).unwrap();
416 root
417 }
418
419 fn write(root: &Path, relative: &str, contents: &str) {
420 let path = root.join(relative);
421 fs::create_dir_all(path.parent().unwrap()).unwrap();
422 fs::write(path, contents).unwrap();
423 }
424
425 fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
426 let roots = roots
427 .iter()
428 .map(|root| (*root).to_owned())
429 .collect::<Vec<_>>();
430 crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
431 }
432
433 #[test]
434 fn explicit_roots_narrow_the_jvm_to_the_code_they_name() {
435 let root = fixture("explicit-roots");
436 write(&root, "pom.xml", "<project/>");
437 write(
438 &root,
439 "src/main/java/app/Good.java",
440 "class Good { int f(int a) { return a; } }",
441 );
442 write(
443 &root,
444 "src/main/java/shaded/Dep.java",
445 "class Dep { int g() { return 1; } }",
446 );
447
448 let project =
449 prepare_jvm_project(&root, Some(&explicit_roots(&root, &["src/main/java/app"])))
450 .unwrap();
451 assert_eq!(
452 project
453 .files
454 .sources
455 .iter()
456 .map(|(file, _)| file.as_str())
457 .collect::<Vec<_>>(),
458 ["src/main/java/app/Good.java"],
459 "{:?}",
460 project.files
461 );
462 assert!(
463 project.files.excluded.contains(&(
464 "src/main/java/shaded/Dep.java".into(),
465 "outside explicit source roots"
466 )),
467 "{:?}",
468 project.files.excluded
469 );
470 let scope: crate::source_discovery::SourceScope = serde_json::from_value(
471 project
472 .manifest
473 .scope
474 .clone()
475 .expect("explicit roots leave a scope"),
476 )
477 .unwrap();
478 assert_eq!(
479 scope.mode,
480 crate::source_discovery::SourceScopeMode::Explicit
481 );
482 assert!(
483 prepare_jvm_project(&root, None)
484 .unwrap()
485 .manifest
486 .scope
487 .is_none()
488 );
489 fs::remove_dir_all(root).ok();
490 }
491
492 #[test]
493 fn the_source_set_decides_what_a_file_is_not_its_name() {
494 let root = fixture("layout");
499 write(&root, "pom.xml", "<project/>");
500 write(
501 &root,
502 "src/main/java/app/TestHelper.java",
503 "class TestHelper {}",
504 );
505 write(&root, "src/main/kotlin/app/Api.kt", "class Api");
506 write(
507 &root,
508 "src/test/java/app/Calculator.java",
509 "class Calculator {}",
510 );
511 write(&root, "src/test/kotlin/app/ApiTest.kt", "class ApiTest");
512
513 let files = discover_jvm_files(&root).unwrap();
514 assert_eq!(
515 files.sources,
516 [
517 (
518 "src/main/java/app/TestHelper.java".to_owned(),
519 JvmLanguage::Java
520 ),
521 ("src/main/kotlin/app/Api.kt".to_owned(), JvmLanguage::Kotlin),
522 ]
523 );
524 assert_eq!(
525 files.tests,
526 [
527 (
528 "src/test/java/app/Calculator.java".to_owned(),
529 JvmLanguage::Java
530 ),
531 (
532 "src/test/kotlin/app/ApiTest.kt".to_owned(),
533 JvmLanguage::Kotlin
534 ),
535 ]
536 );
537 assert_eq!(detect_build(&root), JvmBuild::Maven);
538 fs::remove_dir_all(root).unwrap();
539 }
540
541 #[test]
542 fn build_output_and_loose_files_are_out_of_scope_rather_than_guessed_at() {
543 let root = fixture("scope");
547 write(&root, "build.gradle.kts", "plugins { java }");
548 write(&root, "gradle/libs.versions.toml", "[versions]");
549 write(&root, ".java-version", "21");
550 write(&root, "src/main/java/app/Api.java", "class Api {}");
551 write(&root, "target/classes/app/Api.java", "class Api {}");
552 write(&root, "build/generated/app/Gen.java", "class Gen {}");
553 write(&root, "Scratch.java", "class Scratch {}");
554
555 let files = discover_jvm_files(&root).unwrap();
556 assert_eq!(files.sources.len(), 1);
557 assert_eq!(files.sources[0].0, "src/main/java/app/Api.java");
558 assert!(
559 files
560 .excluded
561 .iter()
562 .any(|(path, why)| path == "Scratch.java" && *why == "outside a src source set")
563 );
564 assert_eq!(detect_build(&root), JvmBuild::Gradle);
565 assert!(
566 files
567 .dependency_files
568 .contains(&PathBuf::from("gradle/libs.versions.toml"))
569 );
570 assert_eq!(files.configuration_files, [PathBuf::from(".java-version")]);
571 fs::remove_dir_all(root).unwrap();
572 }
573
574 #[test]
575 fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
576 let root = fixture("unparseable");
577 write(&root, "pom.xml", "<project/>");
578 write(
579 &root,
580 "src/main/java/app/Good.java",
581 "class Good { boolean f(int a, boolean b) { if (a > 1 && b) { return true; } return false; } }",
582 );
583 write(
584 &root,
585 "src/main/java/app/Broken.java",
586 "class Broken { void f( {",
587 );
588
589 let project = prepare_jvm_project(&root, None).unwrap();
590 assert_eq!(project.unparseable.len(), 1);
591 assert!(project.unparseable[0].0.ends_with("Broken.java"));
592 let declared = project
595 .manifest
596 .limitations
597 .iter()
598 .filter(|limitation| limitation["kind"] == "file-does-not-parse")
599 .collect::<Vec<_>>();
600 assert_eq!(declared.len(), 1, "{declared:?}");
601 assert!(
602 declared[0]["file"]
603 .as_str()
604 .unwrap()
605 .ends_with("Broken.java"),
606 "{declared:?}"
607 );
608 assert!(
609 declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
610 "the declaration needs an id to reference: {declared:?}"
611 );
612 assert_eq!(project.manifest.decisions.len(), 1);
613 assert_eq!(project.decision_widths, [2]);
614 assert!(!project.probes.is_empty());
615 fs::remove_dir_all(root).unwrap();
616 }
617
618 #[test]
619 fn a_project_with_nothing_to_measure_is_refused() {
620 let root = fixture("empty");
623 write(&root, "pom.xml", "<project/>");
624 assert!(prepare_jvm_project(&root, None).is_err());
625 fs::remove_dir_all(root).unwrap();
626 }
627
628 #[test]
629 fn a_multiplatform_layout_is_measured_where_the_jvm_is_the_only_target() {
630 let root = fixture("kmp-jvm");
636 fs::write(root.join("build.gradle.kts"), "kotlin {\n jvm()\n}\n").unwrap();
637 for (path, body) in [
638 ("src/commonMain/kotlin/Shared.kt", "fun shared() {}"),
639 ("src/jvmMain/kotlin/Jvm.kt", "fun onlyJvm() {}"),
640 ("src/commonTest/kotlin/SharedTest.kt", "fun sharedTest() {}"),
641 ("src/jvmTest/kotlin/JvmTest.kt", "fun jvmTest() {}"),
642 ] {
643 let full = root.join(path);
644 fs::create_dir_all(full.parent().unwrap()).unwrap();
645 fs::write(full, body).unwrap();
646 }
647 let files = discover_jvm_files(&root).expect("discovery");
648 assert_eq!(
649 files
650 .sources
651 .iter()
652 .map(|(p, _)| p.as_str())
653 .collect::<Vec<_>>(),
654 [
655 "src/commonMain/kotlin/Shared.kt",
656 "src/jvmMain/kotlin/Jvm.kt"
657 ]
658 );
659 assert_eq!(
660 files
661 .tests
662 .iter()
663 .map(|(p, _)| p.as_str())
664 .collect::<Vec<_>>(),
665 [
666 "src/commonTest/kotlin/SharedTest.kt",
667 "src/jvmTest/kotlin/JvmTest.kt"
668 ]
669 );
670
671 fs::write(
676 root.join("build.gradle.kts"),
677 "kotlin {\n jvm()\n js(IR) { nodejs() }\n}\n",
678 )
679 .unwrap();
680 let files = discover_jvm_files(&root).expect("discovery");
681 assert_eq!(
682 files
683 .sources
684 .iter()
685 .map(|(p, _)| p.as_str())
686 .collect::<Vec<_>>(),
687 ["src/jvmMain/kotlin/Jvm.kt"]
688 );
689 assert!(
690 files
691 .excluded
692 .iter()
693 .any(|(path, reason)| path == "src/commonMain/kotlin/Shared.kt"
694 && *reason == "shared with a target that has no Supercov runtime"),
695 "{:?}",
696 files.excluded
697 );
698 fs::remove_dir_all(root).unwrap();
699 }
700}