1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36use std::path::Path;
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::sync::{Arc, Mutex, OnceLock};
39
40use crate::chunk::Chunk;
41
42static COVERAGE_ON: AtomicBool = AtomicBool::new(false);
43static GLOBAL_REPORT: OnceLock<Mutex<Coverage>> = OnceLock::new();
44
45fn global() -> &'static Mutex<Coverage> {
46 GLOBAL_REPORT.get_or_init(|| Mutex::new(Coverage::new()))
47}
48
49#[inline]
53pub fn is_enabled() -> bool {
54 COVERAGE_ON.load(Ordering::Relaxed)
55}
56
57pub fn begin_session() {
60 {
61 let mut report = global().lock().unwrap();
62 *report = Coverage::new();
63 }
64 COVERAGE_ON.store(true, Ordering::SeqCst);
65}
66
67pub fn end_session() -> Coverage {
69 COVERAGE_ON.store(false, Ordering::SeqCst);
70 let mut report = global().lock().unwrap();
71 std::mem::take(&mut *report)
72}
73
74pub(crate) fn for_primary(primary_file: Option<&str>) -> Option<Coverage> {
79 if !is_enabled() {
80 return None;
81 }
82 let mut cov = Coverage::new();
83 if let Some(file) = primary_file {
84 cov.set_primary_file(file);
85 }
86 Some(cov)
87}
88
89pub(crate) fn merge_into_global(data: Coverage) {
91 if data.files.is_empty() {
92 return;
93 }
94 let mut report = global().lock().unwrap();
95 report.merge(data);
96}
97
98#[derive(Debug, Clone, Default)]
100struct FileLines {
101 total: BTreeSet<u32>,
103 hit: BTreeSet<u32>,
105}
106
107#[derive(Debug, Clone, Default)]
110pub struct Coverage {
111 primary_file: Option<Arc<str>>,
114 files: BTreeMap<Arc<str>, FileLines>,
115 seen: HashSet<u64>,
117 file_of: HashMap<u64, Arc<str>>,
119}
120
121impl Coverage {
122 pub(crate) fn new() -> Self {
123 Self::default()
124 }
125
126 pub(crate) fn set_primary_file(&mut self, file: &str) {
129 if self.primary_file.is_none() {
130 self.primary_file = Some(Arc::from(file));
131 }
132 }
133
134 pub(crate) fn record(&mut self, chunk: &Chunk, ip: usize) {
136 let id = chunk.cache_id();
137 let file = match self.file_of.get(&id) {
138 Some(file) => file.clone(),
139 None => {
140 let effective = self.effective_file(chunk.source_file.as_deref());
141 self.register_tree(chunk, &effective);
142 self.file_of.get(&id).cloned().unwrap_or(effective)
143 }
144 };
145 if let Some(&line) = chunk.lines.get(ip) {
146 if line != 0 {
147 self.files.entry(file).or_default().hit.insert(line);
148 }
149 }
150 }
151
152 fn effective_file(&self, source_file: Option<&str>) -> Arc<str> {
154 match source_file {
155 Some(path) => Arc::from(path),
156 None => self
157 .primary_file
158 .clone()
159 .unwrap_or_else(|| Arc::from("<unknown>")),
160 }
161 }
162
163 fn register_tree(&mut self, chunk: &Chunk, effective: &Arc<str>) {
166 let id = chunk.cache_id();
167 if !self.seen.insert(id) {
168 return;
169 }
170 self.file_of.insert(id, effective.clone());
171 {
172 let entry = self.files.entry(effective.clone()).or_default();
173 for &line in &chunk.lines {
174 if line != 0 {
175 entry.total.insert(line);
176 }
177 }
178 }
179 for func in &chunk.functions {
180 let child = match func.chunk.source_file.as_deref() {
181 Some(path) => Arc::from(path),
182 None => effective.clone(),
183 };
184 self.register_tree(func.chunk.as_ref(), &child);
185 }
186 }
187
188 fn merge(&mut self, other: Coverage) {
189 for (file, lines) in other.files {
190 let entry = self.files.entry(file).or_default();
191 entry.total.extend(lines.total);
192 entry.hit.extend(lines.hit);
193 }
194 }
195
196 fn real_files(&self) -> Vec<(&str, &FileLines)> {
199 self.files
200 .iter()
201 .filter(|(file, _)| Path::new(file.as_ref()).exists())
202 .map(|(file, lines)| (file.as_ref(), lines))
203 .collect()
204 }
205
206 pub fn totals(&self) -> (usize, usize) {
208 self.real_files()
209 .into_iter()
210 .fold((0, 0), |(cov, total), (_, lines)| {
211 (cov + lines.hit.len(), total + lines.total.len())
212 })
213 }
214
215 pub fn percent(&self) -> f64 {
217 let (covered, total) = self.totals();
218 if total == 0 {
219 0.0
220 } else {
221 covered as f64 / total as f64 * 100.0
222 }
223 }
224
225 pub fn is_empty(&self) -> bool {
227 self.real_files().is_empty()
228 }
229
230 pub fn render_text(&self) -> String {
232 let files = self.real_files();
233 if files.is_empty() {
234 return "No coverage data (no executed source files found on disk).".to_string();
235 }
236 let name_width = files
237 .iter()
238 .map(|(file, _)| display_path(file).chars().count())
239 .max()
240 .unwrap_or(4)
241 .clamp(4, 60);
242 let mut out = String::new();
243 out.push_str(&format!(
244 "{:<name_width$} {:>6} {:>7} {:>6}\n",
245 "File", "Lines", "Covered", "%"
246 ));
247 for (file, lines) in &files {
248 let total = lines.total.len();
249 let covered = lines.hit.len();
250 out.push_str(&format!(
251 "{:<name_width$} {:>6} {:>7} {:>5.1}\n",
252 truncate(&display_path(file), name_width),
253 total,
254 covered,
255 pct(covered, total),
256 ));
257 }
258 let (covered, total) = self.totals();
259 out.push_str(&format!(
260 "{:<name_width$} {:>6} {:>7} {:>5.1}\n",
261 "TOTAL",
262 total,
263 covered,
264 pct(covered, total),
265 ));
266 out
267 }
268
269 pub fn render_lcov(&self) -> String {
271 let mut out = String::new();
272 for (file, lines) in self.real_files() {
273 out.push_str("TN:\n");
274 out.push_str(&format!("SF:{file}\n"));
275 for &line in &lines.total {
276 let count = u8::from(lines.hit.contains(&line));
277 out.push_str(&format!("DA:{line},{count}\n"));
278 }
279 out.push_str(&format!("LF:{}\n", lines.total.len()));
280 out.push_str(&format!("LH:{}\n", lines.hit.len()));
281 out.push_str("end_of_record\n");
282 }
283 out
284 }
285}
286
287fn pct(covered: usize, total: usize) -> f64 {
288 if total == 0 {
289 0.0
290 } else {
291 covered as f64 / total as f64 * 100.0
292 }
293}
294
295fn display_path(file: &str) -> String {
297 if let Ok(cwd) = std::env::current_dir() {
298 if let Ok(rel) = Path::new(file).strip_prefix(&cwd) {
299 return rel.to_string_lossy().into_owned();
300 }
301 }
302 file.to_string()
303}
304
305fn truncate(text: &str, width: usize) -> String {
306 let count = text.chars().count();
307 if count <= width {
308 return text.to_string();
309 }
310 let keep = width.saturating_sub(1);
313 let tail: String = text.chars().skip(count - keep).collect();
314 format!("…{tail}")
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::chunk::{Chunk, Op};
321
322 fn chunk_with_lines(lines: &[u32]) -> Chunk {
323 let mut chunk = Chunk::new();
324 for &line in lines {
325 chunk.emit(Op::Nil, line);
326 }
327 chunk
328 }
329
330 #[test]
331 fn denominator_counts_distinct_nonzero_lines() {
332 let chunk = chunk_with_lines(&[1, 1, 2, 0, 3]);
333 let mut cov = Coverage::new();
334 cov.set_primary_file("/does/not/matter.harn");
335 cov.register_tree(&chunk, &Arc::from("/does/not/matter.harn"));
337 let lines = cov.files.values().next().unwrap();
338 assert_eq!(
340 lines.total.iter().copied().collect::<Vec<_>>(),
341 vec![1, 2, 3]
342 );
343 assert!(lines.hit.is_empty());
344 }
345
346 #[test]
347 fn hits_are_a_subset_of_the_denominator() {
348 let chunk = chunk_with_lines(&[10, 11, 12]);
349 let mut cov = Coverage::new();
350 cov.set_primary_file("/x.harn");
351 cov.record(&chunk, 0);
353 cov.record(&chunk, 2);
354 let lines = cov.files.values().next().unwrap();
355 assert_eq!(lines.total.len(), 3);
356 assert_eq!(lines.hit.iter().copied().collect::<Vec<_>>(), vec![10, 12]);
357 }
358
359 #[test]
360 fn line_zero_is_not_instrumentable() {
361 let chunk = chunk_with_lines(&[0, 5]);
362 let mut cov = Coverage::new();
363 cov.set_primary_file("/x.harn");
364 cov.record(&chunk, 0); cov.record(&chunk, 1); let lines = cov.files.values().next().unwrap();
367 assert_eq!(lines.total.iter().copied().collect::<Vec<_>>(), vec![5]);
368 assert_eq!(lines.hit.iter().copied().collect::<Vec<_>>(), vec![5]);
369 }
370
371 #[test]
372 fn merge_unions_totals_and_hits() {
373 let mut a = Coverage::new();
374 a.files.entry(Arc::from("/f.harn")).or_default().total = BTreeSet::from([1, 2, 3]);
375 a.files.entry(Arc::from("/f.harn")).or_default().hit = BTreeSet::from([1]);
376 let mut b = Coverage::new();
377 b.files.entry(Arc::from("/f.harn")).or_default().total = BTreeSet::from([3, 4]);
378 b.files.entry(Arc::from("/f.harn")).or_default().hit = BTreeSet::from([4]);
379 a.merge(b);
380 let lines = &a.files[&Arc::<str>::from("/f.harn")];
381 assert_eq!(
382 lines.total.iter().copied().collect::<Vec<_>>(),
383 vec![1, 2, 3, 4]
384 );
385 assert_eq!(lines.hit.iter().copied().collect::<Vec<_>>(), vec![1, 4]);
386 }
387
388 #[test]
389 fn empty_report_renders_a_valid_empty_lcov() {
390 let cov = Coverage::new();
394 assert!(cov.is_empty());
395 assert_eq!(cov.render_lcov(), "");
396 }
397
398 #[test]
399 fn lcov_shapes_da_lines() {
400 let path = std::env::current_exe().unwrap();
402 let path_str = path.to_string_lossy().into_owned();
403 let mut cov = Coverage::new();
404 let arc: Arc<str> = Arc::from(path_str.as_str());
405 cov.files.entry(arc.clone()).or_default().total = BTreeSet::from([1, 2]);
406 cov.files.entry(arc).or_default().hit = BTreeSet::from([1]);
407 let lcov = cov.render_lcov();
408 assert!(lcov.contains(&format!("SF:{path_str}")));
409 assert!(lcov.contains("DA:1,1"));
410 assert!(lcov.contains("DA:2,0"));
411 assert!(lcov.contains("LF:2"));
412 assert!(lcov.contains("LH:1"));
413 assert!(lcov.contains("end_of_record"));
414 }
415}