use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{Context, Result, bail};
use sinter_core::{Confidence, CorpusScope, Evidence, Node, 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,
" {}@{} ({}, symbol_key {}, id {})",
qualified_of(node.id.as_str()),
node.file,
node.kind.as_str(),
node.symbol_key(),
node.id
)?;
}
Ok(())
}
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> {
match find_symbol(store, symbol)? {
Found::Exact(mut nodes) if nodes.len() == 1 => Ok(nodes.remove(0)),
Found::Exact(nodes) => {
let preferred = scopes
.cloned()
.unwrap_or_else(|| crate::corpus::ScopeSelection::agent_default().as_set());
let scope_index = store.scope_index()?;
let in_scope = |n: &Node| preferred.contains(&scope_index.scope_of(n));
let (keep, excluded): (Vec<Node>, Vec<Node>) =
nodes.into_iter().partition(|n| in_scope(n));
if !keep.is_empty() && !excluded.is_empty() {
let labels: Vec<&str> = preferred.iter().map(|s| s.as_str()).collect();
eprintln!(
"note: {} more `{symbol}` outside scope {} ignored; use `{symbol}@<file>` to pick one of those",
excluded.len(),
labels.join(","),
);
}
let mut candidates = if keep.is_empty() { excluded } else { keep };
if candidates.len() > 1 {
Err(SymbolLookupError::Ambiguous {
requested: symbol.to_string(),
candidates,
}
.into())
} else {
Ok(candidates.remove(0))
}
}
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())
}
}
}
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))
}