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    };
259    let mut probes = std::collections::BTreeMap::new();
260    let mut instrumented = Vec::new();
261    let mut decision_widths = Vec::new();
262    let mut unparseable = Vec::new();
263    let mut next_probe = 0_u64;
264    let mut next_decision = 0_u32;
265    for relative in &files.sources {
266        let path = root.join(relative);
267        let Ok(source) = std::fs::read_to_string(&path) else {
268            let reason = "file could not be read as UTF-8";
269            manifest
270                .limitations
271                .push(unparseable_limitation(relative, reason));
272            unparseable.push((relative.clone(), reason.to_owned()));
273            continue;
274        };
275        match build_go_obligations(relative, &source, &mut next_probe, &mut next_decision) {
276            Ok(obligations) => {
277                manifest.decisions.extend(obligations.manifest.decisions);
278                manifest.points.extend(obligations.manifest.points);
279                manifest.branches.extend(obligations.manifest.branches);
280                // What the file could not be measured for travels with what it
281                // could. Dropping these left the manifest silently claiming a
282                // completeness it had not established.
283                manifest
284                    .limitations
285                    .extend(obligations.manifest.limitations);
286                probes.extend(obligations.probes);
287                decision_widths.extend(obligations.decision_widths);
288                instrumented.push((
289                    relative.clone(),
290                    crate::go_instrumenter::rewrite(&source, &obligations.edits),
291                ));
292            }
293            Err(error) => {
294                manifest
295                    .limitations
296                    .push(unparseable_limitation(relative, &error.to_string()));
297                unparseable.push((relative.clone(), error.to_string()));
298            }
299        }
300    }
301    // Only when roots shaped what is measured: without them this frontend
302    // records no scope, and its default output stays exactly as it was.
303    if let Some(roots) = roots {
304        let kept = files.sources.clone();
305        manifest.scope = Some(
306            serde_json::to_value(
307                roots
308                    .scope(&kept, &files.excluded)
309                    .map_err(|error| error.to_string())?,
310            )
311            .map_err(|error| error.to_string())?,
312        );
313    }
314    Ok(PreparedGoProject {
315        root: root.to_owned(),
316        files,
317        manifest,
318        probes,
319        instrumented,
320        decision_widths,
321        unparseable,
322    })
323}
324
325/// Integrity inputs: sources and tests are hashed separately, dependency and
326/// configuration files identify the environment, and the test command
327/// identifies execution.
328///
329/// The ambient environment is deliberately absent, for the same reason it is
330/// absent everywhere else: it is not a property of the project.
331pub fn go_integrity_inputs(files: &GoFiles, command: &[String]) -> ExplicitIntegrityInputs {
332    ExplicitIntegrityInputs {
333        source_files: files.sources.iter().map(PathBuf::from).collect(),
334        test_files: files.tests.iter().map(PathBuf::from).collect(),
335        dependency_files: files.dependency_files.clone(),
336        configuration_files: files.configuration_files.clone(),
337        execution_configuration: command.join("\0").into_bytes(),
338    }
339}
340
341/// The module path declared in `go.mod`, which package identities are relative
342/// to.
343pub fn module_path(root: &Path) -> Option<String> {
344    let text = std::fs::read_to_string(root.join("go.mod")).ok()?;
345    text.lines()
346        .map(str::trim)
347        .find_map(|line| line.strip_prefix("module "))
348        .map(|path| path.trim().to_owned())
349}
350
351/// Package directories that hold at least one test file, which is the unit
352/// `go test` actually runs.
353pub fn test_packages(files: &GoFiles) -> Vec<String> {
354    files
355        .tests
356        .iter()
357        .map(|test| match test.rsplit_once('/') {
358            Some((directory, _)) => directory.to_owned(),
359            None => ".".to_owned(),
360        })
361        .collect::<BTreeSet<_>>()
362        .into_iter()
363        .collect()
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use std::fs;
370
371    fn fixture(label: &str) -> PathBuf {
372        let root = std::env::temp_dir().join(format!(
373            "supercov-go-project-{label}-{}-{}",
374            std::process::id(),
375            std::time::SystemTime::now()
376                .duration_since(std::time::UNIX_EPOCH)
377                .unwrap()
378                .as_nanos()
379        ));
380        fs::create_dir_all(&root).unwrap();
381        root
382    }
383
384    fn write(root: &Path, relative: &str, contents: &str) {
385        let path = root.join(relative);
386        fs::create_dir_all(path.parent().unwrap()).unwrap();
387        fs::write(path, contents).unwrap();
388    }
389
390    fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
391        let roots = roots
392            .iter()
393            .map(|root| (*root).to_owned())
394            .collect::<Vec<_>>();
395        crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
396    }
397
398    #[test]
399    fn explicit_roots_narrow_go_to_the_code_they_name() {
400        // `extra`, not `third_party`: Go already leaves third_party, vendor and
401        // testdata out by default, and a test passing for that reason would
402        // prove nothing about roots. Go had no scope to record at all, so honouring the variable there has
403        // to leave a trace of what it did, or it is as invisible as ignoring it.
404        let root = fixture("explicit-roots");
405        write(&root, "go.mod", "module example.com/app\n");
406        write(
407            &root,
408            "app.go",
409            "package main\n\nfunc f(a int) int {\n\treturn a\n}\n",
410        );
411        write(
412            &root,
413            "extra/dep.go",
414            "package extra\n\nfunc G() int {\n\treturn 1\n}\n",
415        );
416
417        let project = prepare_go_project(&root, Some(&explicit_roots(&root, &["app.go"]))).unwrap();
418        assert_eq!(project.files.sources, ["app.go"], "{:?}", project.files);
419        assert!(
420            project
421                .files
422                .excluded
423                .contains(&("extra/dep.go".into(), "outside explicit source roots")),
424            "{:?}",
425            project.files.excluded
426        );
427        let scope: crate::source_discovery::SourceScope = serde_json::from_value(
428            project
429                .manifest
430                .scope
431                .clone()
432                .expect("explicit roots leave a scope"),
433        )
434        .unwrap();
435        assert_eq!(
436            scope.mode,
437            crate::source_discovery::SourceScopeMode::Explicit
438        );
439        assert_eq!(scope.roots, ["app.go"]);
440
441        // Unset, Go records no scope, exactly as before.
442        assert!(
443            prepare_go_project(&root, None)
444                .unwrap()
445                .manifest
446                .scope
447                .is_none()
448        );
449        fs::remove_dir_all(root).ok();
450    }
451
452    #[test]
453    fn a_test_file_is_the_one_the_toolchain_says_it_is() {
454        // Go settles this: `_test.go` and nothing else. No directory-name
455        // heuristic to disagree with the compiler about.
456        for test in ["main_test.go", "pkg/api/handler_test.go", "a/b/z_test.go"] {
457            assert!(is_test_file(test), "{test}");
458        }
459        for source in ["main.go", "pkg/api/handler.go", "testing.go", "pkg/test.go"] {
460            assert!(!is_test_file(source), "{source}");
461        }
462    }
463
464    #[test]
465    fn vendored_and_toolchain_directories_are_not_this_project_s_source() {
466        // `vendor` is someone else's code and `testdata` is ignored by the
467        // compiler itself; measuring either puts obligations on code these
468        // tests were never meant to exercise.
469        let root = fixture("scope");
470        write(&root, "go.mod", "module example.com/app\n\ngo 1.22\n");
471        write(&root, "go.sum", "");
472        write(&root, ".go-version", "1.22.0\n");
473        write(&root, "main.go", "package main\n\nfunc main() {}\n");
474        write(
475            &root,
476            "pkg/api/handler.go",
477            "package api\n\nfunc Handle() int {\n\treturn 1\n}\n",
478        );
479        write(
480            &root,
481            "pkg/api/handler_test.go",
482            "package api\n\nimport \"testing\"\n\nfunc TestHandle(t *testing.T) {}\n",
483        );
484        write(
485            &root,
486            "vendor/other/lib.go",
487            "package other\n\nfunc X() {}\n",
488        );
489        write(&root, "testdata/golden.go", "package testdata\n");
490        write(&root, "bin/tool.go", "package main\n");
491
492        let files = discover_go_files(&root).unwrap();
493        assert_eq!(files.sources, ["main.go", "pkg/api/handler.go"]);
494        assert_eq!(files.tests, ["pkg/api/handler_test.go"]);
495        assert_eq!(
496            files.dependency_files,
497            [PathBuf::from("go.mod"), PathBuf::from("go.sum")]
498        );
499        assert_eq!(files.configuration_files, [PathBuf::from(".go-version")]);
500        assert_eq!(module_path(&root).as_deref(), Some("example.com/app"));
501        assert_eq!(test_packages(&files), ["pkg/api"]);
502        fs::remove_dir_all(root).unwrap();
503    }
504
505    #[test]
506    fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
507        // A hole in the denominator that nobody is told about is a wrong
508        // number, so the project still prepares and names what it could not
509        // read.
510        let root = fixture("unparseable");
511        write(&root, "go.mod", "module example.com/app\n");
512        write(
513            &root,
514            "good.go",
515            "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",
516        );
517        write(&root, "broken.go", "package main\n\nfunc f( {\n");
518
519        let project = prepare_go_project(&root, None).unwrap();
520        assert_eq!(project.unparseable.len(), 1);
521        assert_eq!(project.unparseable[0].0, "broken.go");
522        // And the hole it leaves is declared, not merely printed: a
523        // diagnostic scrolls past, a limitation reaches the stored run.
524        let declared = project
525            .manifest
526            .limitations
527            .iter()
528            .filter(|limitation| limitation["kind"] == "file-does-not-parse")
529            .collect::<Vec<_>>();
530        assert_eq!(declared.len(), 1, "{declared:?}");
531        assert!(
532            declared[0]["file"].as_str().unwrap().ends_with("broken.go"),
533            "{declared:?}"
534        );
535        assert!(
536            declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
537            "the declaration needs an id to reference: {declared:?}"
538        );
539        // The readable file still contributed its obligations.
540        assert!(!project.manifest.points.is_empty());
541        assert_eq!(project.manifest.decisions.len(), 1);
542        assert!(!project.probes.is_empty());
543        fs::remove_dir_all(root).unwrap();
544    }
545
546    #[test]
547    fn an_empty_project_is_refused_rather_than_measured_as_complete() {
548        // Zero of zero satisfies every floor, so a project with nothing to
549        // measure has to say so instead of reporting success.
550        let root = fixture("empty");
551        write(&root, "go.mod", "module example.com/app\n");
552        assert!(prepare_go_project(&root, None).is_err());
553        fs::remove_dir_all(root).unwrap();
554    }
555
556    #[test]
557    fn the_ambient_environment_is_not_part_of_run_identity() {
558        // Identity is what the project is, not which shell it was run from.
559        let files = GoFiles::default();
560        let inputs = go_integrity_inputs(
561            &files,
562            &["go".to_owned(), "test".to_owned(), "./...".to_owned()],
563        );
564        assert_eq!(inputs.execution_configuration, b"go\0test\0./...");
565    }
566
567    #[test]
568    fn a_nested_module_belongs_to_itself() {
569        // `go test ./...` walks straight past a directory with a go.mod of its
570        // own, so measuring it would put obligations in the denominator that
571        // no test here can reach -- and a probe file written there would
572        // import a runtime the nested module cannot resolve, which stops the
573        // build that worked before Supercov was asked to measure it.
574        let root = fixture("nested-module");
575        fs::write(root.join("go.mod"), "module example.com/root\n").unwrap();
576        fs::write(root.join("root.go"), "package root\n\nfunc A() {}\n").unwrap();
577        fs::create_dir_all(root.join("sub")).unwrap();
578        fs::write(root.join("sub/go.mod"), "module example.com/sub\n").unwrap();
579        fs::write(root.join("sub/sub.go"), "package sub\n\nfunc B() {}\n").unwrap();
580        // A plain subdirectory of this module is still measured.
581        fs::create_dir_all(root.join("internal")).unwrap();
582        fs::write(
583            root.join("internal/helper.go"),
584            "package internal\n\nfunc C() {}\n",
585        )
586        .unwrap();
587
588        let files = discover_go_files(&root).expect("discovery");
589        assert_eq!(files.sources, ["internal/helper.go", "root.go"]);
590        assert!(
591            files
592                .excluded
593                .iter()
594                .any(|(path, reason)| path == "sub" && *reason == "a module of its own"),
595            "{:?}",
596            files.excluded
597        );
598        fs::remove_dir_all(root).unwrap();
599    }
600
601    #[test]
602    fn a_workspace_names_the_modules_it_uses() {
603        // go.work writes `use` either inline or as a block, with comments and
604        // quoting allowed. Its members are modules Supercov must measure --
605        // the opposite of a nested module it must leave alone -- so reading
606        // the file wrong means either measuring nothing or breaking a build.
607        let root = fixture("go-work");
608        fs::write(
609            root.join("go.work"),
610            "go 1.22\n\n// the services\nuse (\n\t./core\n\t\"./app\"  // quoted\n\t./tools/gen\n)\n",
611        )
612        .unwrap();
613        let modules = workspace_modules(&root);
614        assert_eq!(
615            modules.iter().map(String::as_str).collect::<Vec<_>>(),
616            ["app", "core", "tools/gen"]
617        );
618
619        // The inline form says the same thing.
620        fs::write(root.join("go.work"), "go 1.22\n\nuse ./only\n").unwrap();
621        assert_eq!(
622            workspace_modules(&root)
623                .iter()
624                .map(String::as_str)
625                .collect::<Vec<_>>(),
626            ["only"]
627        );
628
629        // And a repository with no workspace has none, which is how a single
630        // module at the root is told apart from a workspace member.
631        fs::remove_file(root.join("go.work")).unwrap();
632        assert!(workspace_modules(&root).is_empty());
633        fs::remove_dir_all(root).unwrap();
634    }
635
636    #[test]
637    fn a_workspace_member_is_measured_where_a_nested_module_is_not() {
638        // Both are directories with a go.mod under the root. One is part of
639        // what the command runs and one is not, and only go.work says which.
640        let root = fixture("go-work-members");
641        fs::write(root.join("go.work"), "go 1.22\n\nuse (\n\t./core\n)\n").unwrap();
642        for module in ["core", "vendored"] {
643            fs::create_dir_all(root.join(module)).unwrap();
644            fs::write(
645                root.join(module).join("go.mod"),
646                format!("module example.com/{module}\n"),
647            )
648            .unwrap();
649            fs::write(
650                root.join(module).join("code.go"),
651                format!("package {module}\n\nfunc A() {{}}\n"),
652            )
653            .unwrap();
654        }
655        let files = discover_go_files(&root).expect("discovery");
656        assert_eq!(files.sources, ["core/code.go"]);
657        assert!(
658            files
659                .excluded
660                .iter()
661                .any(|(path, reason)| path == "vendored" && *reason == "a module of its own"),
662            "{:?}",
663            files.excluded
664        );
665        fs::remove_dir_all(root).unwrap();
666    }
667}