1use std::collections::BTreeMap;
53
54use rowan::{NodeOrToken, TextRange, TextSize};
55
56use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Axis {
61 Format,
63 Lint,
65 Both,
67}
68
69impl Axis {
70 pub fn covers_format(self) -> bool {
72 matches!(self, Axis::Format | Axis::Both)
73 }
74
75 pub fn covers_lint(self) -> bool {
77 matches!(self, Axis::Lint | Axis::Both)
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Verb {
84 Skip,
89 Off,
92 On,
94 SkipFile,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Directive {
102 pub axis: Axis,
103 pub verb: Verb,
104 pub rule: Option<String>,
107 pub deprecated: bool,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum DirectiveOutcome {
120 Honored,
123 DanglingSkip,
125 UnmatchedOn,
127 UnclosedOff,
129 Unsupported,
131}
132
133#[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
144pub fn parse_directive(comment: &str) -> Option<Directive> {
150 let body = comment.trim_start_matches('%').trim_start();
151 if let Some(rest) = body.strip_prefix("badness-ignore-file") {
154 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 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 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 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
209fn 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
220fn word_end(s: &str) -> usize {
222 s.find(|c: char| c == ':' || c.is_whitespace())
223 .unwrap_or(s.len())
224}
225
226#[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
239struct OpenRegion {
241 axis: Axis,
242 rule: Option<String>,
243 start: TextSize,
244 directive_index: usize,
245}
246
247impl Suppressions {
248 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 let mut open: Vec<OpenRegion> = Vec::new();
267 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 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 ®ion.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 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 ®ion.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 pub fn is_empty(&self) -> bool {
410 self.format.is_empty() && self.lint_all.is_empty() && self.lint_rules.is_empty()
411 }
412
413 pub fn format_ranges(&self) -> &[TextRange] {
415 &self.format
416 }
417
418 pub fn lint_all_ranges(&self) -> &[TextRange] {
420 &self.lint_all
421 }
422
423 pub fn lint_rule_ranges(&self) -> &BTreeMap<String, Vec<TextRange>> {
425 &self.lint_rules
426 }
427
428 pub fn directives(&self) -> &[LocatedDirective] {
431 &self.directives
432 }
433}
434
435fn 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
482fn 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
500fn 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 if grand == parent {
524 return None;
525 }
526 current = grand.first_token()?;
527 }
528}
529
530fn 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", "% badness-lint", "% badness-format nonsense", "% badnessformat off", "% badness-formatting off", "% badnesslint skip", "% the badness-format off", ] {
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}