use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use scribe_core::{Result, ScribeError};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
const STOPLIST: &[&str] = &[
"README",
"readme",
"index",
"test",
"tests",
"spec",
"specs",
"main",
"app",
"lib",
"src",
"build",
"dist",
"target",
"node_modules",
"package",
"config",
"util",
"utils",
"helper",
"helpers",
"common",
"base",
"core",
"api",
"client",
"server",
"data",
"model",
"view",
];
#[derive(Debug, Clone)]
pub struct IndexConfig {
pub min_token_length: usize,
pub max_token_length: usize,
pub max_patterns: usize,
pub chunk_size: usize,
pub include_stems: bool,
pub include_dirs: bool,
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
min_token_length: 3,
max_token_length: 64,
max_patterns: 10_000,
chunk_size: 64 * 1024, include_stems: true,
include_dirs: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FileToken {
pub text: String,
pub token_type: TokenType,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TokenType {
Basename, Stem, Directory, }
#[derive(Debug)]
pub struct AhoCorasickReferenceIndex {
token_to_files: HashMap<String, Vec<usize>>,
file_tokens: Vec<Vec<FileToken>>,
automaton: Option<AhoCorasick>,
patterns: Vec<String>,
config: IndexConfig,
pub metrics: IndexMetrics,
}
#[derive(Debug, Default, Clone)]
pub struct IndexMetrics {
pub total_files: usize,
pub total_tokens: usize,
pub unique_tokens: usize,
pub automaton_build_ms: u64,
pub bytes_scanned: u64,
pub matches_found: usize,
pub boundary_checks: usize,
pub valid_matches: usize,
}
impl AhoCorasickReferenceIndex {
pub fn new(file_paths: &[impl AsRef<Path>], config: IndexConfig) -> Result<Self> {
let start_time = std::time::Instant::now();
let mut index = Self {
token_to_files: HashMap::new(),
file_tokens: Vec::with_capacity(file_paths.len()),
automaton: None,
patterns: Vec::new(),
config,
metrics: IndexMetrics {
total_files: file_paths.len(),
..Default::default()
},
};
for (file_id, path) in file_paths.iter().enumerate() {
let tokens = index.extract_tokens(path.as_ref());
for token in &tokens {
index
.token_to_files
.entry(token.text.clone())
.or_insert_with(Vec::new)
.push(file_id);
}
index.metrics.total_tokens += tokens.len();
index.file_tokens.push(tokens);
}
index.build_automaton()?;
index.metrics.automaton_build_ms = start_time.elapsed().as_millis() as u64;
Ok(index)
}
fn extract_tokens(&self, path: &Path) -> Vec<FileToken> {
let mut tokens = Vec::new();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if self.is_valid_token(filename) {
tokens.push(FileToken {
text: filename.to_string(),
token_type: TokenType::Basename,
});
}
if self.config.include_stems {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if self.is_valid_token(stem) && stem != filename {
tokens.push(FileToken {
text: stem.to_string(),
token_type: TokenType::Stem,
});
}
}
}
}
if self.config.include_dirs {
if let Some(parent) = path.parent() {
for component in parent.components() {
if let Some(dir_name) = component.as_os_str().to_str() {
if self.is_valid_token(dir_name) {
tokens.push(FileToken {
text: dir_name.to_string(),
token_type: TokenType::Directory,
});
}
}
}
}
}
tokens
}
fn is_valid_token(&self, token: &str) -> bool {
let len = token.len();
if len < self.config.min_token_length || len > self.config.max_token_length {
return false;
}
if STOPLIST.contains(&token) {
return false;
}
if token.chars().all(|c| c.is_ascii_digit()) {
return false;
}
if !token.chars().any(|c| c.is_alphanumeric()) {
return false;
}
true
}
fn build_automaton(&mut self) -> Result<()> {
let mut patterns: Vec<String> = self.token_to_files.keys().cloned().collect();
patterns.sort_by_key(|p| std::cmp::Reverse(self.token_to_files[p].len())); patterns.truncate(self.config.max_patterns);
self.metrics.unique_tokens = patterns.len();
if patterns.is_empty() {
return Ok(());
}
let automaton = AhoCorasickBuilder::new()
.match_kind(MatchKind::Standard) .build(&patterns)
.map_err(|e| {
ScribeError::io(
format!("Failed to build Aho-Corasick automaton: {}", e),
std::io::Error::new(std::io::ErrorKind::Other, e),
)
})?;
self.patterns = patterns;
self.automaton = Some(automaton);
Ok(())
}
pub fn scan_file_for_references<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Vec<usize>> {
let Some(ref automaton) = self.automaton else {
return Ok(Vec::new());
};
let file = File::open(file_path.as_ref()).map_err(|e| {
ScribeError::io(
format!("Failed to open file: {}", file_path.as_ref().display()),
e,
)
})?;
let mut reader = BufReader::new(file);
let mut referenced_files = HashSet::new();
let max_pattern_len = self.patterns.iter().map(|p| p.len()).max().unwrap_or(0);
let overlap = max_pattern_len.saturating_sub(1);
let mut buffer = vec![0u8; self.config.chunk_size + overlap];
let mut carried_bytes = 0;
loop {
let bytes_read = reader
.read(&mut buffer[carried_bytes..])
.map_err(|e| ScribeError::io("Failed to read file chunk".to_string(), e))?;
if bytes_read == 0 {
break; }
let total_bytes = carried_bytes + bytes_read;
let chunk = &buffer[..total_bytes];
self.metrics.bytes_scanned += bytes_read as u64;
for mat in automaton.find_iter(chunk) {
self.metrics.matches_found += 1;
if self.is_boundary_valid(chunk, mat.start(), mat.end()) {
self.metrics.boundary_checks += 1;
self.metrics.valid_matches += 1;
let pattern = &self.patterns[mat.pattern()];
if let Some(file_ids) = self.token_to_files.get(pattern) {
referenced_files.extend(file_ids.iter().copied());
}
}
}
if bytes_read == self.config.chunk_size {
if overlap > 0 && total_bytes > overlap {
buffer.copy_within(total_bytes - overlap.., 0);
carried_bytes = overlap;
} else {
carried_bytes = 0;
}
} else {
break; }
}
Ok(referenced_files.into_iter().collect())
}
fn is_boundary_valid(&self, text: &[u8], start: usize, end: usize) -> bool {
let left_ok = start == 0 || !self.is_identifier_char(text[start - 1]);
let right_ok = end == text.len() || !self.is_identifier_char(text[end]);
left_ok && right_ok
}
fn is_identifier_char(&self, byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'.' || byte == b'/' || byte == b'-'
}
pub fn get_reference_count(&self, file_id: usize) -> usize {
0
}
pub fn metrics(&self) -> &IndexMetrics {
&self.metrics
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_token_extraction() {
let config = IndexConfig::default();
let index = AhoCorasickReferenceIndex {
token_to_files: HashMap::new(),
file_tokens: Vec::new(),
automaton: None,
patterns: Vec::new(),
config,
metrics: IndexMetrics::default(),
};
let path = Path::new("src/auth/user.rs");
let tokens = index.extract_tokens(path);
assert!(tokens
.iter()
.any(|t| t.text == "user.rs" && t.token_type == TokenType::Basename));
assert!(tokens
.iter()
.any(|t| t.text == "user" && t.token_type == TokenType::Stem));
}
#[test]
fn test_stoplist_filtering() {
let config = IndexConfig::default();
let index = AhoCorasickReferenceIndex {
token_to_files: HashMap::new(),
file_tokens: Vec::new(),
automaton: None,
patterns: Vec::new(),
config,
metrics: IndexMetrics::default(),
};
assert!(!index.is_valid_token("README"));
assert!(!index.is_valid_token("test"));
assert!(!index.is_valid_token("123"));
assert!(index.is_valid_token("auth"));
assert!(index.is_valid_token("user"));
}
#[test]
fn test_boundary_detection() {
let mut index = AhoCorasickReferenceIndex {
token_to_files: HashMap::new(),
file_tokens: Vec::new(),
automaton: None,
patterns: Vec::new(),
config: IndexConfig::default(),
metrics: IndexMetrics::default(),
};
let text = b"import user from './user.js'";
assert!(index.is_boundary_valid(text, 7, 11)); assert!(!index.is_boundary_valid(text, 8, 11)); }
#[tokio::test]
async fn test_file_scanning() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file1_path = temp_dir.path().join("auth.rs");
let file2_path = temp_dir.path().join("user.rs");
fs::write(&file1_path, "use user;\nmod user_service;")?;
fs::write(&file2_path, "struct User { name: String }")?;
let config = IndexConfig::default();
let paths = vec![file1_path.clone(), file2_path.clone()];
let mut index = AhoCorasickReferenceIndex::new(&paths, config)?;
let references = index.scan_file_for_references(&file1_path)?;
assert!(references.contains(&1));
Ok(())
}
}