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
215fn 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(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 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
305pub 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
321pub 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
331pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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}