M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -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 kigi_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 Ok(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()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Benchmark binary for index building.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use kigi_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()
|
||||
);
|
||||
}
|
||||
@@ -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 kigi_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: &kigi_codebase_graph::NavigationResult) {
|
||||
use serde_json::json;
|
||||
|
||||
let locations: Vec<_> = result
|
||||
.locations
|
||||
.iter()
|
||||
.map(|loc| {
|
||||
json!({
|
||||
"path": &loc.path,
|
||||
"line": loc.line,
|
||||
"symbol": loc.symbol,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = json!({
|
||||
"symbol": result.symbol,
|
||||
"locations": locations,
|
||||
});
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&output).unwrap());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 kigi_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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! # kigi-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 kigi_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 kigi_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};
|
||||
@@ -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 kigi_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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 kigi_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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Edge types for the ScopeGraph.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Describes the relation between two nodes in the ScopeGraph.
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq, Copy, Clone, Debug)]
|
||||
pub enum EdgeKind {
|
||||
/// The edge weight from a nested scope to its parent scope.
|
||||
ScopeToScope,
|
||||
|
||||
/// The edge weight from a definition to its definition scope.
|
||||
DefToScope,
|
||||
|
||||
/// The edge weight from an import to its definition scope.
|
||||
ImportToScope,
|
||||
|
||||
/// The edge weight from a reference to its definition.
|
||||
RefToDef,
|
||||
|
||||
/// The edge weight from a reference to its import.
|
||||
RefToImport,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user