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:
1 parent
f1531a64d4
commit
b5389341a1
1 file changed
+63
-15
+63
-15
@@ -260,8 +260,14 @@ pub fn find_dead_symbols(project: &Path) -> Result<Value> {
|
||||
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<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();
|
||||
|
||||
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.
|
||||
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<Value> {
|
||||
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<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
|
||||
@@ -828,7 +850,7 @@ fn raw_to_nodes(node: &RawNode, base: &str, depth: Option<usize>) -> Vec<TreeNod
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_entry_point;
|
||||
use super::{code_only_line, is_entry_point};
|
||||
|
||||
#[test]
|
||||
fn entry_point_is_language_scoped() {
|
||||
@@ -849,4 +871,30 @@ mod tests {
|
||||
assert!(is_entry_point("tests/integration.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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user