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 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
215pub(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 "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 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 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
326pub 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
342pub 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
352pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}