use crate::ast::{AstToken, CallExpr, Expr, StringLiteral};
use crate::linter::rules::matchers::{self, CallShape};
use crate::syntax::{SyntaxKind, SyntaxNode};
#[derive(Clone, Copy)]
enum Role {
Search(Option<usize>),
Other,
}
struct Shape {
name: &'static str,
arity: usize,
pattern: usize,
role: Role,
}
const SHAPES: &[Shape] = &[
Shape {
name: "occursin",
arity: 2,
pattern: 0,
role: Role::Search(Some(1)),
},
Shape {
name: "contains",
arity: 2,
pattern: 1,
role: Role::Search(Some(0)),
},
Shape {
name: "contains",
arity: 1,
pattern: 0,
role: Role::Search(None),
},
Shape {
name: "startswith",
arity: 2,
pattern: 1,
role: Role::Other,
},
Shape {
name: "startswith",
arity: 1,
pattern: 0,
role: Role::Other,
},
Shape {
name: "endswith",
arity: 2,
pattern: 1,
role: Role::Other,
},
Shape {
name: "endswith",
arity: 1,
pattern: 0,
role: Role::Other,
},
Shape {
name: "split",
arity: 2,
pattern: 1,
role: Role::Other,
},
Shape {
name: "eachsplit",
arity: 2,
pattern: 1,
role: Role::Other,
},
];
pub struct PatternCall {
pub call: CallExpr,
pub literal: StringLiteral,
pub pattern: String,
pub search: bool,
pub haystack: Option<Expr>,
}
impl PatternCall {
pub fn all(node: &SyntaxNode) -> Vec<Self> {
for shape in SHAPES {
let Some((call, args)) = matchers::plain_call(node, shape.name, shape.arity) else {
continue;
};
let haystack = match shape.role {
Role::Search(at) => at.and_then(|at| args.get(at)).cloned(),
Role::Other => None,
};
let search = matches!(shape.role, Role::Search(_));
return Self::build(&call, args.get(shape.pattern).cloned(), search, haystack)
.into_iter()
.collect();
}
Self::replace_pairs(node)
}
pub fn search(node: &SyntaxNode) -> Option<Self> {
Self::all(node).into_iter().find(|found| found.search)
}
fn replace_pairs(node: &SyntaxNode) -> Vec<Self> {
let Some(call) = matchers::call_named(node, "replace") else {
return Vec::new();
};
let shape = CallShape::of(&call);
if shape.positional.len() < 2
|| !shape.keywords.is_empty()
|| shape.positional_open
|| shape.keyword_open
|| shape.do_block
{
return Vec::new();
}
shape.positional[1..]
.iter()
.filter_map(|arg| Self::build(&call, pair_pattern(arg), false, None))
.collect()
}
fn build(
call: &CallExpr,
pattern: Option<Expr>,
search: bool,
haystack: Option<Expr>,
) -> Option<Self> {
let Expr::StringLiteral(literal) = pattern? else {
return None;
};
Some(Self {
call: call.clone(),
pattern: regex_pattern(&literal)?,
literal,
search,
haystack,
})
}
}
fn pair_pattern(arg: &Expr) -> Option<Expr> {
let Expr::BinaryExpr(pair) = arg else {
return None;
};
if pair.op()?.syntax().kind() != SyntaxKind::FAT_ARROW {
return None;
}
pair.lhs()
}
pub fn regex_pattern(literal: &StringLiteral) -> Option<String> {
if literal.prefix()?.text() != "r" {
return None;
}
if literal.suffix().is_some() || literal.interpolations().next().is_some() {
return None;
}
Some(
literal
.content_tokens()
.map(|token| token.text().to_string())
.collect(),
)
}
pub fn is_plain_literal(pattern: &str) -> bool {
!pattern.bytes().any(|b| {
matches!(
b,
b'.' | b'\\'
| b'|'
| b'('
| b')'
| b'['
| b']'
| b'{'
| b'}'
| b'^'
| b'$'
| b'*'
| b'+'
| b'?'
)
})
}
pub fn is_fixed_string(pattern: &str) -> bool {
!pattern.is_empty() && is_plain_literal(pattern)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Anchor {
Start,
End,
}
pub fn single_anchor(pattern: &str) -> Option<(Anchor, &str)> {
let (anchor, rest) = if let Some(rest) = pattern.strip_prefix('^') {
if rest.ends_with('$') {
return None;
}
(Anchor::Start, rest)
} else {
(Anchor::End, pattern.strip_suffix('$')?)
};
is_fixed_string(rest).then_some((anchor, rest))
}
pub fn plain_string_literal(text: &str) -> Option<String> {
let requotable = !text.contains(['"', '\\', '$', '\n', '\r']);
requotable.then(|| format!("\"{text}\""))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{AstNode, Expr};
use crate::parser::parse;
fn pattern_of(src: &str) -> Option<String> {
let literal = parse(src)
.cst
.descendants()
.find_map(|node| match Expr::cast(node)? {
Expr::StringLiteral(literal) => Some(literal),
_ => None,
})
.expect("a string literal");
regex_pattern(&literal)
}
#[test]
fn regex_pattern_reads_a_plain_regex_literal() {
assert_eq!(
pattern_of("occursin(r\"abc\", s)\n").as_deref(),
Some("abc")
);
assert_eq!(
pattern_of("occursin(r\"\"\"a\"b\"\"\", s)\n").as_deref(),
Some("a\"b")
);
assert_eq!(pattern_of("occursin(r\"a$\", s)\n").as_deref(), Some("a$"));
assert_eq!(pattern_of("occursin(r\"\", s)\n").as_deref(), Some(""));
}
#[test]
fn regex_pattern_declines_anything_else() {
assert!(pattern_of("occursin(r\"abc\"i, s)\n").is_none());
assert!(pattern_of("occursin(raw\"abc\", s)\n").is_none());
assert!(pattern_of("replace(s, \"a\" => s\"b\")\n").is_none());
assert!(pattern_of("occursin(\"abc\", s)\n").is_none());
}
fn search_call(src: &str) -> Option<(String, Option<String>)> {
parse(src).cst.descendants().find_map(|node| {
let found = PatternCall::search(&node)?;
Some((
found.pattern,
found
.haystack
.map(|haystack| haystack.syntax().text().to_string()),
))
})
}
fn patterns(src: &str) -> Vec<String> {
parse(src)
.cst
.descendants()
.map(|node| PatternCall::all(&node))
.find(|found| !found.is_empty())
.unwrap_or_default()
.into_iter()
.map(|found| found.pattern)
.collect()
}
#[test]
fn pattern_call_reads_the_whole_family() {
assert_eq!(patterns("split(line, r\"::\")\n"), ["::"]);
assert_eq!(patterns("eachsplit(line, r\"::\")\n"), ["::"]);
assert_eq!(patterns("startswith(name, r\"Test\")\n"), ["Test"]);
assert_eq!(patterns("filter(endswith(r\"jl\"), names)\n"), ["jl"]);
assert_eq!(
patterns("replace(s, r\"a\" => \"1\", r\"b\" => \"2\")\n"),
["a", "b"]
);
assert!(patterns("replace(s, \"a\" => r\"b\")\n").is_empty());
assert!(patterns("replace(s, x, r\"a\")\n").is_empty());
assert!(patterns("rsplit(line, r\"::\")\n").is_empty());
}
#[test]
fn pattern_call_reads_both_argument_orders() {
assert_eq!(
search_call("occursin(r\"abc\", s)\n"),
Some(("abc".to_string(), Some("s".to_string())))
);
assert_eq!(
search_call("contains(s, r\"abc\")\n"),
Some(("abc".to_string(), Some("s".to_string())))
);
assert_eq!(
search_call("filter(contains(r\"abc\"), lines)\n"),
Some(("abc".to_string(), None))
);
}
#[test]
fn pattern_call_declines_a_needle_that_is_not_one() {
assert!(search_call("filter(occursin(r\"abc\"), lines)\n").is_none());
assert!(search_call("occursin(s, r\"abc\")\n").is_none());
assert!(search_call("contains(r\"abc\", s)\n").is_none());
assert!(search_call("occursin(needle, s)\n").is_none());
assert!(search_call("occursin(r\"abc\"i, s)\n").is_none());
assert!(search_call("match(r\"abc\", s)\n").is_none());
}
#[test]
fn plain_literal_rejects_metacharacters() {
assert!(is_plain_literal("abc"));
assert!(is_plain_literal("hello world"));
assert!(is_plain_literal("a-b#c"));
assert!(!is_plain_literal("a.b"));
assert!(!is_plain_literal("a\\db"));
assert!(!is_plain_literal("^abc"));
assert!(!is_plain_literal("a+b"));
}
#[test]
fn fixed_string_requires_nonempty_plain() {
assert!(is_fixed_string("abc"));
assert!(!is_fixed_string(""));
assert!(!is_fixed_string("a.b"));
}
#[test]
fn single_anchor_classifies_one_end() {
assert_eq!(single_anchor("^abc"), Some((Anchor::Start, "abc")));
assert_eq!(single_anchor("abc$"), Some((Anchor::End, "abc")));
assert!(single_anchor("^abc$").is_none());
assert!(single_anchor("abc").is_none());
assert!(single_anchor("^").is_none());
assert!(single_anchor("$").is_none());
assert!(single_anchor("^a.b").is_none());
}
#[test]
fn plain_string_literal_requotes_only_what_it_can_spell() {
assert_eq!(plain_string_literal("abc").as_deref(), Some("\"abc\""));
assert!(plain_string_literal("a\"b").is_none());
assert!(plain_string_literal("a\\b").is_none());
assert!(plain_string_literal("a$b").is_none());
assert!(plain_string_literal("a\nb").is_none());
}
}