badness_parser/directives.rs
1//! Comment directives that turn badness off for part of a file.
2//!
3//! Three families, all spelled as ordinary LaTeX line comments. The **verb
4//! carries the scope**, so every form reads as an imperative (`skip-file` is
5//! "skip this file", not "the file directive") and all three share one grammar:
6//!
7//! ```text
8//! % badness-format <verb> layout only
9//! % badness-lint <verb> [<rule>] linting only, optionally one rule
10//! % badness <verb> both at once
11//! ```
12//!
13//! with `<verb>` one of:
14//!
15//! ```text
16//! skip the next construct
17//! off … on everything between the two
18//! skip-file the whole file, wherever the directive sits
19//! ```
20//!
21//! Only the lint axis takes a `<rule>`, because only the linter has anything to
22//! select; omitting it means every rule. The `: <reason>` tail is optional
23//! everywhere and is never interpreted.
24//!
25//! ## The retired `% badness-ignore` family
26//!
27//! ```text
28//! % badness-ignore <rule>: <reason> → % badness-lint skip <rule>: <reason>
29//! % badness-ignore-file <rule>: <reason> → % badness-lint skip-file <rule>: <reason>
30//! % badness-ignore-file: <reason> → % badness-lint skip-file: <reason>
31//! ```
32//!
33//! Still recognized, and resolved through exactly the same path as their
34//! replacements — the deprecation is in the documentation, never in the
35//! behavior. A directive spelling is user-facing API; breaking one silently
36//! would be worse than carrying it. [`Directive::deprecated`] marks them, so a
37//! future lint rule reporting the retired spelling has the fact it needs
38//! without re-parsing (recorded in `TODO.md`).
39//!
40//! ## Why this lives in the parser crate
41//!
42//! Both consumers need it and neither can reach the other: the formatter is
43//! wasm-clean (and is what the dprint plugin embeds), the linter lives in the
44//! root crate. Resolving a directive is a pure function of the tree, so it sits
45//! below both.
46//!
47//! **Scope limit:** a directive is recognized in a [`SyntaxKind::COMMENT`] token
48//! only. In a `.dtx` documentation line the leading `%` is a `DOC_MARGIN` and the
49//! rest is prose, so a directive written there is inert; inside a `macrocode`
50//! chunk (where `%` comments are ordinary) it works as everywhere else.
51
52use std::collections::BTreeMap;
53
54use rowan::{NodeOrToken, TextRange, TextSize};
55
56use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
57
58/// Which subsystem a directive turns off.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Axis {
61 /// `% badness-format …` — layout only. Lint findings are still reported.
62 Format,
63 /// `% badness-lint …` — linting only, for one rule or all of them.
64 Lint,
65 /// `% badness …` — layout *and* every lint rule.
66 Both,
67}
68
69impl Axis {
70 /// Whether a directive on this axis turns off layout.
71 pub fn covers_format(self) -> bool {
72 matches!(self, Axis::Format | Axis::Both)
73 }
74
75 /// Whether a directive on this axis turns off linting.
76 pub fn covers_lint(self) -> bool {
77 matches!(self, Axis::Lint | Axis::Both)
78 }
79}
80
81/// The scope a directive applies to. The verb *is* the scope.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Verb {
84 /// `skip` — the next meaningful sibling. When the directive comment binds
85 /// forward into a `DOC_COMMENT` (parser trivia rule), the target is the
86 /// whole construct that owns it, which is the shape an author writing a
87 /// directive above `\begin{tikzpicture}` means.
88 Skip,
89 /// `off` — from the next meaningful thing (as [`Verb::Skip`] resolves it) to
90 /// the matching `on`, or to end of file.
91 Off,
92 /// `on` — closes an open `off` with the same axis and rule. Inert without one.
93 On,
94 /// `skip-file` — the whole file, wherever in it the directive sits.
95 SkipFile,
96}
97
98/// One directive, as written. Resolution against the tree happens in
99/// [`Suppressions::build`].
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Directive {
102 pub axis: Axis,
103 pub verb: Verb,
104 /// The rule the directive selects; `None` means every rule. Only ever `Some`
105 /// on [`Axis::Lint`] — the other two axes have nothing to select.
106 pub rule: Option<String>,
107 /// Written in the retired `% badness-ignore` spelling. Behaves identically —
108 /// this exists so a lint rule can report the retired spelling and offer the
109 /// rewrite, without having to re-parse the comment.
110 pub deprecated: bool,
111}
112
113/// Read a directive out of a comment token's text. Returns `None` for an
114/// ordinary comment and for an unrecognized verb.
115///
116/// Leading `%`s are all stripped, so `%%% badness-format off` works; the verb
117/// must be the first word after the family name, separated by whitespace.
118pub fn parse_directive(comment: &str) -> Option<Directive> {
119 let body = comment.trim_start_matches('%').trim_start();
120 // Longest family name first, or a shorter one swallows a longer one's prefix
121 // and the word-boundary check below rejects it for the wrong reason.
122 if let Some(rest) = body.strip_prefix("badness-ignore-file") {
123 // `…-file:` or a bare `…-file` is every rule; `…-file <rule>` is one.
124 return Some(Directive {
125 axis: Axis::Lint,
126 verb: Verb::SkipFile,
127 rule: parse_rule(rest),
128 deprecated: true,
129 });
130 }
131 if let Some(rest) = body.strip_prefix("badness-ignore") {
132 // The retired node form always required a rule; a bare `% badness-ignore`
133 // was inert and stays inert, rather than silently widening to every rule
134 // on the way through the new grammar.
135 return Some(Directive {
136 axis: Axis::Lint,
137 verb: Verb::Skip,
138 rule: Some(parse_rule(rest)?),
139 deprecated: true,
140 });
141 }
142 let (axis, rest) = if let Some(rest) = body.strip_prefix("badness-format") {
143 (Axis::Format, rest)
144 } else if let Some(rest) = body.strip_prefix("badness-lint") {
145 (Axis::Lint, rest)
146 } else {
147 (Axis::Both, body.strip_prefix("badness")?)
148 };
149 // The family name must end at a word boundary, so `% badness-formatting off`
150 // and `% badnesslint skip` are ordinary comments.
151 if !rest.starts_with([' ', '\t']) {
152 return None;
153 }
154 let rest = rest.trim_start();
155 let end = word_end(rest);
156 let verb = match &rest[..end] {
157 "skip" => Verb::Skip,
158 "off" => Verb::Off,
159 "on" => Verb::On,
160 "skip-file" => Verb::SkipFile,
161 _ => return None,
162 };
163 // Only the lint axis takes a selector. A word after the verb on another axis
164 // is prose in the reason position, not a rule we should quietly honor.
165 let rule = if axis == Axis::Lint {
166 parse_rule(&rest[end..])
167 } else {
168 None
169 };
170 Some(Directive {
171 axis,
172 verb,
173 rule,
174 deprecated: false,
175 })
176}
177
178/// The leading `<rule>` word of a `<rule>: <reason>` tail, or `None` when the
179/// tail opens with `:` (a reason and no rule) or is empty.
180fn parse_rule(tail: &str) -> Option<String> {
181 let trimmed = tail.trim_start();
182 let end = word_end(trimmed);
183 if end == 0 {
184 return None;
185 }
186 Some(trimmed[..end].to_string())
187}
188
189/// The end of the first word of `s`, delimited by `:` or whitespace.
190fn word_end(s: &str) -> usize {
191 s.find(|c: char| c == ':' || c.is_whitespace())
192 .unwrap_or(s.len())
193}
194
195/// The byte ranges a file's directives suppress, resolved per axis.
196///
197/// Ranges are sorted and non-overlapping (touching ones are merged), so a
198/// consumer can test containment with a plain scan and never has to reason
199/// about nesting.
200#[derive(Debug, Clone, Default)]
201pub struct Suppressions {
202 format: Vec<TextRange>,
203 lint_all: Vec<TextRange>,
204 lint_rules: BTreeMap<String, Vec<TextRange>>,
205}
206
207/// A region opened by an `off` and waiting for its `on`.
208struct OpenRegion {
209 axis: Axis,
210 rule: Option<String>,
211 start: TextSize,
212}
213
214impl Suppressions {
215 /// Scan `root` for directives and resolve them into ranges.
216 ///
217 /// A `skip-file` becomes a range covering the whole document rather than a
218 /// flag, so every consumer keeps one code path: whole-file suppression is
219 /// just the widest region. (The document-level trailing-edge normalization
220 /// and the `line_ending` post-pass still run over the result — the same
221 /// carve-out protected regions already live under.)
222 ///
223 /// An `off` with no matching `on` runs to end of file, as it does in every
224 /// other formatter that has the directive.
225 pub fn build(root: &SyntaxNode) -> Self {
226 let mut format = Vec::new();
227 let mut lint_all = Vec::new();
228 let mut lint_rules: BTreeMap<String, Vec<TextRange>> = BTreeMap::new();
229 // Regions are keyed by axis *and* rule: a `% badness-lint off` covering
230 // every rule is not closed by a `% badness-lint on some-rule`, which
231 // speaks for a strictly narrower thing.
232 let mut open: Vec<OpenRegion> = Vec::new();
233 // End of the most recent directive comment. A region anchor may never
234 // reach back past it — see the `Verb::Off` arm.
235 let mut prev_directive_end = TextSize::new(0);
236
237 for element in root.descendants_with_tokens() {
238 let NodeOrToken::Token(token) = element else {
239 continue;
240 };
241 if token.kind() != SyntaxKind::COMMENT {
242 continue;
243 }
244 let Some(directive) = parse_directive(token.text()) else {
245 continue;
246 };
247 let mut record = |range: TextRange, rule: &Option<String>| {
248 if directive.axis.covers_format() {
249 format.push(range);
250 }
251 if directive.axis.covers_lint() {
252 match rule {
253 Some(rule) => lint_rules.entry(rule.clone()).or_default().push(range),
254 None => lint_all.push(range),
255 }
256 }
257 };
258 match directive.verb {
259 Verb::SkipFile => record(root.text_range(), &directive.rule),
260 Verb::Skip => {
261 if let Some(range) = skip_target(&token) {
262 record(range, &directive.rule);
263 }
264 }
265 // A region opens at the same place a `skip` would target: the
266 // next meaningful thing. Anchoring to the raw byte after the
267 // comment instead looks simpler and is wrong — an own-line `%`
268 // binds *forward* into the following construct's `DOC_COMMENT`
269 // (parser decision #9), so that construct begins at the comment,
270 // ahead of the region, and a consumer testing containment would
271 // find the very block the author meant to cover sticking out of
272 // it. Resolving through the tree also picks up a preceding
273 // comment run bound into the same `DOC_COMMENT`, which a byte
274 // offset cannot see at all. Falls back to the byte after the
275 // comment when nothing meaningful follows (a directive at EOF).
276 //
277 // Clamped so the anchor never reaches back past the previous
278 // directive: consecutive own-line comments bind into *one*
279 // `DOC_COMMENT`, so in `on` / `off` / `\b` the reopening `off`
280 // resolves to a construct starting at the `on` — and the region
281 // it opens would then swallow the very directive that closed the
282 // one before it, fusing two deliberately separate regions into
283 // one. The clamp is against directives only, so an ordinary
284 // comment run above the directive is still covered.
285 Verb::Off => {
286 let start = skip_target(&token)
287 .map(|r| r.start())
288 .unwrap_or_else(|| token.text_range().end())
289 .max(prev_directive_end);
290 if !open
291 .iter()
292 .any(|o| o.axis == directive.axis && o.rule == directive.rule)
293 {
294 open.push(OpenRegion {
295 axis: directive.axis,
296 rule: directive.rule.clone(),
297 start,
298 });
299 }
300 }
301 Verb::On => {
302 if let Some(i) = open
303 .iter()
304 .position(|o| o.axis == directive.axis && o.rule == directive.rule)
305 {
306 let region = open.remove(i);
307 record(
308 TextRange::new(region.start, token.text_range().start()),
309 ®ion.rule,
310 );
311 }
312 }
313 }
314 prev_directive_end = token.text_range().end();
315 }
316
317 // Unclosed regions run to end of file.
318 let eof = root.text_range().end();
319 for region in open {
320 let range = TextRange::new(region.start, eof);
321 if region.axis.covers_format() {
322 format.push(range);
323 }
324 if region.axis.covers_lint() {
325 match ®ion.rule {
326 Some(rule) => lint_rules.entry(rule.clone()).or_default().push(range),
327 None => lint_all.push(range),
328 }
329 }
330 }
331
332 Self {
333 format: merge(format),
334 lint_all: merge(lint_all),
335 lint_rules: lint_rules
336 .into_iter()
337 .map(|(rule, ranges)| (rule, merge(ranges)))
338 .collect(),
339 }
340 }
341
342 /// Whether the document carries no directive at all — the fast path for the
343 /// overwhelming majority of files, so a consumer can skip its per-node test.
344 pub fn is_empty(&self) -> bool {
345 self.format.is_empty() && self.lint_all.is_empty() && self.lint_rules.is_empty()
346 }
347
348 /// Ranges the formatter must reproduce byte-for-byte.
349 pub fn format_ranges(&self) -> &[TextRange] {
350 &self.format
351 }
352
353 /// Ranges in which *every* lint rule is suppressed.
354 pub fn lint_all_ranges(&self) -> &[TextRange] {
355 &self.lint_all
356 }
357
358 /// Ranges in which one named rule is suppressed.
359 pub fn lint_rule_ranges(&self) -> &BTreeMap<String, Vec<TextRange>> {
360 &self.lint_rules
361 }
362}
363
364/// Sort and coalesce, merging ranges that overlap *or touch*. Touching ranges
365/// merge because two adjacent `off`/`on` regions describe one continuous span of
366/// suppressed text, and leaving them split would let a consumer that tests
367/// containment miss an element straddling the seam.
368fn merge(mut ranges: Vec<TextRange>) -> Vec<TextRange> {
369 ranges.sort_by_key(|r| (r.start(), r.end()));
370 let mut out: Vec<TextRange> = Vec::with_capacity(ranges.len());
371 for range in ranges {
372 match out.last_mut() {
373 Some(last) if range.start() <= last.end() => {
374 *last = TextRange::new(last.start(), last.end().max(range.end()));
375 }
376 _ => out.push(range),
377 }
378 }
379 out
380}
381
382/// The range a node-scoped directive covers: the next non-trivia, non-comment
383/// element after `token`, bubbling up through parents whose remaining siblings
384/// are all trivia. A comment bound into a `DOC_COMMENT` targets the whole
385/// construct that owns it, not a sibling — walking forward from such a comment
386/// only ever finds pieces *inside* that construct (its control word, missing its
387/// arguments), never the construct as a whole.
388fn skip_target(token: &SyntaxToken) -> Option<TextRange> {
389 if let Some(parent) = token.parent()
390 && parent.kind() == SyntaxKind::DOC_COMMENT
391 {
392 return Some(parent.parent()?.text_range());
393 }
394 let mut current = token.clone();
395 loop {
396 let parent = current.parent()?;
397 if let Some(range) = first_meaningful_after(&parent, &NodeOrToken::Token(current.clone())) {
398 return Some(range);
399 }
400 let grand = parent.parent()?;
401 if let Some(range) = first_meaningful_after(&grand, &NodeOrToken::Node(parent.clone())) {
402 return Some(range);
403 }
404 // Guard against a non-progressing climb (a single-child spine).
405 if grand == parent {
406 return None;
407 }
408 current = grand.first_token()?;
409 }
410}
411
412/// The range of the first non-trivia element of `parent` strictly after `after`.
413fn first_meaningful_after(
414 parent: &SyntaxNode,
415 after: &NodeOrToken<SyntaxNode, SyntaxToken>,
416) -> Option<TextRange> {
417 let mut past = false;
418 for element in parent.children_with_tokens() {
419 if !past {
420 past = &element == after;
421 continue;
422 }
423 match &element {
424 NodeOrToken::Token(t)
425 if matches!(
426 t.kind(),
427 SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
428 ) => {}
429 _ => return Some(element.text_range()),
430 }
431 }
432 None
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::parser::parse;
439
440 fn suppressions_of(src: &str) -> Suppressions {
441 Suppressions::build(&SyntaxNode::new_root(parse(src).green))
442 }
443
444 fn slices<'a>(src: &'a str, ranges: &[TextRange]) -> Vec<&'a str> {
445 ranges
446 .iter()
447 .map(|r| &src[usize::from(r.start())..usize::from(r.end())])
448 .collect()
449 }
450
451 fn directive(axis: Axis, verb: Verb) -> Directive {
452 Directive {
453 axis,
454 verb,
455 rule: None,
456 deprecated: false,
457 }
458 }
459
460 #[test]
461 fn parses_every_form_on_every_axis() {
462 for (family, axis) in [
463 ("badness-format", Axis::Format),
464 ("badness-lint", Axis::Lint),
465 ("badness", Axis::Both),
466 ] {
467 for (word, verb) in [
468 ("skip", Verb::Skip),
469 ("off", Verb::Off),
470 ("on", Verb::On),
471 ("skip-file", Verb::SkipFile),
472 ] {
473 let text = format!("% {family} {word}");
474 assert_eq!(
475 parse_directive(&text),
476 Some(directive(axis, verb)),
477 "parsing {text:?}"
478 );
479 }
480 }
481 }
482
483 #[test]
484 fn only_the_lint_axis_takes_a_rule() {
485 assert_eq!(
486 parse_directive("% badness-lint skip deprecated-command: legacy"),
487 Some(Directive {
488 axis: Axis::Lint,
489 verb: Verb::Skip,
490 rule: Some("deprecated-command".into()),
491 deprecated: false,
492 })
493 );
494 // A word after the verb on another axis is reason prose, not a selector.
495 assert_eq!(
496 parse_directive("% badness-format skip deprecated-command"),
497 Some(directive(Axis::Format, Verb::Skip))
498 );
499 assert_eq!(
500 parse_directive("% badness skip deprecated-command"),
501 Some(directive(Axis::Both, Verb::Skip))
502 );
503 }
504
505 #[test]
506 fn lint_rule_is_optional_and_means_every_rule() {
507 assert_eq!(
508 parse_directive("% badness-lint skip-file: generated"),
509 Some(directive(Axis::Lint, Verb::SkipFile))
510 );
511 }
512
513 #[test]
514 fn reason_is_optional_and_ignored() {
515 assert_eq!(
516 parse_directive("% badness-format skip: hand-aligned by eye"),
517 Some(directive(Axis::Format, Verb::Skip))
518 );
519 assert_eq!(
520 parse_directive("%badness skip-file:generated"),
521 Some(directive(Axis::Both, Verb::SkipFile))
522 );
523 }
524
525 #[test]
526 fn repeated_percent_is_allowed() {
527 assert_eq!(
528 parse_directive("%%% badness-format off"),
529 Some(directive(Axis::Format, Verb::Off))
530 );
531 }
532
533 /// The retired spellings resolve exactly like their replacements, and are
534 /// flagged so the lint rule can offer the rewrite.
535 #[test]
536 fn retired_ignore_family_still_parses() {
537 assert_eq!(
538 parse_directive("% badness-ignore deprecated-command: legacy"),
539 Some(Directive {
540 axis: Axis::Lint,
541 verb: Verb::Skip,
542 rule: Some("deprecated-command".into()),
543 deprecated: true,
544 })
545 );
546 assert_eq!(
547 parse_directive("% badness-ignore-file deprecated-command: legacy"),
548 Some(Directive {
549 axis: Axis::Lint,
550 verb: Verb::SkipFile,
551 rule: Some("deprecated-command".into()),
552 deprecated: true,
553 })
554 );
555 assert_eq!(
556 parse_directive("% badness-ignore-file: noisy"),
557 Some(Directive {
558 axis: Axis::Lint,
559 verb: Verb::SkipFile,
560 rule: None,
561 deprecated: true,
562 })
563 );
564 }
565
566 /// The retired node form always required a rule. A bare one was inert and
567 /// must not widen to "every rule" on its way through the new grammar.
568 #[test]
569 fn bare_retired_node_directive_stays_inert() {
570 assert_eq!(parse_directive("% badness-ignore"), None);
571 assert_eq!(parse_directive("% badness-ignore: no rule named"), None);
572 }
573
574 #[test]
575 fn non_directives_are_inert() {
576 for text in [
577 "% just a note",
578 "% badness", // no verb
579 "% badness-lint", // no verb
580 "% badness-format nonsense", // unknown verb
581 "% badnessformat off", // no word boundary
582 "% badness-formatting off", // no word boundary
583 "% badnesslint skip", // no word boundary
584 "% the badness-format off", // not at the start
585 ] {
586 assert_eq!(parse_directive(text), None, "expected {text:?} to be inert");
587 }
588 }
589
590 #[test]
591 fn skip_targets_the_documented_construct() {
592 let src = "% badness-format skip: hand-aligned\n\\begin{tikzpicture}\n\\draw (0,0);\n\\end{tikzpicture}\n";
593 let s = suppressions_of(src);
594 assert_eq!(slices(src, s.format_ranges()), vec![src.trim_end()]);
595 assert!(s.lint_all_ranges().is_empty(), "format axis must not lint");
596 }
597
598 /// A region runs from the construct the `off` documents (so the directive
599 /// comment, bound into that construct's `DOC_COMMENT`, rides inside it) to
600 /// the `on`.
601 #[test]
602 fn region_spans_from_off_to_on() {
603 let src = "\\alpha\n% badness-format off\n\\beta\n% badness-format on\n\\gamma\n";
604 let s = suppressions_of(src);
605 assert_eq!(
606 slices(src, s.format_ranges()),
607 vec!["% badness-format off\n\\beta\n"]
608 );
609 }
610
611 /// An ordinary comment above the directive binds into the same
612 /// `DOC_COMMENT`, and the region covers it — the construct is what the
613 /// author pointed at, whatever else got bound in front of it.
614 #[test]
615 fn region_covers_a_leading_comment_run() {
616 let src = "\\alpha\n% a note\n% badness-format off\n\\beta\n% badness-format on\n";
617 let s = suppressions_of(src);
618 assert_eq!(
619 slices(src, s.format_ranges()),
620 vec!["% a note\n% badness-format off\n\\beta\n"]
621 );
622 }
623
624 #[test]
625 fn unclosed_region_runs_to_end_of_file() {
626 let src = "\\alpha\n% badness-format off\n\\beta\n\\gamma\n";
627 let s = suppressions_of(src);
628 assert_eq!(
629 slices(src, s.format_ranges()),
630 vec!["% badness-format off\n\\beta\n\\gamma\n"]
631 );
632 }
633
634 #[test]
635 fn both_family_suppresses_both_axes() {
636 let src = "% badness off\n\\beta\n% badness on\n";
637 let s = suppressions_of(src);
638 assert_eq!(s.format_ranges(), s.lint_all_ranges());
639 assert_eq!(
640 slices(src, s.lint_all_ranges()),
641 vec!["% badness off\n\\beta\n"]
642 );
643 }
644
645 /// A narrower `on` must not close a wider `off`: the format directive has
646 /// nothing to say about the lint half of a combined region.
647 #[test]
648 fn format_on_does_not_close_a_both_region() {
649 let src = "% badness off\n\\beta\n% badness-format on\n\\gamma\n";
650 let s = suppressions_of(src);
651 assert_eq!(
652 slices(src, s.lint_all_ranges()),
653 vec!["% badness off\n\\beta\n% badness-format on\n\\gamma\n"]
654 );
655 }
656
657 /// The same rule one axis down: a rule-selective `on` does not close an
658 /// every-rule `off`.
659 #[test]
660 fn rule_selective_on_does_not_close_an_every_rule_region() {
661 let src = "% badness-lint off\n\\beta\n% badness-lint on deprecated-command\n\\gamma\n";
662 let s = suppressions_of(src);
663 assert_eq!(s.lint_all_ranges().len(), 1);
664 assert!(
665 slices(src, s.lint_all_ranges())[0].ends_with("\\gamma\n"),
666 "the every-rule region stays open to EOF"
667 );
668 }
669
670 #[test]
671 fn lint_region_is_rule_selective() {
672 let src =
673 "% badness-lint off deprecated-command\n\\beta\n% badness-lint on deprecated-command\n";
674 let s = suppressions_of(src);
675 assert!(s.lint_all_ranges().is_empty(), "one rule, not all of them");
676 assert!(s.format_ranges().is_empty(), "lint axis must not format");
677 let ranges = s
678 .lint_rule_ranges()
679 .get("deprecated-command")
680 .expect("rule recorded");
681 assert_eq!(
682 slices(src, ranges),
683 vec!["% badness-lint off deprecated-command\n\\beta\n"]
684 );
685 }
686
687 #[test]
688 fn skip_file_covers_the_document_on_its_axis() {
689 let src = "\\alpha\n% badness-format skip-file: generated\n\\beta\n";
690 let s = suppressions_of(src);
691 assert_eq!(slices(src, s.format_ranges()), vec![src]);
692 assert!(s.lint_all_ranges().is_empty());
693 }
694
695 #[test]
696 fn stray_on_is_inert() {
697 let src = "\\alpha\n% badness-format on\n\\beta\n";
698 assert!(suppressions_of(src).is_empty());
699 }
700
701 /// A `skip-file` swallows every narrower range on its axis, so a consumer
702 /// never sees the same byte twice.
703 #[test]
704 fn overlapping_ranges_merge() {
705 let src = "% badness-format skip-file: generated\n% badness-format off\n\\b\n";
706 let s = suppressions_of(src);
707 assert_eq!(slices(src, s.format_ranges()), vec![src]);
708 }
709
710 /// Two regions closed and reopened in one comment run stay distinct. Both
711 /// directives bind into the same `DOC_COMMENT`, so without the
712 /// previous-directive clamp the reopening `off` would anchor back onto the
713 /// `on` and the two would fuse into one region.
714 #[test]
715 fn reopened_region_does_not_swallow_its_own_closer() {
716 let src = "% badness-format off\n\\a\n% badness-format on\n% badness-format off\n\\b\n% badness-format on\n";
717 let s = suppressions_of(src);
718 assert_eq!(
719 slices(src, s.format_ranges()),
720 vec![
721 "% badness-format off\n\\a\n",
722 "\n% badness-format off\n\\b\n"
723 ]
724 );
725 }
726
727 /// The retired spellings resolve through the same path as their
728 /// replacements — the deprecation is documentation, never behavior.
729 ///
730 /// Compared by what the range *covers*, not by the text it slices: the two
731 /// directive comments have different lengths and both ride inside the range,
732 /// so the slices can never be equal even when the resolution is identical.
733 #[test]
734 fn retired_and_current_spellings_resolve_identically() {
735 /// Whether `\bf`, the construct the directive points at, is covered.
736 fn covers_target(src: &str, ranges: &[TextRange]) -> bool {
737 let at = TextSize::new(src.find("\\bf").expect("has a target") as u32);
738 ranges.iter().any(|r| r.contains(at))
739 }
740 for (old, new) in [
741 (
742 "% badness-ignore deprecated-command: legacy\n\\bf x\n",
743 "% badness-lint skip deprecated-command: legacy\n\\bf x\n",
744 ),
745 (
746 "% badness-ignore-file deprecated-command: legacy\n\\bf x\n",
747 "% badness-lint skip-file deprecated-command: legacy\n\\bf x\n",
748 ),
749 ] {
750 for (src, label) in [(old, "retired"), (new, "current")] {
751 let s = suppressions_of(src);
752 let ranges = s
753 .lint_rule_ranges()
754 .get("deprecated-command")
755 .unwrap_or_else(|| panic!("{label} spelling records the rule: {src:?}"));
756 assert!(
757 covers_target(src, ranges),
758 "{label} spelling must cover its target: {src:?}"
759 );
760 assert!(
761 s.lint_all_ranges().is_empty() && s.format_ranges().is_empty(),
762 "{label} spelling is lint-only and rule-selective: {src:?}"
763 );
764 }
765 }
766 // …and the every-rule file form likewise.
767 let old = suppressions_of("% badness-ignore-file: noisy\n\\bf x\n");
768 let new = suppressions_of("% badness-lint skip-file: noisy\n\\bf x\n");
769 assert_eq!(old.lint_all_ranges().len(), 1);
770 assert_eq!(new.lint_all_ranges().len(), 1);
771 assert!(old.lint_rule_ranges().is_empty() && new.lint_rule_ranges().is_empty());
772 }
773
774 #[test]
775 fn clean_document_has_no_suppressions() {
776 assert!(suppressions_of("\\alpha\n% an ordinary comment\n\\beta\n").is_empty());
777 }
778}