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