use crate::ast::AstNode;
use crate::linter::diagnostic::{Applicability, Diagnostic, Fix};
use crate::linter::rules::{Example, Rule, RuleContext, regex, rewrite};
use crate::syntax::{SyntaxElement, SyntaxKind};
pub struct FixedRegex;
impl Rule for FixedRegex {
fn id(&self) -> &'static str {
"fixed-regex"
}
fn description(&self) -> &'static str {
"Flag `occursin(r\"abc\", s)`, whose regex literal carries no \
metacharacter and so matches one fixed substring. `occursin(\"abc\", \
s)` asks the same question of the same values and answers it with a \
substring search instead of the regex engine.\n\n\
Every Base function that takes a pattern is read the same way, each at \
the argument that holds it: `occursin(r\"abc\", s)`, the flipped \
`contains(s, r\"abc\")`, `startswith`/`endswith`, `split`/`eachsplit`, \
each `=>` pair of a `replace`, and the curried forms that fix the \
pattern (`contains(r\"abc\")`). The curried `occursin(s)` fixes the \
*haystack* instead, so its argument is no pattern and is left alone, \
and `rsplit` takes no regex at all.\n\n\
The pattern must be a plain `r\"...\"` — no flag suffix, since a flag \
changes what the pattern means, and no interpolation — and it must be \
non-empty and free of every PCRE metacharacter and backslash escape. \
The callee must be confirmed to be Base's, so a local shadow, a \
qualified `Base.occursin`, or a file whose imports cannot be resolved \
reports nothing.\n\n\
The safe fix deletes the `r` prefix and nothing else. A pattern with \
no backslash and no `$` reads identically as an ordinary string \
literal, and the literal keeps its own delimiters, so the string it \
spells is unchanged."
}
fn examples(&self) -> &'static [Example] {
&[
Example {
caption: "The pattern is a plain substring, matched through PCRE:",
source: "if occursin(r\"error\", line)\n push!(failures, line)\nend\n",
},
Example {
caption: "The same search written the other way round, curried:",
source: "failures = filter(contains(r\"error\"), lines)\n",
},
Example {
caption: "Every other pattern position reads the same way:",
source: "fields = split(line, r\"::\")\n",
},
]
}
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 found: Vec<_> = regex::PatternCall::all(node)
.into_iter()
.filter(|found| regex::is_fixed_string(&found.pattern))
.collect();
let Some(first) = found.first() else { return };
if !ctx.resolves_to_base(&first.call) {
return;
}
for found in &found {
let literal = &found.literal;
let Some(prefix) = literal.prefix() else {
continue;
};
let message = match rewrite::inline_text(literal.syntax()) {
Some(old) => format!(
"use the plain string `{}` instead of the regex `{old}`, \
whose pattern has no metacharacter",
&old[prefix.text().len()..]
),
None => "use a plain string instead of this regex, whose pattern has no \
metacharacter"
.to_string(),
};
let mut diag = Diagnostic::new(self.id(), literal.syntax().text_range(), message);
diag.fixes.push(Fix {
description: "Drop the `r` prefix and match the substring".to_string(),
content: String::new(),
start: prefix.text_range().start().into(),
end: prefix.text_range().end().into(),
applicability: Applicability::Safe,
});
sink.push(diag);
}
}
}