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(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
353/// Integrity inputs: the ambient environment is absent, as it is everywhere.
354pub 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        // Maven and Gradle have meant the same thing by these paths for
399        // twenty years, which is a stronger signal than any name-based guess a
400        // coverage tool could invent. `TestHelper.java` under src/main is
401        // product source; `Calculator.java` under src/test is a test.
402        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        // `target` and `build` hold compiled copies of the same source, and a
448        // file outside `src` was never pointed at by the build. Measuring
449        // either adds obligations nobody agreed to.
450        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        // And the hole it leaves is declared, not merely printed: a
497        // diagnostic scrolls past, a limitation reaches the stored run.
498        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        // Zero of zero satisfies every floor, so reporting success for a
525        // project that proved nothing is worse than refusing to start.
526        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        // Kotlin Multiplatform names its source sets by target, so `src/main`
535        // never appears and the whole project used to be invisible. jvmMain
536        // builds for the JVM and nothing else, so it is measured like any
537        // other main; commonMain builds for every target declared, and a probe
538        // there calls a runtime that exists only on the JVM.
539        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        // Declare a second target and the shared source is left alone, because
576        // instrumenting it would stop the build that produces that target.
577        // Losing its coverage costs a measurement; breaking the build costs
578        // the thing being measured.
579        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}