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(root: &Path) -> Result<PreparedJvmProject, String> {
282 let files = discover_jvm_files(root)?;
283 if files.sources.is_empty() && files.tests.is_empty() {
284 return Err(
285 "no Java or Kotlin sources were found under src/main or src/test; Supercov measures the source sets Maven and Gradle define"
286 .into(),
287 );
288 }
289 let mut manifest = CoverageManifest {
290 decisions: Vec::new(),
291 points: Vec::new(),
292 branches: Vec::new(),
293 limitations: Vec::new(),
294 unmeasured: Vec::new(),
295 scope: None,
296 };
297 let mut probes = std::collections::BTreeMap::new();
298 let mut widths = Vec::new();
299 let mut instrumented = Vec::new();
300 let mut unparseable = Vec::new();
301 let mut next_probe = 0_u64;
302 let mut next_decision = 0_u32;
303 for (relative, language) in &files.sources {
304 let path = root.join(relative);
305 let Ok(source) = std::fs::read_to_string(&path) else {
306 let reason = "file could not be read as UTF-8";
307 manifest
308 .limitations
309 .push(unparseable_limitation(relative, reason));
310 unparseable.push((relative.clone(), reason.to_owned()));
311 continue;
312 };
313 match build_jvm_obligations(
314 relative,
315 &source,
316 *language,
317 &mut next_probe,
318 &mut next_decision,
319 ) {
320 Ok(obligations) => {
321 manifest.decisions.extend(obligations.manifest.decisions);
322 manifest.points.extend(obligations.manifest.points);
323 manifest.branches.extend(obligations.manifest.branches);
324 manifest
325 .limitations
326 .extend(obligations.manifest.limitations);
327 probes.extend(obligations.probes);
328 widths.extend(obligations.decision_widths);
329 instrumented.push((
330 relative.clone(),
331 crate::jvm_instrumenter::rewrite(&source, &obligations.edits),
332 ));
333 }
334 Err(error) => {
335 manifest
336 .limitations
337 .push(unparseable_limitation(relative, &error.to_string()));
338 unparseable.push((relative.clone(), error.to_string()));
339 }
340 }
341 }
342 Ok(PreparedJvmProject {
343 root: root.to_owned(),
344 files,
345 manifest,
346 probes,
347 decision_widths: widths,
348 instrumented,
349 unparseable,
350 })
351}
352
353pub fn jvm_integrity_inputs(files: &JvmFiles, command: &[String]) -> ExplicitIntegrityInputs {
355 ExplicitIntegrityInputs {
356 source_files: files
357 .sources
358 .iter()
359 .map(|(path, _)| PathBuf::from(path))
360 .collect(),
361 test_files: files
362 .tests
363 .iter()
364 .map(|(path, _)| PathBuf::from(path))
365 .collect(),
366 dependency_files: files.dependency_files.clone(),
367 configuration_files: files.configuration_files.clone(),
368 execution_configuration: command.join("\0").into_bytes(),
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use std::fs;
376
377 fn fixture(label: &str) -> PathBuf {
378 let root = std::env::temp_dir().join(format!(
379 "supercov-jvm-project-{label}-{}-{}",
380 std::process::id(),
381 std::time::SystemTime::now()
382 .duration_since(std::time::UNIX_EPOCH)
383 .unwrap()
384 .as_nanos()
385 ));
386 fs::create_dir_all(&root).unwrap();
387 root
388 }
389
390 fn write(root: &Path, relative: &str, contents: &str) {
391 let path = root.join(relative);
392 fs::create_dir_all(path.parent().unwrap()).unwrap();
393 fs::write(path, contents).unwrap();
394 }
395
396 #[test]
397 fn the_source_set_decides_what_a_file_is_not_its_name() {
398 let root = fixture("layout");
403 write(&root, "pom.xml", "<project/>");
404 write(
405 &root,
406 "src/main/java/app/TestHelper.java",
407 "class TestHelper {}",
408 );
409 write(&root, "src/main/kotlin/app/Api.kt", "class Api");
410 write(
411 &root,
412 "src/test/java/app/Calculator.java",
413 "class Calculator {}",
414 );
415 write(&root, "src/test/kotlin/app/ApiTest.kt", "class ApiTest");
416
417 let files = discover_jvm_files(&root).unwrap();
418 assert_eq!(
419 files.sources,
420 [
421 (
422 "src/main/java/app/TestHelper.java".to_owned(),
423 JvmLanguage::Java
424 ),
425 ("src/main/kotlin/app/Api.kt".to_owned(), JvmLanguage::Kotlin),
426 ]
427 );
428 assert_eq!(
429 files.tests,
430 [
431 (
432 "src/test/java/app/Calculator.java".to_owned(),
433 JvmLanguage::Java
434 ),
435 (
436 "src/test/kotlin/app/ApiTest.kt".to_owned(),
437 JvmLanguage::Kotlin
438 ),
439 ]
440 );
441 assert_eq!(detect_build(&root), JvmBuild::Maven);
442 fs::remove_dir_all(root).unwrap();
443 }
444
445 #[test]
446 fn build_output_and_loose_files_are_out_of_scope_rather_than_guessed_at() {
447 let root = fixture("scope");
451 write(&root, "build.gradle.kts", "plugins { java }");
452 write(&root, "gradle/libs.versions.toml", "[versions]");
453 write(&root, ".java-version", "21");
454 write(&root, "src/main/java/app/Api.java", "class Api {}");
455 write(&root, "target/classes/app/Api.java", "class Api {}");
456 write(&root, "build/generated/app/Gen.java", "class Gen {}");
457 write(&root, "Scratch.java", "class Scratch {}");
458
459 let files = discover_jvm_files(&root).unwrap();
460 assert_eq!(files.sources.len(), 1);
461 assert_eq!(files.sources[0].0, "src/main/java/app/Api.java");
462 assert!(
463 files
464 .excluded
465 .iter()
466 .any(|(path, why)| path == "Scratch.java" && *why == "outside a src source set")
467 );
468 assert_eq!(detect_build(&root), JvmBuild::Gradle);
469 assert!(
470 files
471 .dependency_files
472 .contains(&PathBuf::from("gradle/libs.versions.toml"))
473 );
474 assert_eq!(files.configuration_files, [PathBuf::from(".java-version")]);
475 fs::remove_dir_all(root).unwrap();
476 }
477
478 #[test]
479 fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
480 let root = fixture("unparseable");
481 write(&root, "pom.xml", "<project/>");
482 write(
483 &root,
484 "src/main/java/app/Good.java",
485 "class Good { boolean f(int a, boolean b) { if (a > 1 && b) { return true; } return false; } }",
486 );
487 write(
488 &root,
489 "src/main/java/app/Broken.java",
490 "class Broken { void f( {",
491 );
492
493 let project = prepare_jvm_project(&root).unwrap();
494 assert_eq!(project.unparseable.len(), 1);
495 assert!(project.unparseable[0].0.ends_with("Broken.java"));
496 let declared = project
499 .manifest
500 .limitations
501 .iter()
502 .filter(|limitation| limitation["kind"] == "file-does-not-parse")
503 .collect::<Vec<_>>();
504 assert_eq!(declared.len(), 1, "{declared:?}");
505 assert!(
506 declared[0]["file"]
507 .as_str()
508 .unwrap()
509 .ends_with("Broken.java"),
510 "{declared:?}"
511 );
512 assert!(
513 declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
514 "the declaration needs an id to reference: {declared:?}"
515 );
516 assert_eq!(project.manifest.decisions.len(), 1);
517 assert_eq!(project.decision_widths, [2]);
518 assert!(!project.probes.is_empty());
519 fs::remove_dir_all(root).unwrap();
520 }
521
522 #[test]
523 fn a_project_with_nothing_to_measure_is_refused() {
524 let root = fixture("empty");
527 write(&root, "pom.xml", "<project/>");
528 assert!(prepare_jvm_project(&root).is_err());
529 fs::remove_dir_all(root).unwrap();
530 }
531
532 #[test]
533 fn a_multiplatform_layout_is_measured_where_the_jvm_is_the_only_target() {
534 let root = fixture("kmp-jvm");
540 fs::write(root.join("build.gradle.kts"), "kotlin {\n jvm()\n}\n").unwrap();
541 for (path, body) in [
542 ("src/commonMain/kotlin/Shared.kt", "fun shared() {}"),
543 ("src/jvmMain/kotlin/Jvm.kt", "fun onlyJvm() {}"),
544 ("src/commonTest/kotlin/SharedTest.kt", "fun sharedTest() {}"),
545 ("src/jvmTest/kotlin/JvmTest.kt", "fun jvmTest() {}"),
546 ] {
547 let full = root.join(path);
548 fs::create_dir_all(full.parent().unwrap()).unwrap();
549 fs::write(full, body).unwrap();
550 }
551 let files = discover_jvm_files(&root).expect("discovery");
552 assert_eq!(
553 files
554 .sources
555 .iter()
556 .map(|(p, _)| p.as_str())
557 .collect::<Vec<_>>(),
558 [
559 "src/commonMain/kotlin/Shared.kt",
560 "src/jvmMain/kotlin/Jvm.kt"
561 ]
562 );
563 assert_eq!(
564 files
565 .tests
566 .iter()
567 .map(|(p, _)| p.as_str())
568 .collect::<Vec<_>>(),
569 [
570 "src/commonTest/kotlin/SharedTest.kt",
571 "src/jvmTest/kotlin/JvmTest.kt"
572 ]
573 );
574
575 fs::write(
580 root.join("build.gradle.kts"),
581 "kotlin {\n jvm()\n js(IR) { nodejs() }\n}\n",
582 )
583 .unwrap();
584 let files = discover_jvm_files(&root).expect("discovery");
585 assert_eq!(
586 files
587 .sources
588 .iter()
589 .map(|(p, _)| p.as_str())
590 .collect::<Vec<_>>(),
591 ["src/jvmMain/kotlin/Jvm.kt"]
592 );
593 assert!(
594 files
595 .excluded
596 .iter()
597 .any(|(path, reason)| path == "src/commonMain/kotlin/Shared.kt"
598 && *reason == "shared with a target that has no Supercov runtime"),
599 "{:?}",
600 files.excluded
601 );
602 fs::remove_dir_all(root).unwrap();
603 }
604}