Skip to main content

omni_dev/coverage/
markers.rs

1//! Source comment markers that exclude or tolerate a *region* of a file in
2//! `coverage diff`.
3//!
4//! `--ignore-filename-regex` (and its `coverage.yaml` twin) drops a **whole
5//! file** from both reports, but the noise it exists to silence is almost always
6//! narrower than a file: one function gated on a *runtime* CPU-feature check is
7//! compiled into the denominator on every run yet executed only on a host that
8//! has the instruction, so it flips whenever the baseline and head runs draw
9//! different runner CPUs. Excluding the file hides far more real coverage than
10//! noise.
11//!
12//! Naming the region in config is not an option either: line numbers are
13//! invalidated by every edit above them, and function *extents* are absent from
14//! the lcov `FN:` records (start line + mangled symbol only). So the region is
15//! delimited in the source itself, and each revision's own source is scanned —
16//! head from the worktree, base from the base blob — which means no line number
17//! is ever stored, and a region that moves, grows, or disappears between base
18//! and head is handled by construction.
19//!
20//! A region is opened by a comment naming a kind and a mandatory reason, and
21//! closed by an `end` comment; there is also a single-line form. **The syntax is
22//! documented, with examples, in `docs/coverage.md`** — deliberately not here:
23//! see [`INTRODUCER`] for why this file must not contain a literal marker.
24//!
25//! Two kinds, differing in what they do to the *reports*:
26//!
27//! - [`MarkerKind::Ignore`] — the lines are removed from **both** reports before
28//!   any analysis, so they cannot move any number. The scoped twin of
29//!   `ignore-filename-regex`.
30//! - [`MarkerKind::Tolerate`] — the lines stay, so the percentage stays honest;
31//!   only the *delta signals* are masked, by scoring each tolerated head line
32//!   with its baseline hit status.
33//!
34//! Matching is a **plain substring** on any line, so it works in any comment
35//! syntax and never depends on parsing the host language. A marker inside a
36//! string literal is therefore matched too — accepted, and documented.
37
38use std::collections::BTreeSet;
39
40use anyhow::{bail, Result};
41
42/// The literal that introduces every marker.
43///
44/// Assembled with `concat!` rather than written out, because omni-dev measures
45/// its own coverage: this file is in its own report, so a contiguous introducer
46/// anywhere in this source — a doc example, a test fixture — would be scanned as
47/// a real marker, and the deliberately-malformed fixtures below would fail
48/// omni-dev's own `coverage diff` run outright. `self_source_contains_no_literal_introducer`
49/// pins that invariant; put examples in `docs/coverage.md`, which is not a
50/// source file and is never scanned.
51///
52/// A file that does not contain this substring anywhere cannot carry a marker,
53/// and so cannot raise a marker error either — which is what makes the
54/// whole-file short-circuit in [`scan`] exact rather than merely fast.
55pub const INTRODUCER: &str = concat!("omni-dev", ": coverage");
56
57/// What a marked region does to the reports.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum MarkerKind {
60    /// Remove the region's lines from both reports before analysis.
61    Ignore,
62    /// Keep the region's lines, but mask coverage *flips* on them.
63    Tolerate,
64}
65
66impl MarkerKind {
67    /// The lowercase keyword used in the marker and in rendered output.
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::Ignore => "ignore",
71            Self::Tolerate => "tolerate",
72        }
73    }
74}
75
76/// One marked region, as a 1-based inclusive line span.
77///
78/// Both marker lines are inside the span. They are comments, so they are never
79/// executable and never appear in a coverage report — including them costs
80/// nothing and keeps the span identical to what a reader sees in the source.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Region {
83    /// Whether the region is ignored or tolerated.
84    pub kind: MarkerKind,
85    /// First line of the region (the opening marker's own line).
86    pub start: u32,
87    /// Last line of the region (the `end` marker's line, or `start` for the
88    /// single-line form).
89    pub end: u32,
90    /// The mandatory reason text explaining why the region is silenced.
91    pub reason: String,
92}
93
94/// The regions of one file, plus the line sets they expand to.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct FileMarkers {
97    /// Every line covered by an [`MarkerKind::Ignore`] region.
98    pub ignored: BTreeSet<u32>,
99    /// Every line covered by a [`MarkerKind::Tolerate`] region.
100    pub tolerated: BTreeSet<u32>,
101    /// The regions themselves, in source order, for reporting.
102    pub regions: Vec<Region>,
103}
104
105impl FileMarkers {
106    /// Expands `regions` into the per-line sets.
107    pub fn new(regions: Vec<Region>) -> Self {
108        let mut ignored = BTreeSet::new();
109        let mut tolerated = BTreeSet::new();
110        for region in &regions {
111            let set = match region.kind {
112                MarkerKind::Ignore => &mut ignored,
113                MarkerKind::Tolerate => &mut tolerated,
114            };
115            set.extend(region.start..=region.end);
116        }
117        Self {
118            ignored,
119            tolerated,
120            regions,
121        }
122    }
123
124    /// Whether this file carries no markers at all.
125    pub fn is_empty(&self) -> bool {
126        self.regions.is_empty()
127    }
128}
129
130/// A marker keyword, its kind, and whether it is the single-line form. Ordered
131/// longest-first so `ignore-line` is never read as `ignore` plus trailing junk.
132const KEYWORDS: &[(&str, MarkerKind, bool)] = &[
133    ("ignore-line", MarkerKind::Ignore, true),
134    ("tolerate-line", MarkerKind::Tolerate, true),
135    ("ignore", MarkerKind::Ignore, false),
136    ("tolerate", MarkerKind::Tolerate, false),
137];
138
139/// A region start that has not been closed yet.
140struct Open {
141    kind: MarkerKind,
142    start: u32,
143    reason: String,
144}
145
146/// Scans `text` for coverage markers, returning the regions in source order.
147///
148/// `path` is used only to build error messages. Every malformed marker is a hard
149/// error naming `path:line` rather than a silent skip: a marker that does not
150/// take effect is worse than one that does not exist, because its author
151/// believes the noise is silenced.
152pub fn scan(path: &str, text: &str) -> Result<Vec<Region>> {
153    // A file with no introducer cannot produce a region *or* an error, so this
154    // short-circuit changes nothing but the cost of the common case.
155    if !text.contains(INTRODUCER) {
156        return Ok(Vec::new());
157    }
158
159    let mut regions = Vec::new();
160    let mut open: Option<Open> = None;
161
162    for (index, raw) in text.lines().enumerate() {
163        let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
164        let Some(rest) = raw.trim_end_matches('\r').split(INTRODUCER).nth(1) else {
165            continue;
166        };
167        let rest = rest.trim_start();
168
169        if let Some(tail) = strip_keyword(rest, "end") {
170            if !tail.trim().is_empty() {
171                bail!(
172                    "{path}:{line}: unexpected text after `{INTRODUCER} end`: `{}`",
173                    tail.trim()
174                );
175            }
176            let Some(open) = open.take() else {
177                bail!("{path}:{line}: `{INTRODUCER} end` without a matching region start");
178            };
179            regions.push(Region {
180                kind: open.kind,
181                start: open.start,
182                end: line,
183                reason: open.reason,
184            });
185            continue;
186        }
187
188        let Some((keyword, kind, single)) = KEYWORDS
189            .iter()
190            .find(|(keyword, _, _)| strip_keyword(rest, keyword).is_some())
191            .copied()
192        else {
193            bail!(
194                "{path}:{line}: unrecognised coverage marker `{INTRODUCER} {}` \
195                 (expected `ignore`, `tolerate`, `ignore-line`, `tolerate-line`, or `end`)",
196                rest.split_whitespace().next().unwrap_or("")
197            );
198        };
199        // `strip_keyword` just succeeded for this keyword.
200        let tail = strip_keyword(rest, keyword).unwrap_or("");
201        let reason = parse_reason(path, line, keyword, tail)?;
202
203        if single {
204            regions.push(Region {
205                kind,
206                start: line,
207                end: line,
208                reason,
209            });
210            continue;
211        }
212
213        if let Some(previous) = &open {
214            bail!(
215                "{path}:{line}: nested coverage region; the `{}` region opened at line {} is \
216                 still open (regions may not overlap)",
217                previous.kind.as_str(),
218                previous.start
219            );
220        }
221        open = Some(Open {
222            kind,
223            start: line,
224            reason,
225        });
226    }
227
228    if let Some(open) = open {
229        bail!(
230            "{path}:{}: unterminated `{INTRODUCER} {}` region (add `{INTRODUCER} end`)",
231            open.start,
232            open.kind.as_str()
233        );
234    }
235
236    Ok(regions)
237}
238
239/// Strips `keyword` from the front of `rest`, requiring it to be followed by
240/// whitespace or end-of-line so `ignore` never matches the prefix of
241/// `ignore-line`.
242fn strip_keyword<'a>(rest: &'a str, keyword: &str) -> Option<&'a str> {
243    let tail = rest.strip_prefix(keyword)?;
244    if tail.is_empty() || tail.starts_with(|c: char| c.is_whitespace()) {
245        Some(tail)
246    } else {
247        None
248    }
249}
250
251/// Extracts the mandatory `reason="…"` from a marker's tail.
252///
253/// The reason is required because silencing must be explained at the site: a
254/// bare marker tells a later reader nothing about whether the noise it hides is
255/// still real.
256fn parse_reason(path: &str, line: u32, keyword: &str, tail: &str) -> Result<String> {
257    let tail = tail.trim();
258    let Some(after) = tail.split_once("reason=\"").map(|(_, after)| after) else {
259        bail!(
260            "{path}:{line}: `{INTRODUCER} {keyword}` needs a reason \
261             (write `{INTRODUCER} {keyword} reason=\"why this is silenced\"`)"
262        );
263    };
264    let Some((reason, _)) = after.split_once('"') else {
265        bail!("{path}:{line}: unterminated `reason=\"…\"` (missing closing quote)");
266    };
267    let reason = reason.trim();
268    if reason.is_empty() {
269        bail!("{path}:{line}: `reason=\"\"` is empty; explain why the region is silenced");
270    }
271    Ok(reason.to_string())
272}
273
274#[cfg(test)]
275#[allow(clippy::unwrap_used, clippy::expect_used)]
276mod tests {
277    use super::*;
278
279    /// Builds a marker line. Every fixture goes through this rather than writing
280    /// the introducer out, so this file's own source stays marker-free — see
281    /// [`INTRODUCER`] and `self_source_contains_no_literal_introducer`.
282    fn mark(comment: &str, rest: &str) -> String {
283        format!("{comment} {INTRODUCER} {rest}")
284    }
285
286    /// Joins fixture lines into a file body with a trailing newline.
287    fn file(lines: &[String]) -> String {
288        format!("{}\n", lines.join("\n"))
289    }
290
291    /// A `//`-commented marker line, the common case.
292    fn rs(rest: &str) -> String {
293        mark("//", rest)
294    }
295
296    /// Scans and unwraps, for the cases that must succeed.
297    fn ok(text: &str) -> Vec<Region> {
298        scan("src/a.rs", text).unwrap()
299    }
300
301    /// Scans and returns the error message, for the cases that must fail.
302    fn err(text: &str) -> String {
303        scan("src/a.rs", text).unwrap_err().to_string()
304    }
305
306    fn region(kind: MarkerKind, start: u32, end: u32, reason: &str) -> Region {
307        Region {
308            kind,
309            start,
310            end,
311            reason: reason.to_string(),
312        }
313    }
314
315    /// The guard that keeps omni-dev able to measure its own coverage. A literal
316    /// introducer anywhere in this file would be scanned as a real marker when
317    /// omni-dev runs `coverage diff` on itself, and the malformed fixtures below
318    /// would then fail that run. Examples belong in `docs/coverage.md`.
319    #[test]
320    fn self_source_contains_no_literal_introducer() {
321        let source = include_str!("markers.rs");
322        assert!(
323            !source.contains(INTRODUCER),
324            "src/coverage/markers.rs must not contain a literal marker introducer; \
325             build fixtures with `mark()` and put examples in docs/coverage.md"
326        );
327    }
328
329    #[test]
330    fn file_without_the_introducer_yields_nothing() {
331        assert!(ok("fn a() {}\n// ordinary comment\n").is_empty());
332    }
333
334    #[test]
335    fn scans_an_ignore_region() {
336        let text = file(&[
337            "fn a() {}".to_string(),
338            rs("ignore reason=\"CPU-gated\""),
339            "fn b() {}".to_string(),
340            rs("end"),
341            "fn c() {}".to_string(),
342        ]);
343        assert_eq!(
344            ok(&text),
345            vec![region(MarkerKind::Ignore, 2, 4, "CPU-gated")]
346        );
347    }
348
349    #[test]
350    fn scans_a_tolerate_region() {
351        let text = file(&[
352            rs("tolerate reason=\"avx512f arm\""),
353            "fn b() {}".to_string(),
354            rs("end"),
355        ]);
356        assert_eq!(
357            ok(&text),
358            vec![region(MarkerKind::Tolerate, 1, 3, "avx512f arm")]
359        );
360    }
361
362    #[test]
363    fn scans_both_single_line_forms() {
364        let text = file(&[
365            rs("ignore-line reason=\"one off\""),
366            rs("tolerate-line reason=\"flaky\""),
367        ]);
368        assert_eq!(
369            ok(&text),
370            vec![
371                region(MarkerKind::Ignore, 1, 1, "one off"),
372                region(MarkerKind::Tolerate, 2, 2, "flaky"),
373            ]
374        );
375    }
376
377    /// `ignore-line` must not be read as `ignore` with trailing junk — the
378    /// keyword table is longest-first *and* `strip_keyword` requires a word
379    /// boundary, so either alone would be enough.
380    #[test]
381    fn single_line_keyword_wins_over_its_prefix() {
382        let text = file(&[rs("ignore-line reason=\"one off\"")]);
383        let regions = ok(&text);
384        assert_eq!(regions.len(), 1);
385        assert_eq!(regions[0].end, 1, "must not open a region");
386    }
387
388    #[test]
389    fn regions_may_repeat_within_a_file() {
390        let text = file(&[
391            rs("ignore reason=\"a\""),
392            "x".to_string(),
393            rs("end"),
394            "y".to_string(),
395            rs("tolerate reason=\"b\""),
396            "z".to_string(),
397            rs("end"),
398        ]);
399        assert_eq!(
400            ok(&text),
401            vec![
402                region(MarkerKind::Ignore, 1, 3, "a"),
403                region(MarkerKind::Tolerate, 5, 7, "b"),
404            ]
405        );
406    }
407
408    /// Matching is a plain substring, so any comment syntax works.
409    #[test]
410    fn any_comment_syntax_matches() {
411        let text = file(&[
412            mark("#", "ignore-line reason=\"shell\""),
413            mark("<!--", "ignore-line reason=\"html\" -->"),
414        ]);
415        assert_eq!(ok(&text).len(), 2);
416    }
417
418    /// The flip side of a plain substring match: a marker inside a *string
419    /// literal* is matched too, because nothing here parses the host language.
420    /// Documented behaviour, not an oversight.
421    #[test]
422    fn marker_inside_a_string_literal_is_matched() {
423        let text = file(&[mark("let s = '", "ignore-line reason=\"in a literal\"';")]);
424        assert_eq!(ok(&text).len(), 1);
425    }
426
427    #[test]
428    fn crlf_line_endings_are_handled() {
429        let lines = [rs("ignore reason=\"crlf\""), "x".to_string(), rs("end")];
430        let text = format!("{}\r\n", lines.join("\r\n"));
431        assert_eq!(ok(&text), vec![region(MarkerKind::Ignore, 1, 3, "crlf")]);
432    }
433
434    #[test]
435    fn marker_on_the_last_line_without_a_trailing_newline() {
436        let text = format!("x\n{}", rs("ignore-line reason=\"last\""));
437        assert_eq!(ok(&text), vec![region(MarkerKind::Ignore, 2, 2, "last")]);
438    }
439
440    #[test]
441    fn reason_is_mandatory() {
442        let text = file(&[rs("ignore"), "x".to_string(), rs("end")]);
443        let message = err(&text);
444        assert!(message.contains("src/a.rs:1"), "{message}");
445        assert!(message.contains("needs a reason"), "{message}");
446    }
447
448    #[test]
449    fn reason_must_not_be_empty() {
450        let message = err(&file(&[rs("ignore-line reason=\"\"")]));
451        assert!(message.contains("is empty"), "{message}");
452    }
453
454    #[test]
455    fn reason_quote_must_be_closed() {
456        let message = err(&file(&[rs("ignore-line reason=\"unclosed")]));
457        assert!(message.contains("unterminated `reason"), "{message}");
458    }
459
460    #[test]
461    fn nested_regions_are_rejected() {
462        let text = file(&[
463            rs("ignore reason=\"a\""),
464            rs("tolerate reason=\"b\""),
465            rs("end"),
466        ]);
467        let message = err(&text);
468        assert!(message.contains("src/a.rs:2"), "{message}");
469        assert!(message.contains("nested"), "{message}");
470        assert!(message.contains("opened at line 1"), "{message}");
471    }
472
473    #[test]
474    fn stray_end_is_rejected() {
475        let message = err(&file(&["x".to_string(), rs("end")]));
476        assert!(message.contains("src/a.rs:2"), "{message}");
477        assert!(
478            message.contains("without a matching region start"),
479            "{message}"
480        );
481    }
482
483    /// An unterminated region must never widen silently to end-of-file: that
484    /// would silence an unbounded amount of code its author never looked at.
485    #[test]
486    fn unterminated_region_is_rejected() {
487        let text = file(&[rs("ignore reason=\"a\""), "x".to_string(), "y".to_string()]);
488        let message = err(&text);
489        assert!(message.contains("src/a.rs:1"), "{message}");
490        assert!(message.contains("unterminated"), "{message}");
491    }
492
493    #[test]
494    fn unknown_keyword_is_rejected() {
495        let message = err(&file(&[rs("skip reason=\"a\"")]));
496        assert!(message.contains("unrecognised"), "{message}");
497        assert!(message.contains("skip"), "{message}");
498    }
499
500    #[test]
501    fn text_after_end_is_rejected() {
502        let text = file(&[rs("ignore reason=\"a\""), rs("end reason=\"b\"")]);
503        let message = err(&text);
504        assert!(message.contains("unexpected text after"), "{message}");
505    }
506
507    #[test]
508    fn file_markers_expand_regions_to_line_sets() {
509        let markers = FileMarkers::new(vec![
510            region(MarkerKind::Ignore, 2, 4, "a"),
511            region(MarkerKind::Tolerate, 7, 7, "b"),
512        ]);
513        assert_eq!(markers.ignored, BTreeSet::from([2, 3, 4]));
514        assert_eq!(markers.tolerated, BTreeSet::from([7]));
515        assert!(!markers.is_empty());
516        assert!(FileMarkers::default().is_empty());
517    }
518}