use std::collections::HashMap;
use rowan::{NodeOrToken, TextRange, TextSize};
use crate::dcf;
use crate::directive::{self, Parsed, RuleScope};
use crate::syntax::{SyntaxKind, SyntaxNode};
use super::diagnostic::Diagnostic;
pub use crate::directive::{MalformedKind, RuleRef, Spelling, Tool, Verb};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Coverage {
File,
Range(TextRange),
Nothing,
}
#[derive(Debug, Clone)]
pub struct Directive {
pub tool: Tool,
pub verb: Verb,
pub prefix: TextRange,
pub scope: RuleScope,
pub spelling: Spelling,
pub reason: Option<String>,
pub comment: TextRange,
pub coverage: Coverage,
pub matched: bool,
pub raw: String,
}
impl Directive {
pub fn rule(&self) -> Option<&RuleRef> {
match &self.scope {
RuleScope::Rule(rule) => Some(rule),
_ => None,
}
}
pub fn has_reason(&self) -> bool {
self.reason.is_some()
}
pub fn is_dangling(&self) -> bool {
self.verb == Verb::Skip && self.coverage == Coverage::Nothing
}
pub fn has_rule_slot(&self) -> bool {
self.tool.affects_lint() && self.verb != Verb::On
}
}
#[derive(Debug, Clone)]
pub struct Malformed {
pub tool: Tool,
pub kind: MalformedKind,
pub range: TextRange,
pub word: String,
pub comment: TextRange,
}
#[derive(Debug, Clone, Default)]
pub struct DirectiveUsage(Vec<bool>);
impl DirectiveUsage {
pub fn is_used(&self, index: usize) -> bool {
self.0.get(index).copied().unwrap_or(false)
}
}
#[derive(Debug, Clone, Default)]
pub struct SuppressionMap {
directives: Vec<Directive>,
malformed: Vec<Malformed>,
all_rules: Vec<usize>,
by_rule: HashMap<String, Vec<usize>>,
}
impl SuppressionMap {
pub fn build(root: &SyntaxNode) -> Self {
let mut map = Self::default();
for element in root.descendants_with_tokens() {
if let NodeOrToken::Token(token) = element
&& token.kind() == SyntaxKind::COMMENT
{
map.classify(token.text(), token.text_range(), || {
next_meaningful_sibling(&token)
});
}
}
map.finish(root.text_range().end());
map
}
pub fn build_dcf(root: &dcf::SyntaxNode) -> Self {
let mut map = Self::default();
for el in root.descendants_with_tokens() {
if let NodeOrToken::Token(tok) = el
&& tok.kind() == dcf::SyntaxKind::COMMENT
{
map.classify(tok.text(), tok.text_range(), || {
next_meaningful_dcf_sibling(&tok)
});
}
}
map.finish(root.text_range().end());
map
}
pub fn directives(&self) -> &[Directive] {
&self.directives
}
pub fn malformed(&self) -> &[Malformed] {
&self.malformed
}
pub fn is_suppressed(&self, rule: &str, range: TextRange) -> bool {
self.candidates(rule).any(|i| self.covers(i, range))
}
pub fn filter(&self, diagnostics: &mut Vec<Diagnostic>) -> DirectiveUsage {
let mut used = vec![false; self.directives.len()];
let mut hits = Vec::new();
diagnostics.retain(|d| {
hits.clear();
hits.extend(self.candidates(d.rule).filter(|&i| self.covers(i, d.range)));
for &i in &hits {
used[i] = true;
}
hits.is_empty()
});
DirectiveUsage(used)
}
fn candidates<'a>(&'a self, rule: &str) -> impl Iterator<Item = usize> + 'a {
self.all_rules
.iter()
.copied()
.chain(self.by_rule.get(rule).into_iter().flatten().copied())
}
fn covers(&self, index: usize, range: TextRange) -> bool {
let directive = &self.directives[index];
if directive.scope == RuleScope::All && self.spans_a_directive(range) {
return false;
}
match directive.coverage {
Coverage::File => true,
Coverage::Range(covered) => covered.contains_range(range),
Coverage::Nothing => false,
}
}
fn spans_a_directive(&self, range: TextRange) -> bool {
self.directives
.iter()
.map(|d| d.comment)
.chain(self.malformed.iter().map(|m| m.comment))
.any(|comment| comment.contains_range(range))
}
fn classify(
&mut self,
text: &str,
comment: TextRange,
target: impl FnOnce() -> Option<TextRange>,
) {
let base = comment.start();
match directive::parse(text) {
Some(Parsed::Directive(parsed)) => {
let coverage = match parsed.verb {
Verb::SkipFile => Coverage::File,
Verb::Skip => target().map_or(Coverage::Nothing, Coverage::Range),
Verb::Off | Verb::On => Coverage::Nothing,
};
self.directives.push(Directive {
tool: parsed.tool,
verb: parsed.verb,
prefix: parsed.prefix + base,
scope: absolute_scope(parsed.scope, base),
spelling: parsed.spelling,
reason: parsed.reason.map(str::to_string),
comment,
coverage,
matched: false,
raw: text.to_string(),
});
}
Some(Parsed::Malformed(bad)) => self.malformed.push(Malformed {
tool: bad.tool,
kind: bad.kind,
range: bad.range + base,
word: bad.word,
comment,
}),
None => {}
}
}
fn finish(&mut self, end_of_file: TextSize) {
self.close_regions(end_of_file);
for index in 0..self.directives.len() {
let directive = &self.directives[index];
if !directive.tool.affects_lint() || directive.verb == Verb::On {
continue;
}
match &directive.scope {
RuleScope::All => self.all_rules.push(index),
RuleScope::Rule(rule) => {
self.by_rule.entry(rule.id.clone()).or_default().push(index);
}
RuleScope::Unnamed => {}
}
}
}
fn close_regions(&mut self, end_of_file: TextSize) {
let ends: Vec<(usize, Option<usize>)> = self
.directives
.iter()
.enumerate()
.filter(|(_, d)| d.verb == Verb::Off)
.map(|(i, off)| {
let closer = self.directives[i + 1..]
.iter()
.position(|d| d.verb == Verb::On && d.tool == off.tool)
.map(|offset| i + 1 + offset);
(i, closer)
})
.collect();
for (index, closer) in ends {
let end = match closer {
Some(j) => {
self.directives[j].matched = true;
self.directives[j].comment.start()
}
None => end_of_file,
};
let start = self.directives[index].comment.end();
self.directives[index].matched = closer.is_some();
if start <= end {
self.directives[index].coverage = Coverage::Range(TextRange::new(start, end));
}
}
}
}
fn absolute_scope(scope: RuleScope, base: TextSize) -> RuleScope {
match scope {
RuleScope::Rule(rule) => RuleScope::Rule(RuleRef {
id: rule.id,
range: rule.range + base,
}),
other => other,
}
}
fn next_meaningful_dcf_sibling(tok: &dcf::SyntaxToken) -> Option<TextRange> {
let line = tok.parent()?;
let field = line.parent().filter(|p| p.kind() == dcf::SyntaxKind::FIELD);
if let Some(field) = field {
let interrupts_value = line
.siblings(rowan::Direction::Next)
.skip(1)
.any(|sibling| sibling.kind() == dcf::SyntaxKind::VALUE_LINE);
if interrupts_value {
return Some(field.text_range());
}
return next_dcf_sibling_of(&field);
}
next_dcf_sibling_of(&line)
}
fn next_dcf_sibling_of(node: &dcf::SyntaxNode) -> Option<TextRange> {
let next = next_dcf_line(node.siblings(rowan::Direction::Next).skip(1))?;
if next.kind() == dcf::SyntaxKind::RECORD {
return Some(next_dcf_line(next.children())?.text_range());
}
Some(next.text_range())
}
fn next_dcf_line(mut lines: impl Iterator<Item = dcf::SyntaxNode>) -> Option<dcf::SyntaxNode> {
lines.find(|node| {
!matches!(
node.kind(),
dcf::SyntaxKind::COMMENT_LINE | dcf::SyntaxKind::BLANK_LINE
)
})
}
fn next_meaningful_sibling(
tok: &rowan::SyntaxToken<crate::syntax::RLanguage>,
) -> Option<TextRange> {
let mut current_token = tok.clone();
loop {
let parent = current_token.parent()?;
let mut found = None;
let mut past_self = false;
for el in parent.children_with_tokens() {
match &el {
NodeOrToken::Token(t) if *t == current_token => {
past_self = true;
continue;
}
_ => {}
}
if !past_self {
continue;
}
match &el {
NodeOrToken::Token(t)
if matches!(
t.kind(),
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
) =>
{
continue;
}
NodeOrToken::Node(child) => {
found = Some(child.text_range());
break;
}
NodeOrToken::Token(t) => {
found = Some(t.text_range());
break;
}
}
}
if let Some(range) = found {
return Some(range);
}
let parent_node = parent.clone();
let grand = parent_node.parent()?;
let mut past_parent = false;
for el in grand.children_with_tokens() {
match &el {
NodeOrToken::Node(n) if *n == parent_node => {
past_parent = true;
continue;
}
_ => {}
}
if !past_parent {
continue;
}
match &el {
NodeOrToken::Token(t)
if matches!(
t.kind(),
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
) =>
{
continue;
}
NodeOrToken::Node(child) => return Some(child.text_range()),
NodeOrToken::Token(t) => return Some(t.text_range()),
}
}
current_token = grand.first_token()?;
if grand == parent {
return None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn map_of(src: &str) -> SuppressionMap {
let parsed = parse(src);
SuppressionMap::build(&parsed.cst)
}
fn only(map: &SuppressionMap) -> &Directive {
match map.directives() {
[d] => d,
other => panic!("expected exactly one directive, got {other:?}"),
}
}
#[test]
fn file_all_suppresses_everything() {
let m = map_of("# arity-ignore-file: noisy\nx <- 1\n");
assert!(m.is_suppressed("anything", TextRange::new(26.into(), 32.into())));
}
#[test]
fn file_rule_suppresses_only_that_rule() {
let m = map_of("# arity-ignore-file unused-binding: temp\nx <- 1\n");
assert!(m.is_suppressed("unused-binding", TextRange::new(41.into(), 47.into())));
assert!(!m.is_suppressed("undefined-symbol", TextRange::new(41.into(), 47.into())));
}
#[test]
fn node_suppression_attaches_to_next_sibling() {
let src = "# arity-ignore unused-binding: temp\nx <- 1\n";
let m = map_of(src);
assert!(m.is_suppressed("unused-binding", TextRange::new(36.into(), 42.into())));
}
#[test]
fn node_suppression_does_not_leak_to_following_statements() {
let src = "# arity-ignore unused-binding: only first\nx <- 1\ny <- 2\n";
let m = map_of(src);
assert!(m.is_suppressed("unused-binding", TextRange::new(42.into(), 48.into())));
assert!(!m.is_suppressed("unused-binding", TextRange::new(49.into(), 55.into())));
}
#[test]
fn directive_records_rule_reason_and_comment_range() {
let src = "# arity-ignore unused-binding: still needed\nx <- 1\n";
let m = map_of(src);
let d = only(&m);
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
assert_eq!(d.rule().map(|r| r.id.as_str()), Some("unused-binding"));
assert_eq!(d.reason.as_deref(), Some("still needed"));
assert_eq!(d.comment, TextRange::new(0.into(), 43.into()));
assert_eq!(d.raw, "# arity-ignore unused-binding: still needed");
assert_eq!(d.spelling, Spelling::Deprecated);
assert!(matches!(d.coverage, Coverage::Range(_)));
assert!(!d.is_dangling());
}
#[test]
fn rule_ref_range_spans_exactly_the_written_id() {
let src = "# arity-ignore unused-binding: r\nx <- 1\n";
let m = map_of(src);
let rule = only(&m).rule().expect("a rule ref").clone();
assert_eq!(&src[rule.range], "unused-binding");
}
#[test]
fn rule_ref_range_is_absolute_for_an_indented_file_directive() {
let src = "f <- function() {\n # arity-ignore-file browser: r\n 1\n}\n";
let m = map_of(src);
let rule = only(&m).rule().expect("a rule ref").clone();
assert_eq!(&src[rule.range], "browser");
}
#[test]
fn directive_without_colon_has_no_reason() {
let m = map_of("# arity-ignore unused-binding\nx <- 1\n");
assert!(!only(&m).has_reason());
}
#[test]
fn directive_with_empty_reason_has_no_reason() {
let m = map_of("# arity-ignore unused-binding: \nx <- 1\n");
assert!(!only(&m).has_reason());
}
#[test]
fn blanket_file_directive_has_no_rule_but_keeps_its_reason() {
let m = map_of("# arity-ignore-file: generated code\nx <- 1\n");
let d = only(&m);
assert_eq!(d.verb, Verb::SkipFile);
assert_eq!(d.scope, RuleScope::All);
assert_eq!(d.reason.as_deref(), Some("generated code"));
assert_eq!(d.coverage, Coverage::File);
}
#[test]
fn scoped_file_directive_keeps_rule_and_reason() {
let m = map_of("# arity-ignore-file unused-binding: temp\nx <- 1\n");
let d = only(&m);
assert_eq!(d.verb, Verb::SkipFile);
assert_eq!(d.rule().map(|r| r.id.as_str()), Some("unused-binding"));
assert_eq!(d.reason.as_deref(), Some("temp"));
}
#[test]
fn bare_directive_naming_no_rule_is_recorded() {
let m = map_of("# arity-ignore\nx <- 1\n");
let d = only(&m);
assert_eq!(d.verb, Verb::Skip);
assert_eq!(d.scope, RuleScope::Unnamed);
}
#[test]
fn node_directive_with_nothing_after_it_is_still_recorded() {
let m = map_of("x <- 1\n# arity-ignore unused-binding: dangling\n");
let d = only(&m);
assert_eq!(d.verb, Verb::Skip);
assert_eq!(d.coverage, Coverage::Nothing);
assert!(d.is_dangling());
}
#[test]
fn unknown_rule_id_is_recorded() {
let m = map_of("# arity-ignore not-a-rule: r\nx <- 1\n");
assert_eq!(only(&m).rule().map(|r| r.id.as_str()), Some("not-a-rule"));
}
#[test]
fn comma_list_yields_a_single_bogus_rule() {
let m = map_of("# arity-ignore browser, repeat: r\nx <- 1\n");
assert_eq!(only(&m).rule().map(|r| r.id.as_str()), Some("browser,"));
}
#[test]
fn non_directive_comments_are_not_recorded() {
let m = map_of("# just a comment\n#' @param x roxygen\nx <- 1\n");
assert!(m.directives().is_empty());
}
#[test]
fn filter_reports_which_directives_fired() {
let src = "# arity-ignore unused-binding: used\n# arity-ignore browser: unused\nx <- 1\n";
let m = map_of(src);
let target = range_of(src, "x <- 1");
let mut diagnostics = vec![diag("unused-binding", target), diag("equals-na", target)];
let usage = m.filter(&mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].rule, "equals-na");
assert!(usage.is_used(0));
assert!(!usage.is_used(1));
}
#[test]
fn filter_marks_every_directive_that_covers_a_finding() {
let src = "# arity-ignore-file unused-binding: broad\n# arity-ignore unused-binding: narrow\nx <- 1\n";
let m = map_of(src);
let target = range_of(src, "x <- 1");
let mut diagnostics = vec![diag("unused-binding", target)];
let usage = m.filter(&mut diagnostics);
assert!(diagnostics.is_empty());
assert!(usage.is_used(0), "the file-wide directive fired");
assert!(usage.is_used(1), "the node directive covers it too");
}
#[test]
fn directive_never_suppresses_a_finding_inside_itself() {
let src = "# arity-ignore-file: shush\nx <- 1\n";
let m = map_of(src);
let own = only(&m).comment;
assert!(!m.is_suppressed("blanket-suppression", own));
assert!(m.is_suppressed("unused-binding", TextRange::new(27.into(), 33.into())));
}
#[test]
fn a_region_covers_from_off_to_on() {
let src = "x <- 1\n# arity-lint off browser: r\ny <- 2\n# arity-lint on\nz <- 3\n";
let m = map_of(src);
assert!(m.is_suppressed("browser", range_of(src, "y <- 2")));
assert!(!m.is_suppressed("browser", range_of(src, "x <- 1")));
assert!(!m.is_suppressed("browser", range_of(src, "z <- 3")));
assert!(m.directives()[0].matched, "the `off` found its `on`");
assert!(m.directives()[1].matched, "the `on` closed a region");
}
#[test]
fn an_unclosed_region_runs_to_end_of_file() {
let src = "x <- 1\n# arity-lint off browser: r\ny <- 2\n";
let m = map_of(src);
assert!(m.is_suppressed("browser", range_of(src, "y <- 2")));
assert!(!m.directives()[0].matched);
}
#[test]
fn an_on_with_nothing_open_covers_nothing() {
let m = map_of("# arity-lint on\nx <- 1\n");
let d = only(&m);
assert_eq!(d.verb, Verb::On);
assert_eq!(d.coverage, Coverage::Nothing);
assert!(!d.matched);
}
#[test]
fn a_region_closes_only_on_its_own_prefix() {
let src = "# arity off\nx <- 1\n# arity-lint on\ny <- 2\n";
let m = map_of(src);
assert!(!m.directives()[0].matched);
assert!(
m.is_suppressed("browser", range_of(src, "y <- 2")),
"runs on to EOF"
);
}
#[test]
fn a_format_only_directive_suppresses_no_lint_finding() {
let src = "# arity-format skip: layout only\nx <- 1\n";
let m = map_of(src);
assert_eq!(only(&m).tool, Tool::Format);
assert!(!m.is_suppressed("unused-binding", range_of(src, "x <- 1")));
}
#[test]
fn a_malformed_directive_is_recorded_separately() {
let src = "# arity-format skipp: typo\nx <- 1\n";
let m = map_of(src);
assert!(m.directives().is_empty());
let [bad] = m.malformed() else {
panic!("expected one malformed directive, got {:?}", m.malformed())
};
assert_eq!(bad.kind, MalformedKind::UnknownVerb);
assert_eq!(&src[bad.range], "skipp");
}
fn range_of(src: &str, needle: &str) -> TextRange {
let start = src.find(needle).expect("needle in src");
TextRange::at((start as u32).into(), (needle.len() as u32).into())
}
fn diag(rule: &'static str, range: TextRange) -> Diagnostic {
Diagnostic {
rule,
severity: Default::default(),
path: Default::default(),
range,
message: crate::linter::diagnostic::ViolationData::new(rule, "test"),
fix: None,
}
}
}