use std::collections::HashMap;
use rowan::NodeOrToken;
use crate::bib::syntax::{SyntaxKind, SyntaxNode};
use crate::directives::{Verb, parse_directive};
#[derive(Debug, Clone, Default)]
pub struct BibSuppressionMap {
all_ranges: Vec<(usize, usize)>,
rule_ranges: HashMap<String, Vec<(usize, usize)>>,
}
struct OpenRegion {
rule: Option<String>,
start: usize,
}
impl BibSuppressionMap {
pub fn build(root: &SyntaxNode) -> Self {
let mut map = Self::default();
let mut open: Vec<OpenRegion> = Vec::new();
for node in root.descendants() {
if node.kind() != SyntaxKind::COMMENT_ENTRY {
continue;
}
let Some(body) = comment_directive_text(&node) else {
continue;
};
let Some(directive) = parse_directive(body.trim()) else {
continue;
};
if !directive.axis.covers_lint() {
continue;
}
match directive.verb {
Verb::SkipFile => {
map.record(span(root.text_range()), &directive.rule);
}
Verb::Skip => {
if let Some(target) = next_meaningful_sibling(&node) {
map.record(target, &directive.rule);
}
}
Verb::Off => {
if !open.iter().any(|o| o.rule == directive.rule) {
let start = next_meaningful_sibling(&node)
.map(|(s, _)| s)
.unwrap_or_else(|| usize::from(node.text_range().end()));
open.push(OpenRegion {
rule: directive.rule.clone(),
start,
});
}
}
Verb::On => {
if let Some(i) = open.iter().position(|o| o.rule == directive.rule) {
let region = open.remove(i);
let end = usize::from(node.text_range().start());
map.record((region.start, end), ®ion.rule);
}
}
}
}
let eof = usize::from(root.text_range().end());
for region in open {
map.record((region.start, eof), ®ion.rule);
}
map
}
fn record(&mut self, range: (usize, usize), rule: &Option<String>) {
match rule {
Some(rule) => self
.rule_ranges
.entry(rule.clone())
.or_default()
.push(range),
None => self.all_ranges.push(range),
}
}
pub fn is_suppressed(&self, rule: &str, start: usize, end: usize) -> bool {
let covers =
|ranges: &[(usize, usize)]| ranges.iter().any(|(rs, re)| *rs <= start && end <= *re);
covers(&self.all_ranges) || self.rule_ranges.get(rule).is_some_and(|r| covers(r))
}
}
fn comment_directive_text(node: &SyntaxNode) -> Option<String> {
let text = node.to_string();
let open = text.find(['{', '('])?;
let close = text.rfind(['}', ')'])?;
if close <= open {
return None;
}
Some(text[open + 1..close].to_string())
}
fn next_meaningful_sibling(node: &SyntaxNode) -> Option<(usize, usize)> {
let parent = node.parent()?;
let mut past = false;
for element in parent.children_with_tokens() {
if !past {
if matches!(&element, NodeOrToken::Node(n) if n == node) {
past = true;
}
continue;
}
match &element {
NodeOrToken::Token(t)
if matches!(t.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) => {}
NodeOrToken::Node(n) if n.kind() == SyntaxKind::COMMENT_ENTRY => {}
_ => return Some(span(element.text_range())),
}
}
None
}
fn span(range: rowan::TextRange) -> (usize, usize) {
(usize::from(range.start()), usize::from(range.end()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bib::parse;
fn map_of(src: &str) -> BibSuppressionMap {
BibSuppressionMap::build(&parse(src).syntax())
}
#[test]
fn skip_file_without_a_rule_suppresses_everything() {
let m = map_of("@comment{badness-lint skip-file: noisy}\n@book{a, title={T}}\n");
assert!(m.is_suppressed("anything", 0, 1));
}
#[test]
fn skip_file_with_a_rule_suppresses_only_that_rule() {
let m = map_of("@comment{badness-lint skip-file missing-required-field: gone}\n");
assert!(m.is_suppressed("missing-required-field", 0, 1));
assert!(!m.is_suppressed("duplicate-key", 0, 1));
}
#[test]
fn skip_targets_the_following_entry() {
let src = "@comment{badness-lint skip missing-required-field: gone}\n@book{a, title={T}}\n";
let m = map_of(src);
let at = src.find("@book").expect("has the entry");
assert!(m.is_suppressed("missing-required-field", at, at + 5));
}
#[test]
fn skip_does_not_leak_to_later_entries() {
let src = "@comment{badness-lint skip missing-required-field: gone}\n\
@book{a, title={T}}\n@book{b, title={U}}\n";
let m = map_of(src);
let later = src.rfind("@book").expect("has a second entry");
assert!(!m.is_suppressed("missing-required-field", later, later + 5));
}
#[test]
fn region_covers_the_entries_between_its_markers() {
let src = "@comment{badness-lint off missing-required-field}\n\
@book{a, title={T}}\n\
@comment{badness-lint on missing-required-field}\n\
@book{b, title={U}}\n";
let m = map_of(src);
let inside = src.find("@book{a").expect("has a");
assert!(m.is_suppressed("missing-required-field", inside, inside + 5));
let outside = src.find("@book{b").expect("has b");
assert!(!m.is_suppressed("missing-required-field", outside, outside + 5));
}
#[test]
fn retired_ignore_family_still_suppresses() {
let src = "@comment{badness-ignore missing-required-field: gone}\n@book{a, title={T}}\n";
let m = map_of(src);
let at = src.find("@book").expect("has the entry");
assert!(m.is_suppressed("missing-required-field", at, at + 5));
let file = map_of("@comment{badness-ignore-file: noisy}\n@book{a, title={T}}\n");
assert!(file.is_suppressed("anything", 0, 1));
}
#[test]
fn format_family_does_not_suppress_lint() {
let m = map_of("@comment{badness-format skip-file}\n@book{a, title={T}}\n");
assert!(!m.is_suppressed("missing-required-field", 0, 1));
}
#[test]
fn non_directive_comment_is_inert() {
let m = map_of("@comment{just a note}\n@book{a, title={T}}\n");
assert!(!m.is_suppressed("missing-required-field", 0, 1));
}
}