use crate::util::{indent_level, mask_strings};
pub trait FunctionDetectionMarkers {
fn function_markers(&self) -> &[&str];
fn brace_scoped(&self) -> bool;
fn function_syntax(&self) -> FunctionSyntax;
fn line_comments(&self) -> &[&str];
}
macro_rules! detection_markers {
($markers:ty) => {
impl $crate::detection::FunctionDetectionMarkers for $markers {
fn function_markers(&self) -> &[&str] {
self.function_markers
}
fn brace_scoped(&self) -> bool {
self.brace_scoped
}
fn function_syntax(&self) -> $crate::detection::FunctionSyntax {
self.function_syntax
}
fn line_comments(&self) -> &[&str] {
self.line_comments
}
}
};
}
pub(crate) use detection_markers;
#[derive(Clone, Copy, PartialEq)]
pub enum FunctionSyntax {
Loose,
Keyword,
KeywordWithLifetimes,
}
pub struct FunctionBody<'a> {
pub name: String,
pub start_line: usize,
pub code_lines: Vec<(usize, &'a str)>,
}
const CONTROL_KEYWORDS: &[&str] = &[
"if", "for", "while", "switch", "else", "do", "catch", "return", "case",
];
fn is_function_declaration(trimmed: &str, markers: &dyn FunctionDetectionMarkers) -> bool {
if markers
.function_markers()
.iter()
.any(|m| trimmed.contains(m))
{
return true;
}
markers.function_syntax() == FunctionSyntax::Loose && is_c_family_function(trimmed)
}
fn is_c_family_function(trimmed: &str) -> bool {
if trimmed.starts_with('#') {
return false;
}
let paren_pos = match trimmed.find('(') {
Some(p) => p,
None => return false,
};
if !(trimmed.ends_with('{') || trimmed.ends_with(')')) {
return false;
}
if trimmed[..paren_pos].contains('=') {
return false;
}
let first_word = trimmed.split_whitespace().next().unwrap_or("");
let first_word = first_word.trim_start_matches('*');
!CONTROL_KEYWORDS.contains(&first_word)
}
pub fn extract_function_name(trimmed: &str, markers: &dyn FunctionDetectionMarkers) -> String {
let mut function_markers = markers.function_markers().iter();
function_markers
.find_map(|marker| name_after(trimmed, marker))
.or_else(|| name_before_paren(trimmed))
.unwrap_or_else(|| "<anonymous>".to_string())
}
fn name_after(trimmed: &str, marker: &str) -> Option<String> {
let after = &trimmed[trimmed.find(marker)? + marker.len()..];
let name: String = after
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
(!name.is_empty()).then_some(name)
}
fn name_before_paren(trimmed: &str) -> Option<String> {
let before = trimmed[..trimmed.find('(')?].trim();
let name = before.split_whitespace().next_back()?;
let name = name.trim_start_matches('*');
(!name.is_empty()).then(|| name.to_string())
}
fn is_plain_assign(bytes: &[u8], pos: usize) -> bool {
let before = pos.checked_sub(1).map(|p| bytes[p]);
let after = bytes.get(pos + 1).copied();
!before.is_some_and(|b| b"=!<>+-*/%&|^:~".contains(&b))
&& !after.is_some_and(|b| b"=>~".contains(&b))
}
fn may_introduce_body(masked: &str) -> bool {
masked.trim_start().starts_with(')') || masked.trim_end().ends_with('=')
}
fn continues_expression(next: Option<&(usize, &str)>, base_indent: usize) -> bool {
next.is_some_and(|(_, line)| {
indent_level(line) > base_indent || line.trim_start().starts_with('{')
})
}
#[derive(Default)]
struct BodyScan {
brace_depth: isize,
paren_depth: isize,
found_open: bool,
expression_body: bool,
declaration_only: bool,
}
impl BodyScan {
fn read(&mut self, masked: &str, may_introduce: bool) {
let bytes = masked.as_bytes();
for (pos, &ch) in bytes.iter().enumerate() {
match ch {
b'{' => {
self.brace_depth += 1;
self.found_open = true;
}
b'}' => self.brace_depth -= 1,
b'(' => self.paren_depth += 1,
b')' => self.paren_depth -= 1,
b'=' if may_introduce && self.outside_signature() => {
self.expression_body |= is_plain_assign(bytes, pos);
}
_ => {}
}
}
self.declaration_only =
!self.expression_body && self.outside_signature() && masked.trim_end().ends_with(';');
}
fn outside_signature(&self) -> bool {
!self.found_open && self.paren_depth <= 0
}
fn ends(&self, next: Option<&(usize, &str)>, base_indent: usize) -> bool {
if self.declaration_only {
true
} else if self.expression_body {
self.brace_depth <= 0 && !continues_expression(next, base_indent)
} else {
self.found_open && self.brace_depth == 0
}
}
}
fn find_function_body<'a>(
code_lines: &[(usize, &'a str)],
start: usize,
line_comments: &[&str],
lifetimes: bool,
) -> (Vec<(usize, &'a str)>, usize) {
let mut scan = BodyScan::default();
let base_indent = indent_level(code_lines[start].1);
let mut func_code_lines: Vec<(usize, &str)> = Vec::new();
let mut j = start;
while j < code_lines.len() {
let (jidx, jline) = code_lines[j];
func_code_lines.push((jidx, jline));
let masked = if lifetimes {
mask_strings(&without_lifetimes(jline), line_comments)
} else {
mask_strings(jline, line_comments)
};
scan.read(&masked, j == start || may_introduce_body(&masked));
if scan.ends(code_lines.get(j + 1), base_indent) {
break;
}
j += 1;
}
if scan.declaration_only {
func_code_lines.clear();
}
(func_code_lines, j)
}
fn without_lifetimes(line: &str) -> String {
let mut bytes = line.as_bytes().to_vec();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'\'' {
i += 1;
continue;
}
let rest = &bytes[i + 1..];
let name = rest
.iter()
.take_while(|b| b.is_ascii_alphanumeric() || **b == b'_')
.count();
if name > 0 && rest.get(name) != Some(&b'\'') {
bytes[i] = b' ';
i += 1 + name;
} else {
i += 1 + literal_len(rest);
}
}
String::from_utf8(bytes).unwrap_or_else(|_| line.to_string())
}
fn literal_len(rest: &[u8]) -> usize {
let (skip, reach) = if rest.first() == Some(&b'\\') {
(2, 12)
} else {
(1, 5)
};
let close = rest.iter().take(reach).skip(skip).position(|b| *b == b'\'');
close.map_or(0, |at| skip + at + 1)
}
pub fn detect_function_bodies<'a>(
all_lines: &[String],
code_lines: &[(usize, &'a str)],
markers: &dyn FunctionDetectionMarkers,
) -> Vec<FunctionBody<'a>> {
if markers.brace_scoped() {
detect_with(
code_lines,
markers,
|trimmed| is_function_declaration(trimmed, markers),
|start| {
let lifetimes = markers.function_syntax() == FunctionSyntax::KeywordWithLifetimes;
let (body, end) =
find_function_body(code_lines, start, markers.line_comments(), lifetimes);
(body, end + 1)
},
)
} else {
detect_with(
code_lines,
markers,
|trimmed| {
let mut function_markers = markers.function_markers().iter();
function_markers.any(|m| trimmed.starts_with(m))
},
|start| indented_body(all_lines, code_lines, start),
)
}
}
fn detect_with<'a>(
code_lines: &[(usize, &'a str)],
markers: &dyn FunctionDetectionMarkers,
is_declaration: impl Fn(&str) -> bool,
body_at: impl Fn(usize) -> (Vec<(usize, &'a str)>, usize),
) -> Vec<FunctionBody<'a>> {
let mut functions = Vec::new();
let mut i = 0;
while i < code_lines.len() {
let (line_idx, line) = code_lines[i];
let trimmed = line.trim();
if !is_declaration(trimmed) {
i += 1;
continue;
}
let (body, next) = body_at(i);
if !body.is_empty() {
functions.push(FunctionBody {
name: extract_function_name(trimmed, markers),
start_line: line_idx + 1,
code_lines: body,
});
}
i = next;
}
functions
}
fn indented_body<'a>(
all_lines: &[String],
code_lines: &[(usize, &'a str)],
start: usize,
) -> (Vec<(usize, &'a str)>, usize) {
let base_indent = indent_level(code_lines[start].1);
let len = code_lines[start + 1..]
.iter()
.take_while(|(idx, line)| {
indent_level(&all_lines[*idx]) > base_indent || line.trim().is_empty()
})
.count();
let end = start + 1 + len;
(code_lines[start..end].to_vec(), end)
}
#[cfg(test)]
#[path = "detection_test.rs"]
mod tests;