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//! Active directives are recognized in [`SyntaxKind::COMMENT`] tokens. A `.dtx`
47//! documentation line starts with `DOC_MARGIN`, not a comment; directive-shaped
48//! prose there is retained as [`DirectiveOutcome::Unsupported`] so the linter can
49//! explain why it is inert. Inside a `macrocode` chunk, `%` comments are ordinary
50//! and directives work 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/// What a recognized directive accomplished after placement and region matching.
114///
115/// The linter consumes this retained resolver fact for meta diagnostics; it must
116/// not repeat the CST attachment walk or try to infer region state from the
117/// merged suppression ranges.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum DirectiveOutcome {
120    /// The directive targets a construct or file, opens a region that is later
121    /// closed, or closes such a region.
122    Honored,
123    /// A `skip` has no following meaningful construct.
124    DanglingSkip,
125    /// An `on` has no matching open region with the same axis and rule.
126    UnmatchedOn,
127    /// An `off` has no matching `on`; its range still extends to EOF.
128    UnclosedOff,
129    /// The carrier or axis is recognized but unsupported by the consumer.
130    Unsupported,
131}
132
133/// A parsed directive together with the exact carrier and family-name ranges,
134/// plus its placement outcome. Consumers retain these facts so diagnostics never
135/// need to parse comment text or repeat the attachment walk.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct LocatedDirective {
138    pub directive: Directive,
139    pub range: TextRange,
140    pub family_range: TextRange,
141    pub outcome: DirectiveOutcome,
142}
143
144/// Read a directive out of a comment token's text. Returns `None` for an
145/// ordinary comment and for an unrecognized verb.
146///
147/// Leading `%`s are all stripped, so `%%% badness-format off` works; the verb
148/// must be the first word after the family name, separated by whitespace.
149pub fn parse_directive(comment: &str) -> Option<Directive> {
150    let body = comment.trim_start_matches('%').trim_start();
151    // Longest family name first, or a shorter one swallows a longer one's prefix
152    // and the word-boundary check below rejects it for the wrong reason.
153    if let Some(rest) = body.strip_prefix("badness-ignore-file") {
154        // `…-file:` or a bare `…-file` is every rule; `…-file <rule>` is one.
155        return Some(Directive {
156            axis: Axis::Lint,
157            verb: Verb::SkipFile,
158            rule: parse_rule(rest),
159            deprecated: true,
160        });
161    }
162    if let Some(rest) = body.strip_prefix("badness-ignore") {
163        // The retired node form always required a rule; a bare `% badness-ignore`
164        // was inert and stays inert, rather than silently widening to every rule
165        // on the way through the new grammar.
166        return Some(Directive {
167            axis: Axis::Lint,
168            verb: Verb::Skip,
169            rule: Some(parse_rule(rest)?),
170            deprecated: true,
171        });
172    }
173    let (axis, rest) = if let Some(rest) = body.strip_prefix("badness-format") {
174        (Axis::Format, rest)
175    } else if let Some(rest) = body.strip_prefix("badness-lint") {
176        (Axis::Lint, rest)
177    } else {
178        (Axis::Both, body.strip_prefix("badness")?)
179    };
180    // The family name must end at a word boundary, so `% badness-formatting off`
181    // and `% badnesslint skip` are ordinary comments.
182    if !rest.starts_with([' ', '\t']) {
183        return None;
184    }
185    let rest = rest.trim_start();
186    let end = word_end(rest);
187    let verb = match &rest[..end] {
188        "skip" => Verb::Skip,
189        "off" => Verb::Off,
190        "on" => Verb::On,
191        "skip-file" => Verb::SkipFile,
192        _ => return None,
193    };
194    // Only the lint axis takes a selector. A word after the verb on another axis
195    // is prose in the reason position, not a rule we should quietly honor.
196    let rule = if axis == Axis::Lint {
197        parse_rule(&rest[end..])
198    } else {
199        None
200    };
201    Some(Directive {
202        axis,
203        verb,
204        rule,
205        deprecated: false,
206    })
207}
208
209/// The leading `<rule>` word of a `<rule>: <reason>` tail, or `None` when the
210/// tail opens with `:` (a reason and no rule) or is empty.
211fn parse_rule(tail: &str) -> Option<String> {
212    let trimmed = tail.trim_start();
213    let end = word_end(trimmed);
214    if end == 0 {
215        return None;
216    }
217    Some(trimmed[..end].to_string())
218}
219
220/// The end of the first word of `s`, delimited by `:` or whitespace.
221fn word_end(s: &str) -> usize {
222    s.find(|c: char| c == ':' || c.is_whitespace())
223        .unwrap_or(s.len())
224}
225
226/// The byte ranges a file's directives suppress, resolved per axis.
227///
228/// Ranges are sorted and non-overlapping (touching ones are merged), so a
229/// consumer can test containment with a plain scan and never has to reason
230/// about nesting.
231#[derive(Debug, Clone, Default)]
232pub struct Suppressions {
233    format: Vec<TextRange>,
234    lint_all: Vec<TextRange>,
235    lint_rules: BTreeMap<String, Vec<TextRange>>,
236    directives: Vec<LocatedDirective>,
237}
238
239/// A region opened by an `off` and waiting for its `on`.
240struct OpenRegion {
241    axis: Axis,
242    rule: Option<String>,
243    start: TextSize,
244    directive_index: usize,
245}
246
247impl Suppressions {
248    /// Scan `root` for directives and resolve them into ranges.
249    ///
250    /// A `skip-file` becomes a range covering the whole document rather than a
251    /// flag, so every consumer keeps one code path: whole-file suppression is
252    /// just the widest region. (The document-level trailing-edge normalization
253    /// and the `line_ending` post-pass still run over the result — the same
254    /// carve-out protected regions already live under.)
255    ///
256    /// An `off` with no matching `on` runs to end of file, as it does in every
257    /// other formatter that has the directive.
258    pub fn build(root: &SyntaxNode) -> Self {
259        let mut format = Vec::new();
260        let mut lint_all = Vec::new();
261        let mut lint_rules: BTreeMap<String, Vec<TextRange>> = BTreeMap::new();
262        let mut directives = Vec::new();
263        // Regions are keyed by axis *and* rule: a `% badness-lint off` covering
264        // every rule is not closed by a `% badness-lint on some-rule`, which
265        // speaks for a strictly narrower thing.
266        let mut open: Vec<OpenRegion> = Vec::new();
267        // End of the most recent directive comment. A region anchor may never
268        // reach back past it — see the `Verb::Off` arm.
269        let mut prev_directive_end = TextSize::new(0);
270
271        for element in root.descendants_with_tokens() {
272            let NodeOrToken::Token(token) = element else {
273                continue;
274            };
275            let Some((carrier, range, supported)) = directive_carrier(&token) else {
276                continue;
277            };
278            let Some(directive) = parse_directive(&carrier) else {
279                continue;
280            };
281            let family = directive_family(&directive);
282            let family_start = carrier
283                .find(family)
284                .expect("parsed directive contains its family name");
285            let token_start = usize::from(range.start());
286            let directive_index = directives.len();
287            directives.push(LocatedDirective {
288                directive: directive.clone(),
289                range,
290                family_range: TextRange::new(
291                    TextSize::from((token_start + family_start) as u32),
292                    TextSize::from((token_start + family_start + family.len()) as u32),
293                ),
294                outcome: if supported {
295                    DirectiveOutcome::Honored
296                } else {
297                    DirectiveOutcome::Unsupported
298                },
299            });
300            if !supported {
301                continue;
302            }
303            let mut record = |range: TextRange, rule: &Option<String>| {
304                if directive.axis.covers_format() {
305                    format.push(range);
306                }
307                if directive.axis.covers_lint() {
308                    match rule {
309                        Some(rule) => lint_rules.entry(rule.clone()).or_default().push(range),
310                        None => lint_all.push(range),
311                    }
312                }
313            };
314            match directive.verb {
315                Verb::SkipFile => record(root.text_range(), &directive.rule),
316                Verb::Skip => {
317                    if let Some(range) = skip_target(&token) {
318                        record(range, &directive.rule);
319                    } else {
320                        directives[directive_index].outcome = DirectiveOutcome::DanglingSkip;
321                    }
322                }
323                // A region opens at the same place a `skip` would target: the
324                // next meaningful thing. Anchoring to the raw byte after the
325                // comment instead looks simpler and is wrong — an own-line `%`
326                // binds *forward* into the following construct's `DOC_COMMENT`
327                // (parser decision #9), so that construct begins at the comment,
328                // ahead of the region, and a consumer testing containment would
329                // find the very block the author meant to cover sticking out of
330                // it. Resolving through the tree also picks up a preceding
331                // comment run bound into the same `DOC_COMMENT`, which a byte
332                // offset cannot see at all. Falls back to the byte after the
333                // comment when nothing meaningful follows (a directive at EOF).
334                //
335                // Clamped so the anchor never reaches back past the previous
336                // directive: consecutive own-line comments bind into *one*
337                // `DOC_COMMENT`, so in `on` / `off` / `\b` the reopening `off`
338                // resolves to a construct starting at the `on` — and the region
339                // it opens would then swallow the very directive that closed the
340                // one before it, fusing two deliberately separate regions into
341                // one. The clamp is against directives only, so an ordinary
342                // comment run above the directive is still covered.
343                Verb::Off => {
344                    let start = skip_target(&token)
345                        .map(|r| r.start())
346                        .unwrap_or_else(|| token.text_range().end())
347                        .max(prev_directive_end);
348                    if !open
349                        .iter()
350                        .any(|o| o.axis == directive.axis && o.rule == directive.rule)
351                    {
352                        directives[directive_index].outcome = DirectiveOutcome::UnclosedOff;
353                        open.push(OpenRegion {
354                            axis: directive.axis,
355                            rule: directive.rule.clone(),
356                            start,
357                            directive_index,
358                        });
359                    }
360                }
361                Verb::On => {
362                    if let Some(i) = open
363                        .iter()
364                        .position(|o| o.axis == directive.axis && o.rule == directive.rule)
365                    {
366                        let region = open.remove(i);
367                        directives[region.directive_index].outcome = DirectiveOutcome::Honored;
368                        record(
369                            TextRange::new(region.start, token.text_range().start()),
370                            &region.rule,
371                        );
372                    } else {
373                        directives[directive_index].outcome = DirectiveOutcome::UnmatchedOn;
374                    }
375                }
376            }
377            prev_directive_end = token.text_range().end();
378        }
379
380        // Unclosed regions run to end of file.
381        let eof = root.text_range().end();
382        for region in open {
383            let range = TextRange::new(region.start, eof);
384            if region.axis.covers_format() {
385                format.push(range);
386            }
387            if region.axis.covers_lint() {
388                match &region.rule {
389                    Some(rule) => lint_rules.entry(rule.clone()).or_default().push(range),
390                    None => lint_all.push(range),
391                }
392            }
393        }
394
395        Self {
396            format: merge(format),
397            lint_all: merge(lint_all),
398            lint_rules: lint_rules
399                .into_iter()
400                .map(|(rule, ranges)| (rule, merge(ranges)))
401                .collect(),
402            directives,
403        }
404    }
405
406    /// Whether the document carries no effective suppression range — the fast
407    /// path for the overwhelming majority of files, so a consumer can skip its
408    /// per-node test.
409    pub fn is_empty(&self) -> bool {
410        self.format.is_empty() && self.lint_all.is_empty() && self.lint_rules.is_empty()
411    }
412
413    /// Ranges the formatter must reproduce byte-for-byte.
414    pub fn format_ranges(&self) -> &[TextRange] {
415        &self.format
416    }
417
418    /// Ranges in which *every* lint rule is suppressed.
419    pub fn lint_all_ranges(&self) -> &[TextRange] {
420        &self.lint_all
421    }
422
423    /// Ranges in which one named rule is suppressed.
424    pub fn lint_rule_ranges(&self) -> &BTreeMap<String, Vec<TextRange>> {
425        &self.lint_rules
426    }
427
428    /// Parsed directives in source order, including directives that resolve to
429    /// no range (such as an unmatched `on`).
430    pub fn directives(&self) -> &[LocatedDirective] {
431        &self.directives
432    }
433}
434
435/// A directive-bearing token's text, full carrier range, and whether that
436/// carrier can take effect. Ordinary comments are active. A `.dtx` documentation
437/// margin is retained for diagnostics, but its line is typeset source rather than
438/// a comment, so it never contributes a suppression range.
439fn directive_carrier(token: &SyntaxToken) -> Option<(String, TextRange, bool)> {
440    match token.kind() {
441        SyntaxKind::COMMENT => Some((token.text().to_owned(), token.text_range(), true)),
442        SyntaxKind::DOC_MARGIN => {
443            let start = token.text_range().start();
444            let mut end = start;
445            let mut text = String::new();
446            let mut current = Some(token.clone());
447            while let Some(part) = current {
448                if part.kind() == SyntaxKind::NEWLINE {
449                    break;
450                }
451                let part_text = part.text();
452                if let Some(line_end) = part_text.find(['\r', '\n']) {
453                    text.push_str(&part_text[..line_end]);
454                    end = part.text_range().start() + TextSize::from(line_end as u32);
455                    break;
456                }
457                text.push_str(part_text);
458                end = part.text_range().end();
459                current = part.next_token();
460            }
461            Some((text, TextRange::new(start, end), false))
462        }
463        _ => None,
464    }
465}
466
467fn directive_family(directive: &Directive) -> &'static str {
468    if directive.deprecated {
469        return match directive.verb {
470            Verb::SkipFile => "badness-ignore-file",
471            Verb::Skip => "badness-ignore",
472            Verb::Off | Verb::On => unreachable!("retired directives have no region verbs"),
473        };
474    }
475    match directive.axis {
476        Axis::Format => "badness-format",
477        Axis::Lint => "badness-lint",
478        Axis::Both => "badness",
479    }
480}
481
482/// Sort and coalesce, merging ranges that overlap *or touch*. Touching ranges
483/// merge because two adjacent `off`/`on` regions describe one continuous span of
484/// suppressed text, and leaving them split would let a consumer that tests
485/// containment miss an element straddling the seam.
486fn merge(mut ranges: Vec<TextRange>) -> Vec<TextRange> {
487    ranges.sort_by_key(|r| (r.start(), r.end()));
488    let mut out: Vec<TextRange> = Vec::with_capacity(ranges.len());
489    for range in ranges {
490        match out.last_mut() {
491            Some(last) if range.start() <= last.end() => {
492                *last = TextRange::new(last.start(), last.end().max(range.end()));
493            }
494            _ => out.push(range),
495        }
496    }
497    out
498}
499
500/// The range a node-scoped directive covers: the next non-trivia, non-comment
501/// element after `token`, bubbling up through parents whose remaining siblings
502/// are all trivia. A comment bound into a `DOC_COMMENT` targets the whole
503/// construct that owns it, not a sibling — walking forward from such a comment
504/// only ever finds pieces *inside* that construct (its control word, missing its
505/// arguments), never the construct as a whole.
506fn skip_target(token: &SyntaxToken) -> Option<TextRange> {
507    if let Some(parent) = token.parent()
508        && parent.kind() == SyntaxKind::DOC_COMMENT
509    {
510        return Some(parent.parent()?.text_range());
511    }
512    let mut current = token.clone();
513    loop {
514        let parent = current.parent()?;
515        if let Some(range) = first_meaningful_after(&parent, &NodeOrToken::Token(current.clone())) {
516            return Some(range);
517        }
518        let grand = parent.parent()?;
519        if let Some(range) = first_meaningful_after(&grand, &NodeOrToken::Node(parent.clone())) {
520            return Some(range);
521        }
522        // Guard against a non-progressing climb (a single-child spine).
523        if grand == parent {
524            return None;
525        }
526        current = grand.first_token()?;
527    }
528}
529
530/// The range of the first non-trivia element of `parent` strictly after `after`.
531fn first_meaningful_after(
532    parent: &SyntaxNode,
533    after: &NodeOrToken<SyntaxNode, SyntaxToken>,
534) -> Option<TextRange> {
535    let mut past = false;
536    for element in parent.children_with_tokens() {
537        if !past {
538            past = &element == after;
539            continue;
540        }
541        match &element {
542            NodeOrToken::Token(t)
543                if matches!(
544                    t.kind(),
545                    SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
546                ) => {}
547            _ => return Some(element.text_range()),
548        }
549    }
550    None
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::parser::{LatexFlavor, LexConfig, parse, parse_with_flavor};
557
558    fn suppressions_of(src: &str) -> Suppressions {
559        Suppressions::build(&SyntaxNode::new_root(parse(src).green))
560    }
561
562    fn dtx_suppressions_of(src: &str) -> Suppressions {
563        let parsed = parse_with_flavor(
564            src,
565            LexConfig {
566                flavor: LatexFlavor::Document,
567                dtx: true,
568            },
569        );
570        assert_eq!(parsed.syntax().to_string(), src);
571        Suppressions::build(&parsed.syntax())
572    }
573
574    #[test]
575    fn classifies_inert_and_incomplete_directives() {
576        for (src, expected) in [
577            (
578                "% badness-lint skip deprecated-command\n",
579                DirectiveOutcome::DanglingSkip,
580            ),
581            (
582                "% badness-lint on deprecated-command\n",
583                DirectiveOutcome::UnmatchedOn,
584            ),
585            (
586                "% badness-lint off deprecated-command\n\\bf\n",
587                DirectiveOutcome::UnclosedOff,
588            ),
589        ] {
590            let suppressions = suppressions_of(src);
591            assert_eq!(suppressions.directives().len(), 1, "{src:?}");
592            assert_eq!(suppressions.directives()[0].outcome, expected, "{src:?}");
593        }
594    }
595
596    #[test]
597    fn matched_and_targeted_directives_are_honored() {
598        for src in [
599            "% badness-lint skip deprecated-command\n\\bf\n",
600            "% badness-lint off deprecated-command\n\\bf\n% badness-lint on deprecated-command\n",
601            "% badness-lint skip-file deprecated-command\n",
602        ] {
603            let suppressions = suppressions_of(src);
604            assert!(
605                suppressions
606                    .directives()
607                    .iter()
608                    .all(|located| located.outcome == DirectiveOutcome::Honored),
609                "{src:?}: {:?}",
610                suppressions.directives()
611            );
612        }
613    }
614
615    #[test]
616    fn retains_dtx_doc_margin_directive_as_unsupported() {
617        let src = "% badness-lint skip deprecated-command\nDocumentation.\n";
618        let suppressions = dtx_suppressions_of(src);
619        let [located] = suppressions.directives() else {
620            panic!(
621                "expected one retained directive: {:?}",
622                suppressions.directives()
623            );
624        };
625        assert_eq!(located.outcome, DirectiveOutcome::Unsupported);
626        assert_eq!(
627            &src[usize::from(located.range.start())..usize::from(located.range.end())],
628            "% badness-lint skip deprecated-command"
629        );
630        assert!(suppressions.format_ranges().is_empty());
631        assert!(suppressions.lint_all_ranges().is_empty());
632        assert!(suppressions.lint_rule_ranges().is_empty());
633    }
634
635    #[test]
636    fn retains_directives_with_their_carrier_ranges() {
637        let src = "% a note\n% badness-ignore deprecated-command: legacy\n\\bf\n";
638        let suppressions = suppressions_of(src);
639        let retained = suppressions.directives();
640
641        assert_eq!(retained.len(), 1);
642        assert!(retained[0].directive.deprecated);
643        assert_eq!(
644            &src[usize::from(retained[0].range.start())..usize::from(retained[0].range.end())],
645            "% badness-ignore deprecated-command: legacy"
646        );
647    }
648
649    fn slices<'a>(src: &'a str, ranges: &[TextRange]) -> Vec<&'a str> {
650        ranges
651            .iter()
652            .map(|r| &src[usize::from(r.start())..usize::from(r.end())])
653            .collect()
654    }
655
656    fn directive(axis: Axis, verb: Verb) -> Directive {
657        Directive {
658            axis,
659            verb,
660            rule: None,
661            deprecated: false,
662        }
663    }
664
665    #[test]
666    fn parses_every_form_on_every_axis() {
667        for (family, axis) in [
668            ("badness-format", Axis::Format),
669            ("badness-lint", Axis::Lint),
670            ("badness", Axis::Both),
671        ] {
672            for (word, verb) in [
673                ("skip", Verb::Skip),
674                ("off", Verb::Off),
675                ("on", Verb::On),
676                ("skip-file", Verb::SkipFile),
677            ] {
678                let text = format!("% {family} {word}");
679                assert_eq!(
680                    parse_directive(&text),
681                    Some(directive(axis, verb)),
682                    "parsing {text:?}"
683                );
684            }
685        }
686    }
687
688    #[test]
689    fn only_the_lint_axis_takes_a_rule() {
690        assert_eq!(
691            parse_directive("% badness-lint skip deprecated-command: legacy"),
692            Some(Directive {
693                axis: Axis::Lint,
694                verb: Verb::Skip,
695                rule: Some("deprecated-command".into()),
696                deprecated: false,
697            })
698        );
699        assert_eq!(
700            parse_directive("% badness-format skip deprecated-command"),
701            Some(directive(Axis::Format, Verb::Skip))
702        );
703        assert_eq!(
704            parse_directive("% badness skip deprecated-command"),
705            Some(directive(Axis::Both, Verb::Skip))
706        );
707    }
708
709    #[test]
710    fn lint_rule_is_optional_and_means_every_rule() {
711        assert_eq!(
712            parse_directive("% badness-lint skip-file: generated"),
713            Some(directive(Axis::Lint, Verb::SkipFile))
714        );
715    }
716
717    #[test]
718    fn reason_is_optional_and_ignored() {
719        assert_eq!(
720            parse_directive("% badness-format skip: hand-aligned by eye"),
721            Some(directive(Axis::Format, Verb::Skip))
722        );
723        assert_eq!(
724            parse_directive("%badness skip-file:generated"),
725            Some(directive(Axis::Both, Verb::SkipFile))
726        );
727    }
728
729    #[test]
730    fn repeated_percent_is_allowed() {
731        assert_eq!(
732            parse_directive("%%% badness-format off"),
733            Some(directive(Axis::Format, Verb::Off))
734        );
735    }
736
737    #[test]
738    fn retired_ignore_family_still_parses() {
739        assert_eq!(
740            parse_directive("% badness-ignore deprecated-command: legacy"),
741            Some(Directive {
742                axis: Axis::Lint,
743                verb: Verb::Skip,
744                rule: Some("deprecated-command".into()),
745                deprecated: true,
746            })
747        );
748        assert_eq!(
749            parse_directive("% badness-ignore-file deprecated-command: legacy"),
750            Some(Directive {
751                axis: Axis::Lint,
752                verb: Verb::SkipFile,
753                rule: Some("deprecated-command".into()),
754                deprecated: true,
755            })
756        );
757        assert_eq!(
758            parse_directive("% badness-ignore-file: noisy"),
759            Some(Directive {
760                axis: Axis::Lint,
761                verb: Verb::SkipFile,
762                rule: None,
763                deprecated: true,
764            })
765        );
766    }
767
768    #[test]
769    fn bare_retired_node_directive_stays_inert() {
770        assert_eq!(parse_directive("% badness-ignore"), None);
771        assert_eq!(parse_directive("% badness-ignore: no rule named"), None);
772    }
773
774    #[test]
775    fn non_directives_are_inert() {
776        for text in [
777            "% just a note",
778            "% badness",                 // no verb
779            "% badness-lint",            // no verb
780            "% badness-format nonsense", // unknown verb
781            "% badnessformat off",       // no word boundary
782            "% badness-formatting off",  // no word boundary
783            "% badnesslint skip",        // no word boundary
784            "% the badness-format off",  // not at the start
785        ] {
786            assert_eq!(parse_directive(text), None, "expected {text:?} to be inert");
787        }
788    }
789
790    #[test]
791    fn skip_targets_the_documented_construct() {
792        let src = "% badness-format skip: hand-aligned\n\\begin{tikzpicture}\n\\draw (0,0);\n\\end{tikzpicture}\n";
793        let s = suppressions_of(src);
794        assert_eq!(slices(src, s.format_ranges()), vec![src.trim_end()]);
795        assert!(s.lint_all_ranges().is_empty(), "format axis must not lint");
796    }
797
798    #[test]
799    fn region_spans_from_off_to_on() {
800        let src = "\\alpha\n% badness-format off\n\\beta\n% badness-format on\n\\gamma\n";
801        let s = suppressions_of(src);
802        assert_eq!(
803            slices(src, s.format_ranges()),
804            vec!["% badness-format off\n\\beta\n"]
805        );
806    }
807
808    #[test]
809    fn region_covers_a_leading_comment_run() {
810        let src = "\\alpha\n% a note\n% badness-format off\n\\beta\n% badness-format on\n";
811        let s = suppressions_of(src);
812        assert_eq!(
813            slices(src, s.format_ranges()),
814            vec!["% a note\n% badness-format off\n\\beta\n"]
815        );
816    }
817
818    #[test]
819    fn unclosed_region_runs_to_end_of_file() {
820        let src = "\\alpha\n% badness-format off\n\\beta\n\\gamma\n";
821        let s = suppressions_of(src);
822        assert_eq!(
823            slices(src, s.format_ranges()),
824            vec!["% badness-format off\n\\beta\n\\gamma\n"]
825        );
826    }
827
828    #[test]
829    fn both_family_suppresses_both_axes() {
830        let src = "% badness off\n\\beta\n% badness on\n";
831        let s = suppressions_of(src);
832        assert_eq!(s.format_ranges(), s.lint_all_ranges());
833        assert_eq!(
834            slices(src, s.lint_all_ranges()),
835            vec!["% badness off\n\\beta\n"]
836        );
837    }
838
839    #[test]
840    fn format_on_does_not_close_a_both_region() {
841        let src = "% badness off\n\\beta\n% badness-format on\n\\gamma\n";
842        let s = suppressions_of(src);
843        assert_eq!(
844            slices(src, s.lint_all_ranges()),
845            vec!["% badness off\n\\beta\n% badness-format on\n\\gamma\n"]
846        );
847    }
848
849    #[test]
850    fn rule_selective_on_does_not_close_an_every_rule_region() {
851        let src = "% badness-lint off\n\\beta\n% badness-lint on deprecated-command\n\\gamma\n";
852        let s = suppressions_of(src);
853        assert_eq!(s.lint_all_ranges().len(), 1);
854        assert!(
855            slices(src, s.lint_all_ranges())[0].ends_with("\\gamma\n"),
856            "the every-rule region stays open to EOF"
857        );
858    }
859
860    #[test]
861    fn lint_region_is_rule_selective() {
862        let src =
863            "% badness-lint off deprecated-command\n\\beta\n% badness-lint on deprecated-command\n";
864        let s = suppressions_of(src);
865        assert!(s.lint_all_ranges().is_empty(), "one rule, not all of them");
866        assert!(s.format_ranges().is_empty(), "lint axis must not format");
867        let ranges = s
868            .lint_rule_ranges()
869            .get("deprecated-command")
870            .expect("rule recorded");
871        assert_eq!(
872            slices(src, ranges),
873            vec!["% badness-lint off deprecated-command\n\\beta\n"]
874        );
875    }
876
877    #[test]
878    fn skip_file_covers_the_document_on_its_axis() {
879        let src = "\\alpha\n% badness-format skip-file: generated\n\\beta\n";
880        let s = suppressions_of(src);
881        assert_eq!(slices(src, s.format_ranges()), vec![src]);
882        assert!(s.lint_all_ranges().is_empty());
883    }
884
885    #[test]
886    fn stray_on_is_inert() {
887        let src = "\\alpha\n% badness-format on\n\\beta\n";
888        assert!(suppressions_of(src).is_empty());
889    }
890
891    #[test]
892    fn overlapping_ranges_merge() {
893        let src = "% badness-format skip-file: generated\n% badness-format off\n\\b\n";
894        let s = suppressions_of(src);
895        assert_eq!(slices(src, s.format_ranges()), vec![src]);
896    }
897
898    #[test]
899    fn reopened_region_does_not_swallow_its_own_closer() {
900        let src = "% badness-format off\n\\a\n% badness-format on\n% badness-format off\n\\b\n% badness-format on\n";
901        let s = suppressions_of(src);
902        assert_eq!(
903            slices(src, s.format_ranges()),
904            vec![
905                "% badness-format off\n\\a\n",
906                "\n% badness-format off\n\\b\n"
907            ]
908        );
909    }
910
911    #[test]
912    fn retired_and_current_spellings_resolve_identically() {
913        fn covers_target(src: &str, ranges: &[TextRange]) -> bool {
914            let at = TextSize::new(src.find("\\bf").expect("has a target") as u32);
915            ranges.iter().any(|r| r.contains(at))
916        }
917        for (old, new) in [
918            (
919                "% badness-ignore deprecated-command: legacy\n\\bf x\n",
920                "% badness-lint skip deprecated-command: legacy\n\\bf x\n",
921            ),
922            (
923                "% badness-ignore-file deprecated-command: legacy\n\\bf x\n",
924                "% badness-lint skip-file deprecated-command: legacy\n\\bf x\n",
925            ),
926        ] {
927            for (src, label) in [(old, "retired"), (new, "current")] {
928                let s = suppressions_of(src);
929                let ranges = s
930                    .lint_rule_ranges()
931                    .get("deprecated-command")
932                    .unwrap_or_else(|| panic!("{label} spelling records the rule: {src:?}"));
933                assert!(
934                    covers_target(src, ranges),
935                    "{label} spelling must cover its target: {src:?}"
936                );
937                assert!(
938                    s.lint_all_ranges().is_empty() && s.format_ranges().is_empty(),
939                    "{label} spelling is lint-only and rule-selective: {src:?}"
940                );
941            }
942        }
943        let old = suppressions_of("% badness-ignore-file: noisy\n\\bf x\n");
944        let new = suppressions_of("% badness-lint skip-file: noisy\n\\bf x\n");
945        assert_eq!(old.lint_all_ranges().len(), 1);
946        assert_eq!(new.lint_all_ranges().len(), 1);
947        assert!(old.lint_rule_ranges().is_empty() && new.lint_rule_ranges().is_empty());
948    }
949
950    #[test]
951    fn clean_document_has_no_suppressions() {
952        assert!(suppressions_of("\\alpha\n% an ordinary comment\n\\beta\n").is_empty());
953    }
954}