Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
74
crates/codegen/xai-codebase-graph/Cargo.toml
Normal file
74
crates/codegen/xai-codebase-graph/Cargo.toml
Normal 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
|
||||
233
crates/codegen/xai-codebase-graph/src/bin/bench_file_listing.rs
Normal file
233
crates/codegen/xai-codebase-graph/src/bin/bench_file_listing.rs
Normal 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, ®istry);
|
||||
let elapsed = start.elapsed();
|
||||
println!("CLI: {} files in {:?}", files.len(), elapsed);
|
||||
}
|
||||
"git2" => {
|
||||
let start = Instant::now();
|
||||
let files = collect_files_git2(root_path, ®istry);
|
||||
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, ®istry);
|
||||
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, ®istry);
|
||||
let _ = collect_files_git2(root_path, ®istry);
|
||||
let _ = collect_files_git2_index_only(root_path, ®istry);
|
||||
|
||||
// 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, ®istry);
|
||||
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, ®istry);
|
||||
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, ®istry);
|
||||
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()
|
||||
}
|
||||
64
crates/codegen/xai-codebase-graph/src/bin/bench_index.rs
Normal file
64
crates/codegen/xai-codebase-graph/src/bin/bench_index.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
394
crates/codegen/xai-codebase-graph/src/bin/code_graph.rs
Normal file
394
crates/codegen/xai-codebase-graph/src/bin/code_graph.rs
Normal 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());
|
||||
}
|
||||
2185
crates/codegen/xai-codebase-graph/src/index_manager.rs
Normal file
2185
crates/codegen/xai-codebase-graph/src/index_manager.rs
Normal file
File diff suppressed because it is too large
Load diff
413
crates/codegen/xai-codebase-graph/src/interner.rs
Normal file
413
crates/codegen/xai-codebase-graph/src/interner.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
73
crates/codegen/xai-codebase-graph/src/languages/golang.rs
Normal file
73
crates/codegen/xai-codebase-graph/src/languages/golang.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
146
crates/codegen/xai-codebase-graph/src/languages/mod.rs
Normal file
146
crates/codegen/xai-codebase-graph/src/languages/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
38
crates/codegen/xai-codebase-graph/src/languages/python.rs
Normal file
38
crates/codegen/xai-codebase-graph/src/languages/python.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
198
crates/codegen/xai-codebase-graph/src/languages/rust.rs
Normal file
198
crates/codegen/xai-codebase-graph/src/languages/rust.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
241
crates/codegen/xai-codebase-graph/src/languages/ts.rs
Normal file
241
crates/codegen/xai-codebase-graph/src/languages/ts.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
82
crates/codegen/xai-codebase-graph/src/languages/types.rs
Normal file
82
crates/codegen/xai-codebase-graph/src/languages/types.rs
Normal 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
|
||||
}
|
||||
}
|
||||
102
crates/codegen/xai-codebase-graph/src/lib.rs
Normal file
102
crates/codegen/xai-codebase-graph/src/lib.rs
Normal 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};
|
||||
507
crates/codegen/xai-codebase-graph/src/manager/builder.rs
Normal file
507
crates/codegen/xai-codebase-graph/src/manager/builder.rs
Normal 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, ®istry))
|
||||
})
|
||||
.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)
|
||||
}
|
||||
106
crates/codegen/xai-codebase-graph/src/manager/cache.rs
Normal file
106
crates/codegen/xai-codebase-graph/src/manager/cache.rs
Normal 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())
|
||||
}
|
||||
539
crates/codegen/xai-codebase-graph/src/manager/lock.rs
Normal file
539
crates/codegen/xai-codebase-graph/src/manager/lock.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
14
crates/codegen/xai-codebase-graph/src/manager/mod.rs
Normal file
14
crates/codegen/xai-codebase-graph/src/manager/mod.rs
Normal 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,
|
||||
};
|
||||
844
crates/codegen/xai-codebase-graph/src/navigation.rs
Normal file
844
crates/codegen/xai-codebase-graph/src/navigation.rs
Normal 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
22
crates/codegen/xai-codebase-graph/src/scope_graph/edges.rs
Normal file
22
crates/codegen/xai-codebase-graph/src/scope_graph/edges.rs
Normal 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,
|
||||
}
|
||||
1726
crates/codegen/xai-codebase-graph/src/scope_graph/graph.rs
Normal file
1726
crates/codegen/xai-codebase-graph/src/scope_graph/graph.rs
Normal file
File diff suppressed because it is too large
Load diff
38
crates/codegen/xai-codebase-graph/src/scope_graph/mod.rs
Normal file
38
crates/codegen/xai-codebase-graph/src/scope_graph/mod.rs
Normal 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 }
|
||||
}
|
||||
180
crates/codegen/xai-codebase-graph/src/scope_graph/nodes.rs
Normal file
180
crates/codegen/xai-codebase-graph/src/scope_graph/nodes.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
78
crates/codegen/xai-codebase-graph/src/types/file_event.rs
Normal file
78
crates/codegen/xai-codebase-graph/src/types/file_event.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
crates/codegen/xai-codebase-graph/src/types/location.rs
Normal file
108
crates/codegen/xai-codebase-graph/src/types/location.rs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
119
crates/codegen/xai-codebase-graph/src/types/mod.rs
Normal file
119
crates/codegen/xai-codebase-graph/src/types/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
365
crates/codegen/xai-codebase-graph/src/types/range.rs
Normal file
365
crates/codegen/xai-codebase-graph/src/types/range.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
136
crates/codegen/xai-codebase-graph/tests/incremental_memory.rs
Normal file
136
crates/codegen/xai-codebase-graph/tests/incremental_memory.rs
Normal 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();
|
||||
}
|
||||
682
crates/codegen/xai-codebase-graph/tests/memory_integration.rs
Normal file
682
crates/codegen/xai-codebase-graph/tests/memory_integration.rs
Normal 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.5–2× 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue