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//! 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                            &region.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 &region.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        assert_eq!(
494            parse_directive("% badness-format skip deprecated-command"),
495            Some(directive(Axis::Format, Verb::Skip))
496        );
497        assert_eq!(
498            parse_directive("% badness skip deprecated-command"),
499            Some(directive(Axis::Both, Verb::Skip))
500        );
501    }
502
503    #[test]
504    fn lint_rule_is_optional_and_means_every_rule() {
505        assert_eq!(
506            parse_directive("% badness-lint skip-file: generated"),
507            Some(directive(Axis::Lint, Verb::SkipFile))
508        );
509    }
510
511    #[test]
512    fn reason_is_optional_and_ignored() {
513        assert_eq!(
514            parse_directive("% badness-format skip: hand-aligned by eye"),
515            Some(directive(Axis::Format, Verb::Skip))
516        );
517        assert_eq!(
518            parse_directive("%badness skip-file:generated"),
519            Some(directive(Axis::Both, Verb::SkipFile))
520        );
521    }
522
523    #[test]
524    fn repeated_percent_is_allowed() {
525        assert_eq!(
526            parse_directive("%%% badness-format off"),
527            Some(directive(Axis::Format, Verb::Off))
528        );
529    }
530
531    #[test]
532    fn retired_ignore_family_still_parses() {
533        assert_eq!(
534            parse_directive("% badness-ignore deprecated-command: legacy"),
535            Some(Directive {
536                axis: Axis::Lint,
537                verb: Verb::Skip,
538                rule: Some("deprecated-command".into()),
539                deprecated: true,
540            })
541        );
542        assert_eq!(
543            parse_directive("% badness-ignore-file deprecated-command: legacy"),
544            Some(Directive {
545                axis: Axis::Lint,
546                verb: Verb::SkipFile,
547                rule: Some("deprecated-command".into()),
548                deprecated: true,
549            })
550        );
551        assert_eq!(
552            parse_directive("% badness-ignore-file: noisy"),
553            Some(Directive {
554                axis: Axis::Lint,
555                verb: Verb::SkipFile,
556                rule: None,
557                deprecated: true,
558            })
559        );
560    }
561
562    #[test]
563    fn bare_retired_node_directive_stays_inert() {
564        assert_eq!(parse_directive("% badness-ignore"), None);
565        assert_eq!(parse_directive("% badness-ignore: no rule named"), None);
566    }
567
568    #[test]
569    fn non_directives_are_inert() {
570        for text in [
571            "% just a note",
572            "% badness",                 // no verb
573            "% badness-lint",            // no verb
574            "% badness-format nonsense", // unknown verb
575            "% badnessformat off",       // no word boundary
576            "% badness-formatting off",  // no word boundary
577            "% badnesslint skip",        // no word boundary
578            "% the badness-format off",  // not at the start
579        ] {
580            assert_eq!(parse_directive(text), None, "expected {text:?} to be inert");
581        }
582    }
583
584    #[test]
585    fn skip_targets_the_documented_construct() {
586        let src = "% badness-format skip: hand-aligned\n\\begin{tikzpicture}\n\\draw (0,0);\n\\end{tikzpicture}\n";
587        let s = suppressions_of(src);
588        assert_eq!(slices(src, s.format_ranges()), vec![src.trim_end()]);
589        assert!(s.lint_all_ranges().is_empty(), "format axis must not lint");
590    }
591
592    #[test]
593    fn region_spans_from_off_to_on() {
594        let src = "\\alpha\n% badness-format off\n\\beta\n% badness-format on\n\\gamma\n";
595        let s = suppressions_of(src);
596        assert_eq!(
597            slices(src, s.format_ranges()),
598            vec!["% badness-format off\n\\beta\n"]
599        );
600    }
601
602    #[test]
603    fn region_covers_a_leading_comment_run() {
604        let src = "\\alpha\n% a note\n% badness-format off\n\\beta\n% badness-format on\n";
605        let s = suppressions_of(src);
606        assert_eq!(
607            slices(src, s.format_ranges()),
608            vec!["% a note\n% badness-format off\n\\beta\n"]
609        );
610    }
611
612    #[test]
613    fn unclosed_region_runs_to_end_of_file() {
614        let src = "\\alpha\n% badness-format off\n\\beta\n\\gamma\n";
615        let s = suppressions_of(src);
616        assert_eq!(
617            slices(src, s.format_ranges()),
618            vec!["% badness-format off\n\\beta\n\\gamma\n"]
619        );
620    }
621
622    #[test]
623    fn both_family_suppresses_both_axes() {
624        let src = "% badness off\n\\beta\n% badness on\n";
625        let s = suppressions_of(src);
626        assert_eq!(s.format_ranges(), s.lint_all_ranges());
627        assert_eq!(
628            slices(src, s.lint_all_ranges()),
629            vec!["% badness off\n\\beta\n"]
630        );
631    }
632
633    #[test]
634    fn format_on_does_not_close_a_both_region() {
635        let src = "% badness off\n\\beta\n% badness-format on\n\\gamma\n";
636        let s = suppressions_of(src);
637        assert_eq!(
638            slices(src, s.lint_all_ranges()),
639            vec!["% badness off\n\\beta\n% badness-format on\n\\gamma\n"]
640        );
641    }
642
643    #[test]
644    fn rule_selective_on_does_not_close_an_every_rule_region() {
645        let src = "% badness-lint off\n\\beta\n% badness-lint on deprecated-command\n\\gamma\n";
646        let s = suppressions_of(src);
647        assert_eq!(s.lint_all_ranges().len(), 1);
648        assert!(
649            slices(src, s.lint_all_ranges())[0].ends_with("\\gamma\n"),
650            "the every-rule region stays open to EOF"
651        );
652    }
653
654    #[test]
655    fn lint_region_is_rule_selective() {
656        let src =
657            "% badness-lint off deprecated-command\n\\beta\n% badness-lint on deprecated-command\n";
658        let s = suppressions_of(src);
659        assert!(s.lint_all_ranges().is_empty(), "one rule, not all of them");
660        assert!(s.format_ranges().is_empty(), "lint axis must not format");
661        let ranges = s
662            .lint_rule_ranges()
663            .get("deprecated-command")
664            .expect("rule recorded");
665        assert_eq!(
666            slices(src, ranges),
667            vec!["% badness-lint off deprecated-command\n\\beta\n"]
668        );
669    }
670
671    #[test]
672    fn skip_file_covers_the_document_on_its_axis() {
673        let src = "\\alpha\n% badness-format skip-file: generated\n\\beta\n";
674        let s = suppressions_of(src);
675        assert_eq!(slices(src, s.format_ranges()), vec![src]);
676        assert!(s.lint_all_ranges().is_empty());
677    }
678
679    #[test]
680    fn stray_on_is_inert() {
681        let src = "\\alpha\n% badness-format on\n\\beta\n";
682        assert!(suppressions_of(src).is_empty());
683    }
684
685    #[test]
686    fn overlapping_ranges_merge() {
687        let src = "% badness-format skip-file: generated\n% badness-format off\n\\b\n";
688        let s = suppressions_of(src);
689        assert_eq!(slices(src, s.format_ranges()), vec![src]);
690    }
691
692    #[test]
693    fn reopened_region_does_not_swallow_its_own_closer() {
694        let src = "% badness-format off\n\\a\n% badness-format on\n% badness-format off\n\\b\n% badness-format on\n";
695        let s = suppressions_of(src);
696        assert_eq!(
697            slices(src, s.format_ranges()),
698            vec![
699                "% badness-format off\n\\a\n",
700                "\n% badness-format off\n\\b\n"
701            ]
702        );
703    }
704
705    #[test]
706    fn retired_and_current_spellings_resolve_identically() {
707        fn covers_target(src: &str, ranges: &[TextRange]) -> bool {
708            let at = TextSize::new(src.find("\\bf").expect("has a target") as u32);
709            ranges.iter().any(|r| r.contains(at))
710        }
711        for (old, new) in [
712            (
713                "% badness-ignore deprecated-command: legacy\n\\bf x\n",
714                "% badness-lint skip deprecated-command: legacy\n\\bf x\n",
715            ),
716            (
717                "% badness-ignore-file deprecated-command: legacy\n\\bf x\n",
718                "% badness-lint skip-file deprecated-command: legacy\n\\bf x\n",
719            ),
720        ] {
721            for (src, label) in [(old, "retired"), (new, "current")] {
722                let s = suppressions_of(src);
723                let ranges = s
724                    .lint_rule_ranges()
725                    .get("deprecated-command")
726                    .unwrap_or_else(|| panic!("{label} spelling records the rule: {src:?}"));
727                assert!(
728                    covers_target(src, ranges),
729                    "{label} spelling must cover its target: {src:?}"
730                );
731                assert!(
732                    s.lint_all_ranges().is_empty() && s.format_ranges().is_empty(),
733                    "{label} spelling is lint-only and rule-selective: {src:?}"
734                );
735            }
736        }
737        let old = suppressions_of("% badness-ignore-file: noisy\n\\bf x\n");
738        let new = suppressions_of("% badness-lint skip-file: noisy\n\\bf x\n");
739        assert_eq!(old.lint_all_ranges().len(), 1);
740        assert_eq!(new.lint_all_ranges().len(), 1);
741        assert!(old.lint_rule_ranges().is_empty() && new.lint_rule_ranges().is_empty());
742    }
743
744    #[test]
745    fn clean_document_has_no_suppressions() {
746        assert!(suppressions_of("\\alpha\n% an ordinary comment\n\\beta\n").is_empty());
747    }
748}