use rowan::ast::AstNode as _;
use crate::linter::diagnostic::{Diagnostic, Fix, ViolationData};
use crate::linter::rules::matchers;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxToken};
pub struct StringBoundary;
const EXAMPLES: &[Example] = &[Example {
caption: "Anchored fixed-string matches:",
source: "grepl(\"^abc\", x)\n",
}];
impl Rule for StringBoundary {
fn id(&self) -> &'static str {
"string-boundary"
}
fn description(&self) -> &'static str {
"Flag `grepl(\"^abc\", x)` and `grepl(\"abc$\", x)`, single-anchored \
fixed-string matches that are the clearer `startsWith(x, \"abc\")` and \
`endsWith(x, \"abc\")`—they state the prefix/suffix test directly and \
skip regex compilation.\n\nThe rule fires only on the clean shape (two \
positional arguments, a one-end-anchored plain-literal pattern) and only \
when `grepl` resolves to base R. The fix is **unsafe**: on `NA` or \
non-character input `startsWith`/`endsWith` diverge from `grepl` (`NA` \
vs `FALSE`, an error vs coercion)."
}
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) = matchers::call_named(node, "grepl") else {
return;
};
let args = matchers::args(&call);
let valued: Vec<_> = args.iter().filter(|a| a.value.is_some()).collect();
if valued.len() != 2 || valued.iter().any(|a| a.name.is_some()) {
return;
}
let pattern_el = valued[0].value.as_ref().unwrap();
let subject_el = valued[1].value.as_ref().unwrap();
let Some(pattern_tok) = pattern_el.as_token() else {
return;
};
let Some((func, new_literal)) = classify(pattern_tok) else {
return;
};
if !ctx.resolves_to_base(&call) {
return;
}
let r = call.syntax().text_range();
let (subject_range, pattern_range) = (subject_el.text_range(), pattern_el.text_range());
let drops_comment = call.syntax().descendants_with_tokens().any(|e| {
e.kind() == SyntaxKind::COMMENT
&& !subject_range.contains_range(e.text_range())
&& !pattern_range.contains_range(e.text_range())
});
let fix = (!drops_comment).then(|| {
Fix::unsafe_(
usize::from(r.start()),
usize::from(r.end()),
format!(
"{func}({}, {new_literal})",
matchers::element_text(subject_el)
),
format!("Replace `grepl(...)` with `{func}(...)`"),
)
});
sink.push(Diagnostic {
rule: "string-boundary",
severity: Default::default(),
path: Default::default(),
range: r,
message: ViolationData::new(
"string-boundary",
format!("single-anchor `grepl()` is the clearer `{func}()`"),
)
.with_suggestion(format!("Use `{func}()`.")),
fix,
});
}
}
fn classify(token: &SyntaxToken) -> Option<(&'static str, String)> {
let (quote, inner) = matchers::string_literal(token)?;
let (func, rest) = if let Some(rest) = inner.strip_prefix('^') {
if rest.ends_with('$') {
return None;
}
("startsWith", rest)
} else if let Some(rest) = inner.strip_suffix('$') {
("endsWith", rest)
} else {
return None;
};
if rest.is_empty() || !matchers::is_plain_regex_literal(rest) {
return None;
}
Some((func, format!("{quote}{rest}{quote}")))
}