Skip to main content

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                            &region.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 &region.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}