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;
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/// True while a coverage session is active. Read once per VM construction and
51/// once per executed instruction, so it is a relaxed atomic load — effectively
52/// free and branch-predicted "off" when no session is running.
53#[inline]
54pub fn is_enabled() -> bool {
55    COVERAGE_ON.load(Ordering::Relaxed)
56}
57
58/// Start a coverage session: clear the merged report and enable recording on
59/// every VM constructed until [`end_session`].
60pub 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
68/// End the coverage session and return the merged report.
69pub 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
75/// Build a per-VM accumulator when a session is active, seeding the primary
76/// file used to attribute same-file (`source_file: None`) chunks. Returns
77/// `None` when coverage is off, so the dispatch-loop hook is a single
78/// `Option::is_some` branch on the hot path.
79pub(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
90/// Fold one VM's accumulator into the global report. Called from `Vm::drop`.
91pub(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/// Hit/total line sets for a single source file.
100#[derive(Debug, Clone, Default)]
101struct FileLines {
102    /// Every instrumentable (non-zero) line emitted for this file.
103    total: BTreeSet<u32>,
104    /// The subset that executed.
105    hit: BTreeSet<u32>,
106}
107
108/// Accumulated line coverage. Used both as a per-VM accumulator and, after
109/// merging, as the whole-run report.
110#[derive(Debug, Clone, Default)]
111pub struct Coverage {
112    /// The script under execution; receives lines from chunks that carry no
113    /// `source_file` of their own.
114    primary_file: Option<Arc<str>>,
115    files: BTreeMap<Arc<str>, FileLines>,
116    /// Chunk ids whose denominator tree has already been walked (per VM).
117    seen: HashSet<u64>,
118    /// Resolved effective file per chunk id, so a hit needs no re-walk.
119    file_of: HashMap<u64, Arc<str>>,
120}
121
122impl Coverage {
123    pub(crate) fn new() -> Self {
124        Self::default()
125    }
126
127    /// Record the VM's primary file (the script passed to `execute`). Only the
128    /// first call wins so a nested sub-execution can't clobber it.
129    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    /// Record execution of the instruction at `ip` in `chunk`.
136    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    /// Resolve the file a `None`-`source_file` chunk belongs to.
154    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    /// Walk `chunk` and its nested function bodies once, adding every
165    /// instrumentable line to the denominator. Idempotent per chunk id.
166    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    /// Files that exist on disk, in deterministic order. Drops the synthetic
198    /// paths embedded-stdlib and in-memory `eval` chunks report.
199    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    /// `(covered, total)` line counts across all on-disk files.
208    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    /// Whole-run line coverage percentage (0.0 when there is nothing to cover).
217    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    /// True when no on-disk file has any instrumentable line.
227    pub fn is_empty(&self) -> bool {
228        self.real_files().is_empty()
229    }
230
231    /// A human-readable per-file table plus a total line.
232    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                // Tail-truncate: the leading directories are the common prefix
254                // that carries the least signal, the file name carries the most.
255                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    /// LCOV `tracefile` output for Codecov / VS Code Coverage Gutters / genhtml.
273    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
298/// Show a path relative to the current dir when possible, for compact tables.
299fn 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        // Register the denominator without executing anything.
327        cov.register_tree(&chunk, &Arc::from("/does/not/matter.harn"));
328        let lines = cov.files.values().next().unwrap();
329        // Lines 1, 2, 3 are instrumentable; the duplicate 1 and the 0 collapse.
330        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        // Execute the instructions at index 0 and 2 (lines 10 and 12).
343        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); // line 0 — synthetic, ignored
356        cov.record(&chunk, 1); // line 5 — counted
357        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        // An empty report has no on-disk records, so the tracefile is empty —
382        // still a valid LCOV file, which `--coverage-out` writes rather than
383        // skipping (a missing artifact would break a CI consumer).
384        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        // Use a real on-disk path so the render filter keeps it.
392        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}