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