Skip to main content

omni_dev/coverage/
lcov.rs

1//! lcov trace-file parser (line coverage only).
2//!
3//! lcov records one source file per `SF:`…`end_of_record` block. Within a block,
4//! `DA:<line>,<hits>[,<checksum>]` gives the hit count for an instrumented line.
5//! Branch (`BRDA`) and function (`FN*`) records are ignored — v1 is scoped to
6//! line coverage to match the existing coverage comment.
7//!
8//! Reference: <https://manpages.debian.org/unstable/lcov/geninfo.1.en.html>
9
10use anyhow::{Context, Result};
11
12use super::model::{CoverageReport, FileCoverage};
13
14/// Parses lcov trace text into a [`CoverageReport`].
15pub fn parse(content: &str) -> Result<CoverageReport> {
16    let mut report = CoverageReport::new();
17    let mut current: Option<FileCoverage> = None;
18
19    for (lineno, raw) in content.lines().enumerate() {
20        let mut line = raw.trim();
21
22        // `cargo llvm-cov` writes no newline after its final `end_of_record`, so
23        // two reports concatenated with `cat` put the next file's `SF:` on the
24        // same line. What follows the terminator is an ordinary record, not
25        // noise: dropping it loses a whole file's coverage without an error.
26        while let Some(rest) = line.strip_prefix("end_of_record") {
27            if let Some(file) = current.take() {
28                report.insert(file);
29            }
30            line = rest.trim_start();
31        }
32        if line.is_empty() {
33            continue;
34        }
35
36        if let Some(path) = line.strip_prefix("SF:") {
37            // A block that never saw its `end_of_record` (a truncated report
38            // followed by another) is kept, not silently replaced.
39            if let Some(unfinished) = current.replace(FileCoverage::new(path.trim())) {
40                report.insert(unfinished);
41            }
42        } else if let Some(rest) = line.strip_prefix("DA:") {
43            let file = current.as_mut().with_context(|| {
44                format!("lcov line {}: DA record outside of an SF block", lineno + 1)
45            })?;
46            let (number, hits) = parse_da(rest)
47                .with_context(|| format!("lcov line {}: malformed DA record", lineno + 1))?;
48            file.record(number, hits);
49        }
50        // All other records (TN, BRDA, FN, FNDA, LF, LH, …) are ignored.
51    }
52
53    // Tolerate a trailing block with no explicit end_of_record.
54    if let Some(file) = current.take() {
55        report.insert(file);
56    }
57
58    Ok(report)
59}
60
61/// Parses the payload of a `DA:` record (`<line>,<hits>[,<checksum>]`).
62fn parse_da(rest: &str) -> Result<(u32, u64)> {
63    let mut parts = rest.split(',');
64    let number: u32 = parts
65        .next()
66        .context("missing line number")?
67        .trim()
68        .parse()
69        .context("invalid line number")?;
70    // Hit counts can overflow i64 in pathological cases; lcov emits them as
71    // decimal integers. Saturate negative or out-of-range values to 0.
72    let hits: u64 = parts
73        .next()
74        .context("missing hit count")?
75        .trim()
76        .parse()
77        .unwrap_or(0);
78    Ok((number, hits))
79}
80
81#[cfg(test)]
82#[allow(clippy::unwrap_used, clippy::expect_used)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn parses_single_file() {
88        let lcov = "\
89TN:
90SF:/repo/src/a.rs
91DA:1,5
92DA:2,0
93DA:3,1
94LF:3
95LH:2
96end_of_record
97";
98        let report = parse(lcov).unwrap();
99        assert_eq!(report.files.len(), 1);
100        assert_eq!(report.hits("/repo/src/a.rs", 1), Some(5));
101        assert_eq!(report.hits("/repo/src/a.rs", 2), Some(0));
102        assert_eq!(report.hits("/repo/src/a.rs", 3), Some(1));
103        assert_eq!(report.total_lines(), 3);
104        assert_eq!(report.covered_lines(), 2);
105    }
106
107    #[test]
108    fn parses_multiple_files() {
109        let lcov = "\
110SF:src/a.rs
111DA:1,1
112end_of_record
113SF:src/b.rs
114DA:1,0
115DA:2,2
116end_of_record
117";
118        let report = parse(lcov).unwrap();
119        assert_eq!(report.files.len(), 2);
120        assert_eq!(report.hits("src/a.rs", 1), Some(1));
121        assert_eq!(report.hits("src/b.rs", 2), Some(2));
122    }
123
124    /// Shard files joined with a newline parse as the same union
125    /// `CoverageReport::merge` builds: the docs tell a caller that needs a single
126    /// file (a baseline) that this is equivalent, so that has to stay true.
127    ///
128    /// The shards end like real `cargo llvm-cov` output — **no trailing newline**
129    /// after the last `end_of_record`, which is why the join needs its own.
130    #[test]
131    fn newline_joined_shards_parse_as_their_merge() {
132        let shard_a = "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record";
133        let shard_b = "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nDA:3,0\nend_of_record\n\
134                       SF:src/b.rs\nDA:1,1\nend_of_record";
135
136        let mut merged = parse(shard_a).unwrap();
137        merged.merge(parse(shard_b).unwrap());
138        let concatenated = parse(&format!("{shard_a}\n{shard_b}")).unwrap();
139
140        assert_eq!(concatenated, merged);
141        assert_eq!(concatenated.hits("src/a.rs", 1), Some(3));
142        assert_eq!(concatenated.hits("src/a.rs", 2), Some(2));
143        assert_eq!(concatenated.hits("src/a.rs", 3), Some(0));
144    }
145
146    /// Plain `cat` of real `cargo llvm-cov` shards (no trailing newline) glues
147    /// `end_of_record` onto the next `SF:`. Both files must survive with their
148    /// own lines: before this was handled the last file of each shard vanished
149    /// and the next file's lines landed on it, understating coverage silently.
150    #[test]
151    fn a_glued_end_of_record_does_not_lose_the_next_file() {
152        let lcov = "SF:src/a.rs\nDA:1,1\nend_of_recordSF:src/b.rs\nDA:1,0\nDA:2,3\nend_of_record";
153        let report = parse(lcov).unwrap();
154        assert_eq!(report.files.len(), 2);
155        assert_eq!(report.hits("src/a.rs", 1), Some(1));
156        assert_eq!(report.hits("src/a.rs", 2), None);
157        assert_eq!(report.hits("src/b.rs", 1), Some(0));
158        assert_eq!(report.hits("src/b.rs", 2), Some(3));
159    }
160
161    #[test]
162    fn plainly_concatenated_shards_parse_as_their_merge() {
163        let shard_a =
164            "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record";
165        let shard_b =
166            "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/c.rs\nDA:1,1\nend_of_record";
167
168        let mut merged = parse(shard_a).unwrap();
169        merged.merge(parse(shard_b).unwrap());
170        // No separator at all: exactly what `cat shard-*.lcov` produces.
171        let catted = parse(&format!("{shard_a}{shard_b}")).unwrap();
172
173        assert_eq!(catted, merged);
174        assert_eq!(catted.hits("src/c.rs", 1), Some(1));
175    }
176
177    #[test]
178    fn a_block_without_end_of_record_is_kept_when_another_file_follows() {
179        let lcov = "SF:src/a.rs\nDA:1,1\nSF:src/b.rs\nDA:1,0\nend_of_record\n";
180        let report = parse(lcov).unwrap();
181        assert_eq!(report.hits("src/a.rs", 1), Some(1));
182        assert_eq!(report.hits("src/b.rs", 1), Some(0));
183    }
184
185    #[test]
186    fn ignores_branch_and_function_records() {
187        let lcov = "\
188SF:src/a.rs
189FN:1,foo
190FNDA:3,foo
191DA:1,3
192BRDA:1,0,0,1
193end_of_record
194";
195        let report = parse(lcov).unwrap();
196        let f = &report.files["src/a.rs"];
197        assert_eq!(f.lines.len(), 1);
198        assert_eq!(f.lines.get(&1), Some(&3));
199    }
200
201    #[test]
202    fn da_with_checksum() {
203        let lcov = "SF:src/a.rs\nDA:1,4,abcdef\nend_of_record\n";
204        let report = parse(lcov).unwrap();
205        assert_eq!(report.hits("src/a.rs", 1), Some(4));
206    }
207
208    #[test]
209    fn tolerates_missing_end_of_record() {
210        let lcov = "SF:src/a.rs\nDA:1,1\n";
211        let report = parse(lcov).unwrap();
212        assert_eq!(report.hits("src/a.rs", 1), Some(1));
213    }
214
215    #[test]
216    fn da_outside_sf_is_error() {
217        let lcov = "DA:1,1\n";
218        assert!(parse(lcov).is_err());
219    }
220
221    #[test]
222    fn skips_blank_lines() {
223        let lcov = "\nSF:a.rs\n\nDA:1,1\n\nend_of_record\n\n";
224        let report = parse(lcov).unwrap();
225        assert_eq!(report.hits("a.rs", 1), Some(1));
226    }
227
228    #[test]
229    fn negative_hit_count_saturates_to_zero() {
230        let report = parse("SF:a.rs\nDA:1,-5\nend_of_record\n").unwrap();
231        assert_eq!(report.hits("a.rs", 1), Some(0));
232    }
233}