Skip to main content

harn_vm/
coverage.rs

1//! Line coverage for executed Harn programs.
2//!
3//! Harn already stores a source line for every emitted instruction
4//! (`Chunk::lines`), so line coverage needs no separate debug-info pass: the
5//! denominator is the set of distinct non-zero lines a chunk (and its nested
6//! function bodies) emit, and the numerator is the subset whose instructions
7//! actually ran.
8//!
9//! ## How it is wired
10//!
11//! Coverage is opt-in and process-global so it captures every VM isolate a run
12//! spins up (imports, parallel branches, spawned agents) without threading a
13//! flag through every constructor:
14//!
15//! * [`begin_session`] flips [`is_enabled`] on and clears the merged report.
16//! * Each [`crate::vm::Vm`] checks [`is_enabled`] at construction; when on it
17//!   carries its own [`Coverage`] accumulator and records a hit per executed
18//!   instruction in the dispatch loop.
19//! * On drop a VM folds its accumulator into the global report.
20//! * [`end_session`] flips coverage off and returns the merged [`Coverage`].
21//!
22//! ## File attribution
23//!
24//! A chunk compiled from an imported module carries its own `source_file`; the
25//! entry file's top-level chunk and its same-file function bodies carry `None`.
26//! We attribute a `None` chunk to the VM's primary file (the script under
27//! execution), and otherwise to the chunk's `source_file`. Nested function
28//! chunks inherit their parent's effective file when they carry no
29//! `source_file` of their own, so a module's uncalled helpers are still counted
30//! against the module — not misattributed to the entry script.
31//!
32//! Render filters to files that exist on disk, which drops the synthetic paths
33//! the embedded stdlib and in-memory `eval` chunks report.
34
35use 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/// True while a coverage session is active. Read once per VM construction and
50/// once per executed instruction, so it is a relaxed atomic load — effectively
51/// free and branch-predicted "off" when no session is running.
52#[inline]
53pub fn is_enabled() -> bool {
54    COVERAGE_ON.load(Ordering::Relaxed)
55}
56
57/// Start a coverage session: clear the merged report and enable recording on
58/// every VM constructed until [`end_session`].
59pub 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
67/// End the coverage session and return the merged report.
68pub 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
74/// Build a per-VM accumulator when a session is active, seeding the primary
75/// file used to attribute same-file (`source_file: None`) chunks. Returns
76/// `None` when coverage is off, so the dispatch-loop hook is a single
77/// `Option::is_some` branch on the hot path.
78pub(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
89/// Fold one VM's accumulator into the global report. Called from `Vm::drop`.
90pub(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/// Hit/total line sets for a single source file.
99#[derive(Debug, Clone, Default)]
100struct FileLines {
101    /// Every instrumentable (non-zero) line emitted for this file.
102    total: BTreeSet<u32>,
103    /// The subset that executed.
104    hit: BTreeSet<u32>,
105}
106
107/// Accumulated line coverage. Used both as a per-VM accumulator and, after
108/// merging, as the whole-run report.
109#[derive(Debug, Clone, Default)]
110pub struct Coverage {
111    /// The script under execution; receives lines from chunks that carry no
112    /// `source_file` of their own.
113    primary_file: Option<Arc<str>>,
114    files: BTreeMap<Arc<str>, FileLines>,
115    /// Chunk ids whose denominator tree has already been walked (per VM).
116    seen: HashSet<u64>,
117    /// Resolved effective file per chunk id, so a hit needs no re-walk.
118    file_of: HashMap<u64, Arc<str>>,
119}
120
121impl Coverage {
122    pub(crate) fn new() -> Self {
123        Self::default()
124    }
125
126    /// Record the VM's primary file (the script passed to `execute`). Only the
127    /// first call wins so a nested sub-execution can't clobber it.
128    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    /// Record execution of the instruction at `ip` in `chunk`.
135    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    /// Resolve the file a `None`-`source_file` chunk belongs to.
153    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    /// Walk `chunk` and its nested function bodies once, adding every
164    /// instrumentable line to the denominator. Idempotent per chunk id.
165    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    /// Files that exist on disk, in deterministic order. Drops the synthetic
197    /// paths embedded-stdlib and in-memory `eval` chunks report.
198    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    /// `(covered, total)` line counts across all on-disk files.
207    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    /// Whole-run line coverage percentage (0.0 when there is nothing to cover).
216    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    /// True when no on-disk file has any instrumentable line.
226    pub fn is_empty(&self) -> bool {
227        self.real_files().is_empty()
228    }
229
230    /// A human-readable per-file table plus a total line.
231    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    /// LCOV `tracefile` output for Codecov / VS Code Coverage Gutters / genhtml.
270    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
295/// Show a path relative to the current dir when possible, for compact tables.
296fn 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    // Keep the tail (the file name) since the leading dirs are the common
311    // prefix that carries the least signal.
312    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        // Register the denominator without executing anything.
336        cov.register_tree(&chunk, &Arc::from("/does/not/matter.harn"));
337        let lines = cov.files.values().next().unwrap();
338        // Lines 1, 2, 3 are instrumentable; the duplicate 1 and the 0 collapse.
339        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        // Execute the instructions at index 0 and 2 (lines 10 and 12).
352        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); // line 0 — synthetic, ignored
365        cov.record(&chunk, 1); // line 5 — counted
366        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        // An empty report has no on-disk records, so the tracefile is empty —
391        // still a valid LCOV file, which `--coverage-out` writes rather than
392        // skipping (a missing artifact would break a CI consumer).
393        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        // Use a real on-disk path so the render filter keeps it.
401        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}