use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use anyhow::{Context, Result, bail};
use sinter_core::{
Confidence, CorpusScope, Evidence, Node, NodeId, Relation, SymbolKey, SymbolKind,
};
use sinter_resolve::qualified_of;
use sinter_store::{EdgeFilter, Store};
use crate::pipeline;
#[derive(Debug)]
pub struct NoMatch(pub String);
impl std::fmt::Display for NoMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for NoMatch {}
#[derive(Debug)]
pub enum SymbolLookupError {
Ambiguous {
requested: String,
candidates: Vec<Node>,
},
Relocated {
requested: String,
candidates: Vec<Node>,
},
StaleSnapshot {
expected: String,
actual: String,
},
}
impl SymbolLookupError {
pub fn code(&self) -> &'static str {
match self {
Self::Ambiguous { .. } => "ambiguous_symbol",
Self::Relocated { .. } => "relocated_handle",
Self::StaleSnapshot { .. } => "stale_snapshot",
}
}
pub fn candidates(&self) -> &[Node] {
match self {
Self::Ambiguous { candidates, .. } | Self::Relocated { candidates, .. } => candidates,
Self::StaleSnapshot { .. } => &[],
}
}
pub fn snapshots(&self) -> Option<(&str, &str)> {
match self {
Self::StaleSnapshot { expected, actual } => Some((expected, actual)),
_ => None,
}
}
}
impl std::fmt::Display for SymbolLookupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ambiguous {
requested,
candidates,
} => {
writeln!(f, "`{requested}` is ambiguous — choose a candidate")?;
write_candidates(f, candidates)
}
Self::Relocated {
requested,
candidates,
} => {
writeln!(
f,
"snapshot-local node id `{requested}` moved — use its stable symbol key or a current candidate"
)?;
write_candidates(f, candidates)
}
Self::StaleSnapshot { expected, actual } => write!(
f,
"graph snapshot changed (expected `{expected}`, current `{actual}`)"
),
}
}
}
impl std::error::Error for SymbolLookupError {}
fn write_candidates(f: &mut std::fmt::Formatter<'_>, candidates: &[Node]) -> std::fmt::Result {
for node in candidates {
writeln!(f, " {}", candidate_label(node))?;
}
Ok(())
}
pub fn candidate_label(node: &Node) -> String {
format!(
"{}@{} ({})",
qualified_of(node.id.as_str()),
node.file,
node.kind.as_str()
)
}
pub fn short_list(nodes: &[Node]) -> String {
nodes
.iter()
.map(|n| format!("{}@{}", qualified_of(n.id.as_str()), n.file))
.collect::<Vec<_>>()
.join(", ")
}
pub fn open_store(repo: &Path) -> Result<Store> {
let repo = pipeline::discover_root(repo);
let repo = repo
.canonicalize()
.with_context(|| format!("repo path {}", repo.display()))?;
let path = pipeline::db_path(&repo);
if !path.exists() {
bail!("no graph at {} — run `sinter build` first", path.display());
}
pipeline::build(&repo, None)?;
open_current(&repo)
}
pub(crate) fn open_current(repo: &Path) -> Result<Store> {
let repo = pipeline::discover_root(repo);
let repo = repo
.canonicalize()
.with_context(|| format!("repo path {}", repo.display()))?;
let path = pipeline::db_path(&repo);
if !path.exists() {
bail!("no graph at {} — run `sinter build` first", path.display());
}
let store = Store::open(&path)?;
if store.node_count()? == 0 {
bail!(
"graph at {} is empty — was `sinter build` run in the right directory?",
path.display()
);
}
Ok(store)
}
pub enum Found {
Exact(Vec<Node>),
Relocated(Vec<Node>),
Suggestions(Vec<Node>),
}
pub fn find_symbol(store: &Store, symbol: &str) -> Result<Found> {
if symbol.starts_with(SymbolKey::PREFIX) {
let key = SymbolKey::parse(symbol.to_string())
.ok_or_else(|| anyhow::anyhow!("invalid stable symbol key `{symbol}`"))?;
let (kind, file, qualified) = key.parts().expect("validated symbol key");
let name = if kind == SymbolKind::File {
file
} else {
qualified.rsplit("::").next().unwrap_or(qualified)
};
let mut matches: Vec<Node> = store
.nodes_named(name)?
.into_iter()
.filter(|node| node.symbol_key() == key)
.collect();
matches.sort_by(|a, b| a.id.cmp(&b.id));
return if matches.is_empty() {
Ok(Found::Suggestions(Vec::new()))
} else {
Ok(Found::Exact(matches))
};
}
if symbol.contains('#') {
if let Some(node) = store.node(&sinter_core::NodeId::new(symbol))? {
return Ok(Found::Exact(vec![node]));
}
let relocated = relocation_candidates(store, symbol)?;
if !relocated.is_empty() {
return Ok(Found::Relocated(relocated));
}
return Ok(Found::Suggestions(Vec::new()));
}
let (symbol, file) = match symbol.rsplit_once('@') {
Some((s, f)) if !s.is_empty() && !f.is_empty() => (s, Some(f)),
_ => (symbol, None),
};
let name = symbol.rsplit("::").next().unwrap_or(symbol);
let mut matches: Vec<Node> = store
.nodes_named(name)?
.into_iter()
.filter(|n| {
let q = qualified_of(n.id.as_str());
(q == symbol || q.ends_with(&format!("::{symbol}")))
&& file.is_none_or(|f| n.file == f || n.file.ends_with(&format!("/{f}")))
})
.collect();
if matches
.iter()
.any(|n| qualified_of(n.id.as_str()) == symbol)
{
matches.retain(|n| qualified_of(n.id.as_str()) == symbol);
}
matches.sort_by(|a, b| a.id.cmp(&b.id));
if matches.is_empty() {
Ok(Found::Suggestions(store.search(symbol, 10)?))
} else {
Ok(Found::Exact(matches))
}
}
fn relocation_candidates(store: &Store, id: &str) -> Result<Vec<Node>> {
let Some((file, rest)) = id.split_once('#') else {
return Ok(Vec::new());
};
let Some((qualified, offset)) = rest.rsplit_once('@') else {
return Ok(Vec::new());
};
if offset.parse::<u64>().is_err() || qualified.is_empty() {
return Ok(Vec::new());
}
let name = qualified.rsplit("::").next().unwrap_or(qualified);
let mut candidates: Vec<Node> = store
.nodes_named(name)?
.into_iter()
.filter(|node| node.file == file && node.id.qualified() == qualified)
.collect();
candidates.sort_by(|a, b| a.id.cmp(&b.id));
Ok(candidates)
}
pub fn ensure_snapshot(store: &Store, expected: Option<&str>) -> Result<String> {
let actual = store.snapshot_token()?;
ensure_snapshot_token(expected, &actual)?;
Ok(actual)
}
pub fn ensure_snapshot_token(expected: Option<&str>, actual: &str) -> Result<()> {
if let Some(expected) = expected
&& expected != actual
{
return Err(SymbolLookupError::StaleSnapshot {
expected: expected.to_string(),
actual: actual.to_string(),
}
.into());
}
Ok(())
}
pub fn unique_symbol(store: &Store, symbol: &str) -> Result<Node> {
unique_symbol_in(store, symbol, None)
}
pub fn unique_symbol_in(
store: &Store,
symbol: &str,
scopes: Option<&BTreeSet<CorpusScope>>,
) -> Result<Node> {
let mut candidates = candidates_in(store, symbol, scopes)?;
if candidates.len() > 1 {
return Err(SymbolLookupError::Ambiguous {
requested: symbol.to_string(),
candidates,
}
.into());
}
Ok(candidates.remove(0))
}
pub fn candidates_in(
store: &Store,
symbol: &str,
scopes: Option<&BTreeSet<CorpusScope>>,
) -> Result<Vec<Node>> {
match find_symbol(store, symbol)? {
Found::Exact(nodes) if nodes.len() == 1 => Ok(nodes),
Found::Exact(nodes) => {
let preferred = scopes
.cloned()
.unwrap_or_else(|| crate::corpus::ScopeSelection::agent_default().as_set());
let scope_index = store.scope_index()?;
let (mut keep, mut ignored) =
select_tier(nodes, &preferred, |n| scope_index.scope_of(n));
let mut reason = {
let mut kinds: Vec<&str> = ignored
.iter()
.map(|n| scope_index.scope_of(n).as_str())
.collect();
kinds.sort_unstable();
kinds.dedup();
kinds.join("/")
};
if keep.len() > 1 {
let dominant = dominant_language(&store.file_scopes()?);
let ids: Vec<NodeId> = keep.iter().map(|n| n.id.clone()).collect();
let in_edges = store.in_edges_many(&ids)?;
let in_degree = |n: &Node| {
in_edges.get(&n.id).map_or(0, |edges| {
edges
.iter()
.filter(|e| e.relation != Relation::Contains)
.count()
})
};
let (kept, dropped, why) = break_ties(keep, dominant.as_deref(), in_degree);
keep = kept;
if let Some(why) = why {
ignored = dropped;
reason = why.to_string();
}
}
if keep.len() == 1 && !ignored.is_empty() {
eprintln!(
"note: {} other `{symbol}` ignored ({reason}): {}",
ignored.len(),
short_list(&ignored)
);
}
Ok(keep)
}
Found::Relocated(nodes) => Err(SymbolLookupError::Relocated {
requested: symbol.to_string(),
candidates: nodes,
}
.into()),
Found::Suggestions(nodes) if nodes.is_empty() => Err(NoMatch(format!(
"no symbol matches `{symbol}` — try `sinter ask \"{symbol}\"` for concept search"
))
.into()),
Found::Suggestions(nodes) => {
let list: Vec<String> = nodes
.iter()
.map(|n| format!(" {}", qualified_of(n.id.as_str())))
.collect();
Err(NoMatch(format!(
"no exact match for `{symbol}`; close names:\n{}",
list.join("\n")
))
.into())
}
}
}
fn select_tier(
nodes: Vec<Node>,
preferred: &BTreeSet<CorpusScope>,
scope_of: impl Fn(&Node) -> CorpusScope,
) -> (Vec<Node>, Vec<Node>) {
let tier = |n: &Node| {
let scope = scope_of(n);
let shipped = matches!(scope, CorpusScope::Production | CorpusScope::Docs);
match (preferred.contains(&scope), shipped) {
(true, true) => 0,
(true, false) => 1,
(false, _) if matches!(scope, CorpusScope::Generated | CorpusScope::Vendor) => 3,
(false, _) => 2,
}
};
let best = nodes.iter().map(&tier).min().unwrap_or(0);
nodes.into_iter().partition(|n| tier(n) == best)
}
fn language_of(node: &Node) -> Option<&'static str> {
sinter_extract::spec_for_path(&node.file).map(|spec| spec.name)
}
fn dominant_language(file_scopes: &HashMap<String, CorpusScope>) -> Option<String> {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for (file, scope) in file_scopes {
if *scope == CorpusScope::Production
&& let Some(spec) = sinter_extract::spec_for_path(file)
{
*counts.entry(spec.name).or_default() += 1;
}
}
counts
.into_iter()
.max_by_key(|(_, n)| *n)
.map(|(name, _)| name.to_string())
}
fn break_ties(
nodes: Vec<Node>,
dominant: Option<&str>,
in_degree: impl Fn(&Node) -> usize,
) -> (Vec<Node>, Vec<Node>, Option<&'static str>) {
let mut dropped = Vec::new();
let mut reason = None;
let mut keep = nodes;
if let Some(dominant) = dominant
&& keep.len() > 1
&& keep.iter().any(|n| language_of(n) == Some(dominant))
{
let (same, other): (Vec<Node>, Vec<Node>) = keep
.into_iter()
.partition(|n| language_of(n) == Some(dominant));
keep = same;
if !other.is_empty() {
dropped.extend(other);
reason = Some("language");
}
}
if keep.len() > 1 {
let best = keep.iter().map(&in_degree).max().unwrap_or(0);
let (top, rest): (Vec<Node>, Vec<Node>) =
keep.into_iter().partition(|n| in_degree(n) == best);
keep = top;
if !rest.is_empty() {
dropped.extend(rest);
reason = Some("in-degree");
}
}
(keep, dropped, reason)
}
pub struct ExternalSite {
pub file: String,
pub enclosing: Option<String>,
pub refs: usize,
}
pub fn external_sites(store: &Store, symbol: &str) -> Result<Vec<ExternalSite>> {
let tail = symbol.rsplit([':', '/', '.']).next().unwrap_or(symbol);
if tail.is_empty() {
return Ok(Vec::new());
}
let matches = |written: &str| {
written == symbol
|| (written.ends_with(symbol)
&& written[..written.len() - symbol.len()]
.chars()
.next_back()
.is_some_and(|c| !c.is_alphanumeric() && c != '_'))
};
let files = store.ref_files(&BTreeSet::from([tail.to_string()]))?;
let mut sites: std::collections::BTreeMap<(String, Option<String>), usize> =
std::collections::BTreeMap::new();
for file in files {
for r in store.references_in(&file)? {
let written = r.path.as_deref().unwrap_or(&r.name);
if matches(written) || matches(&r.name) {
let enclosing = r.enclosing.map(|id| qualified_of(id.as_str()).to_string());
*sites.entry((r.file, enclosing)).or_default() += 1;
}
}
}
Ok(sites
.into_iter()
.map(|((file, enclosing), refs)| ExternalSite {
file,
enclosing,
refs,
})
.collect())
}
pub fn edge_filter(evidence: &[String], certain: bool) -> Result<EdgeFilter> {
let evidence = if evidence.is_empty() {
None
} else {
let mut set = BTreeSet::new();
for e in evidence {
set.insert(match e.as_str() {
"structural" => Evidence::Structural,
"scope" => Evidence::Scope,
"import" => Evidence::Import,
"scip" => Evidence::Scip,
"declared" => Evidence::Declared,
"dynamic" => Evidence::Dynamic,
other => bail!("unknown evidence kind `{other}`"),
});
}
Some(set)
};
Ok(EdgeFilter {
evidence,
min_confidence: certain.then_some(Confidence::Certain),
relations: None,
scopes: None,
})
}
pub fn relation_set(relations: &[String]) -> Result<Option<BTreeSet<sinter_core::Relation>>> {
if relations.is_empty() {
return Ok(None);
}
let mut set = BTreeSet::new();
for r in relations {
set.insert(match r.as_str() {
"calls" => sinter_core::Relation::Calls,
"uses" => sinter_core::Relation::Uses,
"imports" => sinter_core::Relation::Imports,
"implements" => sinter_core::Relation::Implements,
"extends" => sinter_core::Relation::Extends,
other => {
bail!("unknown relation `{other}` (calls, uses, imports, implements, extends)")
}
});
}
Ok(Some(set))
}
#[cfg(test)]
mod tests {
use super::*;
use sinter_core::Span;
fn node(file: &str, kind: SymbolKind) -> Node {
Node {
id: NodeId::new(format!("{file}#Widget@10")),
kind,
name: "Widget".into(),
file: file.into(),
span: Span { start: 10, end: 20 },
signature: String::new(),
doc: None,
}
}
fn pick(files: &[&str]) -> (Vec<String>, Vec<String>) {
let nodes = files.iter().map(|f| node(f, SymbolKind::Struct)).collect();
let preferred = BTreeSet::from([CorpusScope::Production, CorpusScope::Test]);
let (keep, rest) = select_tier(nodes, &preferred, |n| CorpusScope::classify_path(&n.file));
let names = |v: Vec<Node>| v.into_iter().map(|n| n.file).collect();
(names(keep), names(rest))
}
#[test]
fn lone_production_candidate_wins_over_test_copies() {
let (keep, rest) = pick(&["src/a.rs", "tests/a.rs", "fixtures/a.rs"]);
assert_eq!(keep, ["src/a.rs"]);
assert_eq!(rest, ["tests/a.rs", "fixtures/a.rs"]);
}
#[test]
fn several_production_candidates_stay_ambiguous_without_test_noise() {
let (keep, rest) = pick(&["crates/a/src/lib.rs", "crates/b/src/lib.rs", "tests/x.rs"]);
assert_eq!(keep, ["crates/a/src/lib.rs", "crates/b/src/lib.rs"]);
assert_eq!(rest, ["tests/x.rs"]);
}
#[test]
fn generated_and_vendor_lose_to_hand_written() {
let (keep, rest) = pick(&["generated/a.rs", "vendor/a.rs", "tests/a.rs"]);
assert_eq!(keep, ["tests/a.rs"]);
assert_eq!(rest, ["generated/a.rs", "vendor/a.rs"]);
let (keep, _) = pick(&["generated/a.rs", "vendor/a.rs"]);
assert_eq!(keep, ["generated/a.rs", "vendor/a.rs"]);
}
fn files(v: Vec<Node>) -> Vec<String> {
v.into_iter().map(|n| n.file).collect()
}
#[test]
fn dominant_language_breaks_production_tie() {
let nodes = vec![
node("proto/event.proto", SymbolKind::Struct),
node("src/event.rs", SymbolKind::Enum),
];
let (keep, rest, why) = break_ties(nodes, Some("rust"), |_| 0);
assert_eq!(files(keep), ["src/event.rs"]);
assert_eq!(files(rest), ["proto/event.proto"]);
assert_eq!(why, Some("language"));
}
#[test]
fn in_degree_breaks_same_language_tie() {
let nodes = vec![
node("crates/a/src/lib.rs", SymbolKind::Struct),
node("crates/b/src/lib.rs", SymbolKind::Struct),
];
let (keep, rest, why) = break_ties(nodes, Some("rust"), |n| {
usize::from(n.file.starts_with("crates/b"))
});
assert_eq!(files(keep), ["crates/b/src/lib.rs"]);
assert_eq!(files(rest), ["crates/a/src/lib.rs"]);
assert_eq!(why, Some("in-degree"));
}
#[test]
fn fully_tied_candidates_stay_ambiguous() {
let nodes = vec![
node("crates/a/src/main.rs", SymbolKind::Function),
node("crates/b/src/main.rs", SymbolKind::Function),
];
let (keep, rest, why) = break_ties(nodes, Some("rust"), |_| 3);
assert_eq!(keep.len(), 2);
assert!(rest.is_empty());
assert_eq!(why, None);
}
#[test]
fn dominant_language_counts_production_files_only() {
let scopes = HashMap::from([
("a.proto".to_string(), CorpusScope::Production),
("b.proto".to_string(), CorpusScope::Production),
("src/x.rs".to_string(), CorpusScope::Production),
("tests/y.rs".to_string(), CorpusScope::Test),
("tests/z.rs".to_string(), CorpusScope::Test),
]);
assert_eq!(dominant_language(&scopes).as_deref(), Some("proto"));
}
#[test]
fn ambiguous_listing_is_name_at_file_kind_only() {
let err = SymbolLookupError::Ambiguous {
requested: "Widget".into(),
candidates: vec![node("no/such/a.rs", SymbolKind::Enum)],
};
let text = err.to_string();
assert!(text.contains(" Widget@no/such/a.rs (enum)"), "{text}");
assert!(!text.contains("symbol_key"), "{text}");
assert!(!text.contains(" id "), "{text}");
}
}