use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use flate2::write::GzEncoder;
use flate2::Compression;
use rayon::prelude::*;
use sha2::{Digest, Sha256};
use crate::db::{Database, FileRecord, ParseResult};
use crate::parser::CodeParser;
use crate::walker::{discover_files, WalkerConfig};
fn db_error<E: std::fmt::Display>(e: E) -> io::Error {
io::Error::other(e.to_string())
}
fn extract_parent_name(parent_id: Option<&str>) -> Option<&str> {
parent_id.and_then(|p| {
let parts: Vec<&str> = p.split("::").collect();
if parts.len() >= 2 {
Some(parts[parts.len() - 1])
} else {
None
}
})
}
fn rewrite_id(
id: &str,
rel_path: &str,
id_map: &std::collections::HashMap<String, String>,
) -> String {
if let Some(new_id) = id_map.get(id) {
new_id.clone()
} else if let Some((_, rest)) = id.split_once("::") {
format!("{}::{}", rel_path, rest)
} else {
id.to_string()
}
}
pub const CTX_DIR: &str = ".ctx";
pub const DB_FILE: &str = "codebase.sqlite";
#[derive(Debug)]
pub struct IndexResult {
pub files_indexed: usize,
pub files_skipped: usize,
pub files_failed: usize,
pub symbols_extracted: usize,
pub edges_extracted: usize,
pub elapsed_ms: u128,
}
struct ParsedFile {
rel_path: String,
content: String,
hash: String,
compressed: Vec<u8>,
parse_result: ParseResult,
}
pub struct Indexer {
pub db: Database,
parser: CodeParser,
root: PathBuf,
verbose: bool,
walker_config: WalkerConfig,
}
impl Indexer {
pub fn with_config(
root: &Path,
verbose: bool,
walker_config: WalkerConfig,
) -> io::Result<Self> {
let root = root.canonicalize()?;
let ctx_dir = root.join(CTX_DIR);
if !ctx_dir.exists() {
fs::create_dir_all(&ctx_dir)?;
}
let db_path = ctx_dir.join(DB_FILE);
let db = Database::open(&db_path).map_err(|e| io::Error::other(e.to_string()))?;
Ok(Self {
db,
parser: CodeParser::new(),
root,
verbose,
walker_config,
})
}
#[allow(dead_code)]
pub fn new_in_memory(root: &Path) -> io::Result<Self> {
let root = root.canonicalize()?;
let db = Database::open_in_memory().map_err(|e| io::Error::other(e.to_string()))?;
Ok(Self {
db,
parser: CodeParser::new(),
root,
verbose: false,
walker_config: WalkerConfig::default(),
})
}
pub fn index(&mut self) -> io::Result<IndexResult> {
let start = Instant::now();
let entries = discover_files(&self.root, &self.walker_config)?;
let mut result = IndexResult {
files_indexed: 0,
files_skipped: 0,
files_failed: 0,
symbols_extracted: 0,
edges_extracted: 0,
elapsed_ms: 0,
};
let mut seen_files: Vec<String> = Vec::new();
for entry in &entries {
let rel_path = entry.relative_path.to_string_lossy().replace('\\', "/");
if !self.parser.is_supported(&entry.relative_path) {
result.files_skipped += 1;
continue;
}
let content = match fs::read_to_string(&entry.absolute_path) {
Ok(c) => c,
Err(e) => {
if self.verbose {
eprintln!("Warning: could not read {}: {}", rel_path, e);
}
result.files_failed += 1;
continue;
}
};
let hash = compute_hash(&content);
let needs_update = self
.db
.needs_update(&rel_path, &hash)
.map_err(|e| io::Error::other(e.to_string()))?;
if !needs_update {
seen_files.push(rel_path.clone());
result.files_skipped += 1;
continue;
}
if self.verbose {
eprintln!("Indexing: {}", rel_path);
}
let parse_result = match self.parser.parse(&entry.absolute_path, &content) {
Some(r) => r,
None => {
if self.verbose {
eprintln!("Warning: failed to parse {}", rel_path);
}
result.files_failed += 1;
continue;
}
};
if let Err(e) = self.store_file(&rel_path, &content, &hash, &parse_result) {
if self.verbose {
eprintln!("Warning: failed to store {}: {}", rel_path, e);
}
result.files_failed += 1;
continue;
}
seen_files.push(rel_path);
result.files_indexed += 1;
result.symbols_extracted += parse_result.symbols.len();
result.edges_extracted += parse_result.edges.len();
}
if let Err(e) = self.cleanup_deleted_files(&seen_files) {
if self.verbose {
eprintln!("Warning: cleanup failed: {}", e);
}
}
match self.db.resolve_edge_targets() {
Ok(resolved) => {
if self.verbose && resolved > 0 {
eprintln!("Resolved {} cross-file edge targets", resolved);
}
}
Err(e) => {
if self.verbose {
eprintln!("Warning: edge resolution failed: {}", e);
}
}
}
if result.files_indexed > 0 {
if let Err(e) = self.db.clear_symbol_rank() {
if self.verbose {
eprintln!("Warning: failed to clear rank cache: {}", e);
}
}
}
result.elapsed_ms = start.elapsed().as_millis();
Ok(result)
}
pub fn index_parallel(&mut self) -> io::Result<IndexResult> {
let start = Instant::now();
let entries = discover_files(&self.root, &self.walker_config)?;
let files_skipped = AtomicUsize::new(0);
let files_failed = AtomicUsize::new(0);
let files_to_index: Vec<_> = entries
.iter()
.filter_map(|entry| {
let rel_path = entry.relative_path.to_string_lossy().replace('\\', "/");
if !CodeParser::is_supported_static(&entry.relative_path) {
files_skipped.fetch_add(1, Ordering::Relaxed);
return None;
}
let content = match fs::read_to_string(&entry.absolute_path) {
Ok(c) => c,
Err(_) => {
files_failed.fetch_add(1, Ordering::Relaxed);
return None;
}
};
let hash = compute_hash(&content);
match self.db.needs_update(&rel_path, &hash) {
Ok(true) => Some((entry.clone(), rel_path, content, hash)),
Ok(false) => {
files_skipped.fetch_add(1, Ordering::Relaxed);
None
}
Err(_) => {
files_failed.fetch_add(1, Ordering::Relaxed);
None
}
}
})
.collect();
let verbose = self.verbose;
let parsed_files: Vec<ParsedFile> = files_to_index
.par_iter()
.filter_map(|(entry, rel_path, content, hash)| {
let mut parser = CodeParser::new();
if verbose {
eprintln!("Indexing: {}", rel_path);
}
let parse_result = parser.parse(&entry.absolute_path, content)?;
let compressed = compress_source(content);
Some(ParsedFile {
rel_path: rel_path.clone(),
content: content.clone(),
hash: hash.clone(),
compressed,
parse_result,
})
})
.collect();
let mut result = IndexResult {
files_indexed: 0,
files_skipped: files_skipped.load(Ordering::Relaxed),
files_failed: files_failed.load(Ordering::Relaxed),
symbols_extracted: 0,
edges_extracted: 0,
elapsed_ms: 0,
};
let seen_files: Vec<String> = entries
.iter()
.filter_map(|e| {
let rel = e.relative_path.to_string_lossy().replace('\\', "/");
if CodeParser::is_supported_static(&e.relative_path) {
Some(rel)
} else {
None
}
})
.collect();
for parsed in &parsed_files {
if let Err(e) = self.store_file_impl(
&parsed.rel_path,
&parsed.content,
&parsed.hash,
&parsed.compressed,
&parsed.parse_result,
) {
if self.verbose {
eprintln!("Warning: failed to store {}: {}", parsed.rel_path, e);
}
result.files_failed += 1;
continue;
}
result.files_indexed += 1;
result.symbols_extracted += parsed.parse_result.symbols.len();
result.edges_extracted += parsed.parse_result.edges.len();
}
if let Err(e) = self.cleanup_deleted_files(&seen_files) {
if self.verbose {
eprintln!("Warning: cleanup failed: {}", e);
}
}
match self.db.resolve_edge_targets() {
Ok(resolved) => {
if self.verbose && resolved > 0 {
eprintln!("Resolved {} cross-file edge targets", resolved);
}
}
Err(e) => {
if self.verbose {
eprintln!("Warning: edge resolution failed: {}", e);
}
}
}
if result.files_indexed > 0 {
if let Err(e) = self.db.clear_symbol_rank() {
if self.verbose {
eprintln!("Warning: failed to clear rank cache: {}", e);
}
}
}
result.elapsed_ms = start.elapsed().as_millis();
Ok(result)
}
pub fn index_file(&mut self, path: &Path) -> io::Result<bool> {
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
self.root.join(path)
};
let rel_path = abs_path
.strip_prefix(&self.root)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Path not in root"))?
.to_string_lossy()
.replace('\\', "/");
if !self.parser.is_supported(path) {
return Ok(false);
}
let content = fs::read_to_string(&abs_path)?;
let hash = compute_hash(&content);
let needs_update = self
.db
.needs_update(&rel_path, &hash)
.map_err(|e| io::Error::other(e.to_string()))?;
if !needs_update {
return Ok(false);
}
let parse_result = self
.parser
.parse(&abs_path, &content)
.ok_or_else(|| io::Error::other("Parse failed"))?;
self.store_file(&rel_path, &content, &hash, &parse_result)?;
self.db.clear_symbol_rank().map_err(db_error)?;
Ok(true)
}
fn store_file(
&self,
rel_path: &str,
content: &str,
hash: &str,
parse_result: &crate::db::ParseResult,
) -> io::Result<()> {
let compressed = compress_source(content);
self.store_file_impl(rel_path, content, hash, &compressed, parse_result)
}
fn store_file_impl(
&self,
rel_path: &str,
content: &str,
hash: &str,
compressed: &[u8],
parse_result: &crate::db::ParseResult,
) -> io::Result<()> {
let file_record = FileRecord {
path: rel_path.to_string(),
content_hash: hash.to_string(),
size_bytes: content.len() as i64,
language: Some(parse_result.language.clone()),
last_indexed: 0,
};
self.db
.upsert_file(&file_record, Some(compressed))
.map_err(db_error)?;
self.db
.delete_symbols_for_file(rel_path)
.map_err(db_error)?;
let id_map = self.store_symbols(rel_path, &parse_result.symbols)?;
self.store_edges(rel_path, &parse_result.edges, &id_map)?;
if let Some(ref module) = parse_result.module {
let mut m = module.clone();
m.file_path = rel_path.to_string();
self.db.upsert_module(&m).map_err(db_error)?;
}
let lang = crate::parser::Language::from_path(Path::new(rel_path));
let fingerprints = crate::fingerprint::file_fingerprints(
lang,
content,
rel_path,
&parse_result.symbols,
&id_map,
);
self.db
.insert_fingerprints_batch(&fingerprints)
.map_err(db_error)?;
if self.verbose {
eprintln!(
"Fingerprinted {} functions in {}",
fingerprints.len(),
rel_path
);
}
Ok(())
}
fn store_symbols(
&self,
rel_path: &str,
symbols: &[crate::db::Symbol],
) -> io::Result<std::collections::HashMap<String, String>> {
let mut id_map = std::collections::HashMap::new();
for symbol in symbols {
let parent_name = extract_parent_name(symbol.parent_id.as_deref());
let new_id = crate::db::Symbol::make_id_with_line(
rel_path,
&symbol.name,
parent_name,
symbol.line_start,
);
id_map.insert(symbol.id.clone(), new_id.clone());
let mut sym = symbol.clone();
sym.file_path = rel_path.to_string();
sym.id = new_id;
if symbol.parent_id.is_some() {
if let Some(pn) = parent_name {
sym.parent_id = Some(crate::db::Symbol::make_id(rel_path, pn, None));
}
}
self.db.insert_symbol(&sym).map_err(db_error)?;
}
Ok(id_map)
}
fn store_edges(
&self,
rel_path: &str,
edges: &[crate::db::Edge],
id_map: &std::collections::HashMap<String, String>,
) -> io::Result<()> {
for edge in edges {
let mut e = edge.clone();
e.source_id = rewrite_id(&e.source_id, rel_path, id_map);
if let Some(ref target_id) = edge.target_id {
e.target_id = Some(rewrite_id(target_id, rel_path, id_map));
}
self.db.insert_edge(&e).map_err(db_error)?;
}
Ok(())
}
fn cleanup_deleted_files(&self, seen_files: &[String]) -> io::Result<()> {
let indexed_files = self
.db
.get_indexed_files()
.map_err(|e| io::Error::other(e.to_string()))?;
let mut deleted_any = false;
for file in indexed_files {
if !seen_files.contains(&file) {
if self.verbose {
eprintln!("Removing: {}", file);
}
self.db
.delete_file(&file)
.map_err(|e| io::Error::other(e.to_string()))?;
deleted_any = true;
}
}
if deleted_any {
self.db.clear_symbol_rank().map_err(db_error)?;
}
Ok(())
}
pub fn database(&self) -> &Database {
&self.db
}
}
fn compute_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let result = hasher.finalize();
format!("{:x}", result)
}
fn compress_source(content: &str) -> Vec<u8> {
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(content.as_bytes()).ok();
encoder.finish().unwrap_or_default()
}
pub fn open_database(root: &Path) -> crate::error::Result<Database> {
let ctx_dir = root.join(CTX_DIR);
let db_path = ctx_dir.join(DB_FILE);
if !db_path.exists() {
return Err(crate::error::CtxError::IndexNotFound(format!(
"run 'ctx index' first (expected {})",
db_path.display()
)));
}
Database::open(&db_path)
}
pub mod watch {
use std::path::Path;
use std::sync::mpsc::channel;
use std::time::Duration;
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use super::Indexer;
use crate::parser::Language;
use crate::walker::{FileFilter, WalkerConfig};
pub fn watch_and_index(
root: &Path,
verbose: bool,
walker_config: WalkerConfig,
) -> std::io::Result<()> {
let root = root.canonicalize()?;
let file_filter = FileFilter::new(&root, &walker_config)?;
eprintln!("Performing initial index...");
let mut indexer = Indexer::with_config(&root, verbose, walker_config)?;
let result = indexer.index()?;
eprintln!(
"Initial index complete: {} files, {} symbols",
result.files_indexed + result.files_skipped,
result.symbols_extracted
);
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)
.map_err(|e| std::io::Error::other(e.to_string()))?;
debouncer
.watcher()
.watch(&root, RecursiveMode::Recursive)
.map_err(|e| std::io::Error::other(e.to_string()))?;
eprintln!("\nWatching for changes... (press Ctrl+C to stop)");
loop {
match rx.recv() {
Ok(Ok(events)) => {
let mut reindex_needed = false;
for event in events {
if matches!(
event.kind,
DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
) {
let path = &event.path;
if path.starts_with(root.join(super::CTX_DIR)) {
continue;
}
let lang = Language::from_path(path);
if lang == Language::Unknown {
continue;
}
if !file_filter.should_include(path) {
continue;
}
if !path.exists() {
let rel_path = path
.strip_prefix(&root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
if verbose {
eprintln!("Removed: {}", rel_path);
}
if let Err(e) = indexer.db.delete_file(&rel_path) {
eprintln!("Warning: failed to remove {}: {}", rel_path, e);
} else if let Err(e) = indexer.db.clear_symbol_rank() {
eprintln!("Warning: failed to clear rank cache: {}", e);
}
continue;
}
match indexer.index_file(path) {
Ok(true) => {
let rel_path = path
.strip_prefix(&root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.display().to_string());
if let Err(e) = indexer.db.resolve_edge_targets() {
if verbose {
eprintln!("Warning: edge resolution failed: {}", e);
}
}
if verbose {
eprintln!("Reindexed: {}", rel_path);
} else {
eprint!(".");
}
reindex_needed = true;
}
Ok(false) => {
}
Err(e) => {
let rel_path = path
.strip_prefix(&root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.display().to_string());
eprintln!("\nWarning: failed to index {}: {}", rel_path, e);
}
}
}
}
if reindex_needed && !verbose {
eprintln!(); }
}
Ok(Err(error)) => {
eprintln!("Watch error: {:?}", error);
}
Err(e) => {
eprintln!("Channel error: {}", e);
break;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_compute_hash() {
let hash1 = compute_hash("hello");
let hash2 = compute_hash("hello");
let hash3 = compute_hash("world");
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
}
#[test]
fn test_compress_source() {
let content = "fn main() { println!(\"Hello, world!\"); }";
let compressed = compress_source(content);
assert!(!compressed.is_empty());
assert!(compressed.len() < content.len() * 2); }
#[test]
fn test_index_simple_project() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let src_dir = root.join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(
src_dir.join("main.rs"),
r#"
/// Main entry point
fn main() {
println!("Hello, world!");
}
/// A helper function
fn helper() -> i32 {
42
}
"#,
)
.unwrap();
let mut indexer = Indexer::new_in_memory(root).unwrap();
let result = indexer.index().unwrap();
assert_eq!(result.files_indexed, 1);
assert!(result.symbols_extracted >= 2);
let stats = indexer.database().get_stats().unwrap();
assert_eq!(stats.files, 1);
assert!(stats.symbols >= 2);
}
const DUPE_A: &str = r#"
pub fn process_orders(items: &[i64]) -> i64 {
let mut total = 0;
for item in items {
if *item > 10 {
total += *item * 2;
} else {
total += *item + 1;
}
}
println!("processed the batch: {}", total);
total
}
"#;
const DUPE_B: &str = r#"
pub fn sum_invoices(entries: &[i64]) -> i64 {
let mut acc = 0;
for entry in entries {
if *entry > 99 {
acc += *entry * 7;
} else {
acc += *entry + 3;
}
}
println!("done with invoices: {}", acc);
acc
}
"#;
const UNRELATED: &str = r#"
pub fn render_table(headers: &[String], widths: &[usize]) -> String {
let mut out = String::new();
for (header, width) in headers.iter().zip(widths.iter()) {
out.push('|');
out.push_str(header);
while out.len() < *width {
out.push(' ');
}
}
out.push('\n');
for width in widths {
out.push_str(&"-".repeat(*width));
out.push('+');
}
out
}
"#;
fn write_fixture(root: &std::path::Path) {
let src = root.join("src");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("a.rs"), DUPE_A).unwrap();
fs::write(src.join("b.rs"), DUPE_B).unwrap();
fs::write(src.join("c.rs"), UNRELATED).unwrap();
}
#[test]
fn test_near_duplicates_end_to_end() {
let temp = TempDir::new().unwrap();
write_fixture(temp.path());
let mut indexer = Indexer::new_in_memory(temp.path()).unwrap();
indexer.index().unwrap();
let pairs =
crate::fingerprint::find_near_duplicates(indexer.database(), 0.85, 50, None).unwrap();
assert_eq!(pairs.len(), 1, "expected exactly the renamed-copy pair");
let pair = &pairs[0];
let names = [pair.a.name.as_str(), pair.b.name.as_str()];
assert!(names.contains(&"process_orders"), "names: {:?}", names);
assert!(names.contains(&"sum_invoices"), "names: {:?}", names);
assert!(pair.similarity >= 0.85);
assert!(pair.token_count_a >= 50);
assert!(pair.token_count_b >= 50);
assert!(pair.a.id < pair.b.id);
let changed: std::collections::HashSet<String> =
std::iter::once("src/b.rs".to_string()).collect();
let filtered =
crate::fingerprint::find_near_duplicates(indexer.database(), 0.85, 50, Some(&changed))
.unwrap();
assert_eq!(filtered.len(), 1);
let unrelated_change: std::collections::HashSet<String> =
std::iter::once("src/c.rs".to_string()).collect();
let filtered = crate::fingerprint::find_near_duplicates(
indexer.database(),
0.85,
50,
Some(&unrelated_change),
)
.unwrap();
assert!(filtered.is_empty());
let none = crate::fingerprint::find_near_duplicates(indexer.database(), 0.85, 10_000, None)
.unwrap();
assert!(none.is_empty());
}
#[test]
fn test_parallel_indexing_also_fingerprints() {
let temp = TempDir::new().unwrap();
write_fixture(temp.path());
let mut indexer = Indexer::new_in_memory(temp.path()).unwrap();
indexer.index_parallel().unwrap();
let fingerprints = indexer.database().get_fingerprints(0).unwrap();
assert_eq!(fingerprints.len(), 3, "one fingerprint per function");
}
#[test]
fn test_incremental_reindex_preserves_untouched_fingerprints() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_fixture(root);
let mut indexer = Indexer::with_config(root, false, WalkerConfig::default()).unwrap();
indexer.index().unwrap();
let before = indexer.database().get_fingerprints(0).unwrap();
let b_before: Vec<_> = before
.iter()
.filter(|f| f.file_path == "src/b.rs")
.cloned()
.collect();
assert!(!b_before.is_empty());
fs::write(
root.join("src/a.rs"),
DUPE_A.replace("process_orders", "process_orders_v2"),
)
.unwrap();
let result = indexer.index().unwrap();
assert_eq!(result.files_indexed, 1, "only the edited file re-indexes");
let after = indexer.database().get_fingerprints(0).unwrap();
let b_after: Vec<_> = after
.iter()
.filter(|f| f.file_path == "src/b.rs")
.cloned()
.collect();
assert_eq!(
b_before, b_after,
"untouched fingerprints must be byte-identical"
);
assert!(after
.iter()
.any(|f| f.symbol_id.contains("process_orders_v2")));
assert!(!after
.iter()
.any(|f| f.symbol_id.contains("process_orders@")));
}
#[test]
fn test_solidity_files_produce_no_fingerprints() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join("Token.sol"),
"pragma solidity ^0.8.0;\ncontract Token {\n function transfer(address to, uint256 amount) public returns (bool) {\n return amount > 0;\n }\n}\n",
)
.unwrap();
let mut indexer = Indexer::new_in_memory(temp.path()).unwrap();
let result = indexer.index().unwrap();
assert_eq!(result.files_indexed, 1);
assert!(indexer.database().get_fingerprints(0).unwrap().is_empty());
}
}