1use std::collections::BTreeSet;
39
40use anyhow::{bail, Result};
41
42pub const INTRODUCER: &str = concat!("omni-dev", ": coverage");
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum MarkerKind {
60 Ignore,
62 Tolerate,
64}
65
66impl MarkerKind {
67 pub fn as_str(self) -> &'static str {
69 match self {
70 Self::Ignore => "ignore",
71 Self::Tolerate => "tolerate",
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Region {
83 pub kind: MarkerKind,
85 pub start: u32,
87 pub end: u32,
90 pub reason: String,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct FileMarkers {
97 pub ignored: BTreeSet<u32>,
99 pub tolerated: BTreeSet<u32>,
101 pub regions: Vec<Region>,
103}
104
105impl FileMarkers {
106 pub fn new(regions: Vec<Region>) -> Self {
108 let mut ignored = BTreeSet::new();
109 let mut tolerated = BTreeSet::new();
110 for region in ®ions {
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 pub fn is_empty(&self) -> bool {
126 self.regions.is_empty()
127 }
128}
129
130const 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
139struct Open {
141 kind: MarkerKind,
142 start: u32,
143 reason: String,
144}
145
146pub fn scan(path: &str, text: &str) -> Result<Vec<Region>> {
153 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 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
239fn 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
251fn 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 fn mark(comment: &str, rest: &str) -> String {
283 format!("{comment} {INTRODUCER} {rest}")
284 }
285
286 fn file(lines: &[String]) -> String {
288 format!("{}\n", lines.join("\n"))
289 }
290
291 fn rs(rest: &str) -> String {
293 mark("//", rest)
294 }
295
296 fn ok(text: &str) -> Vec<Region> {
298 scan("src/a.rs", text).unwrap()
299 }
300
301 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 #[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 #[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 #[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 #[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 #[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}