Skip to main content

sinter_resolve/
scip.rs

1use std::collections::{BTreeSet, HashMap};
2use std::path::Path;
3
4use protobuf::Message;
5use scip::types::Index;
6use sinter_core::{Edge, Node, NodeId, Reference, Relation, Span, SymbolKind};
7
8use crate::resolver::Binding;
9
10/// Everything one index pass yields: binds to in-corpus definitions plus
11/// the synthesized dependency surface (D29) for references whose symbol
12/// has no definition occurrence anywhere in the corpus.
13pub struct ScipResolution {
14    /// References bound to in-corpus definition nodes.
15    pub bindings: Vec<Binding>,
16    /// References bound to synthesized dep nodes (edge dst is a node in
17    /// `external_nodes`). The caller decides which survive — a ref already
18    /// bound by internal evidence keeps its internal edge.
19    pub external: Vec<Binding>,
20    /// Distinct synthesized dependency-surface nodes, keyed by id via
21    /// `external` edges; unreferenced ones must not be installed.
22    pub external_nodes: Vec<Node>,
23    /// Distinct external symbols that overlapped a reference but did not
24    /// parse as a `<scheme> <manager> <package> <version> <descriptors>`
25    /// moniker (skipped silently, counted here).
26    pub external_skipped: usize,
27    /// Edges from SCIP reference occurrences that overlap NO extracted
28    /// reference (macro token trees: `format!`, `assert_eq!`, ...) but sit
29    /// inside one of our nodes and point at an in-corpus definition. Only
30    /// produced for files in `scope`; src is the enclosing node.
31    pub unanchored: Vec<Edge>,
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum ScipError {
36    #[error("cannot read SCIP index {path}: {source}")]
37    Io {
38        path: String,
39        source: std::io::Error,
40    },
41    #[error("cannot parse SCIP index {path}: {source}")]
42    Parse {
43        path: String,
44        source: protobuf::Error,
45    },
46}
47
48pub fn load_index(path: &Path) -> Result<Index, ScipError> {
49    let bytes = std::fs::read(path).map_err(|source| ScipError::Io {
50        path: path.display().to_string(),
51        source,
52    })?;
53    Index::parse_from_bytes(&bytes).map_err(|source| ScipError::Parse {
54        path: path.display().to_string(),
55        source,
56    })
57}
58
59/// Rewrite document paths from an index produced in a nested project so
60/// they remain repo-relative after indexes from several project roots are
61/// merged. Indexers generally emit paths relative to their working
62/// directory, while Sinter's nodes are always relative to the repository.
63pub fn prefix_index_paths(path: &Path, prefix: &str) -> Result<(), ScipError> {
64    if prefix.is_empty() {
65        return Ok(());
66    }
67    let mut index = load_index(path)?;
68    let prefix = prefix.trim_end_matches('/');
69    for document in &mut index.documents {
70        let relative = document.relative_path.replace('\\', "/");
71        if !Path::new(&relative).is_absolute()
72            && relative != prefix
73            && !relative.starts_with(&format!("{prefix}/"))
74        {
75            document.relative_path = format!("{prefix}/{relative}");
76        } else {
77            document.relative_path = relative;
78        }
79    }
80    let bytes = index.write_to_bytes().map_err(|source| ScipError::Parse {
81        path: path.display().to_string(),
82        source,
83    })?;
84    std::fs::write(path, bytes).map_err(|source| ScipError::Io {
85        path: path.display().to_string(),
86        source,
87    })
88}
89
90/// Merge several on-disk indexes into one file: documents and external
91/// symbols concatenate, metadata comes from the first. Per-language
92/// indexers cover disjoint files, so concatenation is the whole merge.
93pub fn merge_index_files(paths: &[&Path], out: &Path) -> Result<(), ScipError> {
94    let (first, rest) = paths.split_first().expect("at least one index");
95    let mut merged = load_index(first)?;
96    for path in rest {
97        let mut index = load_index(path)?;
98        merged.documents.append(&mut index.documents);
99        merged.external_symbols.append(&mut index.external_symbols);
100    }
101    let bytes = merged.write_to_bytes().map_err(|source| ScipError::Parse {
102        path: out.display().to_string(),
103        source,
104    })?;
105    std::fs::write(out, bytes).map_err(|source| ScipError::Io {
106        path: out.display().to_string(),
107        source,
108    })
109}
110
111/// Bind our extracted references using a compiler-produced SCIP index —
112/// the highest evidence tier. A reference binds when a SCIP reference
113/// occurrence overlaps its span and the symbol's definition occurrence
114/// falls inside one of our nodes. `read_source` maps a repo-relative path
115/// to its content (for line→byte conversion); return None to skip a file.
116/// `scope` is the set of files being (re)resolved: occurrences there that
117/// anchor to no extracted reference still yield `unanchored` edges, so
118/// calls the extractor cannot see (inside macro token trees) are not lost.
119pub fn resolve_with_index(
120    index: &Index,
121    nodes: &[Node],
122    references: &[Reference],
123    scope: &BTreeSet<String>,
124    mut read_source: impl FnMut(&str) -> Option<String>,
125) -> ScipResolution {
126    let mut line_starts: HashMap<String, Vec<u64>> = HashMap::new();
127    for document in &index.documents {
128        if let Some(source) = read_source(&document.relative_path) {
129            let mut starts = vec![0u64];
130            for (i, b) in source.bytes().enumerate() {
131                if b == b'\n' {
132                    starts.push(i as u64 + 1);
133                }
134            }
135            line_starts.insert(document.relative_path.clone(), starts);
136        }
137    }
138    let to_byte = |file: &str, line: i32, col: i32| -> Option<u64> {
139        let starts = line_starts.get(file)?;
140        Some(starts.get(line as usize)? + col as u64)
141    };
142
143    // Pass 1: symbol -> our node containing its definition occurrence.
144    let mut nodes_by_file: HashMap<&str, Vec<&Node>> = HashMap::new();
145    for node in nodes {
146        nodes_by_file
147            .entry(node.file.as_str())
148            .or_default()
149            .push(node);
150    }
151    // Indexers can emit the SAME moniker for distinct definitions (e.g.
152    // rust-analyzer gives every test binary's `fn sinter()` helper one
153    // symbol string). Binding through such a moniker cross-file attaches
154    // refs to the wrong file's definition, so track ambiguity and keep a
155    // per-file map: an ambiguous symbol may only bind within the file
156    // that defines it.
157    let mut def_of_symbol: HashMap<&str, &Node> = HashMap::new();
158    let mut ambiguous: std::collections::HashSet<&str> = std::collections::HashSet::new();
159    let mut same_file_def: HashMap<(&str, &str), &Node> = HashMap::new();
160    for document in &index.documents {
161        for occ in &document.occurrences {
162            if occ.symbol_roles & scip::types::SymbolRole::Definition as i32 == 0 {
163                continue;
164            }
165            // `local N` symbols are document-scoped; a global map would
166            // bind them across files.
167            if occ.symbol.starts_with("local ") {
168                continue;
169            }
170            let Some(pos) = occ
171                .range
172                .first()
173                .zip(occ.range.get(1))
174                .and_then(|(l, c)| to_byte(&document.relative_path, *l, *c))
175            else {
176                continue;
177            };
178            let target = nodes_by_file
179                .get(document.relative_path.as_str())
180                .into_iter()
181                .flatten()
182                .filter(|n| span_contains(n.span, pos))
183                .min_by_key(|n| n.span.end - n.span.start);
184            if let Some(node) = target {
185                same_file_def.insert((&occ.symbol, &document.relative_path), node);
186                match def_of_symbol.entry(&occ.symbol) {
187                    std::collections::hash_map::Entry::Vacant(e) => {
188                        e.insert(node);
189                    }
190                    std::collections::hash_map::Entry::Occupied(e) => {
191                        if e.get().file != node.file {
192                            ambiguous.insert(&occ.symbol);
193                        }
194                    }
195                }
196            }
197        }
198    }
199
200    // Pass 2: SCIP reference occurrences overlapping our reference spans.
201    let mut refs_by_file: HashMap<&str, Vec<(usize, &Reference)>> = HashMap::new();
202    for (i, r) in references.iter().enumerate() {
203        refs_by_file
204            .entry(r.file.as_str())
205            .or_default()
206            .push((i, r));
207    }
208    // Per reference keep the rightmost contained occurrence: a span like
209    // `util::helper` overlaps both the module and the item occurrence, and
210    // the reference's meaning is the item — the last path segment.
211    // Internal (def in corpus) and external (dep surface, D29) occurrences
212    // track separately; the caller prefers internal.
213    let mut best: HashMap<usize, (u64, Edge)> = HashMap::new();
214    let mut best_ext: HashMap<usize, (u64, Edge)> = HashMap::new();
215    // symbol -> parsed dep node (None: unparseable, skip and count once).
216    let mut dep_cache: HashMap<String, Option<Node>> = HashMap::new();
217    let mut skipped: std::collections::HashSet<String> = std::collections::HashSet::new();
218    let mut unanchored: Vec<Edge> = Vec::new();
219    for document in &index.documents {
220        let file = document.relative_path.as_str();
221        let in_scope = scope.contains(file);
222        let file_refs = refs_by_file.get(file).map_or(&[][..], Vec::as_slice);
223        if file_refs.is_empty() && !in_scope {
224            continue;
225        }
226        for occ in &document.occurrences {
227            if occ.symbol_roles & scip::types::SymbolRole::Definition as i32 != 0
228                || occ.symbol.starts_with("local ")
229            {
230                continue;
231            }
232            let target = if ambiguous.contains(occ.symbol.as_str()) {
233                same_file_def.get(&(occ.symbol.as_str(), document.relative_path.as_str()))
234            } else {
235                def_of_symbol.get(occ.symbol.as_str())
236            };
237            let Some(pos) = occ
238                .range
239                .first()
240                .zip(occ.range.get(1))
241                .and_then(|(l, c)| to_byte(&document.relative_path, *l, *c))
242            else {
243                continue;
244            };
245            let mut anchored = false;
246            for (i, r) in file_refs {
247                if !span_contains(r.span, pos) {
248                    continue;
249                }
250                anchored = true;
251                let distance = pos - r.span.start;
252                let make_edge = |dst: NodeId| Edge {
253                    src: r
254                        .enclosing
255                        .clone()
256                        .unwrap_or_else(|| NodeId::new(r.file.clone())),
257                    dst,
258                    relation: r.relation,
259                    evidence: sinter_core::Evidence::Scip,
260                    confidence: sinter_core::Evidence::Scip.confidence(),
261                    site: Some(r.span),
262                };
263                match target {
264                    Some(target) => {
265                        if best.get(i).is_none_or(|(d, _)| distance > *d) {
266                            best.insert(*i, (distance, make_edge(target.id.clone())));
267                        }
268                    }
269                    // No in-corpus definition anywhere: the compiler
270                    // resolved this into a dependency — synthesize the
271                    // dep-surface node instead of discarding its answer.
272                    None => {
273                        let node = dep_cache
274                            .entry(occ.symbol.clone())
275                            .or_insert_with(|| dep_node(&occ.symbol));
276                        match node {
277                            Some(node) => {
278                                if best_ext.get(i).is_none_or(|(d, _)| distance > *d) {
279                                    best_ext.insert(*i, (distance, make_edge(node.id.clone())));
280                                }
281                            }
282                            None => {
283                                skipped.insert(occ.symbol.clone());
284                            }
285                        }
286                    }
287                }
288            }
289            if anchored || !in_scope {
290                continue;
291            }
292            // No extracted reference covers this occurrence (macro token
293            // tree): the compiler still proved the call, so keep it.
294            let (Some(target), Some(src)) = (
295                target,
296                nodes_by_file
297                    .get(file)
298                    .into_iter()
299                    .flatten()
300                    .filter(|n| span_contains(n.span, pos))
301                    .min_by_key(|n| n.span.end - n.span.start),
302            ) else {
303                continue;
304            };
305            let end = match occ.range.as_slice() {
306                [_, _, c] => to_byte(file, occ.range[0], *c),
307                [_, _, l, c] => to_byte(file, *l, *c),
308                _ => None,
309            }
310            .unwrap_or(pos);
311            unanchored.push(Edge {
312                src: src.id.clone(),
313                dst: target.id.clone(),
314                relation: match target.kind {
315                    SymbolKind::Function | SymbolKind::Method => Relation::Calls,
316                    _ => Relation::Uses,
317                },
318                evidence: sinter_core::Evidence::Scip,
319                confidence: sinter_core::Evidence::Scip.confidence(),
320                site: Some(Span { start: pos, end }),
321            });
322        }
323    }
324    let mut external_nodes: HashMap<String, Node> = HashMap::new();
325    for node in dep_cache.into_values().flatten() {
326        external_nodes.insert(node.id.as_str().to_string(), node);
327    }
328    ScipResolution {
329        bindings: best
330            .into_iter()
331            .map(|(reference, (_, edge))| Binding { edge, reference })
332            .collect(),
333        external: best_ext
334            .into_iter()
335            .map(|(reference, (_, edge))| Binding { edge, reference })
336            .collect(),
337        external_nodes: external_nodes.into_values().collect(),
338        external_skipped: skipped.len(),
339        unanchored,
340    }
341}
342
343/// Parse an external SCIP moniker into a dependency-surface node (D29):
344/// `<scheme> <manager> <package> <version> <descriptors...>` (`.` fields
345/// mean absent — no package identity, no node). Descriptors join into a
346/// `::` qualified path rooted at the package name (dashes normalized to
347/// underscores, matching language path heads); the final descriptor marker
348/// picks the kind:
349///   `().` -> Function (Method when the previous segment is a type `#`)
350///   `#`   -> Struct (honest default for SCIP "type"; the moniker cannot
351///            distinguish struct/enum/trait)
352///   `!`   -> Macro
353///   `/`   -> Module
354///   `.`   -> Constant (SCIP "term": consts, statics, fields)
355/// Node id follows the `{file}#{qualified}@{offset}` convention at offset
356/// 0 with span 0..0 — dep pseudo-files have no source.
357fn dep_node(symbol: &str) -> Option<Node> {
358    let mut parts = symbol.splitn(5, ' ');
359    let _scheme = parts.next()?;
360    let manager = parts.next()?;
361    let package = parts.next()?;
362    let version = parts.next()?;
363    let descriptors = parts.next()?;
364    if manager == "."
365        || package == "."
366        || package.is_empty()
367        || version == "."
368        || version.is_empty()
369    {
370        return None;
371    }
372    let (path, kind) = parse_descriptors(descriptors)?;
373    let file = format!("dep:{package}@{version}");
374    let head = package.replace('-', "_");
375    let name = path.last().cloned().unwrap_or_else(|| head.clone());
376    let qualified = std::iter::once(head)
377        .chain(path)
378        .collect::<Vec<_>>()
379        .join("::");
380    Some(Node {
381        id: NodeId::new(format!("{file}#{qualified}@0")),
382        kind,
383        name,
384        file,
385        span: Span { start: 0, end: 0 },
386        signature: String::new(),
387        doc: None,
388    })
389}
390
391/// Split a SCIP descriptor chain into path segments plus the kind its
392/// final marker implies. Backtick-escaped names unescape; parameter
393/// descriptors `(x)`, type parameters `[x]`, and malformed shapes yield
394/// None (the caller counts them skipped).
395fn parse_descriptors(desc: &str) -> Option<(Vec<String>, SymbolKind)> {
396    // (segment, marker): marker is the descriptor suffix, with 'm' for
397    // the method shape `name().`.
398    let mut segs: Vec<(String, char)> = Vec::new();
399    let mut cur = String::new();
400    let mut chars = desc.chars();
401    while let Some(c) = chars.next() {
402        match c {
403            '`' => loop {
404                match chars.next()? {
405                    '`' => break,
406                    escaped => cur.push(escaped),
407                }
408            },
409            '/' | '#' | '.' | '!' | ':' => {
410                if cur.is_empty() {
411                    return None;
412                }
413                segs.push((std::mem::take(&mut cur), c));
414            }
415            '(' => {
416                if cur.is_empty() {
417                    // `(param)` parameter descriptor — not a surface item.
418                    return None;
419                }
420                loop {
421                    if chars.next()? == ')' {
422                        break;
423                    }
424                }
425                if chars.next() != Some('.') {
426                    return None;
427                }
428                segs.push((std::mem::take(&mut cur), 'm'));
429            }
430            '[' => return None,
431            _ => cur.push(c),
432        }
433    }
434    if !cur.is_empty() || segs.is_empty() {
435        return None;
436    }
437    let kind = match segs.last()?.1 {
438        'm' if segs.len() >= 2 && segs[segs.len() - 2].1 == '#' => SymbolKind::Method,
439        'm' => SymbolKind::Function,
440        '#' => SymbolKind::Struct,
441        '/' => SymbolKind::Module,
442        '!' => SymbolKind::Macro,
443        '.' => SymbolKind::Constant,
444        // meta descriptor tail — nothing an agent asks "what breaks" about.
445        _ => return None,
446    };
447    Some((segs.into_iter().map(|(s, _)| s).collect(), kind))
448}
449
450fn span_contains(span: Span, pos: u64) -> bool {
451    span.start <= pos && pos < span.end
452}