diff --git a/src/ops.rs b/src/ops.rs index 4d32c55..f0e8284 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -260,8 +260,14 @@ pub fn find_dead_symbols(project: &Path) -> Result { for s in &symbols { by_name.entry(s.name.to_lowercase()).or_default().push(s); } + // Lowercased symbol names, used to bound the per-file word index to tokens + // that actually matter (avoids indexing every word in every file). + let names: HashSet<&str> = by_name.keys().map(|k| k.as_str()).collect(); - let mut line_cache: HashMap> = HashMap::new(); + // Per-file word → line-number index, built once per file and shared across + // all names. Without this, a file's lines are re-scanned once per symbol + // name — O(names × lines) instead of O(lines). + let mut word_sites: HashMap>> = HashMap::new(); let mut dead: Vec = Vec::new(); for (name_lc, syms) in &by_name { @@ -269,15 +275,12 @@ pub fn find_dead_symbols(project: &Path) -> Result { // contains it (a superset), narrowed by whole-word match. let mut sites: Vec<(String, i64)> = Vec::new(); for rel in search::files_containing(&conn, name_lc, 100_000)? { - let lines = match line_cache.entry(rel.clone()) { - std::collections::hash_map::Entry::Occupied(e) => e.get().clone(), - std::collections::hash_map::Entry::Vacant(e) => e - .insert(read_all_lines(project, &rel).unwrap_or_default()) - .clone(), - }; - for (i, line) in lines.iter().enumerate() { - if word_matches_ci(line, name_lc) && !is_import_line(line) { - sites.push((rel.clone(), (i as i64) + 1)); + let index = word_sites + .entry(rel.clone()) + .or_insert_with(|| index_words(project, &rel, &names)); + if let Some(lines) = index.get(name_lc.as_str()) { + for &ln in lines { + sites.push((rel.clone(), ln)); } } } @@ -302,10 +305,29 @@ pub fn find_dead_symbols(project: &Path) -> Result { Ok(serde_json::json!({"count": dead.len(), "symbols": dead})) } -/// Whole-word, case-insensitive match (Python/Rust names may vary in case). -fn word_matches_ci(line: &str, word: &str) -> bool { - line.split(|c: char| !c.is_alphanumeric() && c != '_') - .any(|tok| tok.eq_ignore_ascii_case(word)) +/// Build a lowercased word → line-number index for one file, keeping only +/// tokens that are symbol names (from `names`) and skipping import/declaration +/// lines (which reference, but don't count as call sites). +fn index_words(project: &Path, rel: &str, names: &HashSet<&str>) -> HashMap> { + let mut m: HashMap> = HashMap::new(); + let Ok(lines) = read_all_lines(project, rel) else { + return m; + }; + for (i, line) in lines.iter().enumerate() { + if is_import_line(line) { + continue; + } + for tok in line.split(|c: char| !c.is_alphanumeric() && c != '_') { + if tok.is_empty() { + continue; + } + let lc = tok.to_lowercase(); + if names.contains(lc.as_str()) { + m.entry(lc).or_default().push((i as i64) + 1); + } + } + } + m } /// Decorator keywords that wire a symbol to an external framework. Such symbols @@ -828,7 +850,7 @@ fn raw_to_nodes(node: &RawNode, base: &str, depth: Option) -> Vec