Make find dead-symbols linear and add code_only_line tests

find_dead_symbols previously re-scanned every candidate file's lines once
per unique symbol name (O(names × lines)). Replace with a per-file
word → line-number index built once per file and shared across all names,
bounded to tokens that are actual symbol names.

Also add unit coverage for code_only_line's string/comment blanking,
including Rust raw strings with multi-hash delimiters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
endergebnisandCopilot committed 2026-09-08 20:02:29 +02:00
1 parent f1531a64d4
commit b5389341a1
1 file changed
+63 -15
+63 -15
View File
@@ -260,8 +260,14 @@ pub fn find_dead_symbols(project: &Path) -> Result<Value> {
for s in &symbols { for s in &symbols {
by_name.entry(s.name.to_lowercase()).or_default().push(s); 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<String, Vec<String>> = 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<String, HashMap<String, Vec<i64>>> = HashMap::new();
let mut dead: Vec<Value> = Vec::new(); let mut dead: Vec<Value> = Vec::new();
for (name_lc, syms) in &by_name { for (name_lc, syms) in &by_name {
@@ -269,15 +275,12 @@ pub fn find_dead_symbols(project: &Path) -> Result<Value> {
// contains it (a superset), narrowed by whole-word match. // contains it (a superset), narrowed by whole-word match.
let mut sites: Vec<(String, i64)> = Vec::new(); let mut sites: Vec<(String, i64)> = Vec::new();
for rel in search::files_containing(&conn, name_lc, 100_000)? { for rel in search::files_containing(&conn, name_lc, 100_000)? {
let lines = match line_cache.entry(rel.clone()) { let index = word_sites
std::collections::hash_map::Entry::Occupied(e) => e.get().clone(), .entry(rel.clone())
std::collections::hash_map::Entry::Vacant(e) => e .or_insert_with(|| index_words(project, &rel, &names));
.insert(read_all_lines(project, &rel).unwrap_or_default()) if let Some(lines) = index.get(name_lc.as_str()) {
.clone(), for &ln in lines {
}; sites.push((rel.clone(), ln));
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));
} }
} }
} }
@@ -302,10 +305,29 @@ pub fn find_dead_symbols(project: &Path) -> Result<Value> {
Ok(serde_json::json!({"count": dead.len(), "symbols": dead})) Ok(serde_json::json!({"count": dead.len(), "symbols": dead}))
} }
/// Whole-word, case-insensitive match (Python/Rust names may vary in case). /// Build a lowercased word → line-number index for one file, keeping only
fn word_matches_ci(line: &str, word: &str) -> bool { /// tokens that are symbol names (from `names`) and skipping import/declaration
line.split(|c: char| !c.is_alphanumeric() && c != '_') /// lines (which reference, but don't count as call sites).
.any(|tok| tok.eq_ignore_ascii_case(word)) fn index_words(project: &Path, rel: &str, names: &HashSet<&str>) -> HashMap<String, Vec<i64>> {
let mut m: HashMap<String, Vec<i64>> = 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 /// 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<usize>) -> Vec<TreeNod
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::is_entry_point; use super::{code_only_line, is_entry_point};
#[test] #[test]
fn entry_point_is_language_scoped() { fn entry_point_is_language_scoped() {
@@ -849,4 +871,30 @@ mod tests {
assert!(is_entry_point("tests/integration.rs", "rust")); assert!(is_entry_point("tests/integration.rs", "rust"));
assert!(is_entry_point("src/bin/tool.rs", "rust")); assert!(is_entry_point("src/bin/tool.rs", "rust"));
} }
#[test]
fn code_only_line_blanks_strings_and_comments() {
// Code references survive.
assert!(code_only_line("let x = foo();").contains("foo"));
assert!(code_only_line("let s = \"x\"; foo();").contains("foo"));
// String literals are blanked.
assert!(!code_only_line("let x = \"foo\";").contains("foo"));
assert!(!code_only_line("let x = 'foo';").contains("foo"));
assert!(!code_only_line("let x = `foo`;").contains("foo"));
assert!(!code_only_line("let x = \"\"\"foo\"\"\";").contains("foo"));
// Rust raw strings, including multi-hash delimiters.
assert!(!code_only_line("let x = r#\"...foo...\"#;").contains("foo"));
assert!(!code_only_line("let x = r##\"...foo...\"##;").contains("foo"));
assert!(!code_only_line("let x = br#\"foo\"#;").contains("foo"));
// Comments (whole-line and trailing) are blanked.
assert!(!code_only_line("// foo bar").contains("foo"));
assert!(!code_only_line("# foo bar").contains("foo"));
assert!(!code_only_line("-- foo bar").contains("foo"));
assert!(!code_only_line("/* foo bar */").contains("foo"));
assert!(!code_only_line("x = 1; // foo").contains("foo"));
assert!(!code_only_line("x = 1; /* foo */ y = 2;").contains("foo"));
}
} }