use rowan::ast::AstNode as _;
use crate::ast::CallExpr;
use crate::linter::diagnostic::{Diagnostic, Fix, ViolationData};
use crate::linter::rules::matchers;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind};
pub struct FixedRegex;
const REGEX_FUNCTIONS: &[&str] = &[
"grepl", "grep", "sub", "gsub", "regexpr", "gregexpr", "regexec",
];
const EXAMPLES: &[Example] = &[Example {
caption: "A literal pattern matched as a regex:",
source: "grepl(\"abc\", x)\n",
}];
impl Rule for FixedRegex {
fn id(&self) -> &'static str {
"fixed-regex"
}
fn description(&self) -> &'static str {
"Flag a base-R regex call (`grepl`, `grep`, `sub`, `gsub`, `regexpr`, \
`gregexpr`, `regexec`) whose pattern is a plain string literal with no \
regex metacharacter, and add `fixed = TRUE`—it skips regex compilation \
and states that the pattern is a literal.\n\nThe rule fires only when the \
callee resolves to base R and no `fixed`/`ignore.case`/`perl` argument is \
already present. Because a metacharacter-free pattern matches identically \
either way, the fix (inserting `, fixed = TRUE`) is safe."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::CALL_EXPR]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(node) = el.as_node() else {
return;
};
let Some(call) = CallExpr::cast(node.clone()) else {
return;
};
let Some(name) = matchers::callee_name(&call) else {
return;
};
if !REGEX_FUNCTIONS.contains(&name.as_str()) {
return;
}
let args = matchers::args(&call);
if args
.iter()
.any(|a| matches!(a.name.as_deref(), Some("fixed" | "ignore.case" | "perl")))
{
return;
}
let Some(pattern) = args.iter().find(|a| a.name.is_none() && a.value.is_some()) else {
return;
};
let Some(tok) = pattern.value.as_ref().and_then(|v| v.as_token()) else {
return;
};
let Some((_, inner)) = matchers::string_literal(tok) else {
return;
};
if inner.is_empty() || !matchers::is_plain_regex_literal(inner) {
return;
}
if !ctx.resolves_to_base(&call) {
return;
}
let Some(arg_list) = call.arg_list() else {
return;
};
let Some(last_end) = arg_list
.args()
.last()
.map(|a| a.syntax().text_range().end())
else {
return;
};
let at = usize::from(last_end);
sink.push(Diagnostic {
rule: "fixed-regex",
severity: Default::default(),
path: Default::default(),
range: tok.text_range(),
message: ViolationData::new(
"fixed-regex",
format!("`{name}()` with a literal pattern should use `fixed = TRUE`"),
)
.with_suggestion("Add `fixed = TRUE`."),
fix: Some(Fix::safe(at, at, ", fixed = TRUE", "Add `fixed = TRUE`")),
});
}
}