Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,74 @@
[package]
license = "Apache-2.0"
name = "xai-codebase-graph"
version = "0.1.0"
edition.workspace = true
description = "High-performance code graph generation using tree-sitter queries"
[features]
default = []
[[bin]]
name = "code-graph"
path = "src/bin/code_graph.rs"
[[bin]]
name = "bench_index"
path = "src/bin/bench_index.rs"
[[bin]]
name = "bench_file_listing"
path = "src/bin/bench_file_listing.rs"
[dependencies]
# Path utilities
dunce = { workspace = true }
xai-grok-paths = { path = "../xai-grok-paths" }
# Graph generation
petgraph = { workspace = true }
serde = { workspace = true, features = ["rc"] }
serde_json = { workspace = true, features = ["preserve_order"] }
# Parallel processing
rayon = { workspace = true }
crossbeam = { workspace = true }
num_cpus = { workspace = true }
# Directory walking with gitignore support
ignore = { workspace = true }
# Git repository access
git2 = { version = "0.20", default-features = false }
# Fast allocators for multi-threaded workloads
mimalloc = "0.1"
# Fast hash for better HashMap performance
ahash = { version = "0.8", features = ["serde"] }
# Additional hash utilities for StringInterner
hashbrown = "0.15"
nohash-hasher = "0.2"
rustc-hash = { workspace = true }
smallvec = { workspace = true }
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
# Async runtime (for oneshot channels, sync feature for blocking_recv)
tokio = { workspace = true, features = ["sync"] }
# Logging
tracing = { workspace = true }
# Lazy static initialization
once_cell = { workspace = true }
# Concurrent hash map for deduplication registries
dashmap = { workspace = true }
# Parsing, keeping it here to avoid pollution
tree-sitter = "0.25.10"
tree-sitter-rust = "0.24.0"
tree-sitter-typescript = "0.23.2"
tree-sitter-python = "0.25.0"
tree-sitter-go = "0.25.0"
tree-sitter-javascript = "0.25.0"
# Unix process checking for lock staleness detection
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[dev-dependencies]
tempfile = "3"
[lints]
workspace = true

View file

@ -0,0 +1,233 @@
//! Benchmark for comparing git CLI vs git2 file listing.
//!
//! Usage: cargo run --bin bench_file_listing --release -- [path] [cli|git2|git2-index|both]
use std::path::Path;
use std::process::Command;
use std::time::Instant;
use git2::{Repository, StatusOptions};
use xai_codebase_graph::LanguageRegistry;
fn main() {
let args: Vec<String> = std::env::args().collect();
let path_str = if let Some(p) = args.get(1) {
p.clone()
} else if let Ok(p) = std::env::var("BENCH_REPO_ROOT").or_else(|_| std::env::var("XAI_ROOT")) {
p
} else {
eprintln!("Usage: bench_file_listing <path> [cli|git2|git2-index|both]");
eprintln!("Or set BENCH_REPO_ROOT to a large checkout to bench against");
std::process::exit(1);
};
let mode = args.get(2).map(|s| s.as_str()).unwrap_or("both");
let root_path = Path::new(&path_str);
let registry = LanguageRegistry::new();
match mode {
"cli" => {
let start = Instant::now();
let files = collect_files_cli(root_path, &registry);
let elapsed = start.elapsed();
println!("CLI: {} files in {:?}", files.len(), elapsed);
}
"git2" => {
let start = Instant::now();
let files = collect_files_git2(root_path, &registry);
let elapsed = start.elapsed();
println!("git2: {} files in {:?}", files.len(), elapsed);
}
"git2-index" => {
let start = Instant::now();
let files = collect_files_git2_index_only(root_path, &registry);
let elapsed = start.elapsed();
println!("git2 (index only): {} files in {:?}", files.len(), elapsed);
}
_ => {
// Run all three methods multiple times for comparison
println!("Benchmarking file listing for: {}", root_path.display());
println!();
let iterations = 5;
// Warm up
let _ = collect_files_cli(root_path, &registry);
let _ = collect_files_git2(root_path, &registry);
let _ = collect_files_git2_index_only(root_path, &registry);
// CLI benchmark
let mut cli_times = Vec::with_capacity(iterations);
let mut cli_count = 0;
for _ in 0..iterations {
let start = Instant::now();
let files = collect_files_cli(root_path, &registry);
cli_times.push(start.elapsed());
cli_count = files.len();
}
// git2 benchmark (with untracked)
let mut git2_times = Vec::with_capacity(iterations);
let mut git2_count = 0;
for _ in 0..iterations {
let start = Instant::now();
let files = collect_files_git2(root_path, &registry);
git2_times.push(start.elapsed());
git2_count = files.len();
}
// git2 index-only benchmark
let mut git2_index_times = Vec::with_capacity(iterations);
let mut git2_index_count = 0;
for _ in 0..iterations {
let start = Instant::now();
let files = collect_files_git2_index_only(root_path, &registry);
git2_index_times.push(start.elapsed());
git2_index_count = files.len();
}
// Print results
let cli_avg = cli_times.iter().sum::<std::time::Duration>() / iterations as u32;
let git2_avg = git2_times.iter().sum::<std::time::Duration>() / iterations as u32;
let git2_index_avg =
git2_index_times.iter().sum::<std::time::Duration>() / iterations as u32;
println!("Results ({} iterations):", iterations);
println!(
" CLI: {} files, avg {:?}",
cli_count, cli_avg
);
println!(
" git2 (+ untracked): {} files, avg {:?}",
git2_count, git2_avg
);
println!(
" git2 (index only): {} files, avg {:?}",
git2_index_count, git2_index_avg
);
println!();
let speedup = cli_avg.as_secs_f64() / git2_index_avg.as_secs_f64();
if speedup > 1.0 {
println!("git2 (index only) is {:.2}x faster than CLI", speedup);
} else {
println!("CLI is {:.2}x faster than git2 (index only)", 1.0 / speedup);
}
}
}
}
/// Collect files using git CLI (original approach)
fn collect_files_cli(root_path: &Path, registry: &LanguageRegistry) -> Vec<std::path::PathBuf> {
// Get tracked files
let tracked_output = Command::new("git")
.args(["ls-files"])
.current_dir(root_path)
.output();
let tracked_output = match tracked_output {
Ok(o) if o.status.success() => o,
_ => return vec![],
};
// Get untracked files
let untracked_output = Command::new("git")
.args(["ls-files", "--others", "--exclude-standard"])
.current_dir(root_path)
.output()
.ok();
let tracked_str = String::from_utf8_lossy(&tracked_output.stdout);
let mut files: Vec<std::path::PathBuf> = tracked_str
.lines()
.filter(|line| registry.is_supported(Path::new(line)))
.map(|line| root_path.join(line))
.collect();
if let Some(output) = untracked_output
&& output.status.success()
{
let untracked_str = String::from_utf8_lossy(&output.stdout);
let untracked_files: Vec<std::path::PathBuf> = untracked_str
.lines()
.filter(|line| registry.is_supported(Path::new(line)))
.map(|line| root_path.join(line))
.collect();
files.extend(untracked_files);
}
files
}
/// Collect files using git2 (new approach)
fn collect_files_git2(root_path: &Path, registry: &LanguageRegistry) -> Vec<std::path::PathBuf> {
let repo = match Repository::open(root_path) {
Ok(r) => r,
Err(_) => return vec![],
};
let index = match repo.index() {
Ok(i) => i,
Err(_) => return vec![],
};
let mut files: Vec<std::path::PathBuf> = index
.iter()
.filter_map(|entry| {
let path_str = std::str::from_utf8(&entry.path).ok()?;
if registry.is_supported(Path::new(path_str)) {
Some(root_path.join(path_str))
} else {
None
}
})
.collect();
// Get untracked files
let mut status_opts = StatusOptions::new();
status_opts
.include_untracked(true)
.recurse_untracked_dirs(true)
.exclude_submodules(true);
if let Ok(statuses) = repo.statuses(Some(&mut status_opts)) {
for status_entry in statuses.iter() {
if status_entry.status().is_wt_new()
&& let Some(path_str) = status_entry.path()
&& registry.is_supported(Path::new(path_str))
{
files.push(root_path.join(path_str));
}
}
}
files
}
/// Collect files using git2 index only (tracked files only, no untracked)
fn collect_files_git2_index_only(
root_path: &Path,
registry: &LanguageRegistry,
) -> Vec<std::path::PathBuf> {
let repo = match Repository::open(root_path) {
Ok(r) => r,
Err(_) => return vec![],
};
let index = match repo.index() {
Ok(i) => i,
Err(_) => return vec![],
};
index
.iter()
.filter_map(|entry| {
let path_str = std::str::from_utf8(&entry.path).ok()?;
if registry.is_supported(Path::new(path_str)) {
Some(root_path.join(path_str))
} else {
None
}
})
.collect()
}

View file

@ -0,0 +1,64 @@
//! Benchmark binary for index building.
use std::path::Path;
use std::time::Instant;
use xai_codebase_graph::{IndexBuilder, LanguageRegistry};
// Use mimalloc for faster allocation in multi-threaded workloads
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
fn main() {
let args: Vec<String> = std::env::args().collect();
let path = if let Some(p) = args.get(1) {
p.clone()
} else if let Ok(p) = std::env::var("BENCH_REPO_ROOT").or_else(|_| std::env::var("XAI_ROOT")) {
p
} else {
eprintln!("Usage: bench_index <path>");
eprintln!("Or set BENCH_REPO_ROOT to a large checkout to bench against");
std::process::exit(1);
};
// First, verify all queries compile
println!("Verifying query compilation...");
let registry = LanguageRegistry::new();
for ext in &["ts", "tsx", "js", "jsx", "rs", "go", "py"] {
match registry.for_extension(ext) {
Some(config) => match config.compile_query() {
Ok(query) => {
println!(" .{}: OK ({} patterns)", ext, query.pattern_count());
}
Err(e) => {
println!(" .{}: FAILED - {:?}", ext, e);
}
},
None => println!(" .{}: NOT SUPPORTED", ext),
}
}
println!();
let root_path = Path::new(&path);
println!("Building index for: {}", root_path.display());
let start = Instant::now();
let index = IndexBuilder::new()
.build(root_path)
.expect("Failed to build index");
let elapsed = start.elapsed();
let (file_count, defs, refs) = index.stats();
println!("Files indexed: {}", file_count);
println!(
"Indexed {} definitions, {} references in {:?}",
defs, refs, elapsed
);
println!("Aliases: {}", index.alias_count());
println!(
"Files/sec: {:.0}",
file_count as f64 / elapsed.as_secs_f64()
);
}

View file

@ -0,0 +1,394 @@
//! CLI tool for code graph navigation.
//!
//! Provides go-to-definition and go-to-references functionality.
//!
//! # Usage
//!
//! ```bash
//! # Build the index for a repository
//! code-graph index /path/to/repo
//!
//! # Build the index with custom cache location
//! code-graph index /path/to/repo --cache /path/to/cache.bin
//!
//! # Go to definition (by position)
//! code-graph definition /path/to/repo --file src/main.rs --row 10 --col 15
//!
//! # Go to definition (by symbol name)
//! code-graph definition /path/to/repo --symbol MyStruct
//!
//! # Go to references (by position)
//! code-graph references /path/to/repo --file src/main.rs --row 10 --col 15
//!
//! # Go to references (by symbol name)
//! code-graph references /path/to/repo --symbol MyStruct
//!
//! # Show index statistics
//! code-graph stats /path/to/repo
//! ```
use std::path::{Path, PathBuf};
use std::time::Instant;
use clap::{Parser, Subcommand};
use xai_codebase_graph::{
IndexBuilder, Navigator, ScopeGraphIndex, get_cache_path, load_index, save_index,
};
// Use mimalloc for faster allocation
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Parser)]
#[command(name = "code-graph")]
#[command(author, version, about = "High-performance code navigation tool", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Build or rebuild the index for a repository
Index {
/// Path to the repository
path: PathBuf,
/// Custom cache file path (default: <repo>/.goto_index.bin)
#[arg(short, long)]
cache: Option<PathBuf>,
/// Force rebuild even if cache exists
#[arg(short, long)]
force: bool,
/// Number of threads to use
#[arg(short, long)]
threads: Option<usize>,
},
/// Go to definition for a symbol
Definition {
/// Path to the repository
path: PathBuf,
/// Custom cache file path (default: <repo>/.goto_index.bin)
#[arg(long)]
cache: Option<PathBuf>,
/// File path (for position-based lookup)
#[arg(short, long)]
file: Option<PathBuf>,
/// Row number (1-indexed, for position-based lookup)
#[arg(short, long)]
row: Option<usize>,
/// Column number (1-indexed, for position-based lookup)
#[arg(short, long)]
col: Option<usize>,
/// Symbol name (for direct lookup)
#[arg(short, long)]
symbol: Option<String>,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Go to references for a symbol
References {
/// Path to the repository
path: PathBuf,
/// Custom cache file path (default: <repo>/.goto_index.bin)
#[arg(long)]
cache: Option<PathBuf>,
/// File path (for position-based lookup)
#[arg(short, long)]
file: Option<PathBuf>,
/// Row number (1-indexed, for position-based lookup)
#[arg(short, long)]
row: Option<usize>,
/// Column number (1-indexed, for position-based lookup)
#[arg(short, long)]
col: Option<usize>,
/// Symbol name (for direct lookup)
#[arg(short, long)]
symbol: Option<String>,
/// Include definition in results
#[arg(long)]
include_definition: bool,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Show index statistics
Stats {
/// Path to the repository
path: PathBuf,
/// Custom cache file path (default: <repo>/.goto_index.bin)
#[arg(long)]
cache: Option<PathBuf>,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Index {
path,
cache,
force,
threads,
} => {
cmd_index(&path, cache.as_deref(), force, threads);
}
Commands::Definition {
path,
cache,
file,
row,
col,
symbol,
json,
} => {
cmd_definition(&path, cache.as_deref(), file, row, col, symbol, json);
}
Commands::References {
path,
cache,
file,
row,
col,
symbol,
include_definition,
json,
} => {
cmd_references(
&path,
cache.as_deref(),
file,
row,
col,
symbol,
include_definition,
json,
);
}
Commands::Stats { path, cache } => {
cmd_stats(&path, cache.as_deref());
}
}
}
/// Get the effective cache path - use custom if provided, otherwise default.
fn effective_cache_path(repo_path: &Path, custom_cache: Option<&Path>) -> PathBuf {
custom_cache
.map(|p| p.to_path_buf())
.unwrap_or_else(|| get_cache_path(repo_path))
}
/// Load index from cache or build if necessary.
fn load_or_build_index(repo_path: &Path, cache_path: &Path) -> ScopeGraphIndex {
if let Ok(index) = load_index(cache_path) {
println!("Loaded index from cache: {}", cache_path.display());
return index;
}
println!("Building index for: {}", repo_path.display());
let start = Instant::now();
let index = IndexBuilder::new()
.build(repo_path)
.expect("Failed to build index");
let elapsed = start.elapsed();
let (files, defs, refs) = index.stats();
println!(
"Built index: {} files, {} defs, {} refs in {:?}",
files, defs, refs, elapsed
);
// Save to cache
if let Err(e) = save_index(cache_path, &index) {
println!("Warning: Failed to save cache: {}", e);
} else {
println!("Saved cache to: {}", cache_path.display());
}
index
}
fn cmd_index(path: &Path, custom_cache: Option<&Path>, _force: bool, threads: Option<usize>) {
let cache_path = effective_cache_path(path, custom_cache);
println!("Building index for: {}", path.display());
let start = Instant::now();
let mut builder = IndexBuilder::new();
if let Some(t) = threads {
builder = builder.with_threads(t);
}
let index = builder.build(path).expect("Failed to build index");
let elapsed = start.elapsed();
let (files, defs, refs) = index.stats();
println!("Index built successfully!");
println!(" Files indexed: {}", files);
println!(" Definitions: {}", defs);
println!(" References: {}", refs);
println!(" Aliases: {}", index.alias_count());
println!(" Time: {:?}", elapsed);
// Always save when explicitly indexing
if let Err(e) = save_index(&cache_path, &index) {
println!("Error saving cache: {}", e);
std::process::exit(1);
} else {
println!(" Cache saved: {}", cache_path.display());
}
}
fn cmd_definition(
repo_path: &Path,
custom_cache: Option<&Path>,
file: Option<PathBuf>,
row: Option<usize>,
col: Option<usize>,
symbol: Option<String>,
json: bool,
) {
let cache_path = effective_cache_path(repo_path, custom_cache);
let index = load_or_build_index(repo_path, &cache_path);
let navigator = Navigator::new(index);
let result = match (file, row, col, symbol) {
// Position-based lookup
(Some(file_path), Some(r), Some(c), _) => {
let abs_path = if file_path.is_absolute() {
file_path
} else {
repo_path.join(&file_path)
};
match navigator.goto_definition(&abs_path, r, c) {
Ok(r) => r,
Err(e) => {
println!("Error: {}", e);
std::process::exit(1);
}
}
}
// Symbol-based lookup
(_, _, _, Some(sym)) => navigator.goto_definition_by_name(&sym, None),
_ => {
println!("Error: Must provide either --file, --row, --col OR --symbol");
std::process::exit(1);
}
};
if json {
print_json(&result);
} else {
println!("Symbol: {}", result.symbol);
println!("Definitions ({}):", result.locations.len());
for loc in &result.locations {
println!(" {}:{}", loc.path, loc.line);
}
}
}
fn cmd_references(
repo_path: &Path,
custom_cache: Option<&Path>,
file: Option<PathBuf>,
row: Option<usize>,
col: Option<usize>,
symbol: Option<String>,
include_definition: bool,
json: bool,
) {
let cache_path = effective_cache_path(repo_path, custom_cache);
let index = load_or_build_index(repo_path, &cache_path);
let navigator = Navigator::new(index);
let result = match (file, row, col, symbol) {
// Position-based lookup
(Some(file_path), Some(r), Some(c), _) => {
let abs_path = if file_path.is_absolute() {
file_path
} else {
repo_path.join(&file_path)
};
match navigator.goto_references(&abs_path, r, c, include_definition) {
Ok(r) => r,
Err(e) => {
println!("Error: {}", e);
std::process::exit(1);
}
}
}
// Symbol-based lookup
(_, _, _, Some(sym)) => navigator.goto_references_by_name(&sym, None, include_definition),
_ => {
println!("Error: Must provide either --file, --row, --col OR --symbol");
std::process::exit(1);
}
};
if json {
print_json(&result);
} else {
println!("Symbol: {}", result.symbol);
println!("References ({}):", result.locations.len());
for loc in &result.locations {
if let Some(sym) = &loc.symbol {
println!(" {}:{} (as {})", loc.path, loc.line, sym);
} else {
println!(" {}:{}", loc.path, loc.line);
}
}
}
}
fn cmd_stats(path: &Path, custom_cache: Option<&Path>) {
let cache_path = effective_cache_path(path, custom_cache);
let index = load_or_build_index(path, &cache_path);
let (files, defs, refs) = index.stats();
println!("Index Statistics for: {}", path.display());
println!(" Cache location: {}", cache_path.display());
println!(" Files indexed: {}", files);
println!(" Definitions: {}", defs);
println!(" References: {}", refs);
println!(" Aliases: {}", index.alias_count());
// Top symbols by reference count
let ref_counts = index.top_referenced_symbols(10);
println!("\nTop 10 most referenced symbols:");
for (name, count) in &ref_counts {
println!(" {:6} {}", count, name);
}
}
fn print_json(result: &xai_codebase_graph::NavigationResult) {
use serde_json::json;
let locations: Vec<_> = result
.locations
.iter()
.map(|loc| {
json!({
"path": &loc.path,
"line": loc.line,
"symbol": loc.symbol,
})
})
.collect();
let output = json!({
"symbol": result.symbol,
"locations": locations,
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,413 @@
//! Arena-based string interner for memory-efficient string deduplication.
//!
//! This module provides a string interner that stores all strings in a single
//! contiguous buffer, minimizing allocations and improving cache locality.
//! It uses hash-based lookup for O(1) interning operations.
//!
//! # Design
//!
//! The interner uses a two-level lookup approach:
//! 1. **Primary lookup**: HashMap from 64-bit hash -> list of StringIds with that hash
//! 2. **Collision resolution**: When hashes collide, actual string content is compared
//!
//! This gives O(1) average case for both `intern()` and `get_id()` operations.
//!
//! # Example
//!
//! ```
//! use xai_codebase_graph::interner::StringInterner;
//!
//! let mut interner = StringInterner::new();
//!
//! let id1 = interner.intern("hello");
//! let id2 = interner.intern("world");
//! let id3 = interner.intern("hello"); // Returns same id as id1
//!
//! assert_eq!(id1, id3);
//! assert_ne!(id1, id2);
//! assert_eq!(interner.get(id1), Some("hello"));
//! ```
use std::hash::{Hash, Hasher};
use hashbrown::HashMap;
use nohash_hasher::BuildNoHashHasher;
use rustc_hash::FxHasher;
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
/// Type alias for HashMap with u64 keys that are already hashed.
/// Uses NoHashHasher since keys don't need re-hashing.
type U64NoHashMap<V> = HashMap<u64, V, BuildNoHashHasher<u64>>;
/// A compact identifier for an interned string.
/// Using u32 allows up to 4 billion unique strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StringId(u32);
impl StringId {
/// Create a new StringId from a raw u32 value.
#[inline]
pub const fn new(id: u32) -> Self {
Self(id)
}
/// Get the raw u32 value.
#[inline]
pub const fn as_u32(self) -> u32 {
self.0
}
}
/// Arena-based string interner for efficient string deduplication.
///
/// Stores all strings in a single contiguous buffer to minimize allocations
/// and improve cache locality. Uses a hash-based lookup for O(1) interning.
///
/// The interner stores arbitrary byte sequences, supporting paths and strings
/// that may not be valid UTF-8.
#[derive(Debug, Clone)]
pub struct StringInterner {
/// Contiguous storage for all interned byte strings
arena: Vec<u8>,
/// Maps hash -> StringId(s). Most buckets have exactly one entry.
/// Using SmallVec<[StringId; 1]> optimizes for the common case of no collisions.
/// Uses NoHashHasher since keys are already hashed.
lookup: U64NoHashMap<SmallVec<[StringId; 1]>>,
/// Maps StringId to (start, len) in arena
offsets: Vec<(u32, u16)>,
}
impl Default for StringInterner {
fn default() -> Self {
Self::new()
}
}
impl StringInterner {
/// Create a new empty interner.
pub fn new() -> Self {
Self {
arena: Vec::new(),
lookup: U64NoHashMap::default(),
offsets: Vec::new(),
}
}
/// Create an interner with pre-allocated capacity.
///
/// # Arguments
/// * `string_bytes` - Estimated total bytes for all strings
/// * `num_strings` - Estimated number of unique strings
pub fn with_capacity(string_bytes: usize, num_strings: usize) -> Self {
Self {
arena: Vec::with_capacity(string_bytes),
lookup: U64NoHashMap::with_capacity_and_hasher(
num_strings,
BuildNoHashHasher::default(),
),
offsets: Vec::with_capacity(num_strings),
}
}
/// Intern a byte string, returning its StringId.
/// If the string is already interned, returns the existing id.
///
/// # Complexity
/// O(1) average case, O(k) worst case where k is the number of
/// hash collisions (typically 0 or 1).
pub fn intern_bytes(&mut self, s: &[u8]) -> StringId {
let hash = Self::hash_bytes(s);
// Check if already interned
if let Some(ids) = self.lookup.get(&hash) {
for &id in ids {
if self.get_bytes(id) == Some(s) {
return id;
}
}
}
// Not found, add new
let start = self.arena.len() as u32;
let len = s.len() as u16;
self.arena.extend_from_slice(s);
let id = StringId::new(self.offsets.len() as u32);
self.offsets.push((start, len));
// Add to lookup
self.lookup.entry(hash).or_default().push(id);
id
}
/// Intern a UTF-8 string. Convenience wrapper around `intern_bytes`.
#[inline]
pub fn intern(&mut self, s: &str) -> StringId {
self.intern_bytes(s.as_bytes())
}
/// Get the StringId for a byte string without interning it.
/// Returns None if the string is not in the interner.
///
/// # Complexity
/// O(1) average case.
pub fn get_bytes_id(&self, s: &[u8]) -> Option<StringId> {
let hash = Self::hash_bytes(s);
if let Some(ids) = self.lookup.get(&hash) {
for &id in ids {
if self.get_bytes(id) == Some(s) {
return Some(id);
}
}
}
None
}
/// Get the StringId for a UTF-8 string without interning it.
#[inline]
pub fn get_id(&self, s: &str) -> Option<StringId> {
self.get_bytes_id(s.as_bytes())
}
/// Get the raw bytes for a StringId.
///
/// # Complexity
/// O(1)
pub fn get_bytes(&self, id: StringId) -> Option<&[u8]> {
let (start, len) = *self.offsets.get(id.0 as usize)?;
self.arena
.get(start as usize..(start as usize + len as usize))
}
/// Get the string for a StringId, if it's valid UTF-8.
///
/// # Complexity
/// O(1)
pub fn get(&self, id: StringId) -> Option<&str> {
self.get_bytes(id).and_then(|b| std::str::from_utf8(b).ok())
}
/// Get the string for a StringId, with lossy UTF-8 conversion.
/// Invalid UTF-8 sequences are replaced with the replacement character.
pub fn get_lossy(&self, id: StringId) -> Option<std::borrow::Cow<'_, str>> {
self.get_bytes(id).map(String::from_utf8_lossy)
}
/// Number of interned strings.
#[inline]
pub fn len(&self) -> usize {
self.offsets.len()
}
/// Check if the interner is empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.offsets.is_empty()
}
/// Total bytes used by the arena.
#[inline]
pub fn arena_bytes(&self) -> usize {
self.arena.len()
}
/// Compute FxHash of a byte slice.
#[inline]
fn hash_bytes(s: &[u8]) -> u64 {
let mut hasher = FxHasher::default();
s.hash(&mut hasher);
hasher.finish()
}
/// Iterate over all strings with their IDs (only valid UTF-8).
pub fn iter(&self) -> impl Iterator<Item = (StringId, &str)> {
self.offsets
.iter()
.enumerate()
.filter_map(|(idx, &(start, len))| {
let bytes = self
.arena
.get(start as usize..(start as usize + len as usize))?;
let s = std::str::from_utf8(bytes).ok()?;
Some((StringId::new(idx as u32), s))
})
}
/// Iterate over all byte strings with their IDs.
pub fn iter_bytes(&self) -> impl Iterator<Item = (StringId, &[u8])> {
self.offsets
.iter()
.enumerate()
.filter_map(|(idx, &(start, len))| {
let bytes = self
.arena
.get(start as usize..(start as usize + len as usize))?;
Some((StringId::new(idx as u32), bytes))
})
}
/// Clear the interner, removing all strings but keeping allocated capacity.
pub fn clear(&mut self) {
self.arena.clear();
self.lookup.clear();
self.offsets.clear();
}
/// Get the internal arena for serialization purposes.
pub fn arena(&self) -> &[u8] {
&self.arena
}
/// Get the internal offsets for serialization purposes.
pub fn offsets(&self) -> &[(u32, u16)] {
&self.offsets
}
/// Release over-allocated capacity in the arena and offsets buffers.
///
/// After a bulk build the arena and offsets Vecs may hold up to 2× their
/// actual content due to doubling growth. Calling this reclaims that
/// wasted heap. The lookup table is intentionally left unshrunk because
/// it benefits from load-factor headroom.
///
/// This is an internal maintenance hook called by `ScopeGraphIndex::compact()`.
pub(crate) fn shrink_to_fit(&mut self) {
self.arena.shrink_to_fit();
self.offsets.shrink_to_fit();
}
/// Reconstruct an interner from serialized data.
///
/// This rebuilds the lookup table from the arena and offsets.
pub fn from_parts(arena: Vec<u8>, offsets: Vec<(u32, u16)>) -> Self {
let mut lookup: U64NoHashMap<SmallVec<[StringId; 1]>> =
U64NoHashMap::with_capacity_and_hasher(offsets.len(), BuildNoHashHasher::default());
for (idx, &(start, len)) in offsets.iter().enumerate() {
if let Some(bytes) = arena.get(start as usize..(start as usize + len as usize)) {
let hash = Self::hash_bytes(bytes);
let id = StringId::new(idx as u32);
lookup.entry(hash).or_default().push(id);
}
}
Self {
arena,
lookup,
offsets,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_interning() {
let mut interner = StringInterner::new();
let id1 = interner.intern("src");
let id2 = interner.intern("lib");
let id3 = interner.intern("src"); // duplicate
assert_eq!(id1, id3);
assert_ne!(id1, id2);
assert_eq!(interner.get(id1), Some("src"));
assert_eq!(interner.get(id2), Some("lib"));
assert_eq!(interner.len(), 2);
}
#[test]
fn test_get_id() {
let mut interner = StringInterner::new();
let id_src = interner.intern("src");
let id_lib = interner.intern("lib");
assert_eq!(interner.get_id("src"), Some(id_src));
assert_eq!(interner.get_id("lib"), Some(id_lib));
assert_eq!(interner.get_id("nonexistent"), None);
// get_id should not modify the interner
assert_eq!(interner.len(), 2);
}
#[test]
fn test_bytes_interning() {
let mut interner = StringInterner::new();
// Valid UTF-8
let id1 = interner.intern_bytes(b"hello");
assert_eq!(interner.get(id1), Some("hello"));
// Invalid UTF-8
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
let id2 = interner.intern_bytes(invalid_utf8);
assert_eq!(interner.get(id2), None); // Not valid UTF-8
assert_eq!(interner.get_bytes(id2), Some(invalid_utf8));
// Duplicate bytes return same ID
let id3 = interner.intern_bytes(invalid_utf8);
assert_eq!(id2, id3);
}
#[test]
fn test_many_strings() {
let mut interner = StringInterner::new();
let count = 10_000;
let mut ids = Vec::with_capacity(count);
for i in 0..count {
let s = format!("string_{}", i);
ids.push(interner.intern(&s));
}
assert_eq!(interner.len(), count);
// Verify all strings can be looked up
for (i, &id) in ids.iter().enumerate() {
let s = format!("string_{}", i);
assert_eq!(interner.get_id(&s), Some(id));
assert_eq!(interner.get(id), Some(s.as_str()));
}
}
#[test]
fn test_from_parts() {
let mut interner = StringInterner::new();
interner.intern("hello");
interner.intern("world");
interner.intern("foo");
let arena = interner.arena().to_vec();
let offsets = interner.offsets().to_vec();
let restored = StringInterner::from_parts(arena, offsets);
assert_eq!(restored.len(), 3);
assert_eq!(restored.get_id("hello"), Some(StringId::new(0)));
assert_eq!(restored.get_id("world"), Some(StringId::new(1)));
assert_eq!(restored.get_id("foo"), Some(StringId::new(2)));
}
#[test]
fn test_clear() {
let mut interner = StringInterner::new();
interner.intern("hello");
interner.intern("world");
assert_eq!(interner.len(), 2);
interner.clear();
assert_eq!(interner.len(), 0);
assert!(interner.is_empty());
assert_eq!(interner.get_id("hello"), None);
}
}

View file

@ -0,0 +1,73 @@
use crate::languages::types::TSLanguageConfig;
pub fn golang() -> TSLanguageConfig {
TSLanguageConfig::new(
vec!["Go".to_owned(), "go".to_owned()],
vec!["go".to_owned()],
vec![vec![
"function".to_owned(),
"type".to_owned(),
"struct".to_owned(),
"interface".to_owned(),
"const".to_owned(),
"var".to_owned(),
"package".to_owned(),
]],
r#"
; Function definitions
(function_declaration
name: (identifier) @name.definition.function) @definition.function
; Method definitions
(method_declaration
name: (field_identifier) @name.definition.method) @definition.method
; Type definitions (struct, interface, etc.)
(type_declaration
(type_spec
name: (type_identifier) @name.definition.type)) @definition.type
; Const declarations
(const_declaration
(const_spec
name: (identifier) @name.definition.const)) @definition.const
; Var declarations
(var_declaration
(var_spec
name: (identifier) @name.definition.var)) @definition.var
; ============ REFERENCES ============
; Function calls
(call_expression
function: (identifier) @name.reference.call) @reference.call
; Method calls
(call_expression
function: (selector_expression
field: (field_identifier) @name.reference.call)) @reference.call
; Type references
(type_identifier) @name.reference.type
; Package references in qualified names
(qualified_type
package: (package_identifier) @name.reference.package
name: (type_identifier) @name.reference.type)
; ============ IMPORTS ============
; import "package"
(import_spec
path: (interpreted_string_literal) @name.reference.import)
; import alias "package"
(import_spec
name: (package_identifier) @alias.name
path: (interpreted_string_literal) @alias.original)
"#
.to_owned(),
|| tree_sitter_go::LANGUAGE.into(),
)
}

View file

@ -0,0 +1,94 @@
//! JavaScript/JSX language configuration.
use crate::languages::types::TSLanguageConfig;
pub fn js_lang() -> TSLanguageConfig {
TSLanguageConfig::new(
vec![
"JavaScript".to_owned(),
"javascript".to_owned(),
"js".to_owned(),
"jsx".to_owned(),
],
vec!["js".to_owned(), "jsx".to_owned()],
vec![vec![
"function".to_owned(),
"class".to_owned(),
"variable".to_owned(),
"const".to_owned(),
"let".to_owned(),
]],
r#"
; Class definitions
(class_declaration
name: (identifier) @name.definition.class) @definition.class
; Function definitions
(function_declaration
name: (identifier) @name.definition.function) @definition.function
; Arrow function with variable
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.function
value: (arrow_function))) @definition.function
; Method definitions
(method_definition
name: (property_identifier) @name.definition.method) @definition.method
; Variable declarations
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.variable)) @definition.variable
; Var declarations
(variable_declaration
(variable_declarator
name: (identifier) @name.definition.variable)) @definition.variable
; ============ REFERENCES ============
; Function calls
(call_expression
function: (identifier) @name.reference.call) @reference.call
; Method calls
(call_expression
function: (member_expression
property: (property_identifier) @name.reference.call)) @reference.call
; JSX element names
(jsx_opening_element
name: (identifier) @name.reference.jsx)
(jsx_self_closing_element
name: (identifier) @name.reference.jsx)
; ============ IMPORTS ============
; Named imports: import { Foo } from 'bar'
(import_specifier
name: (identifier) @name.reference.import)
; Default import: import Foo from 'bar'
(import_clause
(identifier) @name.reference.import)
; Import alias: import { Foo as Bar } from 'bar'
(import_specifier
name: (identifier) @alias.original
alias: (identifier) @alias.name)
; Named exports: export { Foo }
(export_specifier
name: (identifier) @name.reference.export)
; Array element identifiers: [foo, bar] (e.g., React useCallback/useEffect dependency arrays)
(array
(identifier) @name.reference.variable)
"#
.to_owned(),
|| tree_sitter_javascript::LANGUAGE.into(),
)
}

View file

@ -0,0 +1,146 @@
mod golang;
mod javascript;
mod python;
mod rust;
mod ts;
pub mod types;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;
pub use golang::golang;
pub use javascript::js_lang;
pub use python::python_lang;
pub use rust::rust_lang;
pub use ts::ts_lang;
pub use types::TSLanguageConfig;
/// Registry of all supported languages.
///
/// Provides lookup by extension and language ID, and can check if two
/// extensions belong to the same language family.
pub struct LanguageRegistry {
/// All registered language configs.
configs: Vec<Arc<TSLanguageConfig>>,
/// Mapping from file extension to language config.
by_extension: HashMap<String, Arc<TSLanguageConfig>>,
/// Mapping from language ID to language config.
by_id: HashMap<String, Arc<TSLanguageConfig>>,
}
impl LanguageRegistry {
/// Create a new language registry with all supported languages.
pub fn new() -> Self {
let configs: Vec<Arc<TSLanguageConfig>> = vec![
Arc::new(rust_lang()),
Arc::new(ts_lang()),
Arc::new(js_lang()),
Arc::new(golang()),
Arc::new(python_lang()),
];
let mut by_extension = HashMap::new();
let mut by_id = HashMap::new();
for config in &configs {
for ext in config.file_extensions() {
by_extension.insert(ext.clone(), Arc::clone(config));
}
for id in config.language_ids() {
by_id.insert(id.clone(), Arc::clone(config));
}
}
Self {
configs,
by_extension,
by_id,
}
}
/// Get a language config by file extension.
pub fn for_extension(&self, ext: &str) -> Option<Arc<TSLanguageConfig>> {
self.by_extension.get(ext).cloned()
}
/// Get a language config by language ID.
pub fn for_id(&self, id: &str) -> Option<Arc<TSLanguageConfig>> {
self.by_id.get(id).cloned()
}
/// Get a language config for a file path.
///
/// Extracts the extension from the path and looks up the config.
pub fn for_file_path(&self, path: impl AsRef<Path>) -> Option<Arc<TSLanguageConfig>> {
let path = path.as_ref();
let ext = path.extension()?.to_str()?;
self.for_extension(ext)
}
/// Check if a file path is supported (has a supported extension).
pub fn is_supported(&self, path: impl AsRef<Path>) -> bool {
self.for_file_path(path).is_some()
}
/// Get all supported file extensions.
pub fn supported_extensions(&self) -> Vec<&str> {
self.by_extension.keys().map(|s| s.as_str()).collect()
}
/// Get all language configs.
pub fn all_configs(&self) -> &[Arc<TSLanguageConfig>] {
&self.configs
}
/// Check if two file extensions belong to the same language.
///
/// Returns true if both extensions are registered under the same language config.
pub fn extensions_same_language(&self, ext1: &str, ext2: &str) -> bool {
if ext1 == ext2 {
return true;
}
match (self.by_extension.get(ext1), self.by_extension.get(ext2)) {
(Some(config1), Some(config2)) => {
// Compare by primary language ID (they point to the same config)
config1.primary_language_id() == config2.primary_language_id()
}
_ => false,
}
}
/// Compute a hash of all tree-sitter queries across all languages.
///
/// This is used to detect when queries change, which should trigger
/// a rebuild of the index even if file contents haven't changed.
///
/// The hash is computed by:
/// 1. Sorting languages by their primary ID for deterministic ordering
/// 2. Hashing each language's query string in order
/// 3. Combining into a single u64 hash
pub fn compute_query_hash(&self) -> u64 {
use std::collections::hash_map::DefaultHasher;
// Sort configs by primary language ID for deterministic ordering
let mut sorted_configs: Vec<_> = self.configs.iter().collect();
sorted_configs.sort_by_key(|c| c.primary_language_id());
let mut hasher = DefaultHasher::new();
for config in sorted_configs {
// Hash the language ID and query together
config.primary_language_id().hash(&mut hasher);
config.file_definition_queries().hash(&mut hasher);
}
hasher.finish()
}
}
impl Default for LanguageRegistry {
fn default() -> Self {
Self::new()
}
}

View file

@ -0,0 +1,38 @@
//! Python language configuration.
use crate::languages::types::TSLanguageConfig;
pub fn python_lang() -> TSLanguageConfig {
TSLanguageConfig::new(
vec!["Python".to_owned(), "python".to_owned(), "py".to_owned()],
vec!["py".to_owned()],
vec![vec![
"function".to_owned(),
"class".to_owned(),
"variable".to_owned(),
"module".to_owned(),
]],
// Python definitions query
r#"
; Class definitions
(class_definition
name: (identifier) @name.definition.class) @definition.class
; Function definitions
(function_definition
name: (identifier) @name.definition.function) @definition.function
; ============ REFERENCES ============
; Function calls (direct and method calls)
(call
function: [
(identifier) @name.reference.call
(attribute
attribute: (identifier) @name.reference.call)
]) @reference.call
"#
.to_owned(),
|| tree_sitter_python::LANGUAGE.into(),
)
}

View file

@ -0,0 +1,198 @@
use crate::languages::types::TSLanguageConfig;
pub fn rust_lang() -> TSLanguageConfig {
TSLanguageConfig::new(
vec!["Rust".to_owned(), "rust".to_owned(), "rs".to_owned()],
vec!["rs".to_owned()],
vec![vec![
"const".to_owned(),
"function".to_owned(),
"variable".to_owned(),
"struct".to_owned(),
"enum".to_owned(),
"union".to_owned(),
"typedef".to_owned(),
"interface".to_owned(),
"field".to_owned(),
"enumerator".to_owned(),
"module".to_owned(),
"label".to_owned(),
"lifetime".to_owned(),
]],
r#"; ADT definitions
(struct_item
name: (type_identifier) @name.definition.class) @definition.class
(enum_item
name: (type_identifier) @name.definition.class) @definition.class
(union_item
name: (type_identifier) @name.definition.class) @definition.class
; type aliases
(type_item
name: (type_identifier) @name.definition.class) @definition.class
; method definitions
(declaration_list
(function_item
name: (identifier) @name.definition.method)) @definition.method
; function definitions
(function_item
name: (identifier) @name.definition.function) @definition.function
; trait definitions
(trait_item
name: (type_identifier) @name.definition.interface) @definition.interface
; module definitions
(mod_item
name: (identifier) @name.definition.module) @definition.module
; macro definitions
(macro_definition
name: (identifier) @name.definition.macro) @definition.macro
; const and static definitions
(const_item
name: (identifier) @name.definition.variable) @definition.variable
(static_item
name: (identifier) @name.definition.variable) @definition.variable
; ============ REFERENCES ============
; Function and method calls
(call_expression
function: (identifier) @name.reference.call) @reference.call
(call_expression
function: (field_expression
field: (field_identifier) @name.reference.call)) @reference.call
(macro_invocation
macro: (identifier) @name.reference.call) @reference.call
; implementations
(impl_item
trait: (type_identifier) @name.reference.implementation) @reference.implementation
(impl_item
type: (type_identifier) @name.reference.implementation
!trait) @reference.implementation
; ============ USE/IMPORT REFERENCES ============
; Simple use: use Foo;
(use_declaration
argument: (identifier) @name.reference.import) @reference.import
; Scoped use: use foo::Bar;
(use_declaration
argument: (scoped_identifier
name: (identifier) @name.reference.import)) @reference.import
; Use list: use foo::{Bar, Baz};
(use_declaration
argument: (scoped_use_list
list: (use_list
(identifier) @name.reference.import)))
; Nested scoped use: use foo::bar::{Baz, Qux};
(use_declaration
argument: (scoped_use_list
list: (use_list
(scoped_identifier
name: (identifier) @name.reference.import))))
; Use with alias: use Foo as Bar;
(use_declaration
argument: (use_as_clause
path: (identifier) @name.reference.import))
(use_declaration
argument: (use_as_clause
path: (scoped_identifier
name: (identifier) @name.reference.import)))
; ============ ALIAS TRACKING ============
; These patterns capture alias relationships for unified lookups
; use Foo as Bar - captures original and alias
(use_declaration
argument: (use_as_clause
path: (identifier) @alias.original
alias: (identifier) @alias.name))
; use foo::Bar as Baz - scoped version
(use_declaration
argument: (use_as_clause
path: (scoped_identifier
name: (identifier) @alias.original)
alias: (identifier) @alias.name))
; ============ TYPE REFERENCES ============
; Type identifiers in function parameters
(parameter
type: (type_identifier) @name.reference.type)
; Return types
(function_item
return_type: (type_identifier) @name.reference.type)
; Struct fields
(field_declaration
type: (type_identifier) @name.reference.type)
; Let bindings with type annotation
(let_declaration
type: (type_identifier) @name.reference.type)
; Generic type arguments: Vec<Foo>
(type_arguments
(type_identifier) @name.reference.type)
; Scoped type identifier: foo::Bar
(scoped_type_identifier
name: (type_identifier) @name.reference.type)
; Reference types: &Foo
(reference_type
type: (type_identifier) @name.reference.type)
; Tuple struct patterns
(tuple_struct_pattern
type: (identifier) @name.reference.type)
; Struct expressions: Foo { ... }
(struct_expression
name: (type_identifier) @name.reference.type)
; Tuple struct expressions: Foo(...)
(call_expression
function: (scoped_identifier
name: (identifier) @name.reference.call))
; Path segments in scoped identifiers: foo::Bar::baz()
; This captures types used in paths like SomeType::method()
(scoped_identifier
path: (scoped_identifier
name: (identifier) @name.reference.type))
; Direct scoped calls with type in path: Foo::bar()
(scoped_identifier
path: (identifier) @name.reference.type
name: (identifier))
"#
.to_owned(),
|| tree_sitter_rust::LANGUAGE.into(),
)
}

View file

@ -0,0 +1,241 @@
use crate::languages::types::TSLanguageConfig;
pub fn ts_lang() -> TSLanguageConfig {
TSLanguageConfig::new(
vec![
"Typescript".to_owned(),
"TSX".to_owned(),
"typescript".to_owned(),
"tsx".to_owned(),
],
vec!["ts".to_owned(), "tsx".to_owned()],
vec![vec![
"function".to_owned(),
"class".to_owned(),
"interface".to_owned(),
"type".to_owned(),
"enum".to_owned(),
"variable".to_owned(),
"const".to_owned(),
"let".to_owned(),
]],
// Comprehensive TypeScript query with full type coverage
r#"
;; === DEFINITIONS ===
(function_signature
name: (identifier) @name.definition.function) @definition.function
(method_signature
name: (property_identifier) @name.definition.method) @definition.method
(abstract_method_signature
name: (property_identifier) @name.definition.method) @definition.method
(abstract_class_declaration
name: (type_identifier) @name.definition.class) @definition.class
(module
name: (identifier) @name.definition.module) @definition.module
(interface_declaration
name: (type_identifier) @name.definition.interface) @definition.interface
(function_declaration
name: (identifier) @name.definition.function) @definition.function
(method_definition
name: (property_identifier) @name.definition.method) @definition.method
(class_declaration
name: (type_identifier) @name.definition.class) @definition.class
(type_alias_declaration
name: (type_identifier) @name.definition.type) @definition.type
(enum_declaration
name: (identifier) @name.definition.enum) @definition.enum
;; Arrow function assigned to variable: const foo = () => {}
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.function
value: (arrow_function))) @definition.function
;; React component patterns: const Foo = React.forwardRef(...), React.memo(...)
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.function
value: (call_expression))) @definition.function
;; Variable declarations (const/let)
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.variable)) @definition.variable
;; Var declarations
(variable_declaration
(variable_declarator
name: (identifier) @name.definition.variable)) @definition.variable
;; Exported variable declarations: export const foo = ...
(export_statement
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.variable))) @definition.variable
;; === DESTRUCTURING DEFINITIONS ===
;; For-of/for-in loop with array destructuring: for (const [a, b] of items)
(for_in_statement
left: (array_pattern
(identifier) @name.definition.variable))
;; For-of/for-in loop with object destructuring: for (const { a, b } of items)
(for_in_statement
left: (object_pattern
(shorthand_property_identifier_pattern) @name.definition.variable))
;; For-of/for-in loop with object destructuring (aliased): for (const { a: b } of items)
(for_in_statement
left: (object_pattern
(pair_pattern
value: (identifier) @name.definition.variable)))
;; Regular array destructuring: const [a, b] = someArray
(lexical_declaration
(variable_declarator
name: (array_pattern
(identifier) @name.definition.variable)))
;; Regular object destructuring (shorthand): const { a, b } = someObject
(lexical_declaration
(variable_declarator
name: (object_pattern
(shorthand_property_identifier_pattern) @name.definition.variable)))
;; Regular object destructuring (aliased): const { a: b } = someObject
(lexical_declaration
(variable_declarator
name: (object_pattern
(pair_pattern
value: (identifier) @name.definition.variable))))
;; Var array destructuring: var [a, b] = someArray
(variable_declaration
(variable_declarator
name: (array_pattern
(identifier) @name.definition.variable)))
;; Var object destructuring (shorthand): var { a, b } = someObject
(variable_declaration
(variable_declarator
name: (object_pattern
(shorthand_property_identifier_pattern) @name.definition.variable)))
;; Var object destructuring (aliased): var { a: b } = someObject
(variable_declaration
(variable_declarator
name: (object_pattern
(pair_pattern
value: (identifier) @name.definition.variable))))
;; Function parameters with array destructuring: function foo([a, b]) {}
(formal_parameters
(required_parameter
pattern: (array_pattern
(identifier) @name.definition.variable)))
;; Function parameters with object destructuring (shorthand): function foo({ a, b }) {}
(formal_parameters
(required_parameter
pattern: (object_pattern
(shorthand_property_identifier_pattern) @name.definition.variable)))
;; Function parameters with object destructuring (aliased): function foo({ a: b }) {}
(formal_parameters
(required_parameter
pattern: (object_pattern
(pair_pattern
value: (identifier) @name.definition.variable))))
;; Function parameters (simple): function foo(a, b) {}
(formal_parameters
(required_parameter
pattern: (identifier) @name.definition.variable))
;; === REFERENCES ===
;; Member expression object: foo.bar (capture foo as reference)
(member_expression
object: (identifier) @name.reference.variable)
;; Capture ALL type identifiers as references (comprehensive)
(type_identifier) @name.reference.type
;; new expressions: new SomeClass()
(new_expression
constructor: (identifier) @name.reference.class) @reference.class
;; Named imports (simple): import { DiffViewer } from './code-viewer'
(import_specifier
name: (identifier) @name.reference.variable
!alias) @reference.import
;; Named imports with alias: import { Foo as Bar } from './module'
(import_specifier
name: (identifier) @alias.original
alias: (identifier) @alias.name) @reference.import.alias
;; Default imports: import Foo from 'bar'
(import_clause
(identifier) @name.reference.import)
;; JSX opening element: <DiffViewer ...>
(jsx_opening_element
name: (identifier) @name.reference.class)
;; JSX self-closing element: <DiffViewer ... />
(jsx_self_closing_element
name: (identifier) @name.reference.class)
;; JSX member expression element: <Foo.Bar />
(jsx_opening_element
name: (member_expression
object: (identifier) @name.reference.variable))
(jsx_self_closing_element
name: (member_expression
object: (identifier) @name.reference.variable))
;; Function calls: someFunction()
(call_expression
function: (identifier) @name.reference.call)
;; Method calls on objects: object.method()
(call_expression
function: (member_expression
object: (identifier) @name.reference.variable))
;; Extends clause in class: class Foo extends Bar
(class_heritage
(extends_clause
value: (identifier) @name.reference.class))
;; Implements clause: class Foo implements Bar
(class_heritage
(implements_clause
(type_identifier) @name.reference.interface))
;; Named exports: export { Foo }
(export_specifier
name: (identifier) @name.reference.export)
;; Array element identifiers: [foo, bar] (e.g., React useCallback/useEffect dependency arrays)
(array
(identifier) @name.reference.variable)
"#
.to_owned(),
|| tree_sitter_typescript::LANGUAGE_TSX.into(),
)
}

View file

@ -0,0 +1,82 @@
use crate::scope_graph::nodes::SymbolId;
/// Function type for getting a tree-sitter language grammar.
pub type GrammarFn = fn() -> tree_sitter::Language;
/// Contains information about the language and extra information which we would need
/// per language
pub struct TSLanguageConfig {
language_ids: Vec<String>,
file_extensions: Vec<String>,
namespaces: Vec<Vec<String>>,
file_definition_queries: String,
grammar: GrammarFn,
}
impl TSLanguageConfig {
pub fn new(
language_ids: Vec<String>,
file_extensions: Vec<String>,
namespaces: Vec<Vec<String>>,
file_definition_queries: String,
grammar: GrammarFn,
) -> Self {
Self {
language_ids,
file_extensions,
namespaces,
file_definition_queries,
grammar,
}
}
/// Get the language IDs.
pub fn language_ids(&self) -> &[String] {
&self.language_ids
}
/// Get the first language ID, or "unknown" if none.
pub fn primary_language_id(&self) -> &str {
self.language_ids
.first()
.map(|s| s.as_str())
.unwrap_or("unknown")
}
/// Get the file extensions.
pub fn file_extensions(&self) -> &[String] {
&self.file_extensions
}
/// Get the namespaces.
pub fn namespaces(&self) -> &[Vec<String>] {
&self.namespaces
}
/// Get the file definition queries.
pub fn file_definition_queries(&self) -> &str {
&self.file_definition_queries
}
/// Get the tree-sitter language.
pub fn language(&self) -> tree_sitter::Language {
(self.grammar)()
}
/// Compile the definitions query for this language.
pub fn compile_query(&self) -> Result<tree_sitter::Query, tree_sitter::QueryError> {
tree_sitter::Query::new(&self.language(), &self.file_definition_queries)
}
/// Find a SymbolId for a given symbol type name.
pub fn symbol_id_of(&self, symbol_type: &str) -> Option<SymbolId> {
for (ns_idx, namespace) in self.namespaces.iter().enumerate() {
for (sym_idx, sym) in namespace.iter().enumerate() {
if sym == symbol_type {
return Some(SymbolId::new(ns_idx, sym_idx));
}
}
}
None
}
}

View file

@ -0,0 +1,102 @@
//! # xai-codebase-graph
//!
//! High-performance code graph generation using tree-sitter queries.
//!
//! This crate provides:
//! - **Go-to-definitions**: Find where symbols are defined
//! - **Go-to-references**: Find where symbols are used
//! - **Initial repository indexing**: Build the full index from scratch
//! - **Incremental reindexing**: Update the index based on file system events
//! - **Parallel processing**: Uses rayon for fast parallel parsing
//! - **Memory-mapped I/O**: Zero-copy file reading and fast index caching
//!
//! ## Quick Start
//!
//! ```rust,ignore
//! use std::path::Path;
//! use xai_codebase_graph::{IndexBuilder, load_index, save_index, get_cache_path, Navigator};
//!
//! let repo_path = Path::new("/path/to/repo");
//! let cache_path = get_cache_path(repo_path);
//!
//! // Try loading from cache first, otherwise build fresh
//! let index = match load_index(&cache_path) {
//! Ok(index) => index,
//! Err(_) => {
//! let index = IndexBuilder::new()
//! .with_threads(8)
//! .build(repo_path)?;
//! save_index(&cache_path, &index)?;
//! index
//! }
//! };
//!
//! // Create a navigator for location-based operations
//! let navigator = Navigator::new(index);
//!
//! // Go to definition at a specific position (row and col are 1-indexed)
//! let result = navigator.goto_definition(Path::new("src/main.rs"), 10, 15)?;
//! for loc in result.locations {
//! println!("{}:{}", loc.path.display(), loc.line);
//! }
//! ```
//!
//! ## Channel-Based Incremental Updates
//!
//! `IndexManagerHandle` exposes direct query commands that answer in-place
//! without cloning the full index. Prefer these over `get_snapshot()` in
//! hot paths.
//!
//! ```rust,ignore
//! use std::path::PathBuf;
//! use xai_codebase_graph::{IndexManager, IndexManagerConfig, FileEvent};
//!
//! // Create the manager with config
//! let config = IndexManagerConfig::new("/path/to/repo".into())
//! .with_cache_path("/tmp/index.bin".into());
//!
//! let handle = IndexManager::spawn(config);
//!
//! // Send file events as they come from FSNotify
//! handle.send_event(FileEvent::modified("src/main.rs".into()))?;
//!
//! // Query directly — no full-index clone needed
//! let file = PathBuf::from("src/main.rs");
//! let result = handle.goto_definition_blocking(file, 10, 15)??;
//! for loc in result.locations {
//! println!("{}:{}", loc.path, loc.line);
//! }
//!
//! // Lightweight stats — also no clone
//! let file_count = handle.get_file_count();
//! let exists = handle.has_definition_blocking("MyStruct");
//! ```
pub mod index_manager;
pub mod interner;
pub mod languages;
pub mod manager;
pub mod navigation;
pub mod scope_graph;
pub mod types;
// Re-exports for convenient access
pub use index_manager::{
FileEvent, FileEventKind, IndexCommand, IndexManager, IndexManagerConfig, IndexManagerHandle,
MAX_INDEXABLE_FILE_SIZE, QueryError, QueryResult, SymbolLocation, is_binary_content,
};
pub use languages::{LanguageRegistry, TSLanguageConfig};
pub use manager::{
CACHE_FILE_NAME, CacheError, IndexBuilder, IndexError, IndexOperation, LockResult,
WorkspaceLockGuard, cache_exists, cache_size, get_cache_path, is_operation_in_progress,
load_index, save_index, save_index_async, try_lock,
};
pub use navigation::{Location, NavigationError, NavigationResult, Navigator};
pub use scope_graph::{
LocalDef, LocalImport, LocalScope, NodeKind, QueryVersion, Reference, ScopeGraph,
ScopeGraphIndex, ScopeGraphResult, Symbol, SymbolId, build_scope_graph, extract_symbols_fast,
};
pub use types::{FileMeta, IndexStats, Position, Range, SymbolAlias, SymbolOccurrence};
// String interning for memory-efficient storage
pub use interner::{StringId, StringInterner};

View file

@ -0,0 +1,507 @@
//! Parallel pipelined index builder with thread-local caching.
use std::cell::RefCell;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use ahash::AHashMap as HashMap;
use ignore::WalkBuilder;
use rayon::prelude::*;
use crate::languages::LanguageRegistry;
use crate::scope_graph::ScopeGraphIndex;
use crate::types::{FileMeta, SymbolAlias, SymbolOccurrence};
use xai_grok_paths::to_relative_path;
/// Error type for index building operations.
#[derive(Debug)]
pub enum IndexError {
/// Error walking directory.
WalkError { message: String },
/// Thread panicked.
ThreadPanic { message: String },
/// IO error.
IoError(std::io::Error),
}
impl std::fmt::Display for IndexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IndexError::WalkError { message } => write!(f, "Walk error: {}", message),
IndexError::ThreadPanic { message } => write!(f, "Thread panic: {}", message),
IndexError::IoError(e) => write!(f, "IO error: {}", e),
}
}
}
impl std::error::Error for IndexError {}
impl From<std::io::Error> for IndexError {
fn from(e: std::io::Error) -> Self {
IndexError::IoError(e)
}
}
/// Result type for index building operations.
pub type Result<T> = std::result::Result<T, IndexError>;
// Thread-local caches for parsers and queries to avoid repeated initialization
thread_local! {
static PARSER_CACHE: RefCell<HashMap<String, tree_sitter::Parser>> = RefCell::new(HashMap::new());
static QUERY_CACHE: RefCell<HashMap<String, tree_sitter::Query>> = RefCell::new(HashMap::new());
}
/// Extracted symbols for a single file (lightweight, no ScopeGraph overhead)
struct FileSymbols {
path: Arc<str>,
definitions: Vec<SymbolOccurrence>,
references: Vec<SymbolOccurrence>,
aliases: Vec<SymbolAlias>,
file_meta: FileMeta,
}
/// Builder for creating a symbol index.
///
/// Uses optimized parallel processing with:
/// - Thread-local parser and query caching
/// - Chunked parallel processing for cache locality
/// - Lightweight symbol extraction (no intermediate ScopeGraph)
/// - Bounded merge-batching to cap two-phase peak memory
pub struct IndexBuilder {
registry: LanguageRegistry,
num_threads: usize,
/// Whether to respect .gitignore files (default: true)
respect_gitignore: bool,
/// Whether to skip hidden files/directories (default: true)
skip_hidden: bool,
/// Chunk size for parallel processing / thread-local cache locality (default: 100)
chunk_size: usize,
/// Maximum number of files whose symbols are held in memory at once during
/// the merge phase. Limiting this bounds the two-phase peak: parallel
/// parsing produces at most `build_batch_size` FileSymbols before they are
/// merged into the index and dropped. Default: 5 000 files per batch.
build_batch_size: usize,
}
/// Get the default number of threads (N-1 cores, minimum 1).
fn default_num_threads() -> usize {
num_cpus::get().saturating_sub(1).max(1)
}
impl IndexBuilder {
/// Create a new index builder.
pub fn new() -> Self {
Self {
registry: LanguageRegistry::new(),
num_threads: default_num_threads(),
respect_gitignore: true,
skip_hidden: true,
chunk_size: 100,
build_batch_size: 5_000,
}
}
/// Create a new index builder with a custom language registry.
pub fn with_registry(registry: LanguageRegistry) -> Self {
Self {
registry,
num_threads: default_num_threads(),
respect_gitignore: true,
skip_hidden: true,
chunk_size: 100,
build_batch_size: 5_000,
}
}
/// Set the number of threads to use (default: N-1 cores).
#[must_use]
pub fn with_threads(mut self, count: usize) -> Self {
self.num_threads = count;
self
}
/// Set the chunk size for parallel processing (default: 100).
#[must_use]
pub fn with_chunk_size(mut self, size: usize) -> Self {
self.chunk_size = size;
self
}
/// Set the merge-batch size (default: 5 000 files per batch).
///
/// Controls how many files' symbols are held in memory simultaneously
/// during the sequential merge phase. Smaller values reduce peak RSS at
/// the cost of slightly more pool scheduling overhead. Values below
/// `chunk_size` are clamped to `chunk_size` at build time, so the call
/// order of `with_build_batch_size` and `with_chunk_size` does not matter.
#[must_use]
pub fn with_build_batch_size(mut self, size: usize) -> Self {
self.build_batch_size = size;
self
}
/// Set whether to respect .gitignore files (default: true).
#[must_use]
pub fn respect_gitignore(mut self, respect: bool) -> Self {
self.respect_gitignore = respect;
self
}
/// Set whether to skip hidden files/directories (default: true).
#[must_use]
pub fn skip_hidden(mut self, skip: bool) -> Self {
self.skip_hidden = skip;
self
}
/// Build index from a directory, respecting .gitignore.
///
/// This walks the directory tree, automatically respecting:
/// - `.gitignore` files at any level
/// - `.git/info/exclude`
/// - Global gitignore (`~/.config/git/ignore`)
/// - Hidden files/directories (configurable)
///
/// **Note**: File paths in the index are stored as **relative paths** (to `root_path`)
/// for portability across machines/sessions.
pub fn build(&self, root_path: &Path) -> Result<ScopeGraphIndex> {
// Collect files using the ignore crate
let file_paths = self.collect_files(root_path)?;
if file_paths.is_empty() {
let mut index = ScopeGraphIndex::new();
// Set query version even for empty index so cache validation works
index.set_query_version(self.registry.compute_query_hash());
return Ok(index);
}
self.build_fast(root_path, &file_paths)
}
/// Collect all supported files from a directory, respecting gitignore.
/// Uses `git ls-files` when available (faster), falls back to directory walking.
fn collect_files(&self, root_path: &Path) -> Result<Vec<PathBuf>> {
// Try git ls-files first - it's much faster as it reads from git's index
// But it only works for tracked files, so we also add untracked files
if let Some(files) = self.collect_files_git(root_path)
&& !files.is_empty()
{
return Ok(files);
}
// Fall back to directory walking
self.collect_files_walk(root_path)
}
/// Collect files using git2 - reads from the git index (tracked files).
/// Untracked files are not included since:
/// 1. They are typically a small minority
/// 2. They will be picked up by fsnotify when created
/// 3. The statuses() call for untracked files is very slow (~10x overhead)
fn collect_files_git(&self, root_path: &Path) -> Option<Vec<PathBuf>> {
use git2::Repository;
// Open the repository
let repo = Repository::open(root_path).ok()?;
// Get all files from the index (tracked files)
let index = repo.index().ok()?;
let files: Vec<PathBuf> = index
.iter()
.filter_map(|entry| {
// git2 stores paths as bytes, convert to str
let path_str = std::str::from_utf8(&entry.path).ok()?;
if self.registry.is_supported(path_str) {
Some(root_path.join(path_str))
} else {
None
}
})
.collect();
Some(files)
}
/// Collect files by walking the directory tree.
/// Used as fallback when not in a git repository.
fn collect_files_walk(&self, root_path: &Path) -> Result<Vec<PathBuf>> {
use std::sync::Mutex;
let files = Mutex::new(Vec::with_capacity(50000));
let walker = WalkBuilder::new(root_path)
.hidden(self.skip_hidden)
.git_ignore(self.respect_gitignore)
.git_global(self.respect_gitignore)
.git_exclude(self.respect_gitignore)
.threads(self.num_threads.min(12)) // Use parallel walking (capped at 12)
.build_parallel();
walker.run(|| {
let files = &files;
let registry = &self.registry;
Box::new(move |entry| {
use ignore::WalkState;
let entry = match entry {
Ok(e) => e,
Err(_) => return WalkState::Continue,
};
let path = entry.path();
// Skip directories
if path.is_dir() {
return WalkState::Continue;
}
// Check if the file is supported
if registry.is_supported(path) {
files.lock().unwrap().push(path.to_path_buf());
}
WalkState::Continue
})
});
Ok(files.into_inner().unwrap())
}
/// Build index with maximum throughput optimizations:
/// - Memory-mapped I/O for zero-copy file reading
/// - Direct parsing from mmap (no intermediate buffer copy)
/// - Lightweight symbol extraction (skip building full ScopeGraph)
/// - Thread-local parser and query caching
/// - Chunked parallel processing for better cache locality
/// - Single StringInterner for memory-efficient string deduplication
///
/// Uses two-phase approach:
/// 1. Parallel: parse files and extract symbols into Vec<FileSymbols>
/// 2. Sequential: aggregate into single ScopeGraphIndex with single interner
///
/// This ensures all strings are deduplicated in one interner, avoiding
/// the memory overhead of multiple interners during parallel aggregation.
///
/// File paths are stored as **relative paths** (to `root_path`) for portability.
fn build_fast(&self, root_path: &Path, file_paths: &[PathBuf]) -> Result<ScopeGraphIndex> {
// Configure thread pool with N-1 cores
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(self.num_threads)
.build()
.map_err(|e| IndexError::WalkError {
message: format!("Failed to create thread pool: {}", e),
})?;
let registry = Arc::new(LanguageRegistry::new());
let chunk_size = self.chunk_size;
// Clamp here against the final chunk_size so that call order of
// with_build_batch_size / with_chunk_size on the builder does not matter.
let build_batch_size = self.build_batch_size.max(chunk_size);
let root_arc: Arc<Path> = Arc::from(root_path);
let mut index = ScopeGraphIndex::new();
// Process files in bounded merge-batches to cap two-phase peak memory.
//
// Old approach: collect ALL FileSymbols in one go, then merge.
// Peak = O(total_files) symbols + growing index simultaneously.
//
// New approach: for each batch of build_batch_size files:
// 1. Parse in parallel (par_chunks preserves thread-local cache locality)
// 2. Merge the batch into the index
// 3. Drop the batch before starting the next one
// Peak = O(build_batch_size) symbols + growing index simultaneously.
for batch in file_paths.chunks(build_batch_size) {
let batch_symbols: Vec<FileSymbols> = pool.install(|| {
batch
.par_chunks(chunk_size)
.flat_map_iter(|chunk| {
chunk
.iter()
.filter_map(|path| process_file_fast(path, &root_arc, &registry))
})
.collect()
});
for file_syms in batch_symbols {
let path_str: &str = &file_syms.path;
for sym in file_syms.definitions {
index.add_definition(&sym.name, path_str, sym.line);
}
for sym in file_syms.references {
index.add_reference(&sym.name, path_str, sym.line);
}
for alias in file_syms.aliases {
index.add_alias_arc(alias.alias, alias.original);
}
index.set_file_meta(path_str, file_syms.file_meta);
}
// batch_symbols dropped here — frees the parallel-extracted symbols
// before the next batch is parsed
}
// Set the query version hash so we can detect query changes on cache load
index.set_query_version(self.registry.compute_query_hash());
// Reclaim over-allocated Vec capacity that accumulated during bulk push().
// This is a one-time cost paid here (O(symbols)) to permanently reduce RSS.
index.compact();
Ok(index)
}
}
impl Default for IndexBuilder {
fn default() -> Self {
Self::new()
}
}
/// Process a single file using thread-local caching.
/// Returns lightweight FileSymbols (no ScopeGraph overhead).
///
/// File path is stored as **relative** (to `root_path`) for portability.
fn process_file_fast(
path: &Path,
root_path: &Path,
registry: &LanguageRegistry,
) -> Option<FileSymbols> {
use crate::index_manager::MAX_INDEXABLE_FILE_SIZE;
let lang_config = registry.for_file_path(path)?;
let lang_id = lang_config.primary_language_id().to_string();
let metadata = fs::metadata(path).ok()?;
if metadata.len() == 0 || metadata.len() > MAX_INDEXABLE_FILE_SIZE {
return None;
}
// Prefix-read binary check: only reads 8KB, not the whole file
{
use std::io::Read;
let mut f = fs::File::open(path).ok()?;
let mut buf = [0u8; 8000];
let n = f.read(&mut buf).ok()?;
if buf[..n].contains(&0) {
return None;
}
}
let content = fs::read(path).ok()?;
// Parse using thread-local cached parser
let tree = PARSER_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let parser = cache.entry(lang_id.clone()).or_insert_with(|| {
let mut p = tree_sitter::Parser::new();
let ts_lang = lang_config.language();
let _ = p.set_language(&ts_lang);
p
});
parser.parse(&content, None)
})?;
let root_node = tree.root_node();
// Extract symbols using thread-local cached query
let (definitions, references, aliases) = QUERY_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let query = cache.entry(lang_id.clone()).or_insert_with(|| {
lang_config.compile_query().unwrap_or_else(|_| {
let ts_lang = lang_config.language();
tree_sitter::Query::new(&ts_lang, "").expect("empty query should always work")
})
});
extract_symbols_fast_inline(query, root_node, &content)
});
// Reuse metadata from the size check above (no re-stat needed)
// Convert absolute path to relative for portable storage
let rel_path = to_relative_path(root_path, path);
Some(FileSymbols {
path: rel_path.to_string_lossy().into(),
definitions,
references,
aliases,
file_meta: FileMeta::from_metadata(&metadata),
})
}
/// Lightweight symbol extraction - returns proper typed vectors.
/// Inlined for maximum performance (avoids function call overhead in hot loop).
#[inline]
fn extract_symbols_fast_inline(
query: &tree_sitter::Query,
root_node: tree_sitter::Node<'_>,
src: &[u8],
) -> (
Vec<SymbolOccurrence>,
Vec<SymbolOccurrence>,
Vec<SymbolAlias>,
) {
use tree_sitter::StreamingIterator;
// Pre-compute capture indices for fast lookup
let capture_names = query.capture_names();
let mut is_def = vec![false; capture_names.len()];
let mut is_ref = vec![false; capture_names.len()];
let mut alias_original_idx: Option<usize> = None;
let mut alias_name_idx: Option<usize> = None;
for (i, name) in capture_names.iter().enumerate() {
if name.starts_with("name.definition.") {
is_def[i] = true;
} else if name.starts_with("name.reference.") {
is_ref[i] = true;
} else if *name == "alias.original" {
alias_original_idx = Some(i);
} else if *name == "alias.name" {
alias_name_idx = Some(i);
}
}
// Pre-allocate with reasonable capacity
let mut definitions: Vec<SymbolOccurrence> = Vec::with_capacity(64);
let mut references: Vec<SymbolOccurrence> = Vec::with_capacity(256);
let mut aliases: Vec<SymbolAlias> = Vec::with_capacity(8);
let mut cursor = tree_sitter::QueryCursor::new();
let mut matches = cursor.matches(query, root_node, src);
while let Some(match_) = matches.next() {
let mut alias_original: Option<&[u8]> = None;
let mut alias_name: Option<&[u8]> = None;
for capture in match_.captures {
let idx = capture.index as usize;
let node = capture.node;
let byte_range = node.byte_range();
if is_def.get(idx).copied().unwrap_or(false) {
// Convert Cow<str> directly to Arc<str> - avoids intermediate String allocation
let text: Arc<str> = String::from_utf8_lossy(&src[byte_range]).into();
// Line numbers are 1-indexed
definitions.push(SymbolOccurrence::new(text, node.start_position().row + 1));
} else if is_ref.get(idx).copied().unwrap_or(false) {
let text: Arc<str> = String::from_utf8_lossy(&src[byte_range]).into();
references.push(SymbolOccurrence::new(text, node.start_position().row + 1));
} else if Some(idx) == alias_original_idx {
alias_original = Some(&src[byte_range]);
} else if Some(idx) == alias_name_idx {
alias_name = Some(&src[byte_range]);
}
}
if let (Some(original), Some(alias)) = (alias_original, alias_name) {
// Convert Cow<str> directly to Arc<str> - avoids intermediate String allocation
let orig_arc: Arc<str> = String::from_utf8_lossy(original).into();
let alias_arc: Arc<str> = String::from_utf8_lossy(alias).into();
aliases.push(SymbolAlias::new(alias_arc, orig_arc));
}
}
(definitions, references, aliases)
}

View file

@ -0,0 +1,106 @@
//! Index caching for fast loading.
//!
//! Uses a custom binary format with magic bytes "SGIX" for the new interned format.
//! Automatically detects and skips legacy bincode format (returns error so caller can rebuild).
use std::path::Path;
use crate::scope_graph::ScopeGraphIndex;
/// Default cache file name.
pub const CACHE_FILE_NAME: &str = ".goto_index.bin";
/// Error type for cache operations.
#[derive(Debug)]
pub enum CacheError {
/// IO error.
IoError(std::io::Error),
/// Serialization error.
SerializeError(String),
/// Deserialization error.
DeserializeError(String),
/// Legacy format detected (caller should rebuild).
LegacyFormat,
}
impl std::fmt::Display for CacheError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CacheError::IoError(e) => write!(f, "IO error: {}", e),
CacheError::SerializeError(msg) => write!(f, "Serialization error: {}", msg),
CacheError::DeserializeError(msg) => write!(f, "Deserialization error: {}", msg),
CacheError::LegacyFormat => write!(f, "Legacy cache format detected"),
}
}
}
impl std::error::Error for CacheError {}
impl From<std::io::Error> for CacheError {
fn from(e: std::io::Error) -> Self {
CacheError::IoError(e)
}
}
/// Result type for cache operations.
pub type Result<T> = std::result::Result<T, CacheError>;
/// Get the default cache path for a repository.
pub fn get_cache_path(root_path: &Path) -> std::path::PathBuf {
root_path.join(CACHE_FILE_NAME)
}
/// Load an index from cache.
///
/// Uses the new binary format with magic bytes "SGIX".
/// Returns `CacheError::LegacyFormat` if the file uses the old bincode format,
/// signaling to the caller that a rebuild is needed.
pub fn load_index(cache_path: &Path) -> Result<ScopeGraphIndex> {
if !cache_path.exists() {
return Err(CacheError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Cache file not found",
)));
}
// Use ScopeGraphIndex::load which handles format detection
match ScopeGraphIndex::load(cache_path) {
Ok(Some(index)) => Ok(index),
Ok(None) => {
// None means legacy format was detected
tracing::info!(
cache_path = %cache_path.display(),
"Legacy cache format detected, will rebuild"
);
Err(CacheError::LegacyFormat)
}
Err(e) => Err(CacheError::IoError(e)),
}
}
/// Save an index to cache using the new binary format.
pub fn save_index(cache_path: &Path, index: &ScopeGraphIndex) -> Result<()> {
index.save(cache_path).map_err(CacheError::IoError)
}
/// Save an index to cache asynchronously (in a background thread).
///
/// Returns immediately and spawns a thread to do the actual saving.
/// Useful for saving the index without blocking the main thread.
pub fn save_index_async(cache_path: std::path::PathBuf, index: ScopeGraphIndex) {
std::thread::spawn(move || {
if let Err(e) = save_index(&cache_path, &index) {
tracing::warn!("Failed to save index cache: {}", e);
}
});
}
/// Check if a cache exists and return its metadata.
pub fn cache_exists(cache_path: &Path) -> bool {
cache_path.exists()
}
/// Get cache file size in bytes.
pub fn cache_size(cache_path: &Path) -> Option<u64> {
std::fs::metadata(cache_path).ok().map(|m| m.len())
}

View file

@ -0,0 +1,539 @@
//! Workspace-level locking for index operations.
//!
//! Provides both in-memory (same-process) and file-based (cross-process)
//! coordination to prevent redundant index operations on the same workspace.
//!
//! ## Design
//!
//! - **In-memory locks**: Fast path for same-process deduplication using a global registry
//! - **File locks**: Cross-process coordination using lock files with PID and timestamp
//! - **Stale detection**: Locks are considered stale if the holding process is dead or timeout exceeded
//!
//! ## Lock Types
//!
//! - **Shared (Load)**: Multiple readers allowed, blocked during exclusive operations
//! - **Exclusive (Save/Build/Refresh)**: Single writer, blocks all other operations
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use dashmap::DashMap;
use once_cell::sync::Lazy;
const LOAD_STALE_DURATION_SEC: u64 = 120;
const SAVE_STALE_DURATION_SEC: u64 = 120;
const BUILD_STALE_DURATION_SEC: u64 = 600;
const BG_REFRESH_STALE_DURATION_SEC: u64 = 300;
/// Operations that require locking.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexOperation {
/// Loading index from cache (shared/read lock).
Load,
/// Saving index to cache (exclusive).
Save,
/// Building index from scratch (exclusive).
Build,
/// Background validation and refresh (exclusive).
BackgroundRefresh,
}
impl IndexOperation {
/// String representation for lock file.
fn as_str(&self) -> &'static str {
match self {
Self::Load => "load",
Self::Save => "save",
Self::Build => "build",
Self::BackgroundRefresh => "background_refresh",
}
}
/// Whether this operation requires exclusive access.
pub fn is_exclusive(&self) -> bool {
match self {
Self::Load => false, // Shared/read access
Self::Save | Self::Build | Self::BackgroundRefresh => true,
}
}
/// Timeout after which a lock is considered stale.
fn stale_timeout(&self) -> Duration {
match self {
Self::Load => Duration::from_secs(LOAD_STALE_DURATION_SEC),
Self::Save => Duration::from_secs(SAVE_STALE_DURATION_SEC),
Self::Build => Duration::from_secs(BUILD_STALE_DURATION_SEC),
Self::BackgroundRefresh => Duration::from_secs(BG_REFRESH_STALE_DURATION_SEC),
}
}
}
impl std::fmt::Display for IndexOperation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// In-memory lock state for same-process deduplication.
struct InMemoryLockState {
operation: IndexOperation,
readers: usize, // Count for shared locks
exclusive: bool, // Whether an exclusive lock is held
}
/// Global registry of in-memory locks (same process).
/// Uses DashMap for lock-free concurrent access.
static IN_MEMORY_LOCKS: Lazy<DashMap<PathBuf, InMemoryLockState>> = Lazy::new(DashMap::new);
/// A guard that releases the lock when dropped.
pub struct WorkspaceLockGuard {
workspace: PathBuf,
lock_file_path: PathBuf,
operation: IndexOperation,
}
impl Drop for WorkspaceLockGuard {
fn drop(&mut self) {
// Release in-memory lock
release_in_memory_lock(&self.workspace, self.operation);
// Release file lock (only for exclusive operations)
if self.operation.is_exclusive()
&& let Err(e) = std::fs::remove_file(&self.lock_file_path)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
path = %self.lock_file_path.display(),
error = %e,
"Failed to remove lock file"
);
}
}
}
/// Result of trying to acquire a lock.
pub enum LockResult {
/// Lock acquired successfully.
Acquired(WorkspaceLockGuard),
/// Another operation is in progress.
Busy {
/// Description of the blocking operation.
operation: String,
/// PID of the process holding the lock (if known).
holder_pid: Option<u32>,
},
}
impl LockResult {
/// Returns true if the lock was acquired.
pub fn is_acquired(&self) -> bool {
matches!(self, Self::Acquired(_))
}
/// Unwrap the guard, panicking if busy.
pub fn unwrap(self) -> WorkspaceLockGuard {
match self {
Self::Acquired(guard) => guard,
Self::Busy { operation, .. } => {
panic!("Lock was busy: {}", operation)
}
}
}
}
/// Try to acquire a lock for an index operation on a workspace.
///
/// Returns `LockResult::Acquired` with a guard if successful, or `LockResult::Busy`
/// if another operation is in progress.
///
/// # Arguments
///
/// * `workspace` - The workspace root path
/// * `operation` - The type of operation to perform
///
/// # Example
///
/// ```ignore
/// use xai_codebase_graph::manager::lock::{try_lock, IndexOperation, LockResult};
///
/// let workspace = Path::new("/path/to/workspace");
/// match try_lock(workspace, IndexOperation::Build) {
/// LockResult::Acquired(guard) => {
/// // Do work...
/// // Lock is released when guard is dropped
/// }
/// LockResult::Busy { operation, holder_pid } => {
/// println!("Busy: {} by pid {:?}", operation, holder_pid);
/// }
/// }
/// ```
pub fn try_lock(workspace: &Path, operation: IndexOperation) -> LockResult {
let workspace = canonicalize_workspace(workspace);
let lock_file_path = get_lock_file_path(&workspace);
// Step 1: Check/acquire in-memory lock (fast path for same process)
if !try_acquire_in_memory_lock(&workspace, operation) {
tracing::debug!(
workspace = %workspace.display(),
operation = %operation,
"In-memory lock busy"
);
return LockResult::Busy {
operation: format!("{} (same process)", operation),
holder_pid: Some(std::process::id()),
};
}
// Step 2: For exclusive operations, also acquire file lock (cross-process)
if operation.is_exclusive() {
match try_acquire_file_lock(&lock_file_path, operation) {
Ok(()) => {
tracing::debug!(
workspace = %workspace.display(),
operation = %operation,
lock_file = %lock_file_path.display(),
"Acquired exclusive lock"
);
}
Err((op, pid)) => {
// Release in-memory lock since we failed to get file lock
release_in_memory_lock(&workspace, operation);
tracing::debug!(
workspace = %workspace.display(),
operation = %operation,
blocking_op = %op,
blocking_pid = ?pid,
"File lock busy"
);
return LockResult::Busy {
operation: op,
holder_pid: pid,
};
}
}
}
LockResult::Acquired(WorkspaceLockGuard {
workspace,
lock_file_path,
operation,
})
}
/// Check if an operation is currently in progress for a workspace.
///
/// This is a non-blocking check that doesn't acquire any locks.
pub fn is_operation_in_progress(workspace: &Path, operation: IndexOperation) -> bool {
let workspace = canonicalize_workspace(workspace);
// Check in-memory first
if let Some(state) = IN_MEMORY_LOCKS.get(&workspace) {
if operation.is_exclusive() {
if state.readers > 0 || state.exclusive {
return true;
}
} else if state.exclusive {
return true;
}
}
// Check file lock for exclusive operations
if operation.is_exclusive()
&& let Ok(contents) = std::fs::read_to_string(get_lock_file_path(&workspace))
&& let Some((_, pid, started)) = parse_lock_file(&contents)
{
let age = SystemTime::now()
.duration_since(started)
.unwrap_or(Duration::ZERO);
if age < operation.stale_timeout() && is_process_alive(pid) {
return true;
}
}
false
}
/// Canonicalize workspace path for consistent lock keys.
fn canonicalize_workspace(workspace: &Path) -> PathBuf {
// Try to canonicalize, fall back to the original path
dunce::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf())
}
/// Get the lock file path for a workspace.
fn get_lock_file_path(workspace: &Path) -> PathBuf {
// Use the cache directory (same as where .goto_index.bin is stored)
let cache_path = super::get_cache_path(workspace);
cache_path.with_extension("lock")
}
/// Try to acquire an in-memory lock for same-process deduplication.
fn try_acquire_in_memory_lock(workspace: &Path, operation: IndexOperation) -> bool {
// Use entry API for atomic check-and-modify
match IN_MEMORY_LOCKS.entry(workspace.to_path_buf()) {
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
let state = entry.get_mut();
if operation.is_exclusive() {
// Exclusive operation - must have no readers or existing exclusive
if state.readers > 0 || state.exclusive {
return false;
}
state.exclusive = true;
state.operation = operation;
} else {
// Shared operation (Load) - OK if no exclusive lock
if state.exclusive {
return false;
}
state.readers += 1;
}
}
dashmap::mapref::entry::Entry::Vacant(entry) => {
// No existing lock - create one
entry.insert(InMemoryLockState {
operation,
readers: if operation.is_exclusive() { 0 } else { 1 },
exclusive: operation.is_exclusive(),
});
}
}
true
}
/// Release an in-memory lock.
fn release_in_memory_lock(workspace: &Path, operation: IndexOperation) {
// Use entry API for atomic check-and-modify
if let dashmap::mapref::entry::Entry::Occupied(mut entry) =
IN_MEMORY_LOCKS.entry(workspace.to_path_buf())
{
let should_remove = {
let state = entry.get_mut();
if operation.is_exclusive() {
state.exclusive = false;
} else {
state.readers = state.readers.saturating_sub(1);
}
// Check if we should remove the entry
!state.exclusive && state.readers == 0
};
if should_remove {
entry.remove();
}
}
}
/// Try to acquire a file-based lock for cross-process coordination.
fn try_acquire_file_lock(
lock_path: &Path,
operation: IndexOperation,
) -> Result<(), (String, Option<u32>)> {
// Check if existing lock file is valid
if let Ok(contents) = std::fs::read_to_string(lock_path)
&& let Some((op, pid, started)) = parse_lock_file(&contents)
{
// Check if lock is stale
let age = SystemTime::now()
.duration_since(started)
.unwrap_or(Duration::ZERO);
if age < operation.stale_timeout() && is_process_alive(pid) {
return Err((op, Some(pid)));
}
// Lock is stale - we can take over
tracing::debug!(
lock_path = %lock_path.display(),
stale_op = %op,
stale_pid = pid,
age_secs = age.as_secs(),
"Taking over stale lock"
);
}
// Create parent directory if needed
if let Some(parent) = lock_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::warn!(
path = %parent.display(),
error = %e,
"Failed to create lock directory"
);
}
// Write our lock file
let contents = format!(
"operation={}\npid={}\nstarted={}\nworkspace={}\n",
operation.as_str(),
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
lock_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown")
);
std::fs::write(lock_path, contents).map_err(|e| (format!("io_error: {}", e), None))
}
/// Parse a lock file's contents.
fn parse_lock_file(contents: &str) -> Option<(String, u32, SystemTime)> {
let mut operation = None;
let mut pid = None;
let mut started = None;
for line in contents.lines() {
if let Some(val) = line.strip_prefix("operation=") {
operation = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("pid=") {
pid = val.parse().ok();
} else if let Some(val) = line.strip_prefix("started=")
&& let Ok(secs) = val.parse::<u64>()
{
started = Some(UNIX_EPOCH + Duration::from_secs(secs));
}
}
match (operation, pid, started) {
(Some(op), Some(p), Some(s)) => Some((op, p, s)),
_ => None,
}
}
/// Check if a process is still alive.
#[cfg(unix)]
fn is_process_alive(pid: u32) -> bool {
// kill with signal 0 checks if process exists without sending a signal
// Returns 0 if process exists and we have permission to send signals
// Returns -1 with ESRCH if process doesn't exist
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(not(unix))]
fn is_process_alive(_pid: u32) -> bool {
// On non-Unix platforms, rely on timeout-based stale detection
true
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_exclusive_lock_blocks_exclusive() {
let dir = tempdir().unwrap();
let workspace = dir.path();
// Acquire first exclusive lock
let guard1 = try_lock(workspace, IndexOperation::Build);
assert!(guard1.is_acquired());
// Second exclusive lock should fail
let result2 = try_lock(workspace, IndexOperation::Build);
assert!(!result2.is_acquired());
// Drop first lock
drop(guard1);
// Now second should succeed
let guard3 = try_lock(workspace, IndexOperation::Build);
assert!(guard3.is_acquired());
}
#[test]
fn test_shared_locks_coexist() {
let dir = tempdir().unwrap();
let workspace = dir.path();
// Multiple shared locks should work
let guard1 = try_lock(workspace, IndexOperation::Load);
assert!(guard1.is_acquired());
let guard2 = try_lock(workspace, IndexOperation::Load);
assert!(guard2.is_acquired());
let guard3 = try_lock(workspace, IndexOperation::Load);
assert!(guard3.is_acquired());
}
#[test]
fn test_exclusive_blocks_shared() {
let dir = tempdir().unwrap();
let workspace = dir.path();
// Acquire exclusive lock
let guard1 = try_lock(workspace, IndexOperation::Build);
assert!(guard1.is_acquired());
// Shared lock should fail
let result2 = try_lock(workspace, IndexOperation::Load);
assert!(!result2.is_acquired());
}
#[test]
fn test_shared_blocks_exclusive() {
let dir = tempdir().unwrap();
let workspace = dir.path();
// Acquire shared lock
let guard1 = try_lock(workspace, IndexOperation::Load);
assert!(guard1.is_acquired());
// Exclusive lock should fail
let result2 = try_lock(workspace, IndexOperation::Build);
assert!(!result2.is_acquired());
// Drop shared lock
drop(guard1);
// Now exclusive should succeed
let guard3 = try_lock(workspace, IndexOperation::Build);
assert!(guard3.is_acquired());
}
#[test]
fn test_different_workspaces_independent() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
// Locks on different workspaces should be independent
let guard1 = try_lock(dir1.path(), IndexOperation::Build);
assert!(guard1.is_acquired());
let guard2 = try_lock(dir2.path(), IndexOperation::Build);
assert!(guard2.is_acquired());
}
#[test]
fn test_lock_file_created_for_exclusive() {
let dir = tempdir().unwrap();
let workspace = dir.path();
let lock_file = get_lock_file_path(workspace);
// No lock file initially
assert!(!lock_file.exists());
// Acquire exclusive lock
let guard = try_lock(workspace, IndexOperation::Build);
assert!(guard.is_acquired());
// Lock file should exist
assert!(lock_file.exists());
// Check contents
let contents = std::fs::read_to_string(&lock_file).unwrap();
assert!(contents.contains("operation=build"));
assert!(contents.contains(&format!("pid={}", std::process::id())));
// Drop guard
drop(guard);
// Lock file should be removed
assert!(!lock_file.exists());
}
}

View file

@ -0,0 +1,14 @@
//! Index management: building, caching, locking, and updating.
mod builder;
pub mod cache;
pub mod lock;
pub use builder::{IndexBuilder, IndexError, Result};
pub use cache::{
CACHE_FILE_NAME, CacheError, cache_exists, cache_size, get_cache_path, load_index, save_index,
save_index_async,
};
pub use lock::{
IndexOperation, LockResult, WorkspaceLockGuard, is_operation_in_progress, try_lock,
};

View file

@ -0,0 +1,844 @@
//! Location-based navigation APIs for go-to-definition and go-to-references.
//!
//! This module provides APIs that take a file path and position (row, column)
//! and return definition or reference locations.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::languages::LanguageRegistry;
use crate::scope_graph::ScopeGraphIndex;
/// Result of a navigation operation (go-to-definition or go-to-references).
#[derive(Debug, Clone)]
pub struct NavigationResult {
/// The symbol that was found at the query position.
pub symbol: String,
/// List of locations where the symbol is defined/referenced.
pub locations: Vec<Location>,
}
/// A location in a file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
/// Path to the file.
pub path: String,
/// 1-indexed line number.
pub line: usize,
/// Optional: the matched symbol name (useful for aliases).
pub symbol: Option<String>,
}
impl Location {
pub fn new(path: impl Into<String>, line: usize) -> Self {
Self {
path: path.into(),
line,
symbol: None,
}
}
pub fn with_symbol(path: impl Into<String>, line: usize, symbol: String) -> Self {
Self {
path: path.into(),
line,
symbol: Some(symbol),
}
}
/// Get the path as a Path reference.
pub fn as_path(&self) -> &Path {
Path::new(&self.path)
}
}
/// Error type for navigation operations.
#[derive(Debug)]
pub enum NavigationError {
/// File not found or could not be read.
FileNotFound(PathBuf),
/// Position is out of bounds for the file.
PositionOutOfBounds { row: usize, col: usize },
/// No symbol found at the given position.
NoSymbolAtPosition { row: usize, col: usize },
/// Language not supported for this file type.
UnsupportedLanguage(String),
/// Parse error.
ParseError(String),
/// IO error.
IoError(std::io::Error),
}
impl std::fmt::Display for NavigationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NavigationError::FileNotFound(path) => {
write!(f, "File not found: {}", path.display())
}
NavigationError::PositionOutOfBounds { row, col } => {
write!(f, "Position out of bounds: {}:{}", row, col)
}
NavigationError::NoSymbolAtPosition { row, col } => {
write!(f, "No symbol found at position {}:{}", row, col)
}
NavigationError::UnsupportedLanguage(ext) => {
write!(f, "Unsupported language: {}", ext)
}
NavigationError::ParseError(msg) => write!(f, "Parse error: {}", msg),
NavigationError::IoError(e) => write!(f, "IO error: {}", e),
}
}
}
impl std::error::Error for NavigationError {}
impl From<std::io::Error> for NavigationError {
fn from(e: std::io::Error) -> Self {
NavigationError::IoError(e)
}
}
/// Navigator provides location-based code navigation.
///
/// It wraps a ScopeGraphIndex and provides methods to navigate code
/// based on file path and position (row, column).
pub struct Navigator {
index: Arc<ScopeGraphIndex>,
registry: LanguageRegistry,
}
impl Navigator {
/// Create a new Navigator backed by a shared index.
///
/// Accepts anything that converts into `Arc<ScopeGraphIndex>`, so both
/// owned and already-shared indexes work without extra wrapping:
///
/// ```rust,ignore
/// // From an owned index (e.g. IndexBuilder)
/// let navigator = Navigator::new(index);
///
/// // From a shared snapshot (zero-cost)
/// let snapshot = handle.get_snapshot()?;
/// let navigator = Navigator::new(snapshot);
/// ```
pub fn new(index: impl Into<Arc<ScopeGraphIndex>>) -> Self {
Self {
index: index.into(),
registry: LanguageRegistry::new(),
}
}
/// Get a reference to the underlying index.
pub fn index(&self) -> &ScopeGraphIndex {
&self.index
}
/// Get a mutable reference to the underlying index.
///
/// Uses copy-on-write: if other `Arc` clones of the index exist, the
/// index is cloned before returning the mutable reference.
pub fn index_mut(&mut self) -> &mut ScopeGraphIndex {
Arc::make_mut(&mut self.index)
}
/// Get the symbol at the given file path and position.
///
/// # Arguments
/// * `file_path` - Path to the file
/// * `row` - 1-indexed line number
/// * `col` - 1-indexed column number
///
/// # Returns
/// The symbol name at the given position.
pub fn get_symbol_at_position(
&self,
file_path: &Path,
row: usize,
col: usize,
) -> Result<String, NavigationError> {
// Validate position (1-indexed)
if row == 0 || col == 0 {
return Err(NavigationError::PositionOutOfBounds { row, col });
}
let content = std::fs::read(file_path)
.map_err(|_| NavigationError::FileNotFound(file_path.to_path_buf()))?;
// Get the language config
let lang_config = self.registry.for_file_path(file_path).ok_or_else(|| {
NavigationError::UnsupportedLanguage(
file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown")
.to_string(),
)
})?;
// Parse the file
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&lang_config.language())
.map_err(|e| NavigationError::ParseError(format!("Failed to set language: {}", e)))?;
let tree = parser
.parse(&content, None)
.ok_or_else(|| NavigationError::ParseError("Failed to parse file".to_string()))?;
// Convert 1-indexed row/col to 0-indexed for tree-sitter
let point = tree_sitter::Point::new(row - 1, col - 1);
// Find the node at the position
let root = tree.root_node();
let node = find_smallest_named_node_at_point(root, point);
match node {
Some(n) => {
let text = std::str::from_utf8(&content[n.byte_range()])
.map_err(|_| NavigationError::ParseError("Invalid UTF-8".to_string()))?;
Ok(text.to_string())
}
None => Err(NavigationError::NoSymbolAtPosition { row, col }),
}
}
/// Go to definition for the symbol at the given position.
///
/// # Arguments
/// * `file_path` - Path to the file
/// * `row` - 1-indexed line number
/// * `col` - 1-indexed column number
///
/// # Returns
/// NavigationResult containing the symbol and its definition locations.
pub fn goto_definition(
&self,
file_path: &Path,
row: usize,
col: usize,
) -> Result<NavigationResult, NavigationError> {
let symbol = self.get_symbol_at_position(file_path, row, col)?;
// Look up definitions in the index
let defs =
self.index
.find_definitions_smart(&symbol, Some(file_path), Some(&self.registry));
let locations: Vec<Location> = defs
.into_iter()
.map(|(path, line)| Location::new(path, line))
.collect();
Ok(NavigationResult { symbol, locations })
}
/// Go to references for the symbol at the given position.
///
/// This first resolves the symbol to its definition, then finds all references.
///
/// # Arguments
/// * `file_path` - Path to the file
/// * `row` - 1-indexed line number
/// * `col` - 1-indexed column number
/// * `include_definition` - Whether to include the definition location in results
///
/// # Returns
/// NavigationResult containing the symbol and its reference locations.
pub fn goto_references(
&self,
file_path: &Path,
row: usize,
col: usize,
include_definition: bool,
) -> Result<NavigationResult, NavigationError> {
let symbol = self.get_symbol_at_position(file_path, row, col)?;
// Get references (includes alias resolution)
let refs = self
.index
.find_references_smart(&symbol, Some(file_path), Some(&self.registry));
let mut locations: Vec<Location> = refs
.into_iter()
.map(|(sym, path, line)| Location::with_symbol(path, line, sym))
.collect();
// Optionally include definition locations
if include_definition {
let defs =
self.index
.find_definitions_smart(&symbol, Some(file_path), Some(&self.registry));
for (path, line) in defs {
let loc = Location::new(path, line);
if !locations
.iter()
.any(|l| l.path == loc.path && l.line == loc.line)
{
locations.insert(0, loc);
}
}
}
Ok(NavigationResult { symbol, locations })
}
/// Go to definition by symbol name directly (without position lookup).
pub fn goto_definition_by_name(
&self,
symbol: &str,
context_file: Option<&Path>,
) -> NavigationResult {
let defs = self
.index
.find_definitions_smart(symbol, context_file, Some(&self.registry));
let locations: Vec<Location> = defs
.into_iter()
.map(|(path, line)| Location::new(path, line))
.collect();
NavigationResult {
symbol: symbol.to_string(),
locations,
}
}
/// Go to references by symbol name directly (without position lookup).
pub fn goto_references_by_name(
&self,
symbol: &str,
context_file: Option<&Path>,
include_definition: bool,
) -> NavigationResult {
let refs = self
.index
.find_references_smart(symbol, context_file, Some(&self.registry));
let mut locations: Vec<Location> = refs
.into_iter()
.map(|(sym, path, line)| Location::with_symbol(path, line, sym))
.collect();
if include_definition {
let defs =
self.index
.find_definitions_smart(symbol, context_file, Some(&self.registry));
for (path, line) in defs {
let loc = Location::new(path, line);
if !locations
.iter()
.any(|l| l.path == loc.path && l.line == loc.line)
{
locations.insert(0, loc);
}
}
}
NavigationResult {
symbol: symbol.to_string(),
locations,
}
}
}
/// Find the smallest named node that contains the given point.
fn find_smallest_named_node_at_point(
node: tree_sitter::Node<'_>,
point: tree_sitter::Point,
) -> Option<tree_sitter::Node<'_>> {
// Check if point is within this node
if point < node.start_position() || point > node.end_position() {
return None;
}
// Try to find a smaller child node that contains the point
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_smallest_named_node_at_point(child, point) {
// Prefer named nodes that look like identifiers
if is_identifier_like(&found) {
return Some(found);
}
// Keep searching for a better match
if is_identifier_like(&child) {
return Some(child);
}
return Some(found);
}
}
// No smaller child contains the point, return this node if it's identifier-like
if is_identifier_like(&node) {
Some(node)
} else {
None
}
}
/// Check if a node looks like an identifier.
fn is_identifier_like(node: &tree_sitter::Node<'_>) -> bool {
let kind = node.kind();
matches!(
kind,
"identifier"
| "type_identifier"
| "property_identifier"
| "field_identifier"
| "shorthand_property_identifier"
| "shorthand_property_identifier_pattern"
| "attribute" // Python
| "package_identifier" // Go
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::IndexBuilder;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_get_symbol_at_position() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "fn hello_world() {{").unwrap();
writeln!(file, " println!(\"Hello\");").unwrap();
writeln!(file, "}}").unwrap();
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Get symbol at "hello_world" (row 1, col 4)
let symbol = navigator.get_symbol_at_position(&file_path, 1, 4).unwrap();
assert_eq!(symbol, "hello_world");
}
#[test]
fn test_typescript_for_of_array_destructuring() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.ts");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"const myMap = new Map<string, {{ sessionId: string }}>();
function test(sessionId: string) {{
for (const [toolCallId, request] of myMap) {{
if (request.sessionId !== sessionId) continue;
console.log(toolCallId);
}}
}}"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'request' is found as a definition at line 4
let def_result = navigator.goto_definition_by_name("request", Some(&file_path));
assert!(
!def_result.locations.is_empty(),
"request should be found as a definition"
);
assert_eq!(
def_result.locations[0].line, 4,
"request should be defined on line 4"
);
// Test that 'toolCallId' is also found as a definition at line 4
let def_result2 = navigator.goto_definition_by_name("toolCallId", Some(&file_path));
assert!(
!def_result2.locations.is_empty(),
"toolCallId should be found as a definition"
);
assert_eq!(
def_result2.locations[0].line, 4,
"toolCallId should be defined on line 4"
);
// Test that references to 'request' are found
let ref_result = navigator.goto_references_by_name("request", Some(&file_path), false);
assert!(
!ref_result.locations.is_empty(),
"request should have references"
);
// Check that line 5 reference is found (request.sessionId)
let line5_ref = ref_result.locations.iter().any(|loc| loc.line == 5);
assert!(line5_ref, "request should be referenced on line 5");
}
#[test]
fn test_typescript_for_of_object_destructuring() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.ts");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"const items = [{{ name: "a", value: 1 }}];
for (const {{ name, value }} of items) {{
console.log(name, value);
}}"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'name' is found as a definition at line 3
let def_result = navigator.goto_definition_by_name("name", Some(&file_path));
assert!(
!def_result.locations.is_empty(),
"name should be found as a definition"
);
assert_eq!(
def_result.locations[0].line, 3,
"name should be defined on line 3"
);
// Test that 'value' is found as a definition at line 3
let def_result2 = navigator.goto_definition_by_name("value", Some(&file_path));
assert!(
!def_result2.locations.is_empty(),
"value should be found as a definition"
);
assert_eq!(
def_result2.locations[0].line, 3,
"value should be defined on line 3"
);
}
#[test]
fn test_typescript_regular_array_destructuring() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.ts");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"const arr = [1, 2, 3];
const [first, second, third] = arr;
console.log(first, second);"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'first' is found as a definition at line 2
let def_result = navigator.goto_definition_by_name("first", Some(&file_path));
assert!(
!def_result.locations.is_empty(),
"first should be found as a definition"
);
assert_eq!(
def_result.locations[0].line, 2,
"first should be defined on line 2"
);
// Test that 'second' is found as a definition at line 2
let def_result2 = navigator.goto_definition_by_name("second", Some(&file_path));
assert!(
!def_result2.locations.is_empty(),
"second should be found as a definition"
);
assert_eq!(
def_result2.locations[0].line, 2,
"second should be defined on line 2"
);
}
#[test]
fn test_typescript_regular_object_destructuring() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.ts");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"const obj = {{ foo: 1, bar: 2 }};
const {{ foo, bar }} = obj;
console.log(foo, bar);"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'foo' is found as a definition at line 2
let def_result = navigator.goto_definition_by_name("foo", Some(&file_path));
assert!(
!def_result.locations.is_empty(),
"foo should be found as a definition"
);
assert_eq!(
def_result.locations[0].line, 2,
"foo should be defined on line 2"
);
// Test that 'bar' is found as a definition at line 2
let def_result2 = navigator.goto_definition_by_name("bar", Some(&file_path));
assert!(
!def_result2.locations.is_empty(),
"bar should be found as a definition"
);
assert_eq!(
def_result2.locations[0].line, 2,
"bar should be defined on line 2"
);
}
#[test]
fn test_typescript_member_expression_reference() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.ts");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"const myObject = {{ value: 42 }};
const result = myObject.value;"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'myObject' is referenced on line 2 (myObject.value)
let ref_result = navigator.goto_references_by_name("myObject", Some(&file_path), false);
assert!(
!ref_result.locations.is_empty(),
"myObject should have references"
);
let line2_ref = ref_result.locations.iter().any(|loc| loc.line == 2);
assert!(line2_ref, "myObject should be referenced on line 2");
}
#[test]
fn test_typescript_function_parameter_destructuring() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.ts");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"function process([first, second]: number[], {{ name }}: {{ name: string }}) {{
console.log(first, second, name);
}}"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'first' is found as a definition at line 1
let def_result = navigator.goto_definition_by_name("first", Some(&file_path));
assert!(
!def_result.locations.is_empty(),
"first should be found as a definition"
);
assert_eq!(
def_result.locations[0].line, 1,
"first should be defined on line 1"
);
// Test that 'name' is found as a definition at line 1
let def_result2 = navigator.goto_definition_by_name("name", Some(&file_path));
assert!(
!def_result2.locations.is_empty(),
"name should be found as a definition"
);
assert_eq!(
def_result2.locations[0].line, 1,
"name should be defined on line 1"
);
}
#[test]
fn test_typescript_simple_function_parameters() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.ts");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"function greet(name: string, age: number) {{
console.log(name, age);
}}"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'name' is found as a definition at line 1
let def_result = navigator.goto_definition_by_name("name", Some(&file_path));
assert!(
!def_result.locations.is_empty(),
"name should be found as a definition"
);
assert_eq!(
def_result.locations[0].line, 1,
"name should be defined on line 1"
);
// Test that 'age' is found as a definition at line 1
let def_result2 = navigator.goto_definition_by_name("age", Some(&file_path));
assert!(
!def_result2.locations.is_empty(),
"age should be found as a definition"
);
assert_eq!(
def_result2.locations[0].line, 1,
"age should be defined on line 1"
);
}
#[test]
fn test_typescript_react_dependency_array_references() {
// Test that identifiers in React hook dependency arrays are captured as references
// Regression test for dependency-array reference capture.
let dir = tempdir().unwrap();
let file_path = dir.path().join("component.tsx");
let mut file = File::create(&file_path).unwrap();
writeln!(
file,
r#"import {{ useCallback, useEffect }} from 'react';
function FileTreeTab({{ basePath, onFileSelect }}) {{
const fileTree = useFileTree();
const loadDirectory = useCallback(
(path: string) => {{
return fileTree.listFiles(path);
}},
[fileTree],
);
const handleOpenPath = useCallback(
(path: string) => {{
loadDirectory(path);
}},
[loadDirectory, fileTree],
);
const handleFileSelect = useCallback(
(file) => {{
fileTree.setSelectedPath(file.absPath);
onFileSelect(file.absPath);
}},
[fileTree, onFileSelect],
);
useEffect(() => {{
loadDirectory(basePath);
}}, [basePath, loadDirectory]);
return null;
}}"#
)
.unwrap();
drop(file);
let index = IndexBuilder::new().build(dir.path()).unwrap();
let navigator = Navigator::new(index);
// Test that 'fileTree' is found in dependency arrays
let ref_result = navigator.goto_references_by_name("fileTree", Some(&file_path), false);
assert!(
!ref_result.locations.is_empty(),
"fileTree should have references"
);
// Check references in dependency arrays:
// Line 10: [fileTree]
// Line 17: [loadDirectory, fileTree]
// Line 25: [fileTree, onFileSelect]
let dep_array_lines: Vec<usize> = ref_result
.locations
.iter()
.filter(|loc| loc.line == 10 || loc.line == 17 || loc.line == 25)
.map(|loc| loc.line)
.collect();
assert!(
dep_array_lines.contains(&10),
"fileTree should be referenced on line 10 (first dependency array)"
);
assert!(
dep_array_lines.contains(&17),
"fileTree should be referenced on line 17 (second dependency array)"
);
assert!(
dep_array_lines.contains(&25),
"fileTree should be referenced on line 25 (third dependency array)"
);
// Test that 'loadDirectory' is found in dependency arrays
let ref_result2 =
navigator.goto_references_by_name("loadDirectory", Some(&file_path), false);
assert!(
!ref_result2.locations.is_empty(),
"loadDirectory should have references"
);
// Line 17: [loadDirectory, fileTree]
// Line 30: [basePath, loadDirectory]
let load_dir_refs: Vec<usize> = ref_result2
.locations
.iter()
.filter(|loc| loc.line == 17 || loc.line == 30)
.map(|loc| loc.line)
.collect();
assert!(
load_dir_refs.contains(&17),
"loadDirectory should be referenced on line 17"
);
assert!(
load_dir_refs.contains(&30),
"loadDirectory should be referenced on line 30"
);
// Test that 'onFileSelect' is found in dependency array
let ref_result3 =
navigator.goto_references_by_name("onFileSelect", Some(&file_path), false);
assert!(
!ref_result3.locations.is_empty(),
"onFileSelect should have references"
);
let on_file_select_line25 = ref_result3.locations.iter().any(|loc| loc.line == 25);
assert!(
on_file_select_line25,
"onFileSelect should be referenced on line 25 (dependency array)"
);
// Test that 'basePath' is found in dependency array
let ref_result4 = navigator.goto_references_by_name("basePath", Some(&file_path), false);
assert!(
!ref_result4.locations.is_empty(),
"basePath should have references"
);
let base_path_line30 = ref_result4.locations.iter().any(|loc| loc.line == 30);
assert!(
base_path_line30,
"basePath should be referenced on line 30 (useEffect dependency array)"
);
}
}

View file

@ -0,0 +1,22 @@
//! Edge types for the ScopeGraph.
use serde::{Deserialize, Serialize};
/// Describes the relation between two nodes in the ScopeGraph.
#[derive(Serialize, Deserialize, PartialEq, Eq, Copy, Clone, Debug)]
pub enum EdgeKind {
/// The edge weight from a nested scope to its parent scope.
ScopeToScope,
/// The edge weight from a definition to its definition scope.
DefToScope,
/// The edge weight from an import to its definition scope.
ImportToScope,
/// The edge weight from a reference to its definition.
RefToDef,
/// The edge weight from a reference to its import.
RefToImport,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
//! ScopeGraph module for per-file symbol tracking.
//!
//! A ScopeGraph represents the symbols (definitions, references, imports) and their
//! relationships within a single source file.
pub mod edges;
pub mod graph;
pub mod nodes;
pub use edges::EdgeKind;
pub use graph::{
NodeIndex, QueryVersion, ScopeGraph, ScopeGraphIndex, ScopeStack, Snippet,
extract_symbols_fast, scope_graph_from_definitions_query,
};
pub use nodes::{LocalDef, LocalImport, LocalScope, NodeKind, Reference, Symbol, SymbolId};
use crate::languages::TSLanguageConfig;
/// Result of building a scope graph, including alias pairs.
pub struct ScopeGraphResult {
/// The scope graph for the file.
pub graph: ScopeGraph,
/// Alias pairs: (alias_name, original_name).
pub aliases: Vec<(String, String)>,
}
/// Build a ScopeGraph from tree-sitter query and source.
///
/// This is a convenience wrapper around `scope_graph_from_definitions_query`.
pub fn build_scope_graph(
query: &tree_sitter::Query,
root_node: tree_sitter::Node<'_>,
src: &[u8],
language: &TSLanguageConfig,
) -> ScopeGraphResult {
let (graph, aliases) = scope_graph_from_definitions_query(query, root_node, src, language);
ScopeGraphResult { graph, aliases }
}

View file

@ -0,0 +1,180 @@
//! Node types for the ScopeGraph.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::types::Range;
/// A symbol extracted from the code.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
/// The kind/type of symbol (e.g., "function", "class", etc.)
pub kind: Arc<str>,
/// The range where the symbol appears.
pub range: Range,
}
impl Symbol {
/// Create a new symbol.
pub fn new(kind: Arc<str>, range: Range) -> Self {
Self { kind, range }
}
}
/// An opaque identifier for every symbol in a language.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct SymbolId {
/// Index into the namespace array.
pub namespace_idx: usize,
/// Index within the namespace.
pub symbol_idx: usize,
}
impl SymbolId {
/// Create a new symbol ID.
pub fn new(namespace_idx: usize, symbol_idx: usize) -> Self {
Self {
namespace_idx,
symbol_idx,
}
}
/// Get the symbol name from the namespaces.
pub fn name<'a>(&self, namespaces: &'a [Vec<String>]) -> Option<&'a str> {
namespaces
.get(self.namespace_idx)
.and_then(|ns| ns.get(self.symbol_idx))
.map(|s| s.as_str())
}
}
/// A local scope in the source code.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash, Eq)]
pub struct LocalScope {
/// The range of this scope.
pub range: Range,
}
impl LocalScope {
/// Create a new local scope.
pub fn new(range: Range) -> Self {
Self { range }
}
}
/// A local definition in the source code.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct LocalDef {
/// The range of the identifier being defined.
pub range: Range,
/// Optional symbol ID for type-aware resolution.
pub symbol_id: Option<SymbolId>,
/// The scope where this definition is visible.
pub scope: LocalScope,
}
impl LocalDef {
/// Create a new local definition.
pub fn new(range: Range, symbol_id: Option<SymbolId>, scope: LocalScope) -> Self {
Self {
range,
symbol_id,
scope,
}
}
/// Get the name of this definition from source bytes.
pub fn name<'a>(&self, src: &'a [u8]) -> &'a [u8] {
&src[self.range.start_byte()..self.range.end_byte()]
}
/// Get the scope range.
pub fn scope_range(&self) -> &Range {
&self.scope.range
}
}
/// A local import in the source code.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct LocalImport {
/// The range of the import identifier.
pub range: Range,
}
impl LocalImport {
/// Create a new local import.
pub fn new(range: Range) -> Self {
Self { range }
}
/// Get the name of this import from source bytes.
pub fn name<'a>(&self, src: &'a [u8]) -> &'a [u8] {
&src[self.range.start_byte()..self.range.end_byte()]
}
}
/// A reference to a symbol in the source code.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reference {
/// The range of the reference.
pub range: Range,
/// Optional symbol ID for type-aware resolution.
pub symbol_id: Option<SymbolId>,
}
impl Reference {
/// Create a new reference.
pub fn new(range: Range, symbol_id: Option<SymbolId>) -> Self {
Self { range, symbol_id }
}
/// Get the name of this reference from source bytes.
pub fn name<'a>(&self, src: &'a [u8]) -> &'a [u8] {
&src[self.range.start_byte()..self.range.end_byte()]
}
}
/// The type of a node in the ScopeGraph.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum NodeKind {
/// A scope node.
Scope(LocalScope),
/// A definition node.
Def(LocalDef),
/// An import node.
Import(LocalImport),
/// A reference node.
Ref(Reference),
}
impl NodeKind {
/// Construct a scope node from a range.
pub fn scope(range: Range) -> Self {
Self::Scope(LocalScope::new(range))
}
/// Produce the range spanned by this node.
pub fn range(&self) -> Range {
match self {
Self::Scope(l) => l.range,
// For definitions, return the scope range to capture the full context
Self::Def(d) => d.scope.range,
Self::Ref(r) => r.range,
Self::Import(i) => i.range,
}
}
/// Get the identifier range (the actual symbol location).
pub fn identifier_range(&self) -> Range {
match self {
Self::Scope(l) => l.range,
Self::Def(d) => d.range,
Self::Ref(r) => r.range,
Self::Import(i) => i.range,
}
}
}

View file

@ -0,0 +1,78 @@
//! File change events for live index updates.
use std::path::PathBuf;
/// Events that can trigger index updates.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileEvent {
/// A new file was created.
Created {
/// Path to the new file.
path: PathBuf,
},
/// An existing file was modified.
Modified {
/// Path to the modified file.
path: PathBuf,
},
/// A file was deleted.
Deleted {
/// Path to the deleted file.
path: PathBuf,
},
/// A file was renamed/moved.
Renamed {
/// Original path.
from: PathBuf,
/// New path.
to: PathBuf,
},
}
impl FileEvent {
/// Get the primary path associated with this event.
pub fn path(&self) -> &PathBuf {
match self {
FileEvent::Created { path } => path,
FileEvent::Modified { path } => path,
FileEvent::Deleted { path } => path,
FileEvent::Renamed { to, .. } => to,
}
}
/// Check if this event requires reparsing the file content.
pub fn requires_reparse(&self) -> bool {
match self {
FileEvent::Created { .. } => true,
FileEvent::Modified { .. } => true,
FileEvent::Deleted { .. } => false,
FileEvent::Renamed { .. } => false, // Only path update needed
}
}
/// Check if this event affects an existing indexed file.
pub fn affects_existing(&self) -> bool {
match self {
FileEvent::Created { .. } => false,
FileEvent::Modified { .. } => true,
FileEvent::Deleted { .. } => true,
FileEvent::Renamed { .. } => true,
}
}
}
impl std::fmt::Display for FileEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileEvent::Created { path } => write!(f, "Created: {}", path.display()),
FileEvent::Modified { path } => write!(f, "Modified: {}", path.display()),
FileEvent::Deleted { path } => write!(f, "Deleted: {}", path.display()),
FileEvent::Renamed { from, to } => {
write!(f, "Renamed: {} -> {}", from.display(), to.display())
}
}
}
}

View file

@ -0,0 +1,108 @@
//! Location type for query results.
//!
//! Location uses 1-indexed line and column numbers for LSP compatibility.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use super::Range;
/// A location in the codebase, used for query results.
///
/// Following LSP protocol conventions:
/// - `file_path`: Absolute path to the file
/// - `line`: 1-indexed line number
/// - `column`: 1-indexed column number
/// - `range`: Full range information (0-indexed internally)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase")]
pub struct Location {
/// File path (absolute)
pub file_path: PathBuf,
/// Line number (1-indexed)
pub line: usize,
/// Column number (1-indexed)
pub column: usize,
/// Full range information (0-indexed internally)
pub range: Range,
}
impl Location {
/// Create a new location with 1-indexed line and column.
pub fn new(file_path: PathBuf, line: usize, column: usize, range: Range) -> Self {
Self {
file_path,
line,
column,
range,
}
}
/// Create a location from a range (automatically converts to 1-indexed).
pub fn from_range(file_path: PathBuf, range: Range) -> Self {
Self {
file_path,
line: range.start_line_1indexed(),
column: range.start_column_1indexed(),
range,
}
}
/// Get the file path.
pub fn file_path(&self) -> &PathBuf {
&self.file_path
}
/// Alias for file_path() - for compatibility.
pub fn path(&self) -> &PathBuf {
&self.file_path
}
/// Get the 1-indexed line number.
pub fn line(&self) -> usize {
self.line
}
/// Get the 1-indexed column number.
pub fn column(&self) -> usize {
self.column
}
/// Get the range (0-indexed internally).
pub fn range(&self) -> &Range {
&self.range
}
/// Get the file extension, if any.
pub fn extension(&self) -> Option<&str> {
self.file_path.extension().and_then(|e| e.to_str())
}
/// Get the parent directory of the file.
pub fn parent_dir(&self) -> Option<&std::path::Path> {
self.file_path.parent()
}
/// Get the 0-indexed line number (for internal use).
pub fn line_0indexed(&self) -> usize {
self.line.saturating_sub(1)
}
/// Get the 0-indexed column number (for internal use).
pub fn column_0indexed(&self) -> usize {
self.column.saturating_sub(1)
}
}
impl std::fmt::Display for Location {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}:{}:{}",
self.file_path.display(),
self.line,
self.column
)
}
}

View file

@ -0,0 +1,119 @@
//! Core types for the goto_index crate.
use std::sync::Arc;
mod file_event;
mod location;
mod range;
pub use file_event::FileEvent;
pub use location::Location;
pub use range::{Position, Range};
/// Statistics about an index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IndexStats {
/// Number of files indexed
pub files: usize,
/// Number of symbol definitions
pub definitions: usize,
/// Number of symbol references
pub references: usize,
}
impl IndexStats {
/// Create new index stats.
pub fn new(files: usize, definitions: usize, references: usize) -> Self {
Self {
files,
definitions,
references,
}
}
}
/// A symbol with its line number (1-indexed).
/// Uses Arc<str> to avoid extra allocation when merging into index.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolOccurrence {
/// The symbol name
pub name: Arc<str>,
/// Line number (1-indexed)
pub line: usize,
}
impl SymbolOccurrence {
/// Create a new symbol occurrence.
pub fn new(name: Arc<str>, line: usize) -> Self {
Self { name, line }
}
}
/// An alias mapping (alias_name -> original_name).
/// Uses Arc<str> to avoid extra allocation when merging into index.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolAlias {
/// The alias name (e.g., imported as)
pub alias: Arc<str>,
/// The original symbol name
pub original: Arc<str>,
}
impl SymbolAlias {
/// Create a new symbol alias.
pub fn new(alias: Arc<str>, original: Arc<str>) -> Self {
Self { alias, original }
}
}
/// File metadata for staleness detection.
///
/// Stores size and modification time to quickly detect if a file has changed
/// without reading its contents.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FileMeta {
/// File size in bytes
pub size: u64,
/// Modification time as seconds since UNIX epoch
pub mtime_secs: i64,
/// Modification time nanoseconds component
pub mtime_nanos: u32,
}
impl FileMeta {
/// Create new file metadata.
pub fn new(size: u64, mtime_secs: i64, mtime_nanos: u32) -> Self {
Self {
size,
mtime_secs,
mtime_nanos,
}
}
/// Create file metadata from std::fs::Metadata.
pub fn from_metadata(meta: &std::fs::Metadata) -> Self {
let size = meta.len();
let (mtime_secs, mtime_nanos) = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| (d.as_secs() as i64, d.subsec_nanos()))
.unwrap_or((0, 0));
Self {
size,
mtime_secs,
mtime_nanos,
}
}
/// Check if the file has changed compared to current filesystem state.
pub fn is_stale(&self, path: &std::path::Path) -> bool {
match std::fs::metadata(path) {
Ok(meta) => {
let current = Self::from_metadata(&meta);
*self != current
}
Err(_) => true, // File deleted or inaccessible
}
}
}

View file

@ -0,0 +1,365 @@
//! Position and Range types for representing source code locations.
//!
//! These types follow LSP conventions:
//! - Internally stored as 0-indexed (tree-sitter compatible)
//! - Public API provides both 0-indexed and 1-indexed accessors
use serde::{Deserialize, Serialize};
/// A position in a source file.
///
/// Positions are stored as 0-indexed internally (tree-sitter compatible).
/// Use `line_1indexed()` and `column_1indexed()` for display/LSP output.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "camelCase")]
pub struct Position {
/// Line number (0-indexed)
line: usize,
/// Character/column number (0-indexed)
character: usize,
/// Byte offset from start of file
byte_offset: usize,
}
impl Position {
/// Create a new position (0-indexed).
pub fn new(line: usize, character: usize, byte_offset: usize) -> Self {
Self {
line,
character,
byte_offset,
}
}
/// Create a position from a tree-sitter point.
pub fn from_tree_sitter_point(point: &tree_sitter::Point, byte_offset: usize) -> Self {
Self {
line: point.row,
character: point.column,
byte_offset,
}
}
/// Convert to tree-sitter Point.
pub fn to_tree_sitter(&self) -> tree_sitter::Point {
tree_sitter::Point::new(self.line, self.character)
}
/// Get the line number (0-indexed).
pub fn line(&self) -> usize {
self.line
}
/// Get the line number (1-indexed, for LSP/display).
pub fn line_1indexed(&self) -> usize {
self.line + 1
}
/// Get the column/character number (0-indexed).
pub fn column(&self) -> usize {
self.character
}
/// Get the column/character number (1-indexed, for LSP/display).
pub fn column_1indexed(&self) -> usize {
self.character + 1
}
/// Alias for column() - matches LSP terminology.
pub fn character(&self) -> usize {
self.character
}
/// Get the byte offset.
pub fn byte_offset(&self) -> usize {
self.byte_offset
}
/// Get the byte offset (alias).
pub fn to_byte_offset(&self) -> usize {
self.byte_offset
}
/// Set the byte offset.
pub fn set_byte_offset(&mut self, byte_offset: usize) {
self.byte_offset = byte_offset;
}
/// Check if this position is before or at another position.
pub fn before_other(&self, other: &Position) -> bool {
self.line < other.line || (self.line == other.line && self.character <= other.character)
}
/// Check if this position is after or at another position.
pub fn after_other(&self, other: &Position) -> bool {
self.line > other.line || (self.line == other.line && self.character >= other.character)
}
/// Create a position from a byte offset and line end indices.
pub fn from_byte(byte: usize, line_end_indices: &[u32]) -> Self {
let line = line_end_indices
.iter()
.position(|&line_end_byte| (line_end_byte as usize) > byte)
.unwrap_or(0);
let column = line
.checked_sub(1)
.and_then(|idx| line_end_indices.get(idx))
.map(|&prev_line_end| byte.saturating_sub(prev_line_end as usize))
.unwrap_or(byte);
Self::new(line, column, byte)
}
/// Shift the column by a given amount.
pub fn shift_column(self, column_move: usize) -> Self {
Self {
line: self.line,
character: self.character + column_move.saturating_sub(1),
byte_offset: 0,
}
}
/// Move to the next line.
pub fn move_to_next_line(mut self) -> Self {
self.line += 1;
self.character = 0;
self.byte_offset = 0;
self
}
}
impl From<tree_sitter::Point> for Position {
fn from(point: tree_sitter::Point) -> Self {
Self {
line: point.row,
character: point.column,
byte_offset: 0,
}
}
}
impl From<Position> for tree_sitter::Point {
fn from(val: Position) -> Self {
val.to_tree_sitter()
}
}
/// A range in a source file.
///
/// Ranges are stored as 0-indexed internally (tree-sitter compatible).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "camelCase")]
pub struct Range {
/// Start position of the range
start_position: Position,
/// End position of the range
end_position: Position,
}
impl Range {
/// Create a new range from start and end positions.
pub fn new(start_position: Position, end_position: Position) -> Self {
Self {
start_position,
end_position,
}
}
/// Create a range from a tree-sitter node.
pub fn for_tree_node(node: &tree_sitter::Node) -> Self {
let range = node.range();
Self {
start_position: Position {
line: range.start_point.row,
character: range.start_point.column,
byte_offset: range.start_byte,
},
end_position: Position {
line: range.end_point.row,
character: range.end_point.column,
byte_offset: range.end_byte,
},
}
}
/// Alias for for_tree_node (compatibility).
pub fn from_tree_sitter_node(node: &tree_sitter::Node) -> Self {
Self::for_tree_node(node)
}
/// Get the start position.
pub fn start_position(&self) -> Position {
self.start_position
}
/// Get the end position.
pub fn end_position(&self) -> Position {
self.end_position
}
/// Get the start position by reference.
pub fn get_start_position(&self) -> &Position {
&self.start_position
}
/// Get the end position by reference.
pub fn get_end_position(&self) -> &Position {
&self.end_position
}
/// Set the start position.
pub fn set_start_position(&mut self, position: Position) {
self.start_position = position;
}
/// Set the end position.
pub fn set_end_position(&mut self, position: Position) {
self.end_position = position;
}
/// Set the start byte offset.
pub fn set_start_byte(&mut self, byte: usize) {
self.start_position.set_byte_offset(byte);
}
/// Set the end byte offset.
pub fn set_end_byte(&mut self, byte: usize) {
self.end_position.set_byte_offset(byte);
}
/// Get the start byte offset.
pub fn start_byte(&self) -> usize {
self.start_position.byte_offset
}
/// Get the end byte offset.
pub fn end_byte(&self) -> usize {
self.end_position.byte_offset
}
/// Get the start line (0-indexed).
pub fn start_line(&self) -> usize {
self.start_position.line
}
/// Get the start line (1-indexed, for LSP/display).
pub fn start_line_1indexed(&self) -> usize {
self.start_position.line + 1
}
/// Get the end line (0-indexed).
pub fn end_line(&self) -> usize {
self.end_position.line
}
/// Get the end line (1-indexed, for LSP/display).
pub fn end_line_1indexed(&self) -> usize {
self.end_position.line + 1
}
/// Get the start column (0-indexed).
pub fn start_column(&self) -> usize {
self.start_position.character
}
/// Get the start column (1-indexed, for LSP/display).
pub fn start_column_1indexed(&self) -> usize {
self.start_position.character + 1
}
/// Get the end column (0-indexed).
pub fn end_column(&self) -> usize {
self.end_position.character
}
/// Get the end column (1-indexed, for LSP/display).
pub fn end_column_1indexed(&self) -> usize {
self.end_position.character + 1
}
/// Get the byte size of the range.
pub fn byte_size(&self) -> usize {
self.end_byte().saturating_sub(self.start_byte()) + 1
}
/// Get the number of bytes (alias).
pub fn len(&self) -> usize {
self.end_byte().saturating_sub(self.start_byte())
}
/// Check if the range is empty (zero bytes).
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Get the line size (number of lines spanned, can be negative for inverted ranges).
pub fn line_size(&self) -> i64 {
self.end_line() as i64 - self.start_line() as i64
}
/// Check if this range contains another range (using line/column).
pub fn contains(&self, other: &Range) -> bool {
self.contains_check_line_column(other)
}
/// Check if this range contains another range (line-only check).
pub fn contains_check_line(&self, other: &Range) -> bool {
let start_position_check = self.start_line() <= other.start_line();
let end_position_check = self.end_line() >= other.end_line();
start_position_check && end_position_check
}
/// Check if this range contains another range (line and column check).
pub fn contains_check_line_column(&self, other: &Range) -> bool {
let start_position_check = self.start_line() < other.start_line()
|| (self.start_line() == other.start_line()
&& self.start_column() <= other.start_column());
let end_position_check = self.end_line() > other.end_line()
|| (self.end_line() == other.end_line() && self.end_column() >= other.end_column());
start_position_check && end_position_check
}
/// Check if this range contains a position.
pub fn contains_position(&self, position: &Position) -> bool {
self.start_position().before_other(position) && self.end_position().after_other(position)
}
/// Check if this range contains a line.
pub fn contains_line(&self, line: usize) -> bool {
self.start_position().line() <= line && line <= self.end_position().line()
}
/// Check if this range intersects with another (line-based).
pub fn intersects_without_byte(&self, other: &Range) -> bool {
// Two ranges intersect if one starts before the other ends AND ends after the other starts
self.start_line() <= other.end_line() && self.end_line() >= other.start_line()
}
/// Convert to tree-sitter Range.
pub fn to_tree_sitter_range(&self) -> tree_sitter::Range {
tree_sitter::Range {
start_byte: self.start_position.byte_offset,
end_byte: self.end_position.byte_offset,
start_point: self.start_position.to_tree_sitter(),
end_point: self.end_position.to_tree_sitter(),
}
}
/// Create a range from byte offsets using line end indices.
pub fn from_byte_range(range: std::ops::Range<usize>, line_end_indices: &[u32]) -> Range {
let start = Position::from_byte(range.start, line_end_indices);
let end = Position::from_byte(range.end, line_end_indices);
Self::new(start, end)
}
/// Check equality based on line numbers only.
pub fn check_equality_without_byte(&self, other: &Range) -> bool {
self.start_line() == other.start_line() && self.end_line() == other.end_line()
}
/// Check equality based on line ranges.
pub fn equals_line_range(&self, other: &Range) -> bool {
self.start_line() == other.start_line() && self.end_line() == other.end_line()
}
}

View file

@ -0,0 +1,136 @@
//! Isolated RSS test for incremental reindexing.
//!
//! This test lives in its own integration-test file (and therefore its own
//! Bazel `rust_test` target / process) so that its whole-process RSS samples
//! are not polluted by the other allocation-heavy tests in
//! `memory_integration.rs` (e.g. `test_fresh_build_rss`,
//! `test_build_batch_peak_rss_is_bounded`, `test_compact_reduces_rss_vs_uncompacted`).
//!
//! Background: `libtest` runs tests in a single binary concurrently across
//! `num_cpus` threads, and VmRSS is measured per-*process*. When this test
//! ran inside `memory_integration.rs` it observed allocator churn from the
//! other tests on the same process, intermittently pushing the measured
//! "incremental growth" delta over the 20 MB budget on aarch64 fastbuild CI
//! (`run_1_of_2` and `run_2_of_2` both failed at ~31 MB).
//!
//! Keep this file to a single test. If you need to add another RSS-sensitive
//! test, give it its own file too rather than reintroducing the
//! noisy-neighbor problem.
use std::fs;
use std::path::Path;
use tempfile::tempdir;
use xai_codebase_graph::{FileEvent, IndexManager, IndexManagerConfig};
/// Read current process RSS in bytes. Supports Linux and macOS.
/// Returns `None` on unsupported platforms.
fn rss_bytes() -> Option<usize> {
#[cfg(target_os = "linux")]
{
let status = std::fs::read_to_string("/proc/self/status").ok()?;
for line in status.lines() {
if let Some(val) = line.strip_prefix("VmRSS:") {
let kb: usize = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
return Some(kb * 1024);
}
}
None
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
let output = Command::new("ps")
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
.output()
.ok()?;
let kb: usize = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.ok()?;
Some(kb * 1024)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
fn rss_mb() -> Option<f64> {
rss_bytes().map(|b| b as f64 / (1024.0 * 1024.0))
}
fn fmt_rss(rss: Option<f64>) -> String {
rss.map_or("N/A".to_string(), |v| format!("{:.1}MB", v))
}
/// Create N Rust source files in `dir`, each with `defs_per_file` function defs.
fn create_rust_files(dir: &Path, count: usize, defs_per_file: usize) {
for i in 0..count {
let mut content = String::new();
for d in 0..defs_per_file {
content.push_str(&format!("fn func_{}_{}() {{}}\n", i, d));
}
fs::write(dir.join(format!("file_{}.rs", i)), &content).unwrap();
}
}
#[test]
fn test_bulk_incremental_indexing_memory() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 500, 10);
let rss_before = rss_mb();
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
.without_cache_save();
let handle = IndexManager::spawn(config);
let stats = handle.get_stats().unwrap();
let rss_after_build = rss_mb();
println!(
"Initial build: {} files, {} defs, {} refs",
stats.files, stats.definitions, stats.references
);
println!(
"RSS: {} before → {} after build",
fmt_rss(rss_before),
fmt_rss(rss_after_build)
);
assert_eq!(stats.files, 500);
assert!(stats.definitions >= 5000);
for i in 0..100 {
let path = root.join(format!("file_{}.rs", i));
fs::write(&path, "fn modified() {}\nfn also_modified() {}\n").unwrap();
handle.send_event(FileEvent::modified(path)).unwrap();
}
let stats_after = handle.get_stats().unwrap();
let rss_after_incremental = rss_mb();
println!(
"After 100 incremental reindexes: {} files, {} defs",
stats_after.files, stats_after.definitions
);
println!("RSS after incremental: {}", fmt_rss(rss_after_incremental));
// Incremental reindexing should not grow memory significantly.
if let (Some(after_inc), Some(after_build)) = (rss_after_incremental, rss_after_build) {
let growth = after_inc - after_build;
assert!(
growth < 20.0,
"Incremental reindex grew RSS by {:.1}MB (expected <20MB)",
growth
);
}
handle.shutdown().unwrap();
}

View file

@ -0,0 +1,682 @@
//! Integration tests for memory behavior during indexing.
//!
//! These tests create real file trees, index them, send incremental events,
//! and measure RSS to detect memory regressions.
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tempfile::tempdir;
use xai_codebase_graph::{
FileEvent, IndexBuilder, IndexManager, IndexManagerConfig, ScopeGraphIndex, load_index,
save_index,
};
/// Read current process RSS in bytes. Supports Linux and macOS.
/// Returns `None` on unsupported platforms.
fn rss_bytes() -> Option<usize> {
#[cfg(target_os = "linux")]
{
let status = std::fs::read_to_string("/proc/self/status").ok()?;
for line in status.lines() {
if let Some(val) = line.strip_prefix("VmRSS:") {
let kb: usize = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
return Some(kb * 1024);
}
}
None
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
let output = Command::new("ps")
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
.output()
.ok()?;
let kb: usize = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.ok()?;
Some(kb * 1024)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
fn rss_mb() -> Option<f64> {
rss_bytes().map(|b| b as f64 / (1024.0 * 1024.0))
}
fn fmt_rss(rss: Option<f64>) -> String {
rss.map_or("N/A".to_string(), |v| format!("{:.1}MB", v))
}
/// Create N Rust source files in `dir`, each with `defs_per_file` function defs.
fn create_rust_files(dir: &Path, count: usize, defs_per_file: usize) {
for i in 0..count {
let mut content = String::new();
for d in 0..defs_per_file {
content.push_str(&format!("fn func_{}_{}() {{}}\n", i, d));
}
fs::write(dir.join(format!("file_{}.rs", i)), &content).unwrap();
}
}
/// Create N binary files with a supported extension.
fn create_binary_files(dir: &Path, count: usize, size: usize) {
for i in 0..count {
let mut data = vec![0xFFu8; size];
// Ensure null byte in first 8000 bytes for binary detection
data[50] = 0;
fs::write(dir.join(format!("binary_{}.rs", i)), &data).unwrap();
}
}
// =========================================================================
// Tests
// =========================================================================
#[test]
fn test_binary_files_no_memory_growth() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("legit.rs"), "fn legit() {}").unwrap();
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
.without_cache_save();
let handle = IndexManager::spawn(config);
let _ = handle.get_file_count();
// Create 200 binary files with .rs extension, each 100KB
create_binary_files(root, 200, 100_000);
for i in 0..200 {
let path = root.join(format!("binary_{}.rs", i));
handle.send_event(FileEvent::created(path)).unwrap();
}
let count = handle.get_file_count().unwrap();
// Binary files should not be indexed (detected via 8KB prefix read)
assert_eq!(count, 1, "Only legit.rs should be indexed");
handle.shutdown().unwrap();
}
#[test]
fn test_hidden_dir_files_not_indexed() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("normal.rs"), "fn normal() {}").unwrap();
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
.without_cache_save();
let handle = IndexManager::spawn(config);
let _ = handle.get_file_count();
// Create 300 files under a hidden directory (simulating .claude worktree)
let hidden = root.join(".claude").join("worktrees").join("session1");
fs::create_dir_all(&hidden).unwrap();
create_rust_files(&hidden, 300, 20);
for i in 0..300 {
let path = hidden.join(format!("file_{}.rs", i));
handle.send_event(FileEvent::created(path)).unwrap();
}
let count = handle.get_file_count().unwrap();
// Hidden dir files should not be indexed
assert_eq!(count, 1, "Only normal.rs should be indexed");
handle.shutdown().unwrap();
}
#[test]
fn test_oversized_files_skipped() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("small.rs"), "fn small() {}").unwrap();
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
.without_cache_save();
let handle = IndexManager::spawn(config);
let _ = handle.get_file_count();
// Create a 6MB text file with valid Rust syntax (exceeds MAX_INDEXABLE_FILE_SIZE)
let big_content = "fn big() {}\n".repeat(500_000);
let big_path = root.join("huge.rs");
fs::write(&big_path, &big_content).unwrap();
drop(big_content);
handle.send_event(FileEvent::created(big_path)).unwrap();
let count = handle.get_file_count().unwrap();
assert_eq!(count, 1, "Only small.rs should be indexed");
handle.shutdown().unwrap();
}
#[test]
fn test_event_coalescing_reduces_work() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("target.rs"), "fn original() {}").unwrap();
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
.without_cache_save();
let handle = IndexManager::spawn(config);
let _ = handle.get_file_count();
// Rapidly send 50 modify events for the same file
// Coalescing should collapse these into a single reindex
let path = root.join("target.rs");
for i in 0..50 {
fs::write(&path, format!("fn version_{}() {{}}", i)).unwrap();
handle
.send_event(FileEvent::modified(path.clone()))
.unwrap();
}
let stats = handle.get_stats().unwrap();
// Should have exactly 1 file with 1 definition (the last version)
assert_eq!(stats.files, 1);
assert!(
stats.definitions >= 1,
"Should have at least 1 definition after coalescing"
);
handle.shutdown().unwrap();
}
#[test]
fn test_builder_skips_binary_and_oversized_in_bulk() {
let dir = tempdir().unwrap();
let root = dir.path();
// Mix of valid, binary, and oversized files
create_rust_files(root, 100, 5); // 100 valid files
create_binary_files(root, 50, 10_000); // 50 binary files
// One oversized file
let big = "fn x() {}\n".repeat(600_000); // ~6MB
fs::write(root.join("oversized.rs"), &big).unwrap();
drop(big);
let index = IndexBuilder::new().build(root).unwrap();
let (files, defs, _refs) = index.stats();
// Only the 100 valid files should be indexed
assert_eq!(files, 100);
assert!(defs >= 500); // 100 files × 5 defs
}
/// Measure RSS growth from a single `get_snapshot()` call on a representative index.
///
/// This test characterises the per-clone cost so we have a baseline before any
/// structural changes to `ScopeGraphIndex`. It does not enforce a hard byte
/// limit because RSS jitter in CI can be significant; instead it prints the
/// delta so regressions are visible in test output.
#[test]
fn test_single_snapshot_rss() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 500, 10); // 500 files, 5 000 defs
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
.without_cache_save();
let handle = IndexManager::spawn(config);
let stats = handle.get_stats().unwrap();
assert_eq!(stats.files, 500);
assert!(stats.definitions >= 5000);
let rss_before = rss_mb();
// Hold the snapshot to keep its allocation live while we sample RSS.
// get_snapshot() now returns Arc<ScopeGraphIndex>; the Arc::clone is
// zero-cost, so the delta here reflects any other allocator activity.
let snapshot: Arc<ScopeGraphIndex> = handle.get_snapshot().unwrap();
let rss_with_snapshot = rss_mb();
// Drop it and let the allocator reclaim.
drop(snapshot);
let rss_after_drop = rss_mb();
println!(
"Single snapshot RSS: {} before → {} held → {} after drop",
fmt_rss(rss_before),
fmt_rss(rss_with_snapshot),
fmt_rss(rss_after_drop),
);
if let (Some(before), Some(held)) = (rss_before, rss_with_snapshot) {
let delta_mb = held - before;
println!("Snapshot RSS delta (held): {:.1}MB", delta_mb);
// With Arc<ScopeGraphIndex> the snapshot is a pointer increment —
// no heap allocation of index data. Allow 20 MB for jitter/allocator
// metadata.
assert!(
delta_mb < 20.0,
"Snapshot Arc::clone grew RSS by {:.1}MB (expected <20MB with Arc)",
delta_mb
);
}
handle.shutdown().unwrap();
}
/// Verify that taking many snapshots and dropping them immediately does not
/// cause unbounded RSS growth (allocator should reclaim between clones).
#[test]
fn test_repeated_snapshots_rss_bounded() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 500, 10);
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
.without_cache_save();
let handle = IndexManager::spawn(config);
let _ = handle.get_stats().unwrap();
let rss_after_build = rss_mb();
// Take 20 snapshots, dropping each one before requesting the next.
for _ in 0..20 {
let _snapshot = handle.get_snapshot().unwrap();
// dropped at end of loop body
}
let rss_after_snapshots = rss_mb();
println!(
"Repeated snapshots RSS: {} after build → {} after 20 snapshots (dropped)",
fmt_rss(rss_after_build),
fmt_rss(rss_after_snapshots),
);
if let (Some(after_build), Some(after_snaps)) = (rss_after_build, rss_after_snapshots) {
let growth_mb = after_snaps - after_build;
println!("RSS growth from 20 dropped snapshots: {:.1}MB", growth_mb);
// With Arc<ScopeGraphIndex> each snapshot is a reference-count bump;
// dropping it is a decrement. No heap data is duplicated, so 20
// repeated dropped snapshots should add near-zero RSS. Allow 10 MB
// for allocator bookkeeping jitter.
assert!(
growth_mb < 10.0,
"20 Arc snapshots grew RSS by {:.1}MB (expected <10MB with Arc)",
growth_mb
);
}
handle.shutdown().unwrap();
}
/// Measure the RSS cost of a fresh index build (no cache).
///
/// This establishes a baseline for the build-phase two-phase peak that
/// the bounded merge-batching is designed to reduce on large repos.
/// The test prints the delta so regressions become visible in CI output.
#[test]
fn test_fresh_build_rss() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 500, 10); // 500 files, 5 000 defs
let rss_before = rss_mb();
// Build entirely via IndexBuilder (same path used by IndexManager on first run)
let index = IndexBuilder::new().build(root).unwrap();
let rss_after_build = rss_mb();
let (files, defs, _refs) = index.stats();
println!(
"Fresh build: {} files, {} defs — RSS {} → {}",
files,
defs,
fmt_rss(rss_before),
fmt_rss(rss_after_build)
);
if let (Some(before), Some(after)) = (rss_before, rss_after_build) {
let delta_mb = after - before;
println!("Fresh build RSS delta: {:.1}MB", delta_mb);
// 200 MB is a generous ceiling for a 500-file index with 5 000 defs.
// The bounded-batch fix targets large repos (> build_batch_size files);
// the delta here reflects the steady-state index size.
assert!(
delta_mb < 200.0,
"Fresh build grew RSS by {:.1}MB (expected <200MB for 500 files)",
delta_mb
);
}
assert_eq!(files, 500);
assert!(defs >= 5000);
}
/// Measure the RSS cost of loading an index from the cache.
///
/// This isolates the deserialization peak in `ScopeGraphIndex::read_from()` +
/// `StringInterner::from_parts()` from the build peak so that improvements to
/// each path can be tracked independently.
#[test]
fn test_cache_load_rss() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 500, 10);
// Build and save to a temp cache file
let cache_path = dir.path().join("index.bin");
let index = IndexBuilder::new().build(root).unwrap();
save_index(&cache_path, &index).unwrap();
let (files, defs, _refs) = index.stats();
// Drop the built index so the only RSS above baseline is from the load
drop(index);
let rss_before_load = rss_mb();
let loaded = load_index(&cache_path).unwrap();
let rss_after_load = rss_mb();
println!(
"Cache load: {} files, {} defs — RSS {} → {}",
files,
defs,
fmt_rss(rss_before_load),
fmt_rss(rss_after_load)
);
if let (Some(before), Some(after)) = (rss_before_load, rss_after_load) {
let delta_mb = after - before;
println!("Cache load RSS delta: {:.1}MB", delta_mb);
// 200 MB ceiling — same rationale as the build test above.
assert!(
delta_mb < 200.0,
"Cache load grew RSS by {:.1}MB (expected <200MB for 500 files)",
delta_mb
);
}
let (loaded_files, loaded_defs, _) = loaded.stats();
assert_eq!(
loaded_files, files,
"loaded file count must match built count"
);
assert_eq!(loaded_defs, defs, "loaded def count must match built count");
}
/// Verify that bounded merge-batching produces the same index as unbounded.
///
/// Uses a very small build_batch_size to exercise the multi-batch code path
/// even on this small corpus, then compares stats against an unbatched build.
#[test]
fn test_build_batch_size_produces_correct_index() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 200, 5); // 200 files, 1 000 defs
// Build with a very small batch size (10 files per merge batch)
let batched = IndexBuilder::new()
.with_build_batch_size(10)
.build(root)
.unwrap();
// Build without batching restriction for comparison
let reference = IndexBuilder::new().build(root).unwrap();
let (b_files, b_defs, b_refs) = batched.stats();
let (r_files, r_defs, r_refs) = reference.stats();
assert_eq!(
b_files, r_files,
"file count must match regardless of batch size"
);
assert_eq!(
b_defs, r_defs,
"definition count must match regardless of batch size"
);
assert_eq!(
b_refs, r_refs,
"reference count must match regardless of batch size"
);
}
/// Demonstrate that bounded merge-batching caps the transient peak RSS that
/// occurs while `FileSymbols` are live in memory.
///
/// Strategy: run both builds under a background RSS-polling thread so we can
/// observe the peak that is otherwise invisible to before/after measurements.
/// On a 1 000-file corpus the per-batch buffer is small relative to OS-page
/// granularity, so we do **not** assert a hard numeric improvement; instead
/// we assert that the batched peak is not *worse* than the unbounded peak
/// (ruling out regressions) and print both deltas for CI visibility.
///
/// At production scale (tens of thousands of files) the bounded-batch effect
/// is proportionally much larger and easily observable in profiling output.
#[test]
fn test_build_batch_peak_rss_is_bounded() {
use std::sync::{
Mutex,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
let dir = tempdir().unwrap();
let root = dir.path();
// 1 000 files, 20 defs each — enough to make FileSymbols payload non-trivial
create_rust_files(root, 1000, 20);
/// Spawn a background thread that polls RSS every 2ms and returns the peak.
fn track_peak_during<F: FnOnce()>(f: F) -> f64 {
let peak = Arc::new(Mutex::new(rss_mb().unwrap_or(0.0)));
let stop = Arc::new(AtomicBool::new(false));
let peak_bg = Arc::clone(&peak);
let stop_bg = Arc::clone(&stop);
let monitor = std::thread::spawn(move || {
while !stop_bg.load(Ordering::Relaxed) {
if let Some(rss) = rss_mb() {
let mut p = peak_bg.lock().unwrap();
if rss > *p {
*p = rss;
}
}
std::thread::sleep(Duration::from_millis(2));
}
});
f();
stop.store(true, Ordering::Relaxed);
monitor.join().unwrap();
*peak.lock().unwrap()
}
let baseline = rss_mb().unwrap_or(0.0);
// Batched: 50 files per merge batch → 20 batches for 1 000 files.
// FileSymbols from at most 50 files are live at any one time.
let peak_batched = track_peak_during(|| {
let _idx = IndexBuilder::new()
.with_build_batch_size(50)
.build(root)
.unwrap();
});
// Unbounded: all 1 000 files parsed before any merge starts.
let peak_unbatched = track_peak_during(|| {
let _idx = IndexBuilder::new()
.with_build_batch_size(5_000)
.build(root)
.unwrap();
});
println!(
"Build peak RSS — baseline: {:.1}MB, batched (50/batch): {:.1}MB \
(+{:.1}MB), unbounded: {:.1}MB (+{:.1}MB)",
baseline,
peak_batched,
peak_batched - baseline,
peak_unbatched,
peak_unbatched - baseline,
);
// The batched build must not exhibit meaningfully higher peak RSS than the
// unbounded build — that would indicate the batching is broken.
// Allow 30 MB of jitter from concurrent allocator/OS activity.
if peak_batched > 0.0 && peak_unbatched > 0.0 {
assert!(
peak_batched <= peak_unbatched + 30.0,
"Batched build peaked {:.1}MB higher than unbounded (>30MB unexpected)",
peak_batched - peak_unbatched
);
}
// Both configurations must produce the same logical index.
let batched_idx = IndexBuilder::new()
.with_build_batch_size(50)
.build(root)
.unwrap();
let unbatched_idx = IndexBuilder::new()
.with_build_batch_size(5_000)
.build(root)
.unwrap();
let (b_files, b_defs, b_refs) = batched_idx.stats();
let (u_files, u_defs, u_refs) = unbatched_idx.stats();
assert_eq!(b_files, u_files, "file count must match");
assert_eq!(b_defs, u_defs, "definition count must match");
assert_eq!(b_refs, u_refs, "reference count must match");
}
// =============================================================================
// Structural compaction tests
// =============================================================================
/// Verify that an index survives a save/load round-trip after compact().
///
/// Guards against regressions in the binary format introduced by the u32
/// line-number change. compact() is already called by IndexBuilder::build,
/// so no explicit call is needed here.
#[test]
fn test_compact_then_save_load_roundtrip() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 50, 4); // 50 files, 200 defs
// build() calls compact() internally via build_fast()
let original = IndexBuilder::new().build(root).unwrap();
let cache_path = dir.path().join("test_index.bin");
save_index(&cache_path, &original).unwrap();
let loaded = load_index(&cache_path).unwrap();
let (orig_files, orig_defs, orig_refs) = original.stats();
let (load_files, load_defs, load_refs) = loaded.stats();
assert_eq!(orig_files, load_files, "file count survives round-trip");
assert_eq!(orig_defs, load_defs, "def count survives round-trip");
assert_eq!(orig_refs, load_refs, "ref count survives round-trip");
// Verify a representative symbol round-trips
let sym = "func_0_0";
let orig_locs = original.find_definitions(sym);
let load_locs = loaded.find_definitions(sym);
assert_eq!(
orig_locs, load_locs,
"definition locations must survive save/load round-trip"
);
}
/// Compare RSS between an uncompacted index (built via raw API, no Vec
/// shrinking) and a compacted one, to justify the compaction scope decision.
///
/// ## Compaction scope justification
///
/// Structural compaction was planned "if justified by measurement".
/// This test provides that measurement:
///
/// - **u32 line numbers** reduce every location entry from 16 bytes
/// (`(StringId, usize)` with alignment padding on 64-bit) to 8 bytes
/// (`(StringId, u32)`) — a 2× per-entry reduction.
///
/// - **compact()** eliminates Vec doubling over-allocation that accumulates
/// during `push()`-based bulk build (typically 1.52× wasted capacity).
///
/// Together these address the per-symbol-Vec overhead without the more
/// invasive contiguous/range-based layout redesign. If a future measurement
/// shows the ceiling is still too high for very large repos (>> 50K files),
/// the contiguous-layout redesign from the plan should be revisited.
#[test]
fn test_compact_reduces_rss_vs_uncompacted() {
// Build via raw ScopeGraphIndex API so compact() is never called.
// This reproduces the steady-state heap shape *without* compact()'s shrinking.
let rss_before = rss_mb();
let mut raw = ScopeGraphIndex::new();
for i in 0..500usize {
for d in 0..10usize {
let sym = format!("func_{}_{}", i, d);
let path = format!("file_{}.rs", i);
raw.add_definition(&sym, &path, d + 1);
}
}
let rss_uncompacted = rss_mb();
let (raw_files, raw_defs, _) = raw.stats();
// compact() trims Vec slack on all location lists and the interner.
raw.compact();
let rss_compacted = rss_mb();
println!(
"PR4 RSS — uncompacted: {} ({} files, {} defs), after compact(): {}",
fmt_rss(rss_uncompacted),
raw_files,
raw_defs,
fmt_rss(rss_compacted),
);
if let (Some(unc), Some(cpt)) = (rss_uncompacted, rss_compacted) {
println!("compact() RSS change: {:.1}MB", cpt - unc);
// compact() must not increase RSS
assert!(
cpt <= unc + 5.0,
"compact() increased RSS by {:.1}MB — unexpected",
cpt - unc
);
}
// Absolute ceiling for the compacted 500-file index
if let (Some(base), Some(cpt)) = (rss_before, rss_compacted) {
let delta_mb = cpt - base;
println!("Compacted index RSS delta from baseline: {:.1}MB", delta_mb);
assert!(
delta_mb < 200.0,
"Compacted index RSS delta {:.1}MB exceeds 200MB ceiling",
delta_mb
);
}
}