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);
}
}
}
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 {
let file_record = FileRecord {
path: parsed.rel_path.clone(),
content_hash: parsed.hash.clone(),
size_bytes: parsed.content.len() as i64,
language: Some(parsed.parse_result.language.clone()),
last_indexed: 0,
};
if let Err(e) = self.db.upsert_file(&file_record, Some(&parsed.compressed)) {
if self.verbose {
eprintln!("Warning: failed to store file {}: {}", parsed.rel_path, e);
}
result.files_failed += 1;
continue;
}
if let Err(e) = self.db.delete_symbols_for_file(&parsed.rel_path) {
if self.verbose {
eprintln!(
"Warning: failed to clear symbols for {}: {}",
parsed.rel_path, e
);
}
result.files_failed += 1;
continue;
}
let id_map = match self.store_symbols(&parsed.rel_path, &parsed.parse_result.symbols) {
Ok(map) => map,
Err(e) => {
if self.verbose {
eprintln!(
"Warning: failed to store symbols for {}: {}",
parsed.rel_path, e
);
}
result.files_failed += 1;
continue;
}
};
if let Err(e) = self.store_edges(&parsed.rel_path, &parsed.parse_result.edges, &id_map)
{
if self.verbose {
eprintln!(
"Warning: failed to store edges for {}: {}",
parsed.rel_path, e
);
}
result.files_failed += 1;
continue;
}
if let Some(ref module) = parsed.parse_result.module {
let mut m = module.clone();
m.file_path = parsed.rel_path.clone();
if let Err(e) = self.db.upsert_module(&m) {
if self.verbose {
eprintln!(
"Warning: failed to store module for {}: {}",
parsed.rel_path, e
);
}
}
}
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);
}
}
}
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)?;
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);
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)?;
}
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()))?;
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()))?;
}
}
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) -> io::Result<Database> {
let ctx_dir = root.join(CTX_DIR);
let db_path = ctx_dir.join(DB_FILE);
if !db_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Database not found. Run 'ctx index' first.\nExpected: {}",
db_path.display()
),
));
}
Database::open(&db_path).map_err(|e| io::Error::other(e.to_string()))
}
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);
}
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);
}
}