use rowan::{TextRange, TextSize};
use crate::linter::diagnostic::{Applicability, Diagnostic, Fix};
use crate::linter::rules::{Example, Rule, RuleContext, is_shipped_rule};
use crate::linter::suppression::DirectiveUsage;
use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
pub struct OutdatedSuppression;
impl Rule for OutdatedSuppression {
fn id(&self) -> &'static str {
"outdated-suppression"
}
fn description(&self) -> &'static str {
"Flag a `# fatou-ignore` directive that suppressed nothing: either the \
rule it names ran and reported nothing it covers, or the directive has \
no code after it to apply to. A rule that this run did not enable, and \
any rule in a file whose names cannot be resolved (one that `eval`s, \
or `using`s a module the run did not harvest), is dormant rather than \
stale and is never reported. The safe fix deletes the directive."
}
fn examples(&self) -> &'static [Example] {
&[Example {
caption: "A directive at the end of a file, with nothing left to suppress:",
source: "function f(x)\n x + 1\nend\n# fatou-ignore unused-binding: the scratch value below\n",
}]
}
fn check_suppressions(
&self,
ctx: &RuleContext<'_>,
used: &DirectiveUsage,
sink: &mut Vec<Diagnostic>,
) {
for (index, directive) in ctx.suppressions.directives().iter().enumerate() {
let Some(rule) = &directive.rule else {
continue;
};
if !is_shipped_rule(&rule.id) {
continue;
}
let message = if directive.is_dangling() {
"suppression has nothing after it to apply to".to_string()
} else {
if used.is_used(index)
|| !ctx.enabled_rules.contains(&rule.id)
|| !verdict_is_trustworthy(&rule.id, ctx)
{
continue;
}
format!(
"`{}` reports nothing here; this suppression is no longer needed",
rule.id
)
};
let mut diag = Diagnostic::new(self.id(), directive.comment, message);
diag.message = diag.message.with_suggestion("delete the directive");
if let Some(span) = deletion_span(ctx.root, directive.comment) {
diag.fixes.push(Fix {
description: "Delete the suppression".to_string(),
content: String::new(),
start: span.start().into(),
end: span.end().into(),
applicability: Applicability::Safe,
});
}
sink.push(diag);
}
}
}
fn verdict_is_trustworthy(rule: &str, ctx: &RuleContext<'_>) -> bool {
if !ctx.trusts_resolution() {
return false;
}
match rule {
"unresolved-import" => ctx
.resolution
.as_ref()
.is_some_and(|resolution| resolution.declared_deps.is_some()),
"julia-version-compat" => ctx.julia_target.is_some(),
"missing-include-file" | "include-cycle" | "duplicate-include" => !ctx.includes.is_empty(),
_ => true,
}
}
fn deletion_span(root: &SyntaxNode, comment: TextRange) -> Option<TextRange> {
let token = comment_token(root, comment)?;
let indent = token
.prev_token()
.filter(|prev| prev.kind() == SyntaxKind::WHITESPACE);
let before = match &indent {
Some(ws) => ws.prev_token(),
None => token.prev_token(),
};
let own_line = before.is_none_or(|prev| prev.kind() == SyntaxKind::NEWLINE);
let start = match &indent {
Some(ws) => ws.text_range().start(),
None => comment.start(),
};
let end = match token.next_token() {
Some(next) if own_line && next.kind() == SyntaxKind::NEWLINE => next.text_range().end(),
_ => comment.end(),
};
Some(TextRange::new(start, end))
}
fn comment_token(root: &SyntaxNode, range: TextRange) -> Option<SyntaxToken> {
find_comment(root, range.start()).filter(|token| token.text_range() == range)
}
fn find_comment(root: &SyntaxNode, offset: TextSize) -> Option<SyntaxToken> {
root.token_at_offset(offset)
.find(|token| token.kind() == SyntaxKind::COMMENT)
}