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 };
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 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 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
325pub 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
341pub 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
351pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}