use std::collections::HashMap;
use rowan::{NodeOrToken, TextRange};
use crate::syntax::{SyntaxKind, SyntaxNode};
use super::diagnostic::Diagnostic;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectiveKind {
Node,
File,
FileAll,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleRef {
pub id: String,
pub range: TextRange,
}
#[derive(Debug, Clone)]
pub struct Directive {
pub kind: DirectiveKind,
pub rule: Option<RuleRef>,
pub reason: Option<String>,
pub comment: TextRange,
pub target: Option<TextRange>,
pub raw: String,
}
impl Directive {
pub fn has_reason(&self) -> bool {
self.reason.is_some()
}
pub fn is_dangling(&self) -> bool {
self.kind == DirectiveKind::Node && self.target.is_none()
}
}
#[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>,
file_all: Vec<usize>,
file_rules: HashMap<String, Vec<usize>>,
node_skips: HashMap<String, Vec<(TextRange, usize)>>,
}
impl SuppressionMap {
pub fn build(root: &SyntaxNode) -> Self {
let mut map = Self::default();
visit(root, &mut map);
map
}
pub fn directives(&self) -> &[Directive] {
&self.directives
}
pub fn is_suppressed(&self, rule: &str, range: TextRange) -> bool {
self.file_all.iter().any(|&i| self.applies(i, range))
|| self
.file_rules
.get(rule)
.is_some_and(|ix| ix.iter().any(|&i| self.applies(i, range)))
|| self.node_skips.get(rule).is_some_and(|ranges| {
ranges
.iter()
.any(|&(r, i)| r.contains_range(range) && self.applies(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();
self.matches(d.rule, d.range, &mut hits);
for &i in &hits {
used[i] = true;
}
hits.is_empty()
});
DirectiveUsage(used)
}
fn matches(&self, rule: &str, range: TextRange, out: &mut Vec<usize>) {
out.extend(
self.file_all
.iter()
.copied()
.filter(|&i| self.applies(i, range)),
);
if let Some(indices) = self.file_rules.get(rule) {
out.extend(indices.iter().copied().filter(|&i| self.applies(i, range)));
}
if let Some(ranges) = self.node_skips.get(rule) {
out.extend(
ranges
.iter()
.filter(|&&(r, i)| r.contains_range(range) && self.applies(i, range))
.map(|&(_, i)| i),
);
}
}
fn applies(&self, index: usize, range: TextRange) -> bool {
!self.directives[index].comment.contains_range(range)
}
}
fn visit(node: &SyntaxNode, map: &mut SuppressionMap) {
for el in node.descendants_with_tokens() {
if let NodeOrToken::Token(tok) = el
&& tok.kind() == SyntaxKind::COMMENT
{
classify_comment(&tok, map);
}
}
}
fn classify_comment(tok: &rowan::SyntaxToken<crate::syntax::RLanguage>, map: &mut SuppressionMap) {
let text = tok.text();
let base = tok.text_range().start();
let Some(body_start) = text.find('#').map(|i| i + 1) else {
return;
};
let body = text[body_start..].trim_start();
let body_offset = body_start + (text.len() - body_start - body.len());
if let Some(rest) = body.strip_prefix("arity-ignore-file") {
let rest_offset = body_offset + "arity-ignore-file".len();
let (rest, rest_offset) = trim_start_at(rest, rest_offset);
if let Some(reason) = rest.strip_prefix(':') {
record(
map,
Directive {
kind: DirectiveKind::FileAll,
rule: None,
reason: clean_reason(reason),
comment: tok.text_range(),
target: None,
raw: text.to_string(),
},
);
return;
}
record(
map,
Directive {
kind: DirectiveKind::File,
rule: parse_rule(rest, rest_offset, base),
reason: parse_reason(rest),
comment: tok.text_range(),
target: None,
raw: text.to_string(),
},
);
return;
}
if let Some(rest) = body.strip_prefix("arity-ignore") {
let rest_offset = body_offset + "arity-ignore".len();
let (rest, rest_offset) = trim_start_at(rest, rest_offset);
record(
map,
Directive {
kind: DirectiveKind::Node,
rule: parse_rule(rest, rest_offset, base),
reason: parse_reason(rest),
comment: tok.text_range(),
target: next_meaningful_sibling(tok),
raw: text.to_string(),
},
);
}
}
fn record(map: &mut SuppressionMap, directive: Directive) {
let index = map.directives.len();
match (&directive.kind, &directive.rule, &directive.target) {
(DirectiveKind::FileAll, _, _) => map.file_all.push(index),
(DirectiveKind::File, Some(rule), _) => {
map.file_rules
.entry(rule.id.clone())
.or_default()
.push(index);
}
(DirectiveKind::Node, Some(rule), Some(target)) => {
map.node_skips
.entry(rule.id.clone())
.or_default()
.push((*target, index));
}
_ => {}
}
map.directives.push(directive);
}
fn trim_start_at(s: &str, offset: usize) -> (&str, usize) {
let trimmed = s.trim_start();
(trimmed, offset + (s.len() - trimmed.len()))
}
fn parse_rule(rest: &str, offset: usize, base: rowan::TextSize) -> Option<RuleRef> {
let end = rest
.find(|c: char| c == ':' || c.is_whitespace())
.unwrap_or(rest.len());
if end == 0 {
return None;
}
let start = base + rowan::TextSize::from(offset as u32);
Some(RuleRef {
id: rest[..end].to_string(),
range: TextRange::at(start, rowan::TextSize::from(end as u32)),
})
}
fn parse_reason(rest: &str) -> Option<String> {
rest.split_once(':')
.and_then(|(_, reason)| clean_reason(reason))
}
fn clean_reason(reason: &str) -> Option<String> {
let trimmed = reason.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
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.kind, DirectiveKind::Node);
assert_eq!(
d.rule.as_ref().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!(d.target.is_some());
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.clone().expect("a rule ref");
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.clone().expect("a rule ref");
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.kind, DirectiveKind::FileAll);
assert_eq!(d.rule, None);
assert_eq!(d.reason.as_deref(), Some("generated code"));
assert_eq!(d.target, None);
}
#[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.kind, DirectiveKind::File);
assert_eq!(
d.rule.as_ref().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.kind, DirectiveKind::Node);
assert_eq!(d.rule, None);
}
#[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.kind, DirectiveKind::Node);
assert_eq!(d.target, None);
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.as_ref().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.as_ref().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())));
}
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,
}
}
}