1use 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
17const EXCLUDED_DIRECTORIES: &[&str] = &[
22 ".git",
23 ".idea",
24 ".vscode",
25 "bin",
26 "node_modules",
27 "testdata",
28 "third_party",
29 "vendor",
30];
31
32const DEPENDENCY_FILES: &[&str] = &[
34 "go.mod",
35 "go.sum",
36 "go.work",
37 "go.work.sum",
38 "vendor/modules.txt",
39];
40
41const CONFIGURATION_FILES: &[&str] = &[".go-version", "go.env"];
43
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct GoFiles {
46 pub sources: Vec<String>,
48 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 pub instrumented: Vec<(String, String)>,
67 pub decision_widths: Vec<u8>,
70 pub unparseable: Vec<(String, String)>,
74}
75
76pub 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
85pub 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 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 if name.starts_with('_') {
171 ignored_by_the_toolchain(root, &path, files)?;
172 continue;
173 }
174 if path.join("go.mod").is_file() && !members.contains(&relative) {
182 files.excluded.push((relative, "a module of its own"));
183 continue;
184 }
185 walk(root, &path, files)?;
186 continue;
187 }
188 if !file_type.is_file() {
189 continue;
190 }
191 if DEPENDENCY_FILES.contains(&relative.as_str()) {
192 files.dependency_files.push(PathBuf::from(&relative));
193 continue;
194 }
195 if CONFIGURATION_FILES.contains(&name.as_str()) {
196 files.configuration_files.push(PathBuf::from(&relative));
197 continue;
198 }
199 if !name.ends_with(".go") {
200 continue;
201 }
202 if name.starts_with('_') || name.starts_with('.') {
204 files.excluded.push((relative, IGNORED_BY_THE_TOOLCHAIN));
205 continue;
206 }
207 if is_test_file(&relative) {
208 files.tests.push(relative);
209 } else {
210 files.sources.push(relative);
211 }
212 }
213 Ok(())
214}
215
216const IGNORED_BY_THE_TOOLCHAIN: &str = "ignored by the Go toolchain";
217
218fn ignored_by_the_toolchain(
220 root: &Path,
221 directory: &Path,
222 files: &mut GoFiles,
223) -> Result<(), String> {
224 let entries = std::fs::read_dir(directory)
225 .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
226 for entry in entries {
227 let entry =
228 entry.map_err(|error| format!("could not read {}: {error}", directory.display()))?;
229 let path = entry.path();
230 let file_type = entry
231 .file_type()
232 .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
233 if file_type.is_dir() {
234 ignored_by_the_toolchain(root, &path, files)?;
235 } else if file_type.is_file()
236 && path.extension().is_some_and(|extension| extension == "go")
237 && let Ok(relative) = path.strip_prefix(root)
238 {
239 files.excluded.push((
240 relative.to_string_lossy().replace('\\', "/"),
241 IGNORED_BY_THE_TOOLCHAIN,
242 ));
243 }
244 }
245 Ok(())
246}
247
248fn example_program(file: &str) -> bool {
254 file.split_once('/').is_some_and(|(first, _)| {
255 matches!(first.to_ascii_lowercase().as_str(), "examples" | "example")
256 })
257}
258
259pub fn discover_go_files(root: &Path) -> Result<GoFiles, String> {
260 let mut files = GoFiles::default();
261 walk(root, root, &mut files)?;
262 files.sources.sort();
263 files.tests.sort();
264 files.dependency_files.sort();
265 files.configuration_files.sort();
266 files.excluded.sort();
267 Ok(files)
268}
269
270const LANGUAGE: &str = "go";
271
272pub(crate) fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
278 serde_json::json!({
279 "id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
280 "kind": "file-does-not-parse",
281 "file": file,
282 "source": file,
285 "line": 1,
286 "column": 1,
287 "reason": format!(
288 "{reason}; the file carries no obligations and nothing in it counts towards this run"
289 ),
290 })
291}
292
293pub fn prepare_go_project(
294 root: &Path,
295 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
296) -> Result<PreparedGoProject, String> {
297 let mut files = discover_go_files(root)?;
298 match roots {
299 Some(roots) => {
300 roots.narrow(&mut files.sources, &mut files.excluded, String::as_str);
301 roots.refuse_if_empty(files.sources.len(), "Go")?;
302 }
303 None => {
305 let GoFiles {
306 sources, excluded, ..
307 } = &mut files;
308 sources.retain(|file| {
309 let example = example_program(file);
310 if example {
311 excluded.push((file.clone(), "example"));
312 }
313 !example
314 });
315 excluded.sort();
316 }
317 }
318 if files.sources.is_empty() && files.tests.is_empty() {
319 return Err(
320 "no Go source files were found under the project root; Supercov measures .go files outside vendor, testdata and tooling directories"
321 .into(),
322 );
323 }
324 let mut manifest = CoverageManifest {
325 decisions: Vec::new(),
326 points: Vec::new(),
327 branches: Vec::new(),
328 limitations: Vec::new(),
329 unmeasured: Vec::new(),
330 scope: None,
331 assertion_sites: Vec::new(),
332 };
333 let mut probes = std::collections::BTreeMap::new();
334 let mut instrumented = Vec::new();
335 let mut decision_widths = Vec::new();
336 let mut unparseable = Vec::new();
337 let mut next_probe = 0_u64;
338 let mut next_decision = 0_u32;
339 for relative in &files.sources {
340 let path = root.join(relative);
341 let Ok(source) = std::fs::read_to_string(&path) else {
342 let reason = "file could not be read as UTF-8";
343 manifest
344 .limitations
345 .push(unparseable_limitation(relative, reason));
346 unparseable.push((relative.clone(), reason.to_owned()));
347 continue;
348 };
349 match build_go_obligations(relative, &source, &mut next_probe, &mut next_decision) {
350 Ok(obligations) => {
351 manifest.decisions.extend(obligations.manifest.decisions);
352 manifest.points.extend(obligations.manifest.points);
353 manifest.branches.extend(obligations.manifest.branches);
354 manifest
358 .limitations
359 .extend(obligations.manifest.limitations);
360 probes.extend(obligations.probes);
361 decision_widths.extend(obligations.decision_widths);
362 instrumented.push((
363 relative.clone(),
364 crate::go_instrumenter::rewrite(&source, &obligations.edits),
365 ));
366 }
367 Err(error) => {
368 manifest
369 .limitations
370 .push(unparseable_limitation(relative, &error.to_string()));
371 unparseable.push((relative.clone(), error.to_string()));
372 }
373 }
374 }
375 let scope = match roots {
376 Some(roots) => roots
377 .scope(&files.sources, &files.excluded)
378 .map_err(|error| error.to_string())?,
379 None => {
380 let unread = |file: &String| unparseable.iter().any(|(path, _)| path == file);
381 crate::source_discovery::automatic_scope(
382 files
383 .sources
384 .iter()
385 .filter(|file| !unread(file))
386 .map(|file| (file.clone(), "Go application source".to_owned())),
387 files
388 .sources
389 .iter()
390 .filter(|file| unread(file))
391 .map(|file| (file.clone(), "could not be parsed".to_owned()))
392 .chain(
393 files
394 .tests
395 .iter()
396 .map(|file| (file.clone(), "test file".to_owned())),
397 )
398 .chain(
399 files
401 .excluded
402 .iter()
403 .filter(|(file, _)| file.ends_with(".go"))
404 .map(|(file, reason)| (file.clone(), (*reason).to_owned())),
405 ),
406 )
407 }
408 };
409 manifest.scope = Some(serde_json::to_value(scope).map_err(|error| error.to_string())?);
410 Ok(PreparedGoProject {
411 root: root.to_owned(),
412 files,
413 manifest,
414 probes,
415 instrumented,
416 decision_widths,
417 unparseable,
418 })
419}
420
421pub fn go_integrity_inputs(files: &GoFiles, command: &[String]) -> ExplicitIntegrityInputs {
428 ExplicitIntegrityInputs {
429 source_files: files.sources.iter().map(PathBuf::from).collect(),
430 test_files: files.tests.iter().map(PathBuf::from).collect(),
431 dependency_files: files.dependency_files.clone(),
432 configuration_files: files.configuration_files.clone(),
433 execution_configuration: command.join("\0").into_bytes(),
434 }
435}
436
437pub fn module_path(root: &Path) -> Option<String> {
440 let text = std::fs::read_to_string(root.join("go.mod")).ok()?;
441 text.lines()
442 .map(str::trim)
443 .find_map(|line| line.strip_prefix("module "))
444 .map(|path| path.trim().to_owned())
445}
446
447pub fn test_packages(files: &GoFiles) -> Vec<String> {
450 files
451 .tests
452 .iter()
453 .map(|test| match test.rsplit_once('/') {
454 Some((directory, _)) => directory.to_owned(),
455 None => ".".to_owned(),
456 })
457 .collect::<BTreeSet<_>>()
458 .into_iter()
459 .collect()
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::fs;
466
467 fn fixture(label: &str) -> PathBuf {
468 let root = std::env::temp_dir().join(format!(
469 "supercov-go-project-{label}-{}-{}",
470 std::process::id(),
471 std::time::SystemTime::now()
472 .duration_since(std::time::UNIX_EPOCH)
473 .unwrap()
474 .as_nanos()
475 ));
476 fs::create_dir_all(&root).unwrap();
477 root
478 }
479
480 fn write(root: &Path, relative: &str, contents: &str) {
481 let path = root.join(relative);
482 fs::create_dir_all(path.parent().unwrap()).unwrap();
483 fs::write(path, contents).unwrap();
484 }
485
486 fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
487 let roots = roots
488 .iter()
489 .map(|root| (*root).to_owned())
490 .collect::<Vec<_>>();
491 crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
492 }
493
494 #[test]
495 fn explicit_roots_narrow_go_to_the_code_they_name() {
496 let root = fixture("explicit-roots");
501 write(&root, "go.mod", "module example.com/app\n");
502 write(
503 &root,
504 "app.go",
505 "package main\n\nfunc f(a int) int {\n\treturn a\n}\n",
506 );
507 write(
508 &root,
509 "extra/dep.go",
510 "package extra\n\nfunc G() int {\n\treturn 1\n}\n",
511 );
512
513 let project = prepare_go_project(&root, Some(&explicit_roots(&root, &["app.go"]))).unwrap();
514 assert_eq!(project.files.sources, ["app.go"], "{:?}", project.files);
515 assert!(
516 project
517 .files
518 .excluded
519 .contains(&("extra/dep.go".into(), "outside explicit source roots")),
520 "{:?}",
521 project.files.excluded
522 );
523 let scope: crate::source_discovery::SourceScope = serde_json::from_value(
524 project
525 .manifest
526 .scope
527 .clone()
528 .expect("explicit roots leave a scope"),
529 )
530 .unwrap();
531 assert_eq!(
532 scope.mode,
533 crate::source_discovery::SourceScopeMode::Explicit
534 );
535 assert_eq!(scope.roots, ["app.go"]);
536
537 fs::remove_dir_all(root).ok();
538 }
539
540 #[test]
541 fn a_run_lists_what_it_measures_and_what_it_left_out() {
542 let root = fixture("automatic-scope");
547 write(&root, "go.mod", "module example.com/app\n");
548 let function = "\n\nfunc F(a int) int {\n\treturn a\n}\n";
549 write(&root, "app.go", &format!("package app{function}"));
550 write(&root, "app_test.go", "package app\n");
551 write(
552 &root,
553 "_examples/hello/main.go",
554 &format!("package main{function}"),
555 );
556 write(&root, "_draft.go", &format!("package app{function}"));
557 write(
558 &root,
559 "examples/echo/main.go",
560 &format!("package main{function}"),
561 );
562 write(
563 &root,
564 "internal/examples/kept.go",
565 &format!("package examples{function}"),
566 );
567 write(&root, "doc/gen.go", &format!("package doc{function}"));
568 write(
569 &root,
570 "vendor/other/lib.go",
571 &format!("package lib{function}"),
572 );
573
574 let project = prepare_go_project(&root, None).unwrap();
575 assert_eq!(
576 project.files.sources,
577 ["app.go", "doc/gen.go", "internal/examples/kept.go"]
578 );
579 let scope: crate::source_discovery::SourceScope =
580 serde_json::from_value(project.manifest.scope.clone().expect("a scope")).unwrap();
581 assert_eq!(
582 scope.mode,
583 crate::source_discovery::SourceScopeMode::Automatic
584 );
585 let listed = scope
586 .entries
587 .iter()
588 .map(|entry| {
589 (
590 entry.file.as_str(),
591 entry.status == crate::source_discovery::SourceScopeStatus::Included,
592 entry.reason.as_str(),
593 )
594 })
595 .collect::<Vec<_>>();
596 assert_eq!(
597 listed,
598 [
599 ("_draft.go", false, "ignored by the Go toolchain"),
600 (
601 "_examples/hello/main.go",
602 false,
603 "ignored by the Go toolchain"
604 ),
605 ("app.go", true, "Go application source"),
606 ("app_test.go", false, "test file"),
607 ("doc/gen.go", true, "Go application source"),
608 ("examples/echo/main.go", false, "example"),
609 ("internal/examples/kept.go", true, "Go application source"),
610 ]
611 );
612
613 let named = prepare_go_project(&root, Some(&explicit_roots(&root, &["examples"]))).unwrap();
615 assert_eq!(named.files.sources, ["examples/echo/main.go"]);
616 fs::remove_dir_all(root).ok();
617 }
618
619 #[test]
620 fn a_test_file_is_the_one_the_toolchain_says_it_is() {
621 for test in ["main_test.go", "pkg/api/handler_test.go", "a/b/z_test.go"] {
624 assert!(is_test_file(test), "{test}");
625 }
626 for source in ["main.go", "pkg/api/handler.go", "testing.go", "pkg/test.go"] {
627 assert!(!is_test_file(source), "{source}");
628 }
629 }
630
631 #[test]
632 fn vendored_and_toolchain_directories_are_not_this_project_s_source() {
633 let root = fixture("scope");
637 write(&root, "go.mod", "module example.com/app\n\ngo 1.22\n");
638 write(&root, "go.sum", "");
639 write(&root, ".go-version", "1.22.0\n");
640 write(&root, "main.go", "package main\n\nfunc main() {}\n");
641 write(
642 &root,
643 "pkg/api/handler.go",
644 "package api\n\nfunc Handle() int {\n\treturn 1\n}\n",
645 );
646 write(
647 &root,
648 "pkg/api/handler_test.go",
649 "package api\n\nimport \"testing\"\n\nfunc TestHandle(t *testing.T) {}\n",
650 );
651 write(
652 &root,
653 "vendor/other/lib.go",
654 "package other\n\nfunc X() {}\n",
655 );
656 write(&root, "testdata/golden.go", "package testdata\n");
657 write(&root, "bin/tool.go", "package main\n");
658
659 let files = discover_go_files(&root).unwrap();
660 assert_eq!(files.sources, ["main.go", "pkg/api/handler.go"]);
661 assert_eq!(files.tests, ["pkg/api/handler_test.go"]);
662 assert_eq!(
663 files.dependency_files,
664 [PathBuf::from("go.mod"), PathBuf::from("go.sum")]
665 );
666 assert_eq!(files.configuration_files, [PathBuf::from(".go-version")]);
667 assert_eq!(module_path(&root).as_deref(), Some("example.com/app"));
668 assert_eq!(test_packages(&files), ["pkg/api"]);
669 fs::remove_dir_all(root).unwrap();
670 }
671
672 #[test]
673 fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
674 let root = fixture("unparseable");
678 write(&root, "go.mod", "module example.com/app\n");
679 write(
680 &root,
681 "good.go",
682 "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",
683 );
684 write(&root, "broken.go", "package main\n\nfunc f( {\n");
685
686 let project = prepare_go_project(&root, None).unwrap();
687 assert_eq!(project.unparseable.len(), 1);
688 assert_eq!(project.unparseable[0].0, "broken.go");
689 let declared = project
692 .manifest
693 .limitations
694 .iter()
695 .filter(|limitation| limitation["kind"] == "file-does-not-parse")
696 .collect::<Vec<_>>();
697 assert_eq!(declared.len(), 1, "{declared:?}");
698 assert!(
699 declared[0]["file"].as_str().unwrap().ends_with("broken.go"),
700 "{declared:?}"
701 );
702 assert!(
703 declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
704 "the declaration needs an id to reference: {declared:?}"
705 );
706 assert!(!project.manifest.points.is_empty());
708 assert_eq!(project.manifest.decisions.len(), 1);
709 assert!(!project.probes.is_empty());
710 fs::remove_dir_all(root).unwrap();
711 }
712
713 #[test]
714 fn an_empty_project_is_refused_rather_than_measured_as_complete() {
715 let root = fixture("empty");
718 write(&root, "go.mod", "module example.com/app\n");
719 assert!(prepare_go_project(&root, None).is_err());
720 fs::remove_dir_all(root).unwrap();
721 }
722
723 #[test]
724 fn the_ambient_environment_is_not_part_of_run_identity() {
725 let files = GoFiles::default();
727 let inputs = go_integrity_inputs(
728 &files,
729 &["go".to_owned(), "test".to_owned(), "./...".to_owned()],
730 );
731 assert_eq!(inputs.execution_configuration, b"go\0test\0./...");
732 }
733
734 #[test]
735 fn a_nested_module_belongs_to_itself() {
736 let root = fixture("nested-module");
742 fs::write(root.join("go.mod"), "module example.com/root\n").unwrap();
743 fs::write(root.join("root.go"), "package root\n\nfunc A() {}\n").unwrap();
744 fs::create_dir_all(root.join("sub")).unwrap();
745 fs::write(root.join("sub/go.mod"), "module example.com/sub\n").unwrap();
746 fs::write(root.join("sub/sub.go"), "package sub\n\nfunc B() {}\n").unwrap();
747 fs::create_dir_all(root.join("internal")).unwrap();
749 fs::write(
750 root.join("internal/helper.go"),
751 "package internal\n\nfunc C() {}\n",
752 )
753 .unwrap();
754
755 let files = discover_go_files(&root).expect("discovery");
756 assert_eq!(files.sources, ["internal/helper.go", "root.go"]);
757 assert!(
758 files
759 .excluded
760 .iter()
761 .any(|(path, reason)| path == "sub" && *reason == "a module of its own"),
762 "{:?}",
763 files.excluded
764 );
765 fs::remove_dir_all(root).unwrap();
766 }
767
768 #[test]
769 fn a_workspace_names_the_modules_it_uses() {
770 let root = fixture("go-work");
775 fs::write(
776 root.join("go.work"),
777 "go 1.22\n\n// the services\nuse (\n\t./core\n\t\"./app\" // quoted\n\t./tools/gen\n)\n",
778 )
779 .unwrap();
780 let modules = workspace_modules(&root);
781 assert_eq!(
782 modules.iter().map(String::as_str).collect::<Vec<_>>(),
783 ["app", "core", "tools/gen"]
784 );
785
786 fs::write(root.join("go.work"), "go 1.22\n\nuse ./only\n").unwrap();
788 assert_eq!(
789 workspace_modules(&root)
790 .iter()
791 .map(String::as_str)
792 .collect::<Vec<_>>(),
793 ["only"]
794 );
795
796 fs::remove_file(root.join("go.work")).unwrap();
799 assert!(workspace_modules(&root).is_empty());
800 fs::remove_dir_all(root).unwrap();
801 }
802
803 #[test]
804 fn a_workspace_member_is_measured_where_a_nested_module_is_not() {
805 let root = fixture("go-work-members");
808 fs::write(root.join("go.work"), "go 1.22\n\nuse (\n\t./core\n)\n").unwrap();
809 for module in ["core", "vendored"] {
810 fs::create_dir_all(root.join(module)).unwrap();
811 fs::write(
812 root.join(module).join("go.mod"),
813 format!("module example.com/{module}\n"),
814 )
815 .unwrap();
816 fs::write(
817 root.join(module).join("code.go"),
818 format!("package {module}\n\nfunc A() {{}}\n"),
819 )
820 .unwrap();
821 }
822 let files = discover_go_files(&root).expect("discovery");
823 assert_eq!(files.sources, ["core/code.go"]);
824 assert!(
825 files
826 .excluded
827 .iter()
828 .any(|(path, reason)| path == "vendored" && *reason == "a module of its own"),
829 "{:?}",
830 files.excluded
831 );
832 fs::remove_dir_all(root).unwrap();
833 }
834}