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 };
308 let mut probes = std::collections::BTreeMap::new();
309 let mut widths = Vec::new();
310 let mut instrumented = Vec::new();
311 let mut unparseable = Vec::new();
312 let mut next_probe = 0_u64;
313 let mut next_decision = 0_u32;
314 for (relative, language) in &files.sources {
315 let path = root.join(relative);
316 let Ok(source) = std::fs::read_to_string(&path) else {
317 let reason = "file could not be read as UTF-8";
318 manifest
319 .limitations
320 .push(unparseable_limitation(relative, reason));
321 unparseable.push((relative.clone(), reason.to_owned()));
322 continue;
323 };
324 match build_jvm_obligations(
325 relative,
326 &source,
327 *language,
328 &mut next_probe,
329 &mut next_decision,
330 ) {
331 Ok(obligations) => {
332 manifest.decisions.extend(obligations.manifest.decisions);
333 manifest.points.extend(obligations.manifest.points);
334 manifest.branches.extend(obligations.manifest.branches);
335 manifest
336 .limitations
337 .extend(obligations.manifest.limitations);
338 probes.extend(obligations.probes);
339 widths.extend(obligations.decision_widths);
340 instrumented.push((
341 relative.clone(),
342 crate::jvm_instrumenter::rewrite(&source, &obligations.edits),
343 ));
344 }
345 Err(error) => {
346 manifest
347 .limitations
348 .push(unparseable_limitation(relative, &error.to_string()));
349 unparseable.push((relative.clone(), error.to_string()));
350 }
351 }
352 }
353 if let Some(roots) = roots {
356 let kept = files
357 .sources
358 .iter()
359 .map(|(file, _)| file.clone())
360 .collect::<Vec<_>>();
361 manifest.scope = Some(
362 serde_json::to_value(
363 roots
364 .scope(&kept, &files.excluded)
365 .map_err(|error| error.to_string())?,
366 )
367 .map_err(|error| error.to_string())?,
368 );
369 }
370 Ok(PreparedJvmProject {
371 root: root.to_owned(),
372 files,
373 manifest,
374 probes,
375 decision_widths: widths,
376 instrumented,
377 unparseable,
378 })
379}
380
381pub fn jvm_integrity_inputs(files: &JvmFiles, command: &[String]) -> ExplicitIntegrityInputs {
383 ExplicitIntegrityInputs {
384 source_files: files
385 .sources
386 .iter()
387 .map(|(path, _)| PathBuf::from(path))
388 .collect(),
389 test_files: files
390 .tests
391 .iter()
392 .map(|(path, _)| PathBuf::from(path))
393 .collect(),
394 dependency_files: files.dependency_files.clone(),
395 configuration_files: files.configuration_files.clone(),
396 execution_configuration: command.join("\0").into_bytes(),
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use std::fs;
404
405 fn fixture(label: &str) -> PathBuf {
406 let root = std::env::temp_dir().join(format!(
407 "supercov-jvm-project-{label}-{}-{}",
408 std::process::id(),
409 std::time::SystemTime::now()
410 .duration_since(std::time::UNIX_EPOCH)
411 .unwrap()
412 .as_nanos()
413 ));
414 fs::create_dir_all(&root).unwrap();
415 root
416 }
417
418 fn write(root: &Path, relative: &str, contents: &str) {
419 let path = root.join(relative);
420 fs::create_dir_all(path.parent().unwrap()).unwrap();
421 fs::write(path, contents).unwrap();
422 }
423
424 fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
425 let roots = roots
426 .iter()
427 .map(|root| (*root).to_owned())
428 .collect::<Vec<_>>();
429 crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
430 }
431
432 #[test]
433 fn explicit_roots_narrow_the_jvm_to_the_code_they_name() {
434 let root = fixture("explicit-roots");
435 write(&root, "pom.xml", "<project/>");
436 write(
437 &root,
438 "src/main/java/app/Good.java",
439 "class Good { int f(int a) { return a; } }",
440 );
441 write(
442 &root,
443 "src/main/java/shaded/Dep.java",
444 "class Dep { int g() { return 1; } }",
445 );
446
447 let project =
448 prepare_jvm_project(&root, Some(&explicit_roots(&root, &["src/main/java/app"])))
449 .unwrap();
450 assert_eq!(
451 project
452 .files
453 .sources
454 .iter()
455 .map(|(file, _)| file.as_str())
456 .collect::<Vec<_>>(),
457 ["src/main/java/app/Good.java"],
458 "{:?}",
459 project.files
460 );
461 assert!(
462 project.files.excluded.contains(&(
463 "src/main/java/shaded/Dep.java".into(),
464 "outside explicit source roots"
465 )),
466 "{:?}",
467 project.files.excluded
468 );
469 let scope: crate::source_discovery::SourceScope = serde_json::from_value(
470 project
471 .manifest
472 .scope
473 .clone()
474 .expect("explicit roots leave a scope"),
475 )
476 .unwrap();
477 assert_eq!(
478 scope.mode,
479 crate::source_discovery::SourceScopeMode::Explicit
480 );
481 assert!(
482 prepare_jvm_project(&root, None)
483 .unwrap()
484 .manifest
485 .scope
486 .is_none()
487 );
488 fs::remove_dir_all(root).ok();
489 }
490
491 #[test]
492 fn the_source_set_decides_what_a_file_is_not_its_name() {
493 let root = fixture("layout");
498 write(&root, "pom.xml", "<project/>");
499 write(
500 &root,
501 "src/main/java/app/TestHelper.java",
502 "class TestHelper {}",
503 );
504 write(&root, "src/main/kotlin/app/Api.kt", "class Api");
505 write(
506 &root,
507 "src/test/java/app/Calculator.java",
508 "class Calculator {}",
509 );
510 write(&root, "src/test/kotlin/app/ApiTest.kt", "class ApiTest");
511
512 let files = discover_jvm_files(&root).unwrap();
513 assert_eq!(
514 files.sources,
515 [
516 (
517 "src/main/java/app/TestHelper.java".to_owned(),
518 JvmLanguage::Java
519 ),
520 ("src/main/kotlin/app/Api.kt".to_owned(), JvmLanguage::Kotlin),
521 ]
522 );
523 assert_eq!(
524 files.tests,
525 [
526 (
527 "src/test/java/app/Calculator.java".to_owned(),
528 JvmLanguage::Java
529 ),
530 (
531 "src/test/kotlin/app/ApiTest.kt".to_owned(),
532 JvmLanguage::Kotlin
533 ),
534 ]
535 );
536 assert_eq!(detect_build(&root), JvmBuild::Maven);
537 fs::remove_dir_all(root).unwrap();
538 }
539
540 #[test]
541 fn build_output_and_loose_files_are_out_of_scope_rather_than_guessed_at() {
542 let root = fixture("scope");
546 write(&root, "build.gradle.kts", "plugins { java }");
547 write(&root, "gradle/libs.versions.toml", "[versions]");
548 write(&root, ".java-version", "21");
549 write(&root, "src/main/java/app/Api.java", "class Api {}");
550 write(&root, "target/classes/app/Api.java", "class Api {}");
551 write(&root, "build/generated/app/Gen.java", "class Gen {}");
552 write(&root, "Scratch.java", "class Scratch {}");
553
554 let files = discover_jvm_files(&root).unwrap();
555 assert_eq!(files.sources.len(), 1);
556 assert_eq!(files.sources[0].0, "src/main/java/app/Api.java");
557 assert!(
558 files
559 .excluded
560 .iter()
561 .any(|(path, why)| path == "Scratch.java" && *why == "outside a src source set")
562 );
563 assert_eq!(detect_build(&root), JvmBuild::Gradle);
564 assert!(
565 files
566 .dependency_files
567 .contains(&PathBuf::from("gradle/libs.versions.toml"))
568 );
569 assert_eq!(files.configuration_files, [PathBuf::from(".java-version")]);
570 fs::remove_dir_all(root).unwrap();
571 }
572
573 #[test]
574 fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
575 let root = fixture("unparseable");
576 write(&root, "pom.xml", "<project/>");
577 write(
578 &root,
579 "src/main/java/app/Good.java",
580 "class Good { boolean f(int a, boolean b) { if (a > 1 && b) { return true; } return false; } }",
581 );
582 write(
583 &root,
584 "src/main/java/app/Broken.java",
585 "class Broken { void f( {",
586 );
587
588 let project = prepare_jvm_project(&root, None).unwrap();
589 assert_eq!(project.unparseable.len(), 1);
590 assert!(project.unparseable[0].0.ends_with("Broken.java"));
591 let declared = project
594 .manifest
595 .limitations
596 .iter()
597 .filter(|limitation| limitation["kind"] == "file-does-not-parse")
598 .collect::<Vec<_>>();
599 assert_eq!(declared.len(), 1, "{declared:?}");
600 assert!(
601 declared[0]["file"]
602 .as_str()
603 .unwrap()
604 .ends_with("Broken.java"),
605 "{declared:?}"
606 );
607 assert!(
608 declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
609 "the declaration needs an id to reference: {declared:?}"
610 );
611 assert_eq!(project.manifest.decisions.len(), 1);
612 assert_eq!(project.decision_widths, [2]);
613 assert!(!project.probes.is_empty());
614 fs::remove_dir_all(root).unwrap();
615 }
616
617 #[test]
618 fn a_project_with_nothing_to_measure_is_refused() {
619 let root = fixture("empty");
622 write(&root, "pom.xml", "<project/>");
623 assert!(prepare_jvm_project(&root, None).is_err());
624 fs::remove_dir_all(root).unwrap();
625 }
626
627 #[test]
628 fn a_multiplatform_layout_is_measured_where_the_jvm_is_the_only_target() {
629 let root = fixture("kmp-jvm");
635 fs::write(root.join("build.gradle.kts"), "kotlin {\n jvm()\n}\n").unwrap();
636 for (path, body) in [
637 ("src/commonMain/kotlin/Shared.kt", "fun shared() {}"),
638 ("src/jvmMain/kotlin/Jvm.kt", "fun onlyJvm() {}"),
639 ("src/commonTest/kotlin/SharedTest.kt", "fun sharedTest() {}"),
640 ("src/jvmTest/kotlin/JvmTest.kt", "fun jvmTest() {}"),
641 ] {
642 let full = root.join(path);
643 fs::create_dir_all(full.parent().unwrap()).unwrap();
644 fs::write(full, body).unwrap();
645 }
646 let files = discover_jvm_files(&root).expect("discovery");
647 assert_eq!(
648 files
649 .sources
650 .iter()
651 .map(|(p, _)| p.as_str())
652 .collect::<Vec<_>>(),
653 [
654 "src/commonMain/kotlin/Shared.kt",
655 "src/jvmMain/kotlin/Jvm.kt"
656 ]
657 );
658 assert_eq!(
659 files
660 .tests
661 .iter()
662 .map(|(p, _)| p.as_str())
663 .collect::<Vec<_>>(),
664 [
665 "src/commonTest/kotlin/SharedTest.kt",
666 "src/jvmTest/kotlin/JvmTest.kt"
667 ]
668 );
669
670 fs::write(
675 root.join("build.gradle.kts"),
676 "kotlin {\n jvm()\n js(IR) { nodejs() }\n}\n",
677 )
678 .unwrap();
679 let files = discover_jvm_files(&root).expect("discovery");
680 assert_eq!(
681 files
682 .sources
683 .iter()
684 .map(|(p, _)| p.as_str())
685 .collect::<Vec<_>>(),
686 ["src/jvmMain/kotlin/Jvm.kt"]
687 );
688 assert!(
689 files
690 .excluded
691 .iter()
692 .any(|(path, reason)| path == "src/commonMain/kotlin/Shared.kt"
693 && *reason == "shared with a target that has no Supercov runtime"),
694 "{:?}",
695 files.excluded
696 );
697 fs::remove_dir_all(root).unwrap();
698 }
699}