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        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    // Only when roots shaped what is measured: without them this frontend
355    // records no scope, and its default output stays exactly as it was.
356    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
382/// Integrity inputs: the ambient environment is absent, as it is everywhere.
383pub 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        // Maven and Gradle have meant the same thing by these paths for
495        // twenty years, which is a stronger signal than any name-based guess a
496        // coverage tool could invent. `TestHelper.java` under src/main is
497        // product source; `Calculator.java` under src/test is a test.
498        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        // `target` and `build` hold compiled copies of the same source, and a
544        // file outside `src` was never pointed at by the build. Measuring
545        // either adds obligations nobody agreed to.
546        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        // And the hole it leaves is declared, not merely printed: a
593        // diagnostic scrolls past, a limitation reaches the stored run.
594        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        // Zero of zero satisfies every floor, so reporting success for a
621        // project that proved nothing is worse than refusing to start.
622        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        // Kotlin Multiplatform names its source sets by target, so `src/main`
631        // never appears and the whole project used to be invisible. jvmMain
632        // builds for the JVM and nothing else, so it is measured like any
633        // other main; commonMain builds for every target declared, and a probe
634        // there calls a runtime that exists only on the JVM.
635        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        // Declare a second target and the shared source is left alone, because
672        // instrumenting it would stop the build that produces that target.
673        // Losing its coverage costs a measurement; breaking the build costs
674        // the thing being measured.
675        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}