use std::collections::{HashMap, VecDeque};
use crate::cli::language_analyzer::find_brace_balanced_end;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LineSpan {
pub start: u32,
pub end: u32,
}
impl LineSpan {
pub const UNKNOWN: Self = Self { start: 0, end: 0 };
#[must_use]
pub fn line_count(&self) -> u32 {
if self.start == 0 || self.end < self.start {
0
} else {
self.end - self.start + 1
}
}
#[must_use]
pub fn is_known(&self) -> bool {
self.start > 0
}
}
#[derive(Debug, Clone, Default)]
pub struct FunctionSpans {
by_name: HashMap<String, VecDeque<LineSpan>>,
total_lines: u32,
}
impl FunctionSpans {
#[must_use]
pub fn from_source(content: &str) -> Self {
let lines: Vec<&str> = content.lines().collect();
let mut by_name: HashMap<String, VecDeque<LineSpan>> = HashMap::new();
for (idx, line) in lines.iter().enumerate() {
let trimmed = line.trim();
let Some(name) = extract_fn_name(trimmed) else {
continue;
};
let end = definition_end(&lines, idx, trimmed);
by_name.entry(name).or_default().push_back(LineSpan {
start: idx as u32 + 1,
end: end as u32 + 1,
});
}
Self {
by_name,
total_lines: lines.len() as u32,
}
}
#[must_use]
pub fn total_lines(&self) -> u32 {
self.total_lines
}
pub fn take(&mut self, name: &str) -> Option<LineSpan> {
self.by_name.get_mut(name)?.pop_front()
}
#[must_use]
pub fn peek(&self, name: &str) -> Option<LineSpan> {
self.by_name.get(name)?.front().copied()
}
}
fn definition_end(lines: &[&str], start: usize, trimmed: &str) -> usize {
if is_bodyless_declaration(trimmed) {
return start;
}
find_brace_balanced_end(lines, start, true)
}
fn is_bodyless_declaration(trimmed: &str) -> bool {
match (trimmed.find('{'), trimmed.find(';')) {
(Some(brace), Some(semi)) => semi < brace,
(None, Some(_)) => true,
_ => false,
}
}
#[must_use]
pub fn extract_fn_name(trimmed: &str) -> Option<String> {
let fn_pos = trimmed.find("fn ")?;
let before = trimmed.get(..fn_pos).unwrap_or_default();
if before.contains("//") || before.contains("/*") {
return None;
}
let after = trimmed.get(fn_pos + 3..).unwrap_or_default();
let name_end = after
.find(|c: char| c == '(' || c == '<' || c.is_whitespace())
.unwrap_or(after.len());
let name = after.get(..name_end).unwrap_or_default().trim();
if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
Some(name.to_string())
} else {
None
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spans_are_real_extents_not_padded_guesses() {
let src = "fn only_one() -> i32 { 42 }\n";
let spans = FunctionSpans::from_source(src);
assert_eq!(spans.total_lines(), 1);
assert_eq!(spans.peek("only_one"), Some(LineSpan { start: 1, end: 1 }));
}
#[test]
fn last_function_ends_at_its_closing_brace() {
let src = concat!(
"pub fn cc_six(a: i32) -> i32 {\n",
" let mut t = 0;\n",
" if a > 1 { t += 1; }\n",
" if a > 2 { t += 1; }\n",
" if a > 3 { t += 1; }\n",
" if a > 4 { t += 1; }\n",
" if a > 5 { t += 1; }\n",
" t\n",
"}\n",
"\n",
"pub fn cc_one(a: i32) -> i32 {\n",
" a + 1\n",
"}\n",
);
let spans = FunctionSpans::from_source(src);
assert_eq!(spans.total_lines(), 13);
assert_eq!(spans.peek("cc_six"), Some(LineSpan { start: 1, end: 9 }));
assert_eq!(spans.peek("cc_one"), Some(LineSpan { start: 11, end: 13 }));
}
#[test]
fn duplicate_names_are_returned_in_textual_order() {
let src = "impl A {\n fn go(&self) {}\n}\nfn go() {\n ()\n}\n";
let mut spans = FunctionSpans::from_source(src);
assert_eq!(spans.take("go"), Some(LineSpan { start: 2, end: 2 }));
assert_eq!(spans.take("go"), Some(LineSpan { start: 4, end: 6 }));
assert_eq!(spans.take("go"), None);
}
#[test]
fn bodyless_trait_method_does_not_swallow_the_file() {
let src = "trait T {\n fn required(&self) -> u8;\n}\n\nfn after() {}\n";
let spans = FunctionSpans::from_source(src);
assert_eq!(spans.peek("required"), Some(LineSpan { start: 2, end: 2 }));
assert_eq!(spans.peek("after"), Some(LineSpan { start: 5, end: 5 }));
}
#[test]
fn doc_comments_are_not_functions() {
let src = "/// fn 2: helper\n/// fn 3: other\nfn real() {}\n";
let spans = FunctionSpans::from_source(src);
assert_eq!(spans.peek("2"), None);
assert_eq!(spans.peek("3"), None);
assert_eq!(spans.peek("real"), Some(LineSpan { start: 3, end: 3 }));
}
#[test]
fn unknown_span_reports_zero_lines_not_one() {
assert_eq!(LineSpan::UNKNOWN.line_count(), 0);
assert!(!LineSpan::UNKNOWN.is_known());
assert!(LineSpan { start: 4, end: 4 }.is_known());
assert_eq!(LineSpan { start: 4, end: 4 }.line_count(), 1);
}
#[test]
fn empty_source_yields_empty_index() {
let spans = FunctionSpans::from_source("");
assert_eq!(spans.total_lines(), 0);
assert_eq!(spans.peek("anything"), None);
}
#[test]
fn extract_fn_name_handles_qualifiers_and_rejects_non_functions() {
assert_eq!(extract_fn_name("fn foo() {}"), Some("foo".to_string()));
assert_eq!(extract_fn_name("pub fn bar() {}"), Some("bar".to_string()));
assert_eq!(
extract_fn_name("async fn baz() {}"),
Some("baz".to_string())
);
assert_eq!(
extract_fn_name("pub async fn qux() {}"),
Some("qux".to_string())
);
assert_eq!(
extract_fn_name("fn generic<T>() {}"),
Some("generic".to_string())
);
assert_eq!(extract_fn_name("let x = 42;"), None);
assert_eq!(extract_fn_name("// fn commented() {}"), None);
}
}