Skip to main content

supercov_engine/
jvm_project.rs

1//! JVM project discovery for Maven and Gradle layouts.
2//!
3//! Both build systems put tests under `src/test` and product source under
4//! `src/main`, and have done for twenty years. That convention is stronger
5//! than any heuristic a coverage tool could invent, so it is the rule here:
6//! a file's place in the tree decides what it is, not its name.
7//!
8//! Where the convention is absent — a loose file outside `src` — the file is
9//! reported as out of scope rather than guessed at. Measuring source this
10//! project's tests were never pointed at would put obligations in the
11//! denominator nobody agreed to.
12
13use 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
20/// Directories that never hold this project's measured source.
21const EXCLUDED_DIRECTORIES: &[&str] = &[
22    ".git",
23    ".gradle",
24    ".idea",
25    ".mvn",
26    ".vscode",
27    "build",
28    "node_modules",
29    "out",
30    "target",
31];
32
33/// Files that pin what the build resolves to.
34const 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
45/// Configuration that decides what actually executes.
46const CONFIGURATION_FILES: &[&str] = &[".java-version", ".sdkmanrc", "junit-platform.properties"];
47
48#[derive(Debug, Clone, PartialEq, Eq, Default)]
49pub struct JvmFiles {
50    /// Relative, `/`-separated paths of measured application sources, paired
51    /// with the language that reads them.
52    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    /// Each measured source and what it becomes once instrumented, in
67    /// discovery order. Carried rather than recomputed: preparing the
68    /// obligations already parsed and rewrote every file.
69    pub instrumented: Vec<(String, String)>,
70    /// Files that did not parse, with the reason. Reported rather than skipped:
71    /// a hole in the denominator nobody is told about is a wrong number.
72    pub unparseable: Vec<(String, String)>,
73}
74
75/// Which build system, decided by what is actually in the tree.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum JvmBuild {
78    Maven,
79    Gradle,
80    /// Sources laid out conventionally with no build file; still measurable.
81    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
111/// Maven and Gradle both mean the same thing by these paths, and have for
112/// long enough that the convention is more reliable than any name-based guess.
113fn 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        // `src/<sourceSet>/<language>/...`, where the source set is `main`,
120        // `test`, or a custom one a project defined.
121        return match parts.next() {
122            Some("test") | Some("integrationTest") | Some("testFixtures") => Some("test"),
123            Some("main") => Some("main"),
124            // A Kotlin Multiplatform project names its source sets by target:
125            // `jvmMain` builds for the JVM and nothing else, so it is measured
126            // like any other main. `commonMain` builds for every target the
127            // project declares, and a probe there is a call to a runtime that
128            // exists only on the JVM -- so it is measured only where the JVM
129            // is the only target, which `discover_jvm_files` decides.
130            Some("jvmMain") => Some("main"),
131            Some("commonMain") => Some("common"),
132            // Tests are never instrumented on the JVM -- attribution comes
133            // from the framework's own lifecycle -- so recognising a test
134            // source set only decides whether a module has tests at all.
135            Some(set) if set.ends_with("Test") => Some("test"),
136            Some(_) => Some("other"),
137            None => None,
138        };
139    }
140    None
141}
142
143/// Whether the JVM is the only target this build produces.
144///
145/// A Kotlin Multiplatform build compiles `commonMain` for every target it
146/// declares. Instrumenting it inserts a call to a runtime that exists on the
147/// JVM and nowhere else, so where anything but the JVM is declared that source
148/// is left alone: losing its coverage is a cost, and breaking the build that
149/// produces the other targets is not a trade worth making.
150fn 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            // A loose file outside `src` was never pointed at by this
239            // project's build, so measuring it would add obligations nobody
240            // agreed to.
241            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
260/// A file the parser could not read is a hole in the denominator, and a hole
261/// nobody can see is worse than one they can. A diagnostic line scrolls past;
262/// this puts the file in the manifest, so it reaches the declaration's
263/// structural limitations and `supercov runs latest` can still name it long
264/// after the build log is gone.
265fn 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        // The surface is the whole file: there is no construct to quote,
271        // because nothing in it parsed.
272        "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    // Only when roots shaped what is measured: without them this frontend
354    // records no scope, and its default output stays exactly as it was.
355    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
381/// Integrity inputs: the ambient environment is absent, as it is everywhere.
382pub 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        // Maven and Gradle have meant the same thing by these paths for
494        // twenty years, which is a stronger signal than any name-based guess a
495        // coverage tool could invent. `TestHelper.java` under src/main is
496        // product source; `Calculator.java` under src/test is a test.
497        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        // `target` and `build` hold compiled copies of the same source, and a
543        // file outside `src` was never pointed at by the build. Measuring
544        // either adds obligations nobody agreed to.
545        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        // And the hole it leaves is declared, not merely printed: a
592        // diagnostic scrolls past, a limitation reaches the stored run.
593        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        // Zero of zero satisfies every floor, so reporting success for a
620        // project that proved nothing is worse than refusing to start.
621        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        // Kotlin Multiplatform names its source sets by target, so `src/main`
630        // never appears and the whole project used to be invisible. jvmMain
631        // builds for the JVM and nothing else, so it is measured like any
632        // other main; commonMain builds for every target declared, and a probe
633        // there calls a runtime that exists only on the JVM.
634        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        // Declare a second target and the shared source is left alone, because
671        // instrumenting it would stop the build that produces that target.
672        // Losing its coverage costs a measurement; breaking the build costs
673        // the thing being measured.
674        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}