use crate::ast::AstNode;
use crate::linter::diagnostic::{Applicability, Diagnostic, Fix};
use crate::linter::rules::regex::{Anchor, PatternCall};
use crate::linter::rules::{Example, Rule, RuleContext, regex, rewrite};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
pub struct StringBoundary;
impl Rule for StringBoundary {
fn id(&self) -> &'static str {
"string-boundary"
}
fn description(&self) -> &'static str {
"Flag `occursin(r\"^abc\", s)` and `occursin(r\"abc$\", s)`, boundary \
tests written as anchored regex searches. `startswith(s, \"abc\")` and \
`endswith(s, \"abc\")` name the boundary they test and need no match \
engine.\n\n\
The pattern must be a plain `r\"...\"` — no flag suffix, since `i` and \
`m` change what an anchor means — anchored at exactly one end with a \
non-empty, metacharacter-free remainder; `r\"^abc$\"` is an exact \
match rather than a boundary test. The callee must be confirmed to be \
Base's, and the needle is read wherever its spelling puts it: \
`occursin(r\"^abc\", s)`, the flipped `contains(s, r\"^abc\")`, and \
the curried `contains(r\"^abc\")`, which rewrites to the curried \
`startswith(\"abc\")`.\n\n\
The `startswith` fix is safe: `^` matches at the start of the subject \
and nowhere else. The `endswith` fix needs `--unsafe-fixes`, because \
PCRE's `$` also matches before a final newline — \
`occursin(r\"abc$\", \"abc\\n\")` is `true` where \
`endswith(\"abc\\n\", \"abc\")` is `false`. Either fix is withheld — \
the finding still stands — when the file's `startswith`/`endswith` is \
not Base's, or when a comment sits in the rewritten span outside the \
haystack and the pattern."
}
fn examples(&self) -> &'static [Example] {
&[
Example {
caption: "A leading anchor is a prefix test:",
source: "if occursin(r\"^Test\", name)\n run(name)\nend\n",
},
Example {
caption: "A trailing anchor is a suffix test:",
source: "sources = filter(f -> occursin(r\"_test$\", f), files)\n",
},
Example {
caption: "A curried `contains` rewrites to a curried predicate:",
source: "sources = filter(contains(r\"^src\"), files)\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 Some(found) = regex::PatternCall::search(node) else {
return;
};
let Some((anchor, rest)) = regex::single_anchor(&found.pattern) else {
return;
};
if !ctx.resolves_to_base(&found.call) {
return;
}
let boundary = Boundary::of(anchor, rest);
let func = boundary.func;
let haystack = match &found.haystack {
Some(haystack) => rewrite::inline_text(haystack.syntax()).map(Some),
None => Some(None),
};
let message = match (
boundary.literal.as_deref(),
haystack,
rewrite::inline_text(found.literal.syntax()),
) {
(Some(new_literal), Some(haystack), Some(old)) => format!(
"test `{}` instead of matching the anchored regex `{old}`",
boundary.call(haystack.as_deref(), new_literal)
),
_ => format!("test the boundary with `{func}` instead of matching an anchored regex"),
};
let mut diag = Diagnostic::new(self.id(), found.call.syntax().text_range(), message);
if let Some(fix) = boundary_rewrite(ctx, node, &found, &boundary) {
diag.fixes.push(fix);
}
sink.push(diag);
}
}
struct Boundary {
func: &'static str,
anchor: Anchor,
literal: Option<String>,
}
impl Boundary {
fn of(anchor: Anchor, rest: &str) -> Self {
Self {
func: match anchor {
Anchor::Start => "startswith",
Anchor::End => "endswith",
},
anchor,
literal: regex::plain_string_literal(rest),
}
}
fn call(&self, haystack: Option<&str>, literal: &str) -> String {
match haystack {
Some(haystack) => format!("{}({haystack}, {literal})", self.func),
None => format!("{}({literal})", self.func),
}
}
}
fn boundary_rewrite(
ctx: &RuleContext<'_>,
call: &SyntaxNode,
found: &PatternCall,
boundary: &Boundary,
) -> Option<Fix> {
let span = call.text_range();
let new_literal = boundary.literal.as_deref()?;
if !ctx.name_resolves_to_base(boundary.func, span.start()) {
return None;
}
let mut keep = vec![found.literal.syntax().text_range()];
if let Some(haystack) = &found.haystack {
keep.push(haystack.syntax().text_range());
keep.sort_by_key(|range| range.start());
}
if rewrite::drops_a_comment(call, &keep) {
return None;
}
let (applicability, description) = match boundary.anchor {
Anchor::Start => (
Applicability::Safe,
"Test the prefix with `startswith`".to_string(),
),
Anchor::End => (
Applicability::Unsafe,
"Test the suffix with `endswith`, which does not match before a trailing newline"
.to_string(),
),
};
let haystack = found
.haystack
.as_ref()
.map(|haystack| haystack.syntax().text().to_string());
Some(Fix {
description,
content: boundary.call(haystack.as_deref(), new_literal),
start: span.start().into(),
end: span.end().into(),
applicability,
})
}