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