1use std::collections::BTreeMap;
52
53use rowan::{NodeOrToken, TextRange, TextSize};
54
55use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Axis {
60 Format,
62 Lint,
64 Both,
66}
67
68impl Axis {
69 pub fn covers_format(self) -> bool {
71 matches!(self, Axis::Format | Axis::Both)
72 }
73
74 pub fn covers_lint(self) -> bool {
76 matches!(self, Axis::Lint | Axis::Both)
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Verb {
83 Skip,
88 Off,
91 On,
93 SkipFile,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Directive {
101 pub axis: Axis,
102 pub verb: Verb,
103 pub rule: Option<String>,
106 pub deprecated: bool,
110}
111
112pub fn parse_directive(comment: &str) -> Option<Directive> {
118 let body = comment.trim_start_matches('%').trim_start();
119 if let Some(rest) = body.strip_prefix("badness-ignore-file") {
122 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 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 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 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
177fn 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
188fn word_end(s: &str) -> usize {
190 s.find(|c: char| c == ':' || c.is_whitespace())
191 .unwrap_or(s.len())
192}
193
194#[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
206struct OpenRegion {
208 axis: Axis,
209 rule: Option<String>,
210 start: TextSize,
211}
212
213impl Suppressions {
214 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 let mut open: Vec<OpenRegion> = Vec::new();
232 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 Verb::Off => {
285 let start = skip_target(&token)
286 .map(|r| r.start())
287 .unwrap_or_else(|| token.text_range().end())
288 .max(prev_directive_end);
289 if !open
290 .iter()
291 .any(|o| o.axis == directive.axis && o.rule == directive.rule)
292 {
293 open.push(OpenRegion {
294 axis: directive.axis,
295 rule: directive.rule.clone(),
296 start,
297 });
298 }
299 }
300 Verb::On => {
301 if let Some(i) = open
302 .iter()
303 .position(|o| o.axis == directive.axis && o.rule == directive.rule)
304 {
305 let region = open.remove(i);
306 record(
307 TextRange::new(region.start, token.text_range().start()),
308 ®ion.rule,
309 );
310 }
311 }
312 }
313 prev_directive_end = token.text_range().end();
314 }
315
316 let eof = root.text_range().end();
318 for region in open {
319 let range = TextRange::new(region.start, eof);
320 if region.axis.covers_format() {
321 format.push(range);
322 }
323 if region.axis.covers_lint() {
324 match ®ion.rule {
325 Some(rule) => lint_rules.entry(rule.clone()).or_default().push(range),
326 None => lint_all.push(range),
327 }
328 }
329 }
330
331 Self {
332 format: merge(format),
333 lint_all: merge(lint_all),
334 lint_rules: lint_rules
335 .into_iter()
336 .map(|(rule, ranges)| (rule, merge(ranges)))
337 .collect(),
338 }
339 }
340
341 pub fn is_empty(&self) -> bool {
344 self.format.is_empty() && self.lint_all.is_empty() && self.lint_rules.is_empty()
345 }
346
347 pub fn format_ranges(&self) -> &[TextRange] {
349 &self.format
350 }
351
352 pub fn lint_all_ranges(&self) -> &[TextRange] {
354 &self.lint_all
355 }
356
357 pub fn lint_rule_ranges(&self) -> &BTreeMap<String, Vec<TextRange>> {
359 &self.lint_rules
360 }
361}
362
363fn 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
381fn 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 if grand == parent {
405 return None;
406 }
407 current = grand.first_token()?;
408 }
409}
410
411fn 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", "% badness-lint", "% badness-format nonsense", "% badnessformat off", "% badness-formatting off", "% badnesslint skip", "% the badness-format off", ] {
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}