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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectiveOutcome {
Honored,
DanglingSkip,
UnmatchedOn,
UnclosedOff,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocatedDirective {
pub directive: Directive,
pub range: TextRange,
pub family_range: TextRange,
pub outcome: DirectiveOutcome,
}
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>>,
directives: Vec<LocatedDirective>,
}
struct OpenRegion {
axis: Axis,
rule: Option<String>,
start: TextSize,
directive_index: usize,
}
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 directives = Vec::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;
};
let Some((carrier, range, supported)) = directive_carrier(&token) else {
continue;
};
let Some(directive) = parse_directive(&carrier) else {
continue;
};
let family = directive_family(&directive);
let family_start = carrier
.find(family)
.expect("parsed directive contains its family name");
let token_start = usize::from(range.start());
let directive_index = directives.len();
directives.push(LocatedDirective {
directive: directive.clone(),
range,
family_range: TextRange::new(
TextSize::from((token_start + family_start) as u32),
TextSize::from((token_start + family_start + family.len()) as u32),
),
outcome: if supported {
DirectiveOutcome::Honored
} else {
DirectiveOutcome::Unsupported
},
});
if !supported {
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);
} else {
directives[directive_index].outcome = DirectiveOutcome::DanglingSkip;
}
}
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)
{
directives[directive_index].outcome = DirectiveOutcome::UnclosedOff;
open.push(OpenRegion {
axis: directive.axis,
rule: directive.rule.clone(),
start,
directive_index,
});
}
}
Verb::On => {
if let Some(i) = open
.iter()
.position(|o| o.axis == directive.axis && o.rule == directive.rule)
{
let region = open.remove(i);
directives[region.directive_index].outcome = DirectiveOutcome::Honored;
record(
TextRange::new(region.start, token.text_range().start()),
®ion.rule,
);
} else {
directives[directive_index].outcome = DirectiveOutcome::UnmatchedOn;
}
}
}
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(),
directives,
}
}
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
}
pub fn directives(&self) -> &[LocatedDirective] {
&self.directives
}
}
fn directive_carrier(token: &SyntaxToken) -> Option<(String, TextRange, bool)> {
match token.kind() {
SyntaxKind::COMMENT => Some((token.text().to_owned(), token.text_range(), true)),
SyntaxKind::DOC_MARGIN => {
let start = token.text_range().start();
let mut end = start;
let mut text = String::new();
let mut current = Some(token.clone());
while let Some(part) = current {
if part.kind() == SyntaxKind::NEWLINE {
break;
}
let part_text = part.text();
if let Some(line_end) = part_text.find(['\r', '\n']) {
text.push_str(&part_text[..line_end]);
end = part.text_range().start() + TextSize::from(line_end as u32);
break;
}
text.push_str(part_text);
end = part.text_range().end();
current = part.next_token();
}
Some((text, TextRange::new(start, end), false))
}
_ => None,
}
}
fn directive_family(directive: &Directive) -> &'static str {
if directive.deprecated {
return match directive.verb {
Verb::SkipFile => "badness-ignore-file",
Verb::Skip => "badness-ignore",
Verb::Off | Verb::On => unreachable!("retired directives have no region verbs"),
};
}
match directive.axis {
Axis::Format => "badness-format",
Axis::Lint => "badness-lint",
Axis::Both => "badness",
}
}
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::{LatexFlavor, LexConfig, parse, parse_with_flavor};
fn suppressions_of(src: &str) -> Suppressions {
Suppressions::build(&SyntaxNode::new_root(parse(src).green))
}
fn dtx_suppressions_of(src: &str) -> Suppressions {
let parsed = parse_with_flavor(
src,
LexConfig {
flavor: LatexFlavor::Document,
dtx: true,
},
);
assert_eq!(parsed.syntax().to_string(), src);
Suppressions::build(&parsed.syntax())
}
#[test]
fn classifies_inert_and_incomplete_directives() {
for (src, expected) in [
(
"% badness-lint skip deprecated-command\n",
DirectiveOutcome::DanglingSkip,
),
(
"% badness-lint on deprecated-command\n",
DirectiveOutcome::UnmatchedOn,
),
(
"% badness-lint off deprecated-command\n\\bf\n",
DirectiveOutcome::UnclosedOff,
),
] {
let suppressions = suppressions_of(src);
assert_eq!(suppressions.directives().len(), 1, "{src:?}");
assert_eq!(suppressions.directives()[0].outcome, expected, "{src:?}");
}
}
#[test]
fn matched_and_targeted_directives_are_honored() {
for src in [
"% badness-lint skip deprecated-command\n\\bf\n",
"% badness-lint off deprecated-command\n\\bf\n% badness-lint on deprecated-command\n",
"% badness-lint skip-file deprecated-command\n",
] {
let suppressions = suppressions_of(src);
assert!(
suppressions
.directives()
.iter()
.all(|located| located.outcome == DirectiveOutcome::Honored),
"{src:?}: {:?}",
suppressions.directives()
);
}
}
#[test]
fn retains_dtx_doc_margin_directive_as_unsupported() {
let src = "% badness-lint skip deprecated-command\nDocumentation.\n";
let suppressions = dtx_suppressions_of(src);
let [located] = suppressions.directives() else {
panic!(
"expected one retained directive: {:?}",
suppressions.directives()
);
};
assert_eq!(located.outcome, DirectiveOutcome::Unsupported);
assert_eq!(
&src[usize::from(located.range.start())..usize::from(located.range.end())],
"% badness-lint skip deprecated-command"
);
assert!(suppressions.format_ranges().is_empty());
assert!(suppressions.lint_all_ranges().is_empty());
assert!(suppressions.lint_rule_ranges().is_empty());
}
#[test]
fn retains_directives_with_their_carrier_ranges() {
let src = "% a note\n% badness-ignore deprecated-command: legacy\n\\bf\n";
let suppressions = suppressions_of(src);
let retained = suppressions.directives();
assert_eq!(retained.len(), 1);
assert!(retained[0].directive.deprecated);
assert_eq!(
&src[usize::from(retained[0].range.start())..usize::from(retained[0].range.end())],
"% badness-ignore deprecated-command: legacy"
);
}
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());
}
}