use std::collections::{HashMap, HashSet};
use serde_json::Value;
use crate::schema::Symbol;
pub(crate) struct Stale {
pub key: String,
pub symbol: Symbol,
}
pub(crate) fn base_qname(qname: &str) -> &str {
match qname.rsplit_once(':') {
Some((base, line)) if !line.is_empty() && line.bytes().all(|b| b.is_ascii_digit()) => base,
_ => qname,
}
}
pub(crate) fn find_stale(
by_qname: &serde_json::Map<String, Value>,
produced_qnames: &HashSet<&str>,
parsed_files: &HashSet<String>,
is_gone: &dyn Fn(&str) -> bool,
) -> Vec<Stale> {
by_qname
.iter()
.filter(|(qname, _)| !produced_qnames.contains(qname.as_str()))
.filter_map(|(qname, value)| {
let symbol: Symbol = serde_json::from_value(value.clone()).ok()?;
let in_scope = parsed_files.contains(&symbol.file) || is_gone(&symbol.file);
in_scope.then(|| Stale {
key: qname.clone(),
symbol,
})
})
.collect()
}
pub(crate) fn find_displaced(
by_qname: &serde_json::Map<String, Value>,
produced: &[Symbol],
parsed_files: &HashSet<String>,
is_gone: &dyn Fn(&str) -> bool,
) -> Vec<(Stale, String)> {
let produced_ids: HashSet<&str> = produced.iter().map(|s| s.symbol_id.as_str()).collect();
let winners: HashMap<&str, &str> = produced
.iter()
.map(|s| (s.qname.as_str(), s.symbol_id.as_str()))
.collect();
let mut displaced: Vec<(Stale, String)> = winners
.into_iter()
.filter_map(|(qname, new_id)| {
let symbol: Symbol = serde_json::from_value(by_qname.get(qname)?.clone()).ok()?;
let replaced = symbol.symbol_id != new_id
&& !produced_ids.contains(symbol.symbol_id.as_str())
&& (parsed_files.contains(&symbol.file) || is_gone(&symbol.file));
replaced.then(|| {
(
Stale {
key: qname.to_string(),
symbol,
},
new_id.to_string(),
)
})
})
.collect();
displaced.sort_by(|a, b| a.0.key.cmp(&b.0.key));
displaced
}
pub(crate) fn match_moves(stale: &[Stale], produced: &[Symbol]) -> HashMap<String, String> {
fn key(s: &Symbol) -> (&str, String, &str, &str) {
(
s.file.as_str(),
format!("{:?}", s.kind),
base_qname(&s.qname),
s.symbol_fp.as_str(),
)
}
let mut candidates: HashMap<(&str, String, &str, &str), Vec<&str>> = HashMap::new();
for s in produced {
candidates
.entry(key(s))
.or_default()
.push(s.symbol_id.as_str());
}
stale
.iter()
.filter_map(
|st| match candidates.get(&key(&st.symbol)).map(Vec::as_slice) {
Some([only]) if *only != st.symbol.symbol_id => {
Some((st.symbol.symbol_id.clone(), only.to_string()))
}
_ => None,
},
)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{Position, SymbolKind};
fn sym(qname: &str, file: &str, fp: &str) -> Symbol {
Symbol {
symbol_id: format!("sym_{qname}_{file}"),
symbol_fp: fp.into(),
qname: qname.into(),
language: "python".into(),
kind: SymbolKind::Function,
file: file.into(),
start: Position { line: 1, col: 0 },
end: Position { line: 2, col: 0 },
signature: None,
doc: None,
}
}
fn map(symbols: &[Symbol]) -> serde_json::Map<String, Value> {
symbols
.iter()
.map(|s| (s.qname.clone(), serde_json::to_value(s).unwrap()))
.collect()
}
#[test]
fn base_qname_strips_only_a_line_suffix() {
assert_eq!(base_qname("m.f:12"), "m.f");
assert_eq!(base_qname("m.f"), "m.f");
assert_eq!(base_qname("Foo::bar"), "Foo::bar");
assert_eq!(base_qname("m.f:x"), "m.f:x");
}
#[test]
fn only_entries_in_files_the_run_parsed_or_saw_deleted_are_stale() {
let stored = map(&[
sym("a.f:1", "a.py", "fp1"),
sym("a.g", "a.py", "fp2"),
sym("b.h", "b.py", "fp3"),
sym("c.k", "c.py", "fp4"),
]);
let produced: HashSet<&str> = ["a.g"].into();
let parsed: HashSet<String> = ["a.py".to_string()].into();
let gone = |f: &str| f == "c.py";
let mut stale: Vec<_> = find_stale(&stored, &produced, &parsed, &gone)
.into_iter()
.map(|s| s.key)
.collect();
stale.sort();
assert_eq!(stale, vec!["a.f:1", "c.k"]);
}
#[test]
fn a_line_shift_is_matched_by_body_and_ambiguity_is_left_alone() {
let old = sym("m.f:5", "m.py", "body2");
let stale = vec![Stale {
key: old.qname.clone(),
symbol: old.clone(),
}];
let moved = sym("m.f:7", "m.py", "body2");
let other = sym("m.f:3", "m.py", "body1");
let found = match_moves(&stale, &[moved.clone(), other.clone()]);
assert_eq!(found.get(&old.symbol_id), Some(&moved.symbol_id));
let twin = sym("m.f:9", "m.py", "body2");
assert!(match_moves(&stale, &[moved, twin]).is_empty());
assert!(match_moves(&stale, &[sym("m.f:7", "n.py", "body2")]).is_empty());
}
#[test]
fn a_qname_taken_over_from_a_file_in_scope_is_displaced() {
let stored = map(&[
sym("T.moved", "old/t.swift", "fp1"),
sym("T.elsewhere", "other/t.swift", "fp2"),
sym("T.collides", "a/t.swift", "fp3"),
]);
let moved = sym("T.moved", "new/t.swift", "fp1");
let elsewhere = sym("T.elsewhere", "new/t.swift", "fp2");
let collides_a = sym("T.collides", "a/t.swift", "fp3");
let collides_b = sym("T.collides", "b/t.swift", "fp3");
let parsed: HashSet<String> = ["new/t.swift", "a/t.swift", "b/t.swift"]
.map(String::from)
.into();
let gone = |f: &str| f == "old/t.swift";
let found = find_displaced(
&stored,
&[moved.clone(), elsewhere, collides_a, collides_b],
&parsed,
&gone,
);
assert_eq!(
found
.iter()
.map(|(st, new_id)| (st.key.as_str(), new_id.as_str()))
.collect::<Vec<_>>(),
vec![("T.moved", moved.symbol_id.as_str())]
);
}
}