use std::collections::BTreeMap;
use rowan::{NodeOrToken, TextRange, TextSize};
use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
Format,
Lint,
Both,
}
impl Axis {
pub fn covers_format(self) -> bool {
matches!(self, Axis::Format | Axis::Both)
}
pub fn covers_lint(self) -> bool {
matches!(self, Axis::Lint | Axis::Both)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
Skip,
Off,
On,
SkipFile,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Directive {
pub axis: Axis,
pub verb: Verb,
pub rule: Option<String>,
pub deprecated: bool,
}
pub fn parse_directive(comment: &str) -> Option<Directive> {
let body = comment.trim_start_matches('%').trim_start();
if let Some(rest) = body.strip_prefix("badness-ignore-file") {
return Some(Directive {
axis: Axis::Lint,
verb: Verb::SkipFile,
rule: parse_rule(rest),
deprecated: true,
});
}
if let Some(rest) = body.strip_prefix("badness-ignore") {
return Some(Directive {
axis: Axis::Lint,
verb: Verb::Skip,
rule: Some(parse_rule(rest)?),
deprecated: true,
});
}
let (axis, rest) = if let Some(rest) = body.strip_prefix("badness-format") {
(Axis::Format, rest)
} else if let Some(rest) = body.strip_prefix("badness-lint") {
(Axis::Lint, rest)
} else {
(Axis::Both, body.strip_prefix("badness")?)
};
if !rest.starts_with([' ', '\t']) {
return None;
}
let rest = rest.trim_start();
let end = word_end(rest);
let verb = match &rest[..end] {
"skip" => Verb::Skip,
"off" => Verb::Off,
"on" => Verb::On,
"skip-file" => Verb::SkipFile,
_ => return None,
};
let rule = if axis == Axis::Lint {
parse_rule(&rest[end..])
} else {
None
};
Some(Directive {
axis,
verb,
rule,
deprecated: false,
})
}
fn parse_rule(tail: &str) -> Option<String> {
let trimmed = tail.trim_start();
let end = word_end(trimmed);
if end == 0 {
return None;
}
Some(trimmed[..end].to_string())
}
fn word_end(s: &str) -> usize {
s.find(|c: char| c == ':' || c.is_whitespace())
.unwrap_or(s.len())
}
#[derive(Debug, Clone, Default)]
pub struct Suppressions {
format: Vec<TextRange>,
lint_all: Vec<TextRange>,
lint_rules: BTreeMap<String, Vec<TextRange>>,
}
struct OpenRegion {
axis: Axis,
rule: Option<String>,
start: TextSize,
}
impl Suppressions {
pub fn build(root: &SyntaxNode) -> Self {
let mut format = Vec::new();
let mut lint_all = Vec::new();
let mut lint_rules: BTreeMap<String, Vec<TextRange>> = BTreeMap::new();
let mut open: Vec<OpenRegion> = Vec::new();
let mut prev_directive_end = TextSize::new(0);
for element in root.descendants_with_tokens() {
let NodeOrToken::Token(token) = element else {
continue;
};
if token.kind() != SyntaxKind::COMMENT {
continue;
}
let Some(directive) = parse_directive(token.text()) else {
continue;
};
let mut record = |range: TextRange, rule: &Option<String>| {
if directive.axis.covers_format() {
format.push(range);
}
if directive.axis.covers_lint() {
match rule {
Some(rule) => lint_rules.entry(rule.clone()).or_default().push(range),
None => lint_all.push(range),
}
}
};
match directive.verb {
Verb::SkipFile => record(root.text_range(), &directive.rule),
Verb::Skip => {
if let Some(range) = skip_target(&token) {
record(range, &directive.rule);
}
}
Verb::Off => {
let start = skip_target(&token)
.map(|r| r.start())
.unwrap_or_else(|| token.text_range().end())
.max(prev_directive_end);
if !open
.iter()
.any(|o| o.axis == directive.axis && o.rule == directive.rule)
{
open.push(OpenRegion {
axis: directive.axis,
rule: directive.rule.clone(),
start,
});
}
}
Verb::On => {
if let Some(i) = open
.iter()
.position(|o| o.axis == directive.axis && o.rule == directive.rule)
{
let region = open.remove(i);
record(
TextRange::new(region.start, token.text_range().start()),
®ion.rule,
);
}
}
}
prev_directive_end = token.text_range().end();
}
let eof = root.text_range().end();
for region in open {
let range = TextRange::new(region.start, eof);
if region.axis.covers_format() {
format.push(range);
}
if region.axis.covers_lint() {
match ®ion.rule {
Some(rule) => lint_rules.entry(rule.clone()).or_default().push(range),
None => lint_all.push(range),
}
}
}
Self {
format: merge(format),
lint_all: merge(lint_all),
lint_rules: lint_rules
.into_iter()
.map(|(rule, ranges)| (rule, merge(ranges)))
.collect(),
}
}
pub fn is_empty(&self) -> bool {
self.format.is_empty() && self.lint_all.is_empty() && self.lint_rules.is_empty()
}
pub fn format_ranges(&self) -> &[TextRange] {
&self.format
}
pub fn lint_all_ranges(&self) -> &[TextRange] {
&self.lint_all
}
pub fn lint_rule_ranges(&self) -> &BTreeMap<String, Vec<TextRange>> {
&self.lint_rules
}
}
fn merge(mut ranges: Vec<TextRange>) -> Vec<TextRange> {
ranges.sort_by_key(|r| (r.start(), r.end()));
let mut out: Vec<TextRange> = Vec::with_capacity(ranges.len());
for range in ranges {
match out.last_mut() {
Some(last) if range.start() <= last.end() => {
*last = TextRange::new(last.start(), last.end().max(range.end()));
}
_ => out.push(range),
}
}
out
}
fn skip_target(token: &SyntaxToken) -> Option<TextRange> {
if let Some(parent) = token.parent()
&& parent.kind() == SyntaxKind::DOC_COMMENT
{
return Some(parent.parent()?.text_range());
}
let mut current = token.clone();
loop {
let parent = current.parent()?;
if let Some(range) = first_meaningful_after(&parent, &NodeOrToken::Token(current.clone())) {
return Some(range);
}
let grand = parent.parent()?;
if let Some(range) = first_meaningful_after(&grand, &NodeOrToken::Node(parent.clone())) {
return Some(range);
}
if grand == parent {
return None;
}
current = grand.first_token()?;
}
}
fn first_meaningful_after(
parent: &SyntaxNode,
after: &NodeOrToken<SyntaxNode, SyntaxToken>,
) -> Option<TextRange> {
let mut past = false;
for element in parent.children_with_tokens() {
if !past {
past = &element == after;
continue;
}
match &element {
NodeOrToken::Token(t)
if matches!(
t.kind(),
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
) => {}
_ => return Some(element.text_range()),
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn suppressions_of(src: &str) -> Suppressions {
Suppressions::build(&SyntaxNode::new_root(parse(src).green))
}
fn slices<'a>(src: &'a str, ranges: &[TextRange]) -> Vec<&'a str> {
ranges
.iter()
.map(|r| &src[usize::from(r.start())..usize::from(r.end())])
.collect()
}
fn directive(axis: Axis, verb: Verb) -> Directive {
Directive {
axis,
verb,
rule: None,
deprecated: false,
}
}
#[test]
fn parses_every_form_on_every_axis() {
for (family, axis) in [
("badness-format", Axis::Format),
("badness-lint", Axis::Lint),
("badness", Axis::Both),
] {
for (word, verb) in [
("skip", Verb::Skip),
("off", Verb::Off),
("on", Verb::On),
("skip-file", Verb::SkipFile),
] {
let text = format!("% {family} {word}");
assert_eq!(
parse_directive(&text),
Some(directive(axis, verb)),
"parsing {text:?}"
);
}
}
}
#[test]
fn only_the_lint_axis_takes_a_rule() {
assert_eq!(
parse_directive("% badness-lint skip deprecated-command: legacy"),
Some(Directive {
axis: Axis::Lint,
verb: Verb::Skip,
rule: Some("deprecated-command".into()),
deprecated: false,
})
);
assert_eq!(
parse_directive("% badness-format skip deprecated-command"),
Some(directive(Axis::Format, Verb::Skip))
);
assert_eq!(
parse_directive("% badness skip deprecated-command"),
Some(directive(Axis::Both, Verb::Skip))
);
}
#[test]
fn lint_rule_is_optional_and_means_every_rule() {
assert_eq!(
parse_directive("% badness-lint skip-file: generated"),
Some(directive(Axis::Lint, Verb::SkipFile))
);
}
#[test]
fn reason_is_optional_and_ignored() {
assert_eq!(
parse_directive("% badness-format skip: hand-aligned by eye"),
Some(directive(Axis::Format, Verb::Skip))
);
assert_eq!(
parse_directive("%badness skip-file:generated"),
Some(directive(Axis::Both, Verb::SkipFile))
);
}
#[test]
fn repeated_percent_is_allowed() {
assert_eq!(
parse_directive("%%% badness-format off"),
Some(directive(Axis::Format, Verb::Off))
);
}
#[test]
fn retired_ignore_family_still_parses() {
assert_eq!(
parse_directive("% badness-ignore deprecated-command: legacy"),
Some(Directive {
axis: Axis::Lint,
verb: Verb::Skip,
rule: Some("deprecated-command".into()),
deprecated: true,
})
);
assert_eq!(
parse_directive("% badness-ignore-file deprecated-command: legacy"),
Some(Directive {
axis: Axis::Lint,
verb: Verb::SkipFile,
rule: Some("deprecated-command".into()),
deprecated: true,
})
);
assert_eq!(
parse_directive("% badness-ignore-file: noisy"),
Some(Directive {
axis: Axis::Lint,
verb: Verb::SkipFile,
rule: None,
deprecated: true,
})
);
}
#[test]
fn bare_retired_node_directive_stays_inert() {
assert_eq!(parse_directive("% badness-ignore"), None);
assert_eq!(parse_directive("% badness-ignore: no rule named"), None);
}
#[test]
fn non_directives_are_inert() {
for text in [
"% just a note",
"% badness", "% badness-lint", "% badness-format nonsense", "% badnessformat off", "% badness-formatting off", "% badnesslint skip", "% the badness-format off", ] {
assert_eq!(parse_directive(text), None, "expected {text:?} to be inert");
}
}
#[test]
fn skip_targets_the_documented_construct() {
let src = "% badness-format skip: hand-aligned\n\\begin{tikzpicture}\n\\draw (0,0);\n\\end{tikzpicture}\n";
let s = suppressions_of(src);
assert_eq!(slices(src, s.format_ranges()), vec![src.trim_end()]);
assert!(s.lint_all_ranges().is_empty(), "format axis must not lint");
}
#[test]
fn region_spans_from_off_to_on() {
let src = "\\alpha\n% badness-format off\n\\beta\n% badness-format on\n\\gamma\n";
let s = suppressions_of(src);
assert_eq!(
slices(src, s.format_ranges()),
vec!["% badness-format off\n\\beta\n"]
);
}
#[test]
fn region_covers_a_leading_comment_run() {
let src = "\\alpha\n% a note\n% badness-format off\n\\beta\n% badness-format on\n";
let s = suppressions_of(src);
assert_eq!(
slices(src, s.format_ranges()),
vec!["% a note\n% badness-format off\n\\beta\n"]
);
}
#[test]
fn unclosed_region_runs_to_end_of_file() {
let src = "\\alpha\n% badness-format off\n\\beta\n\\gamma\n";
let s = suppressions_of(src);
assert_eq!(
slices(src, s.format_ranges()),
vec!["% badness-format off\n\\beta\n\\gamma\n"]
);
}
#[test]
fn both_family_suppresses_both_axes() {
let src = "% badness off\n\\beta\n% badness on\n";
let s = suppressions_of(src);
assert_eq!(s.format_ranges(), s.lint_all_ranges());
assert_eq!(
slices(src, s.lint_all_ranges()),
vec!["% badness off\n\\beta\n"]
);
}
#[test]
fn format_on_does_not_close_a_both_region() {
let src = "% badness off\n\\beta\n% badness-format on\n\\gamma\n";
let s = suppressions_of(src);
assert_eq!(
slices(src, s.lint_all_ranges()),
vec!["% badness off\n\\beta\n% badness-format on\n\\gamma\n"]
);
}
#[test]
fn rule_selective_on_does_not_close_an_every_rule_region() {
let src = "% badness-lint off\n\\beta\n% badness-lint on deprecated-command\n\\gamma\n";
let s = suppressions_of(src);
assert_eq!(s.lint_all_ranges().len(), 1);
assert!(
slices(src, s.lint_all_ranges())[0].ends_with("\\gamma\n"),
"the every-rule region stays open to EOF"
);
}
#[test]
fn lint_region_is_rule_selective() {
let src =
"% badness-lint off deprecated-command\n\\beta\n% badness-lint on deprecated-command\n";
let s = suppressions_of(src);
assert!(s.lint_all_ranges().is_empty(), "one rule, not all of them");
assert!(s.format_ranges().is_empty(), "lint axis must not format");
let ranges = s
.lint_rule_ranges()
.get("deprecated-command")
.expect("rule recorded");
assert_eq!(
slices(src, ranges),
vec!["% badness-lint off deprecated-command\n\\beta\n"]
);
}
#[test]
fn skip_file_covers_the_document_on_its_axis() {
let src = "\\alpha\n% badness-format skip-file: generated\n\\beta\n";
let s = suppressions_of(src);
assert_eq!(slices(src, s.format_ranges()), vec![src]);
assert!(s.lint_all_ranges().is_empty());
}
#[test]
fn stray_on_is_inert() {
let src = "\\alpha\n% badness-format on\n\\beta\n";
assert!(suppressions_of(src).is_empty());
}
#[test]
fn overlapping_ranges_merge() {
let src = "% badness-format skip-file: generated\n% badness-format off\n\\b\n";
let s = suppressions_of(src);
assert_eq!(slices(src, s.format_ranges()), vec![src]);
}
#[test]
fn reopened_region_does_not_swallow_its_own_closer() {
let src = "% badness-format off\n\\a\n% badness-format on\n% badness-format off\n\\b\n% badness-format on\n";
let s = suppressions_of(src);
assert_eq!(
slices(src, s.format_ranges()),
vec![
"% badness-format off\n\\a\n",
"\n% badness-format off\n\\b\n"
]
);
}
#[test]
fn retired_and_current_spellings_resolve_identically() {
fn covers_target(src: &str, ranges: &[TextRange]) -> bool {
let at = TextSize::new(src.find("\\bf").expect("has a target") as u32);
ranges.iter().any(|r| r.contains(at))
}
for (old, new) in [
(
"% badness-ignore deprecated-command: legacy\n\\bf x\n",
"% badness-lint skip deprecated-command: legacy\n\\bf x\n",
),
(
"% badness-ignore-file deprecated-command: legacy\n\\bf x\n",
"% badness-lint skip-file deprecated-command: legacy\n\\bf x\n",
),
] {
for (src, label) in [(old, "retired"), (new, "current")] {
let s = suppressions_of(src);
let ranges = s
.lint_rule_ranges()
.get("deprecated-command")
.unwrap_or_else(|| panic!("{label} spelling records the rule: {src:?}"));
assert!(
covers_target(src, ranges),
"{label} spelling must cover its target: {src:?}"
);
assert!(
s.lint_all_ranges().is_empty() && s.format_ranges().is_empty(),
"{label} spelling is lint-only and rule-selective: {src:?}"
);
}
}
let old = suppressions_of("% badness-ignore-file: noisy\n\\bf x\n");
let new = suppressions_of("% badness-lint skip-file: noisy\n\\bf x\n");
assert_eq!(old.lint_all_ranges().len(), 1);
assert_eq!(new.lint_all_ranges().len(), 1);
assert!(old.lint_rule_ranges().is_empty() && new.lint_rule_ranges().is_empty());
}
#[test]
fn clean_document_has_no_suppressions() {
assert!(suppressions_of("\\alpha\n% an ordinary comment\n\\beta\n").is_empty());
}
}