Skip to main content

omni_dev/coverage/
lcov.rs

1//! lcov trace-file parser and writer (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 std::fmt::Write as _;
11
12use anyhow::{bail, Context, Result};
13
14use super::model::{CoverageReport, FileCoverage};
15
16/// Parses lcov trace text into a [`CoverageReport`].
17pub fn parse(content: &str) -> Result<CoverageReport> {
18    let mut report = CoverageReport::new();
19    let mut current: Option<FileCoverage> = None;
20
21    for (lineno, raw) in content.lines().enumerate() {
22        let mut line = raw.trim();
23
24        // `cargo llvm-cov` writes no newline after its final `end_of_record`, so
25        // two reports concatenated with `cat` put the next file's `SF:` on the
26        // same line. What follows the terminator is an ordinary record, not
27        // noise: dropping it loses a whole file's coverage without an error.
28        while let Some(rest) = line.strip_prefix("end_of_record") {
29            if let Some(file) = current.take() {
30                report.insert(file);
31            }
32            line = rest.trim_start();
33        }
34        if line.is_empty() {
35            continue;
36        }
37
38        if let Some(path) = line.strip_prefix("SF:") {
39            // A block that never saw its `end_of_record` (a truncated report
40            // followed by another) is kept, not silently replaced.
41            if let Some(unfinished) = current.replace(FileCoverage::new(path.trim())) {
42                report.insert(unfinished);
43            }
44        } else if let Some(rest) = line.strip_prefix("DA:") {
45            let file = current.as_mut().with_context(|| {
46                format!("lcov line {}: DA record outside of an SF block", lineno + 1)
47            })?;
48            let (number, hits) = parse_da(rest)
49                .with_context(|| format!("lcov line {}: malformed DA record", lineno + 1))?;
50            file.record(number, hits);
51        }
52        // All other records (TN, BRDA, FN, FNDA, LF, LH, …) are ignored.
53    }
54
55    // Tolerate a trailing block with no explicit end_of_record.
56    if let Some(file) = current.take() {
57        report.insert(file);
58    }
59
60    Ok(report)
61}
62
63/// Renders `report` as lcov trace text.
64///
65/// The output is a function of the report alone: files come out in path order
66/// and each file's `DA` records in line order, so two reports that compare equal
67/// write byte-identical text, whatever order their inputs were merged in. Every
68/// record ends in a newline — the last `end_of_record` included, which
69/// `cargo llvm-cov` omits and which makes its files unsafe to join with `cat`.
70///
71/// Only what the line model holds is written: `TN`, `SF`, `DA`, `LF`, `LH` and
72/// `end_of_record`. Function (`FN*`) and branch (`BRDA`) records are not part of
73/// the model and are dropped. `LF`/`LH` count the `DA` records written, so a
74/// file is consistent with itself; `llvm-cov` writes them from its own summary
75/// instead, which counts some lines more than once (#2131).
76///
77/// A file with no executable lines is written as an empty record (`LF:0`,
78/// `LH:0`), the way [`parse`] reads one: leaving it out would make the text read
79/// back as a different report, and a consumer that ranks a missing file against
80/// an empty one would answer differently.
81///
82/// # Errors
83///
84/// Fails when a path has a line break or leading or trailing whitespace. lcov has
85/// no way to carry either — [`parse`] trims the `SF:` value and splits on lines —
86/// so the text would read back keyed under a different path.
87pub fn write(report: &CoverageReport) -> Result<String> {
88    let mut out = String::new();
89    for (path, file) in &report.files {
90        if path.contains(['\n', '\r']) || path.trim() != path {
91            bail!(
92                "cannot write {path:?} to lcov: a path with a line break or surrounding \
93                 whitespace would read back as a different path"
94            );
95        }
96        // Writing to a `String` cannot fail.
97        let _ = writeln!(out, "TN:\nSF:{path}");
98        for (line, hits) in &file.lines {
99            let _ = writeln!(out, "DA:{line},{hits}");
100        }
101        let _ = writeln!(
102            out,
103            "LF:{}\nLH:{}\nend_of_record",
104            file.total_lines(),
105            file.covered_lines()
106        );
107    }
108    Ok(out)
109}
110
111/// Parses the payload of a `DA:` record (`<line>,<hits>[,<checksum>]`).
112fn parse_da(rest: &str) -> Result<(u32, u64)> {
113    let mut parts = rest.split(',');
114    let number: u32 = parts
115        .next()
116        .context("missing line number")?
117        .trim()
118        .parse()
119        .context("invalid line number")?;
120    // Hit counts can overflow i64 in pathological cases; lcov emits them as
121    // decimal integers. Saturate negative or out-of-range values to 0.
122    let hits: u64 = parts
123        .next()
124        .context("missing hit count")?
125        .trim()
126        .parse()
127        .unwrap_or(0);
128    Ok((number, hits))
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used, clippy::expect_used)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn parses_single_file() {
138        let lcov = "\
139TN:
140SF:/repo/src/a.rs
141DA:1,5
142DA:2,0
143DA:3,1
144LF:3
145LH:2
146end_of_record
147";
148        let report = parse(lcov).unwrap();
149        assert_eq!(report.files.len(), 1);
150        assert_eq!(report.hits("/repo/src/a.rs", 1), Some(5));
151        assert_eq!(report.hits("/repo/src/a.rs", 2), Some(0));
152        assert_eq!(report.hits("/repo/src/a.rs", 3), Some(1));
153        assert_eq!(report.total_lines(), 3);
154        assert_eq!(report.covered_lines(), 2);
155    }
156
157    #[test]
158    fn parses_multiple_files() {
159        let lcov = "\
160SF:src/a.rs
161DA:1,1
162end_of_record
163SF:src/b.rs
164DA:1,0
165DA:2,2
166end_of_record
167";
168        let report = parse(lcov).unwrap();
169        assert_eq!(report.files.len(), 2);
170        assert_eq!(report.hits("src/a.rs", 1), Some(1));
171        assert_eq!(report.hits("src/b.rs", 2), Some(2));
172    }
173
174    /// Shard files joined with a newline parse as the same union
175    /// `CoverageReport::merge` builds. `coverage merge` is the supported way to a
176    /// single file, but a caller that joined shards by hand before it existed is
177    /// still read correctly, so that has to stay true.
178    ///
179    /// The shards end like real `cargo llvm-cov` output — **no trailing newline**
180    /// after the last `end_of_record`, which is why the join needs its own.
181    #[test]
182    fn newline_joined_shards_parse_as_their_merge() {
183        let shard_a = "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record";
184        let shard_b = "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nDA:3,0\nend_of_record\n\
185                       SF:src/b.rs\nDA:1,1\nend_of_record";
186
187        let mut merged = parse(shard_a).unwrap();
188        merged.merge(parse(shard_b).unwrap());
189        let concatenated = parse(&format!("{shard_a}\n{shard_b}")).unwrap();
190
191        assert_eq!(concatenated, merged);
192        assert_eq!(concatenated.hits("src/a.rs", 1), Some(3));
193        assert_eq!(concatenated.hits("src/a.rs", 2), Some(2));
194        assert_eq!(concatenated.hits("src/a.rs", 3), Some(0));
195    }
196
197    /// Plain `cat` of real `cargo llvm-cov` shards (no trailing newline) glues
198    /// `end_of_record` onto the next `SF:`. Both files must survive with their
199    /// own lines: before this was handled the last file of each shard vanished
200    /// and the next file's lines landed on it, understating coverage silently.
201    #[test]
202    fn a_glued_end_of_record_does_not_lose_the_next_file() {
203        let lcov = "SF:src/a.rs\nDA:1,1\nend_of_recordSF:src/b.rs\nDA:1,0\nDA:2,3\nend_of_record";
204        let report = parse(lcov).unwrap();
205        assert_eq!(report.files.len(), 2);
206        assert_eq!(report.hits("src/a.rs", 1), Some(1));
207        assert_eq!(report.hits("src/a.rs", 2), None);
208        assert_eq!(report.hits("src/b.rs", 1), Some(0));
209        assert_eq!(report.hits("src/b.rs", 2), Some(3));
210    }
211
212    #[test]
213    fn plainly_concatenated_shards_parse_as_their_merge() {
214        let shard_a =
215            "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record";
216        let shard_b =
217            "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/c.rs\nDA:1,1\nend_of_record";
218
219        let mut merged = parse(shard_a).unwrap();
220        merged.merge(parse(shard_b).unwrap());
221        // No separator at all: exactly what `cat shard-*.lcov` produces.
222        let catted = parse(&format!("{shard_a}{shard_b}")).unwrap();
223
224        assert_eq!(catted, merged);
225        assert_eq!(catted.hits("src/c.rs", 1), Some(1));
226    }
227
228    #[test]
229    fn a_block_without_end_of_record_is_kept_when_another_file_follows() {
230        let lcov = "SF:src/a.rs\nDA:1,1\nSF:src/b.rs\nDA:1,0\nend_of_record\n";
231        let report = parse(lcov).unwrap();
232        assert_eq!(report.hits("src/a.rs", 1), Some(1));
233        assert_eq!(report.hits("src/b.rs", 1), Some(0));
234    }
235
236    #[test]
237    fn ignores_branch_and_function_records() {
238        let lcov = "\
239SF:src/a.rs
240FN:1,foo
241FNDA:3,foo
242DA:1,3
243BRDA:1,0,0,1
244end_of_record
245";
246        let report = parse(lcov).unwrap();
247        let f = &report.files["src/a.rs"];
248        assert_eq!(f.lines.len(), 1);
249        assert_eq!(f.lines.get(&1), Some(&3));
250    }
251
252    #[test]
253    fn da_with_checksum() {
254        let lcov = "SF:src/a.rs\nDA:1,4,abcdef\nend_of_record\n";
255        let report = parse(lcov).unwrap();
256        assert_eq!(report.hits("src/a.rs", 1), Some(4));
257    }
258
259    #[test]
260    fn tolerates_missing_end_of_record() {
261        let lcov = "SF:src/a.rs\nDA:1,1\n";
262        let report = parse(lcov).unwrap();
263        assert_eq!(report.hits("src/a.rs", 1), Some(1));
264    }
265
266    #[test]
267    fn da_outside_sf_is_error() {
268        let lcov = "DA:1,1\n";
269        assert!(parse(lcov).is_err());
270    }
271
272    #[test]
273    fn skips_blank_lines() {
274        let lcov = "\nSF:a.rs\n\nDA:1,1\n\nend_of_record\n\n";
275        let report = parse(lcov).unwrap();
276        assert_eq!(report.hits("a.rs", 1), Some(1));
277    }
278
279    #[test]
280    fn negative_hit_count_saturates_to_zero() {
281        let report = parse("SF:a.rs\nDA:1,-5\nend_of_record\n").unwrap();
282        assert_eq!(report.hits("a.rs", 1), Some(0));
283    }
284
285    // ── write (#2118) ────────────────────────────────────────────────────
286
287    fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
288        let mut report = CoverageReport::new();
289        for (path, lines) in files {
290            let mut file = FileCoverage::new(*path);
291            for &(line, hits) in *lines {
292                file.record(line, hits);
293            }
294            report.insert(file);
295        }
296        report
297    }
298
299    #[test]
300    fn write_emits_one_sorted_record_per_file() {
301        // Inserted out of order on purpose: the output must not depend on it.
302        let report = report(&[("src/b.rs", &[(2, 0), (1, 3)]), ("src/a.rs", &[(5, 1)])]);
303        assert_eq!(
304            write(&report).unwrap(),
305            "TN:\nSF:src/a.rs\nDA:5,1\nLF:1\nLH:1\nend_of_record\n\
306             TN:\nSF:src/b.rs\nDA:1,3\nDA:2,0\nLF:2\nLH:1\nend_of_record\n"
307        );
308    }
309
310    /// `cargo llvm-cov` omits the newline after its last `end_of_record`, so a
311    /// file that stops there cannot be concatenated safely. Ours must not.
312    #[test]
313    fn write_ends_every_record_with_a_newline() {
314        let text = write(&report(&[("src/a.rs", &[(1, 1)])])).unwrap();
315        assert!(text.ends_with("end_of_record\n"), "{text:?}");
316    }
317
318    #[test]
319    fn write_of_an_empty_report_is_empty() {
320        assert_eq!(write(&CoverageReport::new()).unwrap(), "");
321    }
322
323    #[test]
324    fn write_keeps_a_file_with_no_executable_lines_as_an_empty_record() {
325        let report = report(&[("src/empty.rs", &[]), ("src/a.rs", &[(1, 0)])]);
326        let text = write(&report).unwrap();
327        assert!(
328            text.contains("SF:src/empty.rs\nLF:0\nLH:0\nend_of_record\n"),
329            "{text}"
330        );
331        assert_eq!(parse(&text).unwrap(), report);
332    }
333
334    #[test]
335    fn write_reads_back_as_the_same_report() {
336        let original = report(&[
337            ("src/a.rs", &[(1, 0), (2, 7), (40, 1)]),
338            ("/abs/b.rs", &[(3, 2)]),
339            ("src/empty.rs", &[]),
340        ]);
341        assert_eq!(parse(&write(&original).unwrap()).unwrap(), original);
342    }
343
344    /// `LF`/`LH` are written from the `DA` records, so the totals agree with the
345    /// lines listed — unlike `llvm-cov`'s own lcov (#2131).
346    #[test]
347    fn write_counts_lf_and_lh_from_its_own_da_records() {
348        let text = write(&report(&[("a.rs", &[(1, 0), (2, 5), (3, 9)])])).unwrap();
349        assert!(text.contains("\nLF:3\nLH:2\n"), "{text}");
350    }
351
352    #[test]
353    fn write_is_independent_of_the_order_shards_were_merged_in() {
354        let shards = [
355            "SF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record",
356            "SF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/b.rs\nDA:9,1\nend_of_record",
357            "SF:src/c.rs\nDA:1,4\nDA:7,0\nend_of_record",
358        ];
359        let merged_in = |order: [usize; 3]| {
360            let mut merged = CoverageReport::new();
361            for index in order {
362                merged.merge(parse(shards[index]).unwrap());
363            }
364            write(&merged).unwrap()
365        };
366        let reference = merged_in([0, 1, 2]);
367        for order in [[0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
368            assert_eq!(merged_in(order), reference, "{order:?}");
369        }
370    }
371
372    #[test]
373    fn write_rejects_a_path_that_would_not_read_back() {
374        for path in [
375            "src/a\nSF:evil.rs",
376            "src/a\rb.rs",
377            " src/a.rs",
378            "src/a.rs\t",
379        ] {
380            let message = write(&report(&[(path, &[(1, 1)])]))
381                .unwrap_err()
382                .to_string();
383            assert!(
384                message.contains("read back as a different path"),
385                "{message}"
386            );
387        }
388    }
389}