use std::ops::Range;
use std::sync::Arc;
use regex_automata::nfa::thompson::{self, pikevm};
use regex_automata::util::captures::Captures;
use regex_automata::util::syntax;
use regex_automata::{Anchored, Input, MatchKind, meta};
const NFA_SIZE_LIMIT: usize = 4 * 1024 * 1024;
#[derive(Clone, Debug, Default)]
pub struct FunctionLines(Kind);
#[derive(Clone, Debug, Default)]
enum Kind {
#[default]
Default,
Patterns(Arc<[Rule]>),
}
#[derive(Debug)]
struct Rule {
negated: bool,
find: meta::Regex,
longest: pikevm::PikeVM,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PatternError {
#[error("the last function pattern expression is negated")]
NegatedLast,
#[error("function pattern has an empty expression")]
Empty,
#[error("function pattern is not UTF-8")]
Encoding,
#[error("unsupported function pattern construct: {0}")]
Unsupported(&'static str),
#[error("invalid function pattern")]
Invalid(#[source] Box<dyn std::error::Error + Send + Sync>),
}
#[derive(Clone, Copy, PartialEq)]
enum Flavor {
Extended,
Basic,
}
const RUST: &str = concat!(
r#"^[\t ]*((pub(\([^)]+\))?[\t ]+)?((async|const|unsafe|extern[\t ]+"[^"]+")[\t ]+)?"#,
r"(struct|enum|union|mod|trait|fn|impl|macro_rules!)[<\t ]+[^;]*)$",
);
impl FunctionLines {
pub fn extended(patterns: &[u8]) -> Result<Self, PatternError> {
Self::compile(patterns, Flavor::Extended)
}
pub fn basic(patterns: &[u8]) -> Result<Self, PatternError> {
Self::compile(patterns, Flavor::Basic)
}
#[must_use]
pub fn builtin(driver: &[u8]) -> Option<Self> {
match driver {
b"rust" => rule(RUST, false)
.ok()
.map(|rule| Self(Kind::Patterns(Arc::from([rule])))),
_ => None,
}
}
fn compile(patterns: &[u8], flavor: Flavor) -> Result<Self, PatternError> {
let patterns = std::str::from_utf8(patterns).map_err(|_| PatternError::Encoding)?;
let mut rules = Vec::new();
for line in patterns.split('\n') {
let (negated, expression) = match line.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, line),
};
if expression.is_empty() {
return Err(PatternError::Empty);
}
rules.push(rule(&translate(expression, flavor)?, negated)?);
}
if rules.last().is_some_and(|rule| rule.negated) {
return Err(PatternError::NegatedLast);
}
Ok(Self(Kind::Patterns(rules.into())))
}
pub(super) fn matcher(&self) -> Matcher<'_> {
Matcher {
lines: self,
caches: Vec::new(),
}
}
}
fn rule(expression: &str, negated: bool) -> Result<Rule, PatternError> {
let invalid = |error: Box<dyn std::error::Error + Send + Sync>| PatternError::Invalid(error);
let find = meta::Regex::builder()
.syntax(syntax::Config::new())
.configure(meta::Config::new().nfa_size_limit(Some(NFA_SIZE_LIMIT)))
.build(expression)
.map_err(|error| invalid(Box::new(error)))?;
let longest = pikevm::PikeVM::builder()
.syntax(syntax::Config::new())
.thompson(thompson::Config::new().nfa_size_limit(Some(NFA_SIZE_LIMIT)))
.configure(pikevm::Config::new().match_kind(MatchKind::All))
.build(expression)
.map_err(|error| invalid(Box::new(error)))?;
Ok(Rule {
negated,
find,
longest,
})
}
pub(super) struct Matcher<'a> {
lines: &'a FunctionLines,
caches: Vec<(pikevm::Cache, Captures)>,
}
impl Matcher<'_> {
pub(super) fn name(&mut self, line: &[u8]) -> Option<Range<usize>> {
let span = match &self.lines.0 {
Kind::Default => matches!(line.first(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'$'))
.then_some(0..line.len()),
Kind::Patterns(rules) => {
if self.caches.is_empty() {
self.caches = rules
.iter()
.map(|rule| (rule.longest.create_cache(), rule.longest.create_captures()))
.collect();
}
rules
.iter()
.zip(&mut self.caches)
.find_map(|(rule, (cache, captures))| {
let found = rule.find.search(&Input::new(line))?;
let input = Input::new(line)
.range(found.start()..)
.anchored(Anchored::Yes);
rule.longest.search(cache, &input, captures);
let span = captures.get_group(1).or_else(|| captures.get_group(0))?;
Some((!rule.negated).then(|| span.range()))
})
.flatten()
}
}?;
let end = span.end.min(span.start + 80);
let kept = line.get(span.start..end)?.trim_ascii_end().len();
Some(span.start..span.start + kept)
}
}
fn translate(pattern: &str, flavor: Flavor) -> Result<String, PatternError> {
let chars: Vec<char> = pattern.chars().collect();
let mut out = String::with_capacity(pattern.len() + 8);
let mut index = 0;
let mut start = true;
while let Some(&c) = chars.get(index) {
index += 1;
let at_start = std::mem::replace(&mut start, false);
match (c, flavor) {
('[', _) => index = bracket(&chars, index, &mut out)?,
('\\', _) => {
let next = *chars
.get(index)
.ok_or(PatternError::Unsupported("trailing backslash"))?;
index += 1;
match (next, flavor) {
('(' | '|', Flavor::Basic) => {
out.push(next);
start = true;
}
(')', Flavor::Basic) => out.push(')'),
('{', Flavor::Basic) if !at_start => {
index = interval(&chars, index, flavor, &mut out)
.ok_or(PatternError::Unsupported("malformed interval"))?;
}
('+' | '?', Flavor::Basic) if !at_start => out.push(next),
('<', _) => out.push_str(r"\b{start}"),
('>', _) => out.push_str(r"\b{end}"),
('b' | 'B' | 'w' | 'W' | 's' | 'S', _) => {
out.push('\\');
out.push(next);
}
('`', _) => out.push_str(r"\A"),
('\'', _) => out.push_str(r"\z"),
('1'..='9', _) => return Err(PatternError::Unsupported("back-reference")),
_ => literal(next, &mut out),
}
}
('(' | '|', Flavor::Extended) => {
out.push(c);
start = true;
}
(')', Flavor::Extended) => out.push(')'),
('+' | '?', Flavor::Extended) if !at_start => out.push(c),
('{', Flavor::Extended) if !at_start => {
match interval(&chars, index, flavor, &mut out) {
Some(next) => index = next,
None => literal('{', &mut out),
}
}
('*', _) if !at_start => out.push('*'),
('^', Flavor::Extended) => {
out.push('^');
start = true;
}
('^', Flavor::Basic) if at_start => {
out.push('^');
start = true;
}
('$', Flavor::Extended) => out.push('$'),
('$', Flavor::Basic)
if index == chars.len()
|| matches!(chars.get(index..index + 2), Some(['\\', ')' | '|'])) =>
{
out.push('$');
}
('.', _) => out.push('.'),
_ => literal(c, &mut out),
}
}
Ok(out)
}
fn bracket(chars: &[char], mut index: usize, out: &mut String) -> Result<usize, PatternError> {
out.push('[');
if chars.get(index) == Some(&'^') {
out.push('^');
index += 1;
}
let mut first = true;
loop {
let c = *chars
.get(index)
.ok_or(PatternError::Unsupported("unterminated bracket expression"))?;
if c == ']' && !first {
out.push(']');
return Ok(index + 1);
}
first = false;
if c == '['
&& let Some(&kind @ (':' | '=' | '.')) = chars.get(index + 1)
{
let body = chars.get(index + 2..).unwrap_or_default();
let end = body
.windows(2)
.position(|pair| pair == [kind, ']'])
.ok_or(PatternError::Unsupported("unterminated bracket class"))?;
if kind != ':' {
return Err(PatternError::Unsupported(
"collating element or equivalence class",
));
}
out.push_str("[:");
out.extend(body.iter().take(end));
out.push_str(":]");
index += 2 + end + 2;
continue;
}
literal(c, out);
index += 1;
if chars.get(index) == Some(&'-')
&& let Some(&high) = chars.get(index + 1)
&& high != ']'
{
out.push('-');
literal(high, out);
index += 2;
}
}
}
fn interval(chars: &[char], index: usize, flavor: Flavor, out: &mut String) -> Option<usize> {
let close: &[char] = match flavor {
Flavor::Extended => &['}'],
Flavor::Basic => &['\\', '}'],
};
let rest = chars.get(index..)?;
let length = rest
.iter()
.position(|c| !(c.is_ascii_digit() || *c == ','))?;
let body: String = rest.get(..length)?.iter().collect();
if !rest.get(length..)?.starts_with(close) || body.matches(',').count() > 1 || body.is_empty() {
return None;
}
let (low, high) = body.split_once(',').unwrap_or((&body, &body));
if low.is_empty() && high.is_empty() {
return None;
}
out.push('{');
out.push_str(if low.is_empty() { "0" } else { low });
if body.contains(',') {
out.push(',');
out.push_str(high);
}
out.push('}');
Some(index + length + close.len())
}
fn is_meta(c: char) -> bool {
matches!(
c,
'\\' | '.'
| '+'
| '*'
| '?'
| '('
| ')'
| '|'
| '['
| ']'
| '{'
| '}'
| '^'
| '$'
| '#'
| '&'
| '-'
| '~'
)
}
fn literal(c: char, out: &mut String) {
if is_meta(c) {
out.push('\\');
}
out.push(c);
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::{Flavor, FunctionLines, translate};
fn name(lines: &FunctionLines, line: &str) -> Option<String> {
let range = lines.matcher().name(line.as_bytes())?;
Some(line[range].to_owned())
}
#[rstest]
#[case::bracket_backslash_literal(r"[\t]", Flavor::Extended, r"[\\t]")]
#[case::bracket_leading_close(r"[]a]", Flavor::Extended, r"[\]a]")]
#[case::bracket_class(r"[[:alpha:]_]", Flavor::Extended, r"[[:alpha:]_]")]
#[case::leading_star_literal("*a", Flavor::Extended, r"\*a")]
#[case::invalid_interval_literal("a{x", Flavor::Extended, r"a\{x")]
#[case::interval("a{2,}", Flavor::Extended, "a{2,}")]
#[case::open_low_interval("a{,3}", Flavor::Extended, "a{0,3}")]
#[case::word_start(r"\<fn\>", Flavor::Extended, r"\b{start}fn\b{end}")]
#[case::basic_group(r"\(a\)*", Flavor::Basic, "(a)*")]
#[case::basic_literal_operators("(a)+?|{", Flavor::Basic, r"\(a\)\+\?\|\{")]
#[case::basic_interval(r"a\{2\}", Flavor::Basic, "a{2}")]
#[case::basic_inner_caret_literal("a^b$c$", Flavor::Basic, r"a\^b\$c$")]
fn translates(#[case] pattern: &str, #[case] flavor: Flavor, #[case] expected: &str) {
assert_eq!(translate(pattern, flavor).unwrap(), expected);
}
#[rstest]
#[case::group(r"BEGIN (f[a-z]+)", " BEGIN foo(bar) tail ", Some("foo"))]
#[case::whole_match_from_start(
"BEGIN .*",
" BEGIN foo(bar) tail ",
Some("BEGIN foo(bar) tail")
)]
#[case::leftmost_longest("BEGIN (f|foo)", " BEGIN foo(bar)", Some("foo"))]
#[case::no_match("NOMATCH", "line", None)]
#[case::case_sensitive("bar", "fooBAR", None)]
#[case::end_anchor("end$", "line0 end", Some("end"))]
#[case::first_expression_decides("^ P.*\n!POS\n^ .*", " POS two", Some(" POS two"))]
#[case::negation_rejects("!POS\n^ .*", " POS two", None)]
fn extended_names(#[case] pattern: &str, #[case] line: &str, #[case] expected: Option<&str>) {
let lines = FunctionLines::extended(pattern.as_bytes()).unwrap();
assert_eq!(name(&lines, line).as_deref(), expected);
}
#[test]
fn cuts_to_80_bytes() {
let lines = FunctionLines::extended(b"BEGIN.*").unwrap();
let line = format!("BEGIN {} yyy", "x".repeat(73));
assert_eq!(
name(&lines, &line),
Some(format!("BEGIN {}", "x".repeat(73)))
);
}
#[rstest]
#[case::function("fn foo() {", Some("fn foo() {"))]
#[case::indented(
" pub(crate) fn foo(&self) -> u8 {",
Some("pub(crate) fn foo(&self) -> u8 {")
)]
#[case::restricted("pub(in crate::a) fn foo() {", Some("pub(in crate::a) fn foo() {"))]
#[case::qualifier("pub async fn foo() {", Some("pub async fn foo() {"))]
#[case::extern_abi("extern \"C\" fn foo() {", Some("extern \"C\" fn foo() {"))]
#[case::generic("impl<'a> Foo {", Some("impl<'a> Foo {"))]
#[case::macro_rules("macro_rules! foo {", Some("macro_rules! foo {"))]
#[case::keyword_only("fn ", Some("fn"))]
#[case::two_qualifiers("pub const unsafe fn foo() {", None)]
#[case::extern_without_abi("extern fn foo() {", None)]
#[case::semicolon("struct Foo;", None)]
#[case::declaration("mod foo;", None)]
#[case::attribute("#[test] fn foo() {", None)]
#[case::no_space_after_restriction("pub(crate)fn foo() {", None)]
#[case::brace_after_keyword("impl{", None)]
#[case::other_item("const FOO: u8 = 1;", None)]
fn rust_names(#[case] line: &str, #[case] expected: Option<&str>) {
let lines = FunctionLines::builtin(b"rust").unwrap();
assert_eq!(name(&lines, line).as_deref(), expected);
}
#[rstest]
#[case::negated_last(b"a\n!b")]
#[case::back_reference(br"(a)\1")]
#[case::equivalence_class(b"[[=a=]]")]
#[case::malformed(b"(")]
#[case::not_utf8(b"\xff")]
#[case::trailing_newline(b"^HEADER\n")]
#[case::empty_line(b"^a\n\n^b")]
#[case::empty(b"")]
fn refuses(#[case] pattern: &[u8]) {
assert!(FunctionLines::extended(pattern).is_err());
}
}