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 let scope =
355 match roots {
356 Some(roots) => {
357 let kept = files
358 .sources
359 .iter()
360 .map(|(file, _)| file.clone())
361 .collect::<Vec<_>>();
362 roots
363 .scope(&kept, &files.excluded)
364 .map_err(|error| error.to_string())?
365 }
366 None => {
367 let unread = |file: &String| unparseable.iter().any(|(path, _)| path == file);
368 let named = |language: &JvmLanguage| match language {
369 JvmLanguage::Java => "Java",
370 JvmLanguage::Kotlin => "Kotlin",
371 };
372 crate::source_discovery::automatic_scope(
373 files.sources.iter().filter(|(file, _)| !unread(file)).map(
374 |(file, language)| {
375 (
376 file.clone(),
377 format!("{} application source", named(language)),
378 )
379 },
380 ),
381 files
382 .sources
383 .iter()
384 .filter(|(file, _)| unread(file))
385 .map(|(file, _)| (file.clone(), "could not be parsed".to_owned()))
386 .chain(
387 files
388 .tests
389 .iter()
390 .map(|(file, _)| (file.clone(), "test file".to_owned())),
391 )
392 .chain(
393 files
394 .excluded
395 .iter()
396 .filter(|(file, _)| language_of(file).is_some())
397 .map(|(file, reason)| (file.clone(), (*reason).to_owned())),
398 ),
399 )
400 }
401 };
402 manifest.scope = Some(serde_json::to_value(scope).map_err(|error| error.to_string())?);
403 Ok(PreparedJvmProject {
404 root: root.to_owned(),
405 files,
406 manifest,
407 probes,
408 decision_widths: widths,
409 instrumented,
410 unparseable,
411 })
412}
413
414pub fn jvm_integrity_inputs(files: &JvmFiles, command: &[String]) -> ExplicitIntegrityInputs {
416 ExplicitIntegrityInputs {
417 source_files: files
418 .sources
419 .iter()
420 .map(|(path, _)| PathBuf::from(path))
421 .collect(),
422 test_files: files
423 .tests
424 .iter()
425 .map(|(path, _)| PathBuf::from(path))
426 .collect(),
427 dependency_files: files.dependency_files.clone(),
428 configuration_files: files.configuration_files.clone(),
429 execution_configuration: command.join("\0").into_bytes(),
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use std::fs;
437
438 fn fixture(label: &str) -> PathBuf {
439 let root = std::env::temp_dir().join(format!(
440 "supercov-jvm-project-{label}-{}-{}",
441 std::process::id(),
442 std::time::SystemTime::now()
443 .duration_since(std::time::UNIX_EPOCH)
444 .unwrap()
445 .as_nanos()
446 ));
447 fs::create_dir_all(&root).unwrap();
448 root
449 }
450
451 fn write(root: &Path, relative: &str, contents: &str) {
452 let path = root.join(relative);
453 fs::create_dir_all(path.parent().unwrap()).unwrap();
454 fs::write(path, contents).unwrap();
455 }
456
457 fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
458 let roots = roots
459 .iter()
460 .map(|root| (*root).to_owned())
461 .collect::<Vec<_>>();
462 crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
463 }
464
465 #[test]
466 fn explicit_roots_narrow_the_jvm_to_the_code_they_name() {
467 let root = fixture("explicit-roots");
468 write(&root, "pom.xml", "<project/>");
469 write(
470 &root,
471 "src/main/java/app/Good.java",
472 "class Good { int f(int a) { return a; } }",
473 );
474 write(
475 &root,
476 "src/main/java/shaded/Dep.java",
477 "class Dep { int g() { return 1; } }",
478 );
479
480 let project =
481 prepare_jvm_project(&root, Some(&explicit_roots(&root, &["src/main/java/app"])))
482 .unwrap();
483 assert_eq!(
484 project
485 .files
486 .sources
487 .iter()
488 .map(|(file, _)| file.as_str())
489 .collect::<Vec<_>>(),
490 ["src/main/java/app/Good.java"],
491 "{:?}",
492 project.files
493 );
494 assert!(
495 project.files.excluded.contains(&(
496 "src/main/java/shaded/Dep.java".into(),
497 "outside explicit source roots"
498 )),
499 "{:?}",
500 project.files.excluded
501 );
502 let scope: crate::source_discovery::SourceScope = serde_json::from_value(
503 project
504 .manifest
505 .scope
506 .clone()
507 .expect("explicit roots leave a scope"),
508 )
509 .unwrap();
510 assert_eq!(
511 scope.mode,
512 crate::source_discovery::SourceScopeMode::Explicit
513 );
514 write(
517 &root,
518 "src/test/java/app/GoodTest.java",
519 "class GoodTest {}",
520 );
521 write(&root, "Loose.java", "class Loose {}");
522 let scope: crate::source_discovery::SourceScope = serde_json::from_value(
523 prepare_jvm_project(&root, None)
524 .unwrap()
525 .manifest
526 .scope
527 .expect("a scope"),
528 )
529 .unwrap();
530 assert_eq!(
531 scope.mode,
532 crate::source_discovery::SourceScopeMode::Automatic
533 );
534 assert_eq!(
535 scope
536 .entries
537 .iter()
538 .map(|entry| (
539 entry.file.as_str(),
540 entry.status == crate::source_discovery::SourceScopeStatus::Included,
541 entry.reason.as_str()
542 ))
543 .collect::<Vec<_>>(),
544 [
545 ("Loose.java", false, "outside a src source set"),
546 (
547 "src/main/java/app/Good.java",
548 true,
549 "Java application source"
550 ),
551 (
552 "src/main/java/shaded/Dep.java",
553 true,
554 "Java application source"
555 ),
556 ("src/test/java/app/GoodTest.java", false, "test file"),
557 ]
558 );
559 fs::remove_dir_all(root).ok();
560 }
561
562 #[test]
563 fn the_source_set_decides_what_a_file_is_not_its_name() {
564 let root = fixture("layout");
569 write(&root, "pom.xml", "<project/>");
570 write(
571 &root,
572 "src/main/java/app/TestHelper.java",
573 "class TestHelper {}",
574 );
575 write(&root, "src/main/kotlin/app/Api.kt", "class Api");
576 write(
577 &root,
578 "src/test/java/app/Calculator.java",
579 "class Calculator {}",
580 );
581 write(&root, "src/test/kotlin/app/ApiTest.kt", "class ApiTest");
582
583 let files = discover_jvm_files(&root).unwrap();
584 assert_eq!(
585 files.sources,
586 [
587 (
588 "src/main/java/app/TestHelper.java".to_owned(),
589 JvmLanguage::Java
590 ),
591 ("src/main/kotlin/app/Api.kt".to_owned(), JvmLanguage::Kotlin),
592 ]
593 );
594 assert_eq!(
595 files.tests,
596 [
597 (
598 "src/test/java/app/Calculator.java".to_owned(),
599 JvmLanguage::Java
600 ),
601 (
602 "src/test/kotlin/app/ApiTest.kt".to_owned(),
603 JvmLanguage::Kotlin
604 ),
605 ]
606 );
607 assert_eq!(detect_build(&root), JvmBuild::Maven);
608 fs::remove_dir_all(root).unwrap();
609 }
610
611 #[test]
612 fn build_output_and_loose_files_are_out_of_scope_rather_than_guessed_at() {
613 let root = fixture("scope");
617 write(&root, "build.gradle.kts", "plugins { java }");
618 write(&root, "gradle/libs.versions.toml", "[versions]");
619 write(&root, ".java-version", "21");
620 write(&root, "src/main/java/app/Api.java", "class Api {}");
621 write(&root, "target/classes/app/Api.java", "class Api {}");
622 write(&root, "build/generated/app/Gen.java", "class Gen {}");
623 write(&root, "Scratch.java", "class Scratch {}");
624
625 let files = discover_jvm_files(&root).unwrap();
626 assert_eq!(files.sources.len(), 1);
627 assert_eq!(files.sources[0].0, "src/main/java/app/Api.java");
628 assert!(
629 files
630 .excluded
631 .iter()
632 .any(|(path, why)| path == "Scratch.java" && *why == "outside a src source set")
633 );
634 assert_eq!(detect_build(&root), JvmBuild::Gradle);
635 assert!(
636 files
637 .dependency_files
638 .contains(&PathBuf::from("gradle/libs.versions.toml"))
639 );
640 assert_eq!(files.configuration_files, [PathBuf::from(".java-version")]);
641 fs::remove_dir_all(root).unwrap();
642 }
643
644 #[test]
645 fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
646 let root = fixture("unparseable");
647 write(&root, "pom.xml", "<project/>");
648 write(
649 &root,
650 "src/main/java/app/Good.java",
651 "class Good { boolean f(int a, boolean b) { if (a > 1 && b) { return true; } return false; } }",
652 );
653 write(
654 &root,
655 "src/main/java/app/Broken.java",
656 "class Broken { void f( {",
657 );
658
659 let project = prepare_jvm_project(&root, None).unwrap();
660 assert_eq!(project.unparseable.len(), 1);
661 assert!(project.unparseable[0].0.ends_with("Broken.java"));
662 let declared = project
665 .manifest
666 .limitations
667 .iter()
668 .filter(|limitation| limitation["kind"] == "file-does-not-parse")
669 .collect::<Vec<_>>();
670 assert_eq!(declared.len(), 1, "{declared:?}");
671 assert!(
672 declared[0]["file"]
673 .as_str()
674 .unwrap()
675 .ends_with("Broken.java"),
676 "{declared:?}"
677 );
678 assert!(
679 declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
680 "the declaration needs an id to reference: {declared:?}"
681 );
682 assert_eq!(project.manifest.decisions.len(), 1);
683 assert_eq!(project.decision_widths, [2]);
684 assert!(!project.probes.is_empty());
685 fs::remove_dir_all(root).unwrap();
686 }
687
688 #[test]
689 fn a_project_with_nothing_to_measure_is_refused() {
690 let root = fixture("empty");
693 write(&root, "pom.xml", "<project/>");
694 assert!(prepare_jvm_project(&root, None).is_err());
695 fs::remove_dir_all(root).unwrap();
696 }
697
698 #[test]
699 fn a_multiplatform_layout_is_measured_where_the_jvm_is_the_only_target() {
700 let root = fixture("kmp-jvm");
706 fs::write(root.join("build.gradle.kts"), "kotlin {\n jvm()\n}\n").unwrap();
707 for (path, body) in [
708 ("src/commonMain/kotlin/Shared.kt", "fun shared() {}"),
709 ("src/jvmMain/kotlin/Jvm.kt", "fun onlyJvm() {}"),
710 ("src/commonTest/kotlin/SharedTest.kt", "fun sharedTest() {}"),
711 ("src/jvmTest/kotlin/JvmTest.kt", "fun jvmTest() {}"),
712 ] {
713 let full = root.join(path);
714 fs::create_dir_all(full.parent().unwrap()).unwrap();
715 fs::write(full, body).unwrap();
716 }
717 let files = discover_jvm_files(&root).expect("discovery");
718 assert_eq!(
719 files
720 .sources
721 .iter()
722 .map(|(p, _)| p.as_str())
723 .collect::<Vec<_>>(),
724 [
725 "src/commonMain/kotlin/Shared.kt",
726 "src/jvmMain/kotlin/Jvm.kt"
727 ]
728 );
729 assert_eq!(
730 files
731 .tests
732 .iter()
733 .map(|(p, _)| p.as_str())
734 .collect::<Vec<_>>(),
735 [
736 "src/commonTest/kotlin/SharedTest.kt",
737 "src/jvmTest/kotlin/JvmTest.kt"
738 ]
739 );
740
741 fs::write(
746 root.join("build.gradle.kts"),
747 "kotlin {\n jvm()\n js(IR) { nodejs() }\n}\n",
748 )
749 .unwrap();
750 let files = discover_jvm_files(&root).expect("discovery");
751 assert_eq!(
752 files
753 .sources
754 .iter()
755 .map(|(p, _)| p.as_str())
756 .collect::<Vec<_>>(),
757 ["src/jvmMain/kotlin/Jvm.kt"]
758 );
759 assert!(
760 files
761 .excluded
762 .iter()
763 .any(|(path, reason)| path == "src/commonMain/kotlin/Shared.kt"
764 && *reason == "shared with a target that has no Supercov runtime"),
765 "{:?}",
766 files.excluded
767 );
768 fs::remove_dir_all(root).unwrap();
769 }
770}