Skip to main content

supercov_engine/
go_project.rs

1//! Go project discovery: what Supercov measures, and what it deliberately does not.
2//!
3//! Go settles two questions other languages leave ambiguous. A test file is
4//! exactly one ending in `_test.go` — the toolchain enforces it, so there is no
5//! heuristic to get wrong. And `vendor/` and `testdata/` have meanings fixed by
6//! the toolchain: vendored code is someone else's, and the compiler ignores
7//! `testdata` entirely, so measuring either would put obligations on code this
8//! project's tests were never meant to exercise.
9
10use std::collections::BTreeSet;
11use std::path::{Path, PathBuf};
12
13use crate::coverage_report::CoverageManifest;
14use crate::go_instrumenter::{GoProbe, build_go_obligations};
15use crate::integrity::ExplicitIntegrityInputs;
16
17/// Directories whose contents are never this project's measured source.
18///
19/// `vendor` and `testdata` are toolchain-defined. The rest are conventional
20/// build and tooling output that happens to contain `.go` files.
21const EXCLUDED_DIRECTORIES: &[&str] = &[
22    ".git",
23    ".idea",
24    ".vscode",
25    "bin",
26    "node_modules",
27    "testdata",
28    "third_party",
29    "vendor",
30];
31
32/// Files that pin what the build resolves to.
33const DEPENDENCY_FILES: &[&str] = &[
34    "go.mod",
35    "go.sum",
36    "go.work",
37    "go.work.sum",
38    "vendor/modules.txt",
39];
40
41/// Configuration that decides what actually executes.
42const CONFIGURATION_FILES: &[&str] = &[".go-version", "go.env"];
43
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct GoFiles {
46    /// Relative, `/`-separated paths of measured application sources.
47    pub sources: Vec<String>,
48    /// Relative paths of `_test.go` files.
49    pub tests: Vec<String>,
50    pub dependency_files: Vec<PathBuf>,
51    pub configuration_files: Vec<PathBuf>,
52    pub excluded: Vec<(String, &'static str)>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct PreparedGoProject {
57    pub root: PathBuf,
58    pub files: GoFiles,
59    pub manifest: CoverageManifest,
60    pub probes: std::collections::BTreeMap<u64, GoProbe>,
61    /// Each measured source and what it becomes once instrumented, in
62    /// discovery order. Carried rather than recomputed: preparing the
63    /// obligations already parsed and rewrote every file, and parsing a
64    /// project twice to get the same answer is the kind of cost that shows up
65    /// as a slow tool with no explanation.
66    pub instrumented: Vec<(String, String)>,
67    /// Conditions per decision, indexed the way the runtime indexes its
68    /// decision state.
69    pub decision_widths: Vec<u8>,
70    /// Files that did not parse, with the reason. They are reported rather
71    /// than skipped silently: a file Supercov cannot read is a hole in the
72    /// denominator, and a hole nobody is told about is a wrong number.
73    pub unparseable: Vec<(String, String)>,
74}
75
76/// A Go test file is exactly one whose name ends `_test.go`. The toolchain
77/// enforces this, so unlike every other language there is no guessing here.
78pub fn is_test_file(relative: &str) -> bool {
79    relative
80        .rsplit('/')
81        .next()
82        .is_some_and(|name| name.ends_with("_test.go"))
83}
84
85/// The module directories a `go.work` declares, relative to the workspace root
86/// and `/`-separated.
87///
88/// Empty when there is no workspace, which is also the answer for a repository
89/// that simply has one module at its root.
90pub fn workspace_modules(root: &Path) -> BTreeSet<String> {
91    let Ok(text) = std::fs::read_to_string(root.join("go.work")) else {
92        return BTreeSet::new();
93    };
94    let mut modules = BTreeSet::new();
95    let mut in_block = false;
96    for line in text.lines() {
97        let line = line.split("//").next().unwrap_or_default().trim();
98        if line.is_empty() {
99            continue;
100        }
101        // `use ./a`, or a `use (` block of one directory per line.
102        let entry = if in_block {
103            if line == ")" {
104                in_block = false;
105                continue;
106            }
107            Some(line)
108        } else if let Some(rest) = line.strip_prefix("use ") {
109            let rest = rest.trim();
110            if rest == "(" {
111                in_block = true;
112                continue;
113            }
114            Some(rest)
115        } else {
116            if line.starts_with("use(") {
117                in_block = true;
118            }
119            None
120        };
121        if let Some(entry) = entry {
122            let entry = entry.trim_matches('"').trim();
123            let entry = entry.strip_prefix("./").unwrap_or(entry);
124            let entry = entry.trim_end_matches('/');
125            if !entry.is_empty() {
126                modules.insert(if entry == "." {
127                    ".".to_owned()
128                } else {
129                    entry.replace('\\', "/")
130                });
131            }
132        }
133    }
134    modules
135}
136
137fn walk(root: &Path, directory: &Path, files: &mut GoFiles) -> Result<(), String> {
138    let members = workspace_modules(root);
139    let entries = std::fs::read_dir(directory)
140        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
141    let mut sorted = entries
142        .collect::<Result<Vec<_>, _>>()
143        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
144    sorted.sort_by_key(std::fs::DirEntry::path);
145    for entry in sorted {
146        let path = entry.path();
147        let Ok(relative) = path.strip_prefix(root) else {
148            continue;
149        };
150        let relative = relative.to_string_lossy().replace('\\', "/");
151        let name = path
152            .file_name()
153            .map(|name| name.to_string_lossy().into_owned())
154            .unwrap_or_default();
155        let file_type = entry
156            .file_type()
157            .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
158        if file_type.is_dir() {
159            if EXCLUDED_DIRECTORIES.contains(&name.as_str()) || name.starts_with('.') {
160                files
161                    .excluded
162                    .push((relative, "tooling or vendored directory"));
163                continue;
164            }
165            // A directory with a go.mod of its own is a different module. The
166            // toolchain does not compile it as part of this one -- `go test
167            // ./...` walks straight past it -- so measuring it would put
168            // obligations in the denominator that no test here can reach, and
169            // writing a probe file there would import a runtime the nested
170            // module cannot resolve. That turns a build that worked into one
171            // that does not.
172            if path.join("go.mod").is_file() && !members.contains(&relative) {
173                files.excluded.push((relative, "a module of its own"));
174                continue;
175            }
176            walk(root, &path, files)?;
177            continue;
178        }
179        if !file_type.is_file() {
180            continue;
181        }
182        if DEPENDENCY_FILES.contains(&relative.as_str()) {
183            files.dependency_files.push(PathBuf::from(&relative));
184            continue;
185        }
186        if CONFIGURATION_FILES.contains(&name.as_str()) {
187            files.configuration_files.push(PathBuf::from(&relative));
188            continue;
189        }
190        if !name.ends_with(".go") {
191            continue;
192        }
193        if is_test_file(&relative) {
194            files.tests.push(relative);
195        } else {
196            files.sources.push(relative);
197        }
198    }
199    Ok(())
200}
201
202pub fn discover_go_files(root: &Path) -> Result<GoFiles, String> {
203    let mut files = GoFiles::default();
204    walk(root, root, &mut files)?;
205    files.sources.sort();
206    files.tests.sort();
207    files.dependency_files.sort();
208    files.configuration_files.sort();
209    files.excluded.sort();
210    Ok(files)
211}
212
213const LANGUAGE: &str = "go";
214
215/// A file the parser could not read is a hole in the denominator, and a hole
216/// nobody can see is worse than one they can. A diagnostic line scrolls past;
217/// this puts the file in the manifest, so it reaches the declaration's
218/// structural limitations and `supercov runs latest` can still name it long
219/// after the build log is gone.
220pub(crate) fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
221    serde_json::json!({
222        "id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
223        "kind": "file-does-not-parse",
224        "file": file,
225        // The surface is the whole file: there is no construct to quote,
226        // because nothing in it parsed.
227        "source": file,
228        "line": 1,
229        "column": 1,
230        "reason": format!(
231            "{reason}; the file carries no obligations and nothing in it counts towards this run"
232        ),
233    })
234}
235
236pub fn prepare_go_project(
237    root: &Path,
238    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
239) -> Result<PreparedGoProject, String> {
240    let mut files = discover_go_files(root)?;
241    if let Some(roots) = roots {
242        roots.narrow(&mut files.sources, &mut files.excluded, String::as_str);
243        roots.refuse_if_empty(files.sources.len(), "Go")?;
244    }
245    if files.sources.is_empty() && files.tests.is_empty() {
246        return Err(
247            "no Go source files were found under the project root; Supercov measures .go files outside vendor, testdata and tooling directories"
248                .into(),
249        );
250    }
251    let mut manifest = CoverageManifest {
252        decisions: Vec::new(),
253        points: Vec::new(),
254        branches: Vec::new(),
255        limitations: Vec::new(),
256        unmeasured: Vec::new(),
257        scope: None,
258        assertion_sites: Vec::new(),
259    };
260    let mut probes = std::collections::BTreeMap::new();
261    let mut instrumented = Vec::new();
262    let mut decision_widths = Vec::new();
263    let mut unparseable = Vec::new();
264    let mut next_probe = 0_u64;
265    let mut next_decision = 0_u32;
266    for relative in &files.sources {
267        let path = root.join(relative);
268        let Ok(source) = std::fs::read_to_string(&path) else {
269            let reason = "file could not be read as UTF-8";
270            manifest
271                .limitations
272                .push(unparseable_limitation(relative, reason));
273            unparseable.push((relative.clone(), reason.to_owned()));
274            continue;
275        };
276        match build_go_obligations(relative, &source, &mut next_probe, &mut next_decision) {
277            Ok(obligations) => {
278                manifest.decisions.extend(obligations.manifest.decisions);
279                manifest.points.extend(obligations.manifest.points);
280                manifest.branches.extend(obligations.manifest.branches);
281                // What the file could not be measured for travels with what it
282                // could. Dropping these left the manifest silently claiming a
283                // completeness it had not established.
284                manifest
285                    .limitations
286                    .extend(obligations.manifest.limitations);
287                probes.extend(obligations.probes);
288                decision_widths.extend(obligations.decision_widths);
289                instrumented.push((
290                    relative.clone(),
291                    crate::go_instrumenter::rewrite(&source, &obligations.edits),
292                ));
293            }
294            Err(error) => {
295                manifest
296                    .limitations
297                    .push(unparseable_limitation(relative, &error.to_string()));
298                unparseable.push((relative.clone(), error.to_string()));
299            }
300        }
301    }
302    // Only when roots shaped what is measured: without them this frontend
303    // records no scope, and its default output stays exactly as it was.
304    if let Some(roots) = roots {
305        let kept = files.sources.clone();
306        manifest.scope = Some(
307            serde_json::to_value(
308                roots
309                    .scope(&kept, &files.excluded)
310                    .map_err(|error| error.to_string())?,
311            )
312            .map_err(|error| error.to_string())?,
313        );
314    }
315    Ok(PreparedGoProject {
316        root: root.to_owned(),
317        files,
318        manifest,
319        probes,
320        instrumented,
321        decision_widths,
322        unparseable,
323    })
324}
325
326/// Integrity inputs: sources and tests are hashed separately, dependency and
327/// configuration files identify the environment, and the test command
328/// identifies execution.
329///
330/// The ambient environment is deliberately absent, for the same reason it is
331/// absent everywhere else: it is not a property of the project.
332pub fn go_integrity_inputs(files: &GoFiles, command: &[String]) -> ExplicitIntegrityInputs {
333    ExplicitIntegrityInputs {
334        source_files: files.sources.iter().map(PathBuf::from).collect(),
335        test_files: files.tests.iter().map(PathBuf::from).collect(),
336        dependency_files: files.dependency_files.clone(),
337        configuration_files: files.configuration_files.clone(),
338        execution_configuration: command.join("\0").into_bytes(),
339    }
340}
341
342/// The module path declared in `go.mod`, which package identities are relative
343/// to.
344pub fn module_path(root: &Path) -> Option<String> {
345    let text = std::fs::read_to_string(root.join("go.mod")).ok()?;
346    text.lines()
347        .map(str::trim)
348        .find_map(|line| line.strip_prefix("module "))
349        .map(|path| path.trim().to_owned())
350}
351
352/// Package directories that hold at least one test file, which is the unit
353/// `go test` actually runs.
354pub fn test_packages(files: &GoFiles) -> Vec<String> {
355    files
356        .tests
357        .iter()
358        .map(|test| match test.rsplit_once('/') {
359            Some((directory, _)) => directory.to_owned(),
360            None => ".".to_owned(),
361        })
362        .collect::<BTreeSet<_>>()
363        .into_iter()
364        .collect()
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use std::fs;
371
372    fn fixture(label: &str) -> PathBuf {
373        let root = std::env::temp_dir().join(format!(
374            "supercov-go-project-{label}-{}-{}",
375            std::process::id(),
376            std::time::SystemTime::now()
377                .duration_since(std::time::UNIX_EPOCH)
378                .unwrap()
379                .as_nanos()
380        ));
381        fs::create_dir_all(&root).unwrap();
382        root
383    }
384
385    fn write(root: &Path, relative: &str, contents: &str) {
386        let path = root.join(relative);
387        fs::create_dir_all(path.parent().unwrap()).unwrap();
388        fs::write(path, contents).unwrap();
389    }
390
391    fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
392        let roots = roots
393            .iter()
394            .map(|root| (*root).to_owned())
395            .collect::<Vec<_>>();
396        crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
397    }
398
399    #[test]
400    fn explicit_roots_narrow_go_to_the_code_they_name() {
401        // `extra`, not `third_party`: Go already leaves third_party, vendor and
402        // testdata out by default, and a test passing for that reason would
403        // prove nothing about roots. Go had no scope to record at all, so honouring the variable there has
404        // to leave a trace of what it did, or it is as invisible as ignoring it.
405        let root = fixture("explicit-roots");
406        write(&root, "go.mod", "module example.com/app\n");
407        write(
408            &root,
409            "app.go",
410            "package main\n\nfunc f(a int) int {\n\treturn a\n}\n",
411        );
412        write(
413            &root,
414            "extra/dep.go",
415            "package extra\n\nfunc G() int {\n\treturn 1\n}\n",
416        );
417
418        let project = prepare_go_project(&root, Some(&explicit_roots(&root, &["app.go"]))).unwrap();
419        assert_eq!(project.files.sources, ["app.go"], "{:?}", project.files);
420        assert!(
421            project
422                .files
423                .excluded
424                .contains(&("extra/dep.go".into(), "outside explicit source roots")),
425            "{:?}",
426            project.files.excluded
427        );
428        let scope: crate::source_discovery::SourceScope = serde_json::from_value(
429            project
430                .manifest
431                .scope
432                .clone()
433                .expect("explicit roots leave a scope"),
434        )
435        .unwrap();
436        assert_eq!(
437            scope.mode,
438            crate::source_discovery::SourceScopeMode::Explicit
439        );
440        assert_eq!(scope.roots, ["app.go"]);
441
442        // Unset, Go records no scope, exactly as before.
443        assert!(
444            prepare_go_project(&root, None)
445                .unwrap()
446                .manifest
447                .scope
448                .is_none()
449        );
450        fs::remove_dir_all(root).ok();
451    }
452
453    #[test]
454    fn a_test_file_is_the_one_the_toolchain_says_it_is() {
455        // Go settles this: `_test.go` and nothing else. No directory-name
456        // heuristic to disagree with the compiler about.
457        for test in ["main_test.go", "pkg/api/handler_test.go", "a/b/z_test.go"] {
458            assert!(is_test_file(test), "{test}");
459        }
460        for source in ["main.go", "pkg/api/handler.go", "testing.go", "pkg/test.go"] {
461            assert!(!is_test_file(source), "{source}");
462        }
463    }
464
465    #[test]
466    fn vendored_and_toolchain_directories_are_not_this_project_s_source() {
467        // `vendor` is someone else's code and `testdata` is ignored by the
468        // compiler itself; measuring either puts obligations on code these
469        // tests were never meant to exercise.
470        let root = fixture("scope");
471        write(&root, "go.mod", "module example.com/app\n\ngo 1.22\n");
472        write(&root, "go.sum", "");
473        write(&root, ".go-version", "1.22.0\n");
474        write(&root, "main.go", "package main\n\nfunc main() {}\n");
475        write(
476            &root,
477            "pkg/api/handler.go",
478            "package api\n\nfunc Handle() int {\n\treturn 1\n}\n",
479        );
480        write(
481            &root,
482            "pkg/api/handler_test.go",
483            "package api\n\nimport \"testing\"\n\nfunc TestHandle(t *testing.T) {}\n",
484        );
485        write(
486            &root,
487            "vendor/other/lib.go",
488            "package other\n\nfunc X() {}\n",
489        );
490        write(&root, "testdata/golden.go", "package testdata\n");
491        write(&root, "bin/tool.go", "package main\n");
492
493        let files = discover_go_files(&root).unwrap();
494        assert_eq!(files.sources, ["main.go", "pkg/api/handler.go"]);
495        assert_eq!(files.tests, ["pkg/api/handler_test.go"]);
496        assert_eq!(
497            files.dependency_files,
498            [PathBuf::from("go.mod"), PathBuf::from("go.sum")]
499        );
500        assert_eq!(files.configuration_files, [PathBuf::from(".go-version")]);
501        assert_eq!(module_path(&root).as_deref(), Some("example.com/app"));
502        assert_eq!(test_packages(&files), ["pkg/api"]);
503        fs::remove_dir_all(root).unwrap();
504    }
505
506    #[test]
507    fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
508        // A hole in the denominator that nobody is told about is a wrong
509        // number, so the project still prepares and names what it could not
510        // read.
511        let root = fixture("unparseable");
512        write(&root, "go.mod", "module example.com/app\n");
513        write(
514            &root,
515            "good.go",
516            "package main\n\nfunc f(a int) bool {\n\tif a > 1 && a < 9 {\n\t\treturn true\n\t}\n\treturn false\n}\n",
517        );
518        write(&root, "broken.go", "package main\n\nfunc f( {\n");
519
520        let project = prepare_go_project(&root, None).unwrap();
521        assert_eq!(project.unparseable.len(), 1);
522        assert_eq!(project.unparseable[0].0, "broken.go");
523        // And the hole it leaves is declared, not merely printed: a
524        // diagnostic scrolls past, a limitation reaches the stored run.
525        let declared = project
526            .manifest
527            .limitations
528            .iter()
529            .filter(|limitation| limitation["kind"] == "file-does-not-parse")
530            .collect::<Vec<_>>();
531        assert_eq!(declared.len(), 1, "{declared:?}");
532        assert!(
533            declared[0]["file"].as_str().unwrap().ends_with("broken.go"),
534            "{declared:?}"
535        );
536        assert!(
537            declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
538            "the declaration needs an id to reference: {declared:?}"
539        );
540        // The readable file still contributed its obligations.
541        assert!(!project.manifest.points.is_empty());
542        assert_eq!(project.manifest.decisions.len(), 1);
543        assert!(!project.probes.is_empty());
544        fs::remove_dir_all(root).unwrap();
545    }
546
547    #[test]
548    fn an_empty_project_is_refused_rather_than_measured_as_complete() {
549        // Zero of zero satisfies every floor, so a project with nothing to
550        // measure has to say so instead of reporting success.
551        let root = fixture("empty");
552        write(&root, "go.mod", "module example.com/app\n");
553        assert!(prepare_go_project(&root, None).is_err());
554        fs::remove_dir_all(root).unwrap();
555    }
556
557    #[test]
558    fn the_ambient_environment_is_not_part_of_run_identity() {
559        // Identity is what the project is, not which shell it was run from.
560        let files = GoFiles::default();
561        let inputs = go_integrity_inputs(
562            &files,
563            &["go".to_owned(), "test".to_owned(), "./...".to_owned()],
564        );
565        assert_eq!(inputs.execution_configuration, b"go\0test\0./...");
566    }
567
568    #[test]
569    fn a_nested_module_belongs_to_itself() {
570        // `go test ./...` walks straight past a directory with a go.mod of its
571        // own, so measuring it would put obligations in the denominator that
572        // no test here can reach -- and a probe file written there would
573        // import a runtime the nested module cannot resolve, which stops the
574        // build that worked before Supercov was asked to measure it.
575        let root = fixture("nested-module");
576        fs::write(root.join("go.mod"), "module example.com/root\n").unwrap();
577        fs::write(root.join("root.go"), "package root\n\nfunc A() {}\n").unwrap();
578        fs::create_dir_all(root.join("sub")).unwrap();
579        fs::write(root.join("sub/go.mod"), "module example.com/sub\n").unwrap();
580        fs::write(root.join("sub/sub.go"), "package sub\n\nfunc B() {}\n").unwrap();
581        // A plain subdirectory of this module is still measured.
582        fs::create_dir_all(root.join("internal")).unwrap();
583        fs::write(
584            root.join("internal/helper.go"),
585            "package internal\n\nfunc C() {}\n",
586        )
587        .unwrap();
588
589        let files = discover_go_files(&root).expect("discovery");
590        assert_eq!(files.sources, ["internal/helper.go", "root.go"]);
591        assert!(
592            files
593                .excluded
594                .iter()
595                .any(|(path, reason)| path == "sub" && *reason == "a module of its own"),
596            "{:?}",
597            files.excluded
598        );
599        fs::remove_dir_all(root).unwrap();
600    }
601
602    #[test]
603    fn a_workspace_names_the_modules_it_uses() {
604        // go.work writes `use` either inline or as a block, with comments and
605        // quoting allowed. Its members are modules Supercov must measure --
606        // the opposite of a nested module it must leave alone -- so reading
607        // the file wrong means either measuring nothing or breaking a build.
608        let root = fixture("go-work");
609        fs::write(
610            root.join("go.work"),
611            "go 1.22\n\n// the services\nuse (\n\t./core\n\t\"./app\"  // quoted\n\t./tools/gen\n)\n",
612        )
613        .unwrap();
614        let modules = workspace_modules(&root);
615        assert_eq!(
616            modules.iter().map(String::as_str).collect::<Vec<_>>(),
617            ["app", "core", "tools/gen"]
618        );
619
620        // The inline form says the same thing.
621        fs::write(root.join("go.work"), "go 1.22\n\nuse ./only\n").unwrap();
622        assert_eq!(
623            workspace_modules(&root)
624                .iter()
625                .map(String::as_str)
626                .collect::<Vec<_>>(),
627            ["only"]
628        );
629
630        // And a repository with no workspace has none, which is how a single
631        // module at the root is told apart from a workspace member.
632        fs::remove_file(root.join("go.work")).unwrap();
633        assert!(workspace_modules(&root).is_empty());
634        fs::remove_dir_all(root).unwrap();
635    }
636
637    #[test]
638    fn a_workspace_member_is_measured_where_a_nested_module_is_not() {
639        // Both are directories with a go.mod under the root. One is part of
640        // what the command runs and one is not, and only go.work says which.
641        let root = fixture("go-work-members");
642        fs::write(root.join("go.work"), "go 1.22\n\nuse (\n\t./core\n)\n").unwrap();
643        for module in ["core", "vendored"] {
644            fs::create_dir_all(root.join(module)).unwrap();
645            fs::write(
646                root.join(module).join("go.mod"),
647                format!("module example.com/{module}\n"),
648            )
649            .unwrap();
650            fs::write(
651                root.join(module).join("code.go"),
652                format!("package {module}\n\nfunc A() {{}}\n"),
653            )
654            .unwrap();
655        }
656        let files = discover_go_files(&root).expect("discovery");
657        assert_eq!(files.sources, ["core/code.go"]);
658        assert!(
659            files
660                .excluded
661                .iter()
662                .any(|(path, reason)| path == "vendored" && *reason == "a module of its own"),
663            "{:?}",
664            files.excluded
665        );
666        fs::remove_dir_all(root).unwrap();
667    }
668}