use std::collections::HashMap;
use std::path::Path;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use crate::core::import_resolver;
use crate::core::signatures;
mod edges;
pub(crate) use edges::*;
#[cfg(test)]
mod tests;
const INDEX_VERSION: u32 = 6;
use crate::core::index_paths::normalize_absolute_path;
pub use crate::core::index_paths::{graph_match_key, graph_relative_key, normalize_project_root};
pub fn is_safe_scan_root_public(path: &str) -> bool {
is_safe_scan_root(path)
}
fn is_filesystem_root(path: &str) -> bool {
let p = Path::new(path);
p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
}
fn dir_has_project_marker(dir: &Path) -> bool {
crate::core::pathutil::has_project_marker(dir)
}
fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
let mut cur = Some(p);
while let Some(dir) = cur {
if dir == stop {
return false;
}
if dir_has_project_marker(dir) {
return true;
}
cur = dir.parent();
}
false
}
fn is_safe_scan_root(path: &str) -> bool {
let normalized = normalize_project_root(path);
let p = Path::new(&normalized);
if !crate::core::pathutil::may_probe_path(p) {
return false;
}
if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
tracing::warn!("[graph_index: refusing to scan filesystem root]");
return false;
}
if normalized == "." || normalized.is_empty() {
tracing::warn!("[graph_index: refusing to scan relative/empty root]");
return false;
}
if let Some(home) = dirs::home_dir() {
let home_norm = normalize_project_root(&home.to_string_lossy());
if normalized == home_norm {
use std::sync::Once;
static HOME_WARN: Once = Once::new();
HOME_WARN.call_once(|| {
tracing::warn!(
"[graph_index: skipping — cannot index home directory {normalized}.\n \
Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
);
});
return false;
}
if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
tracing::warn!(
"[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
);
return false;
}
let home_path = Path::new(&home_norm);
const BLOCKED_HOME_SUBDIRS: &[&str] = &[
"Desktop",
"Documents",
"Downloads",
"Pictures",
"Music",
"Videos",
"Movies",
"Library",
".local",
".cache",
".config",
"snap",
"Applications",
"OneDrive",
"Dropbox",
"Google Drive",
];
for blocked in BLOCKED_HOME_SUBDIRS {
let blocked_path = home_path.join(blocked);
let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
let has_marker = has_marker_in_ancestry(p, &blocked_path);
if is_inside_blocked
&& !has_marker
&& !crate::core::pathutil::has_multi_repo_children(p)
{
tracing::warn!(
"[graph_index: refusing to scan {normalized} — \
inside home/{blocked} without project markers]"
);
return false;
}
}
if p.parent() == Some(home_path)
&& !dir_has_project_marker(p)
&& !crate::core::pathutil::has_multi_repo_children(p)
{
tracing::warn!(
"[graph_index: refusing to scan {normalized} — \
direct child of home without project markers]"
);
return false;
}
}
let breadth_markers = [
".git",
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"setup.py",
"Makefile",
"CMakeLists.txt",
"pnpm-workspace.yaml",
".projectile",
"BUILD.bazel",
"go.work",
];
if !breadth_markers.iter().any(|m| p.join(m).exists()) && !dir_has_dotnet_project(p) {
if crate::core::pathutil::has_multi_repo_children(p) {
return true;
}
let child_count = std::fs::read_dir(p).map_or(0, |rd| {
rd.filter_map(Result::ok)
.filter(|e| e.path().is_dir())
.count()
});
if child_count > 50 {
tracing::warn!(
"[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
skipping scan to avoid indexing broad directories]"
);
return false;
}
}
true
}
fn dir_has_dotnet_project(dir: &Path) -> bool {
std::fs::read_dir(dir).is_ok_and(|rd| {
rd.filter_map(Result::ok).any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| {
matches!(
x.to_ascii_lowercase().as_str(),
"csproj" | "sln" | "fsproj" | "vbproj"
)
})
})
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectIndex {
pub version: u32,
pub project_root: String,
pub last_scan: String,
pub files: HashMap<String, FileEntry>,
pub edges: Vec<IndexEdge>,
pub symbols: HashMap<String, SymbolEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileEntry {
pub path: String,
pub hash: String,
pub language: String,
pub line_count: usize,
pub token_count: usize,
pub exports: Vec<String>,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SymbolEntry {
pub file: String,
pub name: String,
pub kind: String,
pub start_line: usize,
pub end_line: usize,
pub is_exported: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IndexEdge {
pub from: String,
pub to: String,
pub kind: String,
#[serde(default = "default_edge_weight")]
pub weight: f32,
}
fn default_edge_weight() -> f32 {
1.0
}
impl ProjectIndex {
pub fn new(project_root: &str) -> Self {
Self {
version: INDEX_VERSION,
project_root: normalize_project_root(project_root),
last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
files: HashMap::new(),
edges: Vec::new(),
symbols: HashMap::new(),
}
}
pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
let normalized = normalize_project_root(project_root);
let hash = crate::core::project_hash::hash_project_root(&normalized);
crate::core::data_dir::lean_ctx_data_dir()
.ok()
.map(|d| d.join("graphs").join(hash))
}
pub fn load(project_root: &str) -> Option<Self> {
let graph = crate::core::property_graph::CodeGraph::open(project_root).ok()?;
if graph.file_catalog_count().unwrap_or(0) == 0 {
return None;
}
let provider = crate::core::graph_provider::GraphProvider::PropertyGraph(graph);
Some(provider.materialize_project_index(project_root))
}
pub fn save(&self) -> Result<(), String> {
crate::core::property_graph::mirror_index(&self.project_root, self)
.map_err(|e| e.to_string())
}
pub fn purge_stale_indices() {
let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
return;
};
let graphs_dir = data_dir.join("graphs");
let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
return;
};
let cfg = crate::core::config::Config::load();
let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let meta = path.join("graph.meta.json");
let db = path.join("graph.db");
let index_file = if meta.exists() {
&meta
} else if db.exists() {
&db
} else {
continue;
};
let is_old = index_file
.metadata()
.and_then(|m| m.modified())
.is_ok_and(|mtime| {
mtime
.elapsed()
.is_ok_and(|age| age.as_secs() > max_age_secs)
});
if is_old {
tracing::info!("[graph_index: purging stale index at {}]", path.display());
let _ = std::fs::remove_dir_all(&path);
}
}
}
pub fn file_count(&self) -> usize {
self.files.len()
}
pub fn symbol_count(&self) -> usize {
self.symbols.len()
}
pub fn edge_count(&self) -> usize {
self.edges.len()
}
pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
self.symbols.get(key)
}
pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
let mut result = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
while let Some((current, d)) = queue.pop() {
if d > depth || visited.contains(¤t) {
continue;
}
visited.insert(current.clone());
if current != path {
result.push(current.clone());
}
for edge in &self.edges {
if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
queue.push((edge.from.clone(), d + 1));
}
}
}
result
}
pub fn get_forward_deps(&self, path: &str, depth: usize) -> Vec<String> {
let mut result = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
while let Some((current, d)) = queue.pop() {
if d > depth || visited.contains(¤t) {
continue;
}
visited.insert(current.clone());
if current != path {
result.push(current.clone());
}
for edge in &self.edges {
if edge.from == current && edge.kind == "import" && !visited.contains(&edge.to) {
queue.push((edge.to.clone(), d + 1));
}
}
}
result
}
pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
let mut result = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
while let Some((current, d)) = queue.pop() {
if d > depth || visited.contains(¤t) {
continue;
}
visited.insert(current.clone());
if current != path {
result.push(current.clone());
}
for edge in &self.edges {
if edge.from == current && !visited.contains(&edge.to) {
queue.push((edge.to.clone(), d + 1));
}
if edge.to == current && !visited.contains(&edge.from) {
queue.push((edge.from.clone(), d + 1));
}
}
}
result
}
}
pub fn load_or_build(project_root: &str) -> ProjectIndex {
if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
}
let root_abs = if project_root.trim().is_empty() || project_root == "." {
std::env::current_dir().ok().map_or_else(
|| ".".to_string(),
|p| normalize_project_root(&p.to_string_lossy()),
)
} else {
normalize_project_root(project_root)
};
if !is_safe_scan_root(&root_abs) {
return ProjectIndex::new(&root_abs);
}
if let Some(idx) = ProjectIndex::load(&root_abs)
&& !idx.files.is_empty()
{
if index_looks_stale(&idx, &root_abs) {
tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
return scan(&root_abs);
}
return idx;
}
if let Ok(cwd) = std::env::current_dir() {
let cwd_str = normalize_project_root(&cwd.to_string_lossy());
if cwd_str != root_abs
&& cwd_str.starts_with(&root_abs)
&& let Some(idx) = ProjectIndex::load(&cwd_str)
&& !idx.files.is_empty()
{
if index_looks_stale(&idx, &cwd_str) {
return scan(&cwd_str);
}
return idx;
}
}
scan(&root_abs)
}
fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
if index.files.is_empty() {
return true;
}
if let Ok(scan_time) =
chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
{
let cfg = crate::core::config::Config::load();
let effective_hours = cfg.archive_max_age_hours_effective();
let max_age = chrono::Duration::hours(effective_hours as i64);
let now = chrono::Local::now().naive_local();
if now.signed_duration_since(scan_time) > max_age {
tracing::info!(
"[graph_index: index is older than {}h — marking stale]",
effective_hours
);
return true;
}
}
const CONTAMINATION_MARKERS: &[&str] = &[
"Desktop/",
"Documents/",
"Downloads/",
"Pictures/",
"Music/",
"Videos/",
"Movies/",
"Library/",
".cache/",
"snap/",
];
let contaminated = index.files.keys().take(200).any(|rel| {
CONTAMINATION_MARKERS
.iter()
.any(|m| rel.starts_with(m) || rel.contains(&format!("/{m}")))
});
if contaminated {
tracing::warn!(
"[graph_index: index contains files from user directories (Desktop/Documents/...) — \
marking stale to force clean rebuild]"
);
return true;
}
let root_path = Path::new(root_abs);
let sample_size = index.files.len().min(20);
for rel in index.files.keys().take(sample_size) {
let rel = rel.trim_start_matches(['/', '\\']);
if rel.is_empty() {
continue;
}
let abs = root_path.join(rel);
if !abs.exists() {
return true;
}
}
if source_content_changed_since_index(index, root_abs) {
tracing::info!("[graph_index: source content changed since last scan — marking stale]");
return true;
}
false
}
fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
let dir = ProjectIndex::index_dir(root_abs)?;
for name in ["graph.meta.json", "graph.db"] {
if let Ok(meta) = std::fs::metadata(dir.join(name))
&& let Ok(modified) = meta.modified()
{
return Some(modified);
}
}
None
}
fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
let Some(index_mtime) = index_file_mtime(root_abs) else {
return false;
};
let index_filter = crate::core::index_filter::IndexFileFilter::effective();
let walker = ignore::WalkBuilder::new(root_abs)
.hidden(true)
.git_ignore(index_filter.respect_gitignore)
.git_global(index_filter.respect_gitignore)
.git_exclude(index_filter.respect_gitignore)
.require_git(false)
.max_depth(Some(20))
.filter_entry(crate::core::walk_filter::keep_entry)
.build();
const MAX_VISIT: usize = 50_000;
const MAX_CONFIRM_READS: usize = 4_000;
let mut visited = 0usize;
let mut confirm_reads = 0usize;
for entry in walker.filter_map(std::result::Result::ok) {
visited += 1;
if visited > MAX_VISIT {
break;
}
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !is_indexable_ext(ext) {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(modified) = meta.modified() else {
continue;
};
if modified <= index_mtime {
continue;
}
let rel = make_relative(&path.to_string_lossy(), root_abs);
if index_filter.is_excluded(&rel.replace('\\', "/")) {
continue;
}
let Some(file_entry) = index.files.get(&rel) else {
return true;
};
confirm_reads += 1;
if confirm_reads > MAX_CONFIRM_READS {
return true;
}
match std::fs::read_to_string(path) {
Ok(content) if compute_hash(&content) == file_entry.hash => {}
_ => return true,
}
}
false
}
pub fn purge_index(project_root: &str) {
if let Some(dir) = ProjectIndex::index_dir(project_root) {
for name in [
"graph.db",
"graph.db-wal",
"graph.db-shm",
"graph.meta.json",
"index.json.zst",
"index.json",
"call_graph.json.zst",
] {
let _ = std::fs::remove_file(dir.join(name));
}
}
}
pub fn scan(project_root: &str) -> ProjectIndex {
scan_inner(project_root).0
}
pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
scan_inner(project_root)
}
fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
return (ProjectIndex::new(project_root), HashMap::new());
}
let project_root = normalize_project_root(project_root);
if !is_safe_scan_root(&project_root) {
tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
return (ProjectIndex::new(&project_root), HashMap::new());
}
let lock_name = format!(
"graph-idx-{}",
&crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
);
let _lock = crate::core::startup_guard::try_acquire_lock(
&lock_name,
std::time::Duration::from_millis(800),
std::time::Duration::from_mins(3),
);
if _lock.is_none() {
tracing::info!(
"[graph_index: another process is scanning {project_root} — returning cached or empty]"
);
return (
ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
HashMap::new(),
);
}
let existing = ProjectIndex::load(&project_root);
let mut index = ProjectIndex::new(&project_root);
let old_files: OldFileSymbols = if let Some(ref prev) = existing {
prev.files
.iter()
.map(|(path, entry)| {
let syms: Vec<(String, SymbolEntry)> = prev
.symbols
.iter()
.filter(|(_, s)| s.file == *path)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
(path.clone(), (entry.hash.clone(), syms))
})
.collect()
} else {
HashMap::new()
};
let cfg = crate::core::config::Config::load();
let index_filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
let walker = ignore::WalkBuilder::new(&project_root)
.hidden(true)
.git_ignore(index_filter.respect_gitignore)
.git_global(index_filter.respect_gitignore)
.git_exclude(index_filter.respect_gitignore)
.require_git(false)
.max_depth(Some(20))
.filter_entry(crate::core::walk_filter::keep_entry)
.build();
let extra_ignores: Vec<glob::Pattern> = cfg
.extra_ignore_patterns
.iter()
.filter_map(|p| glob::Pattern::new(p).ok())
.collect();
let mut scanned = 0usize;
let mut reused = 0usize;
let mut entries_visited = 0usize;
let mut content_cache: HashMap<String, String> = HashMap::new();
let max_files = if cfg.graph_index_max_files == 0 {
usize::MAX } else {
cfg.graph_index_max_files as usize
};
const MAX_ENTRIES_VISITED: usize = 500_000;
const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; const SCAN_BATCH_FILES: usize = 2_000;
let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
let mut targets: Vec<(String, String, String)> = Vec::new();
for entry in walker.filter_map(std::result::Result::ok) {
entries_visited += 1;
if entries_visited > MAX_ENTRIES_VISITED {
tracing::warn!(
"[graph_index: walked {entries_visited} entries — aborting scan to prevent \
runaway traversal. Indexed {} files so far.]",
targets.len()
);
break;
}
if entries_visited.is_multiple_of(5000) {
if std::time::Instant::now() > scan_deadline {
tracing::warn!(
"[graph_index: scan timeout (120s) after {entries_visited} entries — \
saving partial index with {} files]",
targets.len()
);
break;
}
if crate::core::memory_guard::abort_requested() {
tracing::warn!(
"[graph_index: memory pressure abort after {entries_visited} entries — \
saving partial index with {} files]",
targets.len()
);
break;
}
if crate::core::memory_guard::is_under_pressure() {
tracing::warn!(
"[graph_index: memory pressure detected at {entries_visited} entries — \
stopping scan with {} files]",
targets.len()
);
break;
}
if let Some(ref g) = _lock {
g.touch();
}
}
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
if entry.path_is_symlink() {
continue;
}
let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
continue;
}
if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
if meta.file_type().is_symlink() || !meta.is_file() {
continue;
}
if meta.len() > MAX_FILE_SIZE_BYTES {
tracing::debug!(
"[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
meta.len() as f64 / 1_048_576.0,
MAX_FILE_SIZE_BYTES / (1024 * 1024),
);
continue;
}
}
let ext = Path::new(&file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !is_indexable_ext(ext) {
continue;
}
let rel = make_relative(&file_path, &project_root);
if extra_ignores.iter().any(|p| p.matches(&rel)) {
continue;
}
if index_filter.is_excluded(&rel.replace('\\', "/")) {
continue;
}
if max_files != usize::MAX && targets.len() >= max_files {
tracing::info!(
"[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
max_files
);
break;
}
let ext = ext.to_string();
targets.push((file_path, rel, ext));
}
let parallel = !crate::core::memory_guard::is_under_pressure();
for (batch_no, batch) in targets.chunks(SCAN_BATCH_FILES).enumerate() {
if batch_no > 0 {
if crate::core::memory_guard::abort_requested() {
tracing::warn!(
"[graph_index: aborting scan after {} files due to critical memory pressure]",
batch_no * SCAN_BATCH_FILES
);
break;
}
if crate::core::memory_guard::is_under_pressure() {
tracing::warn!(
"[graph_index: stopping scan after {} files due to memory pressure]",
batch_no * SCAN_BATCH_FILES
);
break;
}
}
let results = process_scan_targets(batch, &old_files, existing.as_ref(), parallel);
for r in results {
if r.reused {
reused += 1;
} else {
scanned += 1;
}
index.files.insert(r.rel.clone(), r.file_entry);
for (key, sym) in r.symbols {
index.symbols.insert(key, sym);
}
content_cache.insert(r.rel, r.content);
}
}
build_edges_cached(&mut index, &content_cache);
if let Err(e) = index.save() {
tracing::warn!("could not save graph index: {e}");
}
tracing::debug!(
"[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
index.file_count(),
scanned,
reused,
index.symbol_count(),
index.edge_count()
);
(index, content_cache)
}
type OldFileSymbols = HashMap<String, (String, Vec<(String, SymbolEntry)>)>;
#[derive(Debug, PartialEq)]
struct ScanFileResult {
rel: String,
file_entry: FileEntry,
symbols: Vec<(String, SymbolEntry)>,
content: String,
reused: bool,
}
fn process_scan_file(
file_path: &str,
rel: &str,
ext: &str,
old_files: &OldFileSymbols,
existing: Option<&ProjectIndex>,
) -> Option<ScanFileResult> {
let content = std::fs::read_to_string(file_path).ok()?;
let hash = compute_hash(&content);
if let Some((old_hash, old_syms)) = old_files.get(rel)
&& *old_hash == hash
&& let Some(old_entry) = existing.and_then(|p| p.files.get(rel))
{
return Some(ScanFileResult {
rel: rel.to_string(),
file_entry: old_entry.clone(),
symbols: old_syms.clone(),
content,
reused: true,
});
}
let sigs = signatures::extract_signatures(&content, ext);
let line_count = content.lines().count();
let token_count = crate::core::tokens::count_tokens(&content);
let summary = extract_summary(&content);
let exports: Vec<String> = sigs
.iter()
.filter(|s| s.is_exported)
.map(|s| s.name.clone())
.collect();
let file_entry = FileEntry {
path: rel.to_string(),
hash,
language: ext.to_string(),
line_count,
token_count,
exports,
summary,
};
let symbols: Vec<(String, SymbolEntry)> = sigs
.iter()
.map(|sig| {
let (start, end) = sig
.start_line
.zip(sig.end_line)
.unwrap_or_else(|| find_symbol_range(&content, sig));
let key = format!("{rel}::{}", sig.name);
(
key,
SymbolEntry {
file: rel.to_string(),
name: sig.name.clone(),
kind: sig.kind.to_string(),
start_line: start,
end_line: end,
is_exported: sig.is_exported,
},
)
})
.collect();
Some(ScanFileResult {
rel: rel.to_string(),
file_entry,
symbols,
content,
reused: false,
})
}
fn process_scan_targets(
targets: &[(String, String, String)],
old_files: &OldFileSymbols,
existing: Option<&ProjectIndex>,
parallel: bool,
) -> Vec<ScanFileResult> {
if parallel {
targets
.par_iter()
.filter_map(|(file_path, rel, ext)| {
process_scan_file(file_path, rel, ext, old_files, existing)
})
.collect()
} else {
targets
.iter()
.filter_map(|(file_path, rel, ext)| {
process_scan_file(file_path, rel, ext, old_files, existing)
})
.collect()
}
}
fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
let lines: Vec<&str> = content.lines().collect();
let mut start = 0;
for (i, line) in lines.iter().enumerate() {
if line.contains(&sig.name) {
let trimmed = line.trim();
let is_def = trimmed.starts_with("fn ")
|| trimmed.starts_with("pub fn ")
|| trimmed.starts_with("pub(crate) fn ")
|| trimmed.starts_with("async fn ")
|| trimmed.starts_with("pub async fn ")
|| trimmed.starts_with("struct ")
|| trimmed.starts_with("pub struct ")
|| trimmed.starts_with("enum ")
|| trimmed.starts_with("pub enum ")
|| trimmed.starts_with("trait ")
|| trimmed.starts_with("pub trait ")
|| trimmed.starts_with("impl ")
|| trimmed.starts_with("class ")
|| trimmed.starts_with("export class ")
|| trimmed.starts_with("export function ")
|| trimmed.starts_with("export async function ")
|| trimmed.starts_with("function ")
|| trimmed.starts_with("async function ")
|| trimmed.starts_with("def ")
|| trimmed.starts_with("async def ")
|| trimmed.starts_with("func ")
|| trimmed.starts_with("interface ")
|| trimmed.starts_with("export interface ")
|| trimmed.starts_with("type ")
|| trimmed.starts_with("export type ")
|| trimmed.starts_with("const ")
|| trimmed.starts_with("export const ")
|| trimmed.starts_with("fun ")
|| trimmed.starts_with("private fun ")
|| trimmed.starts_with("public fun ")
|| trimmed.starts_with("internal fun ")
|| trimmed.starts_with("class ")
|| trimmed.starts_with("data class ")
|| trimmed.starts_with("sealed class ")
|| trimmed.starts_with("sealed interface ")
|| trimmed.starts_with("enum class ")
|| trimmed.starts_with("object ")
|| trimmed.starts_with("private object ")
|| trimmed.starts_with("interface ")
|| trimmed.starts_with("typealias ")
|| trimmed.starts_with("private typealias ");
if is_def {
start = i + 1;
break;
}
}
}
if start == 0 {
return (1, lines.len().min(20));
}
let base_indent = lines
.get(start - 1)
.map_or(0, |l| l.len() - l.trim_start().len());
let mut end = start;
let mut brace_depth: i32 = 0;
let mut found_open = false;
for (i, line) in lines.iter().enumerate().skip(start - 1) {
for ch in line.chars() {
if ch == '{' {
brace_depth += 1;
found_open = true;
} else if ch == '}' {
brace_depth -= 1;
}
}
end = i + 1;
if found_open && brace_depth <= 0 {
break;
}
if !found_open && i > start {
let indent = line.len() - line.trim_start().len();
if indent <= base_indent && !line.trim().is_empty() && i > start {
end = i;
break;
}
}
if end - start > 200 {
break;
}
}
(start, end)
}
fn extract_summary(content: &str) -> String {
for line in content.lines().take(20) {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with("//")
|| trimmed.starts_with('#')
|| trimmed.starts_with("/*")
|| trimmed.starts_with('*')
|| trimmed.starts_with("use ")
|| trimmed.starts_with("import ")
|| trimmed.starts_with("from ")
|| trimmed.starts_with("require(")
|| trimmed.starts_with("package ")
{
continue;
}
return trimmed.chars().take(120).collect();
}
String::new()
}
fn compute_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
fn short_hash(input: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
}
fn make_relative(path: &str, root: &str) -> String {
graph_relative_key(path, root)
}
fn is_indexable_ext(ext: &str) -> bool {
crate::core::language_capabilities::is_indexable_ext(ext)
}
#[cfg(test)]
fn kotlin_package_name(content: &str) -> Option<String> {
content.lines().map(str::trim).find_map(|line| {
line.strip_prefix("package ")
.map(|rest| rest.trim().trim_end_matches(';').to_string())
})
}