Skip to main content

differential_engine/artefact/
symbols.rs

1//! Symbol extraction: the port, and the rule for choosing between readers.
2//!
3//! **This is a domain use case, not a mechanism.** The graph needs to know what
4//! each line defines and references. Whether a regex or a parser answered is
5//! not a distinction it can see or act on — those are the same capability at
6//! different effort and precision.
7//!
8//! So the trait lives here, beside its only consumer ([`super::graph`]), and
9//! the readers live in an adapter crate that depends on this one. The arrow
10//! never points the other way (ADR 0020).
11//!
12//! `dyn` is correct here, and for the reason `CLAUDE.md` allows it: which
13//! reader answers is chosen at RUN time, per file. A Rust file picks a reader
14//! with a tuned query, a Java file one with generic rules, a shell script the
15//! crude one. `lang::LanguageRegistry` already selects this way.
16
17/// How far a name reaches, and therefore what it may be compared against.
18///
19/// A `Global` name is one other files can use, and an edge on it may cross a
20/// file boundary. A `File` name reaches only its own file — a `const` inside a
21/// function body, a parameter, an import binding — and an edge on it may only
22/// join classes in that same file.
23///
24/// The distinction is the whole guard. ADR 0023 counted only global names
25/// because `mod template;` and `fn from` became globally unique symbols that
26/// every file mentioning the word then linked to: six such words made 64% of
27/// one range's edges. A name confined to its file cannot do that, however
28/// common it is — the worst it can cost is an ordering inside one file.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub enum Scope {
31    /// Usable from another file. Edges may cross files.
32    Global,
33    /// Usable only inside the file it was read from.
34    File,
35}
36
37/// Where a symbol sits on its line, and how far what it declares reaches.
38///
39/// The graph does not read any of this — [`super::graph`] keys a symbol by its
40/// namespace and name alone, so nothing here can move an edge. It exists for
41/// the reader who has to LOOK at the dependency: a highlight needs the token's
42/// columns, and showing what a name was declared as needs the declaration's
43/// last line.
44///
45/// **`start`/`end` are byte offsets into the RAW line**, before any tab
46/// expansion. A renderer that expands tabs — the TUI does — must translate them
47/// against its own expansion rather than assume they index its display text.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub struct Site {
50    pub start: u32,
51    pub end: u32,
52    /// The last line of what this name declares, counting from 1.
53    ///
54    /// Zero when the reader cannot see an extent — a regex has no tree to ask,
55    /// and saying so beats guessing. A consumer reads zero as "the line itself
56    /// is all I know".
57    pub through: u32,
58}
59
60/// One name a line introduces or consumes, and how far it reaches.
61#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
62pub struct Symbol {
63    pub name: Vec<u8>,
64    pub scope: Scope,
65    pub site: Site,
66}
67
68impl Symbol {
69    /// A name other files can use.
70    pub fn global(name: impl Into<Vec<u8>>) -> Symbol {
71        Symbol {
72            name: name.into(),
73            scope: Scope::Global,
74            site: Site::default(),
75        }
76    }
77
78    /// A name that reaches only the file it was read from.
79    pub fn local(name: impl Into<Vec<u8>>) -> Symbol {
80        Symbol {
81            name: name.into(),
82            scope: Scope::File,
83            site: Site::default(),
84        }
85    }
86
87    /// The token's byte range within its own raw line.
88    ///
89    /// A builder rather than a constructor argument: a reader that cannot
90    /// answer leaves the default, and every caller that never cared — the stub
91    /// reader, the domain's own tests — stays as it was.
92    pub fn at(mut self, start: u32, end: u32) -> Symbol {
93        self.site.start = start;
94        self.site.end = end;
95        self
96    }
97
98    /// The last line of what this name declares.
99    pub fn through(mut self, line: u32) -> Symbol {
100        self.site.through = line;
101        self
102    }
103}
104
105/// A file's symbols, indexed by NEW-SIDE line number.
106///
107/// Both vectors are parallel and one entry per line, so an entry is addressed
108/// by the line number a hunk already carries. Lines with no symbols hold an
109/// empty `Vec`, which does not allocate — a 100k-line file costs pointers.
110#[derive(Debug, Default, Clone)]
111pub struct FileSymbols {
112    /// What these names are written in, as the reader chooses to name it. Two
113    /// GLOBAL symbols are the same symbol only if their namespaces match as
114    /// well as their names (ADR 0031).
115    ///
116    /// **Opaque to the domain.** It is compared and never interpreted, so this
117    /// still does not tell the graph which reader answered — only whether two
118    /// answers are about the same body of names. An empty namespace is a
119    /// namespace like any other, and matches only other empty ones.
120    pub namespace: Vec<u8>,
121    pub defines: Vec<Vec<Symbol>>,
122    pub references: Vec<Vec<Symbol>>,
123}
124
125impl FileSymbols {
126    /// Symbols defined on `line`, counting from 1. Empty when out of range.
127    pub fn defines_at(&self, line: u32) -> &[Symbol] {
128        at(&self.defines, line)
129    }
130
131    /// Symbols referenced on `line`, counting from 1.
132    pub fn references_at(&self, line: u32) -> &[Symbol] {
133        at(&self.references, line)
134    }
135}
136
137fn at(rows: &[Vec<Symbol>], line: u32) -> &[Symbol] {
138    line.checked_sub(1)
139        .and_then(|i| rows.get(i as usize))
140        .map_or(&[], |v| v.as_slice())
141}
142
143/// One way of reading a file's symbols.
144///
145/// Reading is per FILE, never per line or per hunk: a line inside a block
146/// comment or a multi-line string cannot be told from code on its own, and
147/// those are the tokens most worth dropping.
148pub trait SymbolSource: Send + Sync {
149    /// How good this reader's answer would be for `path`. Higher wins.
150    ///
151    /// `None` means it does not read this file at all.
152    ///
153    /// **A reader ranks itself.** Nothing outside it knows why one beats
154    /// another, and no registration order can get the ranking wrong — which is
155    /// why this sits on the port rather than in whatever wires the readers up.
156    fn priority(&self, path: &[u8]) -> Option<u8>;
157
158    /// The file's symbols, per new-side line.
159    ///
160    /// `None` means this reader claimed the file and then could not read it —
161    /// a parser meeting something it cannot parse. The caller falls to the next
162    /// best reader rather than letting the file lose every symbol.
163    fn file_symbols(&self, path: &[u8], content: &[u8]) -> Option<FileSymbols>;
164
165    /// Identifies this reader's extraction behaviour.
166    ///
167    /// Part of the grouping cache key: the class graph is what the model reads
168    /// (ADR 0022), so a reader that answers differently must cold the cache.
169    /// Behaviour changes therefore need a new fingerprint, exactly as
170    /// `Language::id` works for normalisation.
171    fn fingerprint(&self) -> String;
172}
173
174/// Every reader available, and the rule for choosing between them.
175///
176/// The rule is the whole of the policy, and it is business logic: **ask the
177/// best reader that claims the file; if it fails, ask the next best; if none
178/// claims it, the file contributes no symbols.**
179///
180/// That last clause is the load-bearing one. A file nothing can read — a
181/// lockfile, a README, a SQL query — used to get crude guesses, and on the
182/// validation corpus 32% of all dependency edges came from exactly those files.
183/// A guess costs more than silence.
184#[derive(Default)]
185pub struct SymbolReaders {
186    readers: Vec<Box<dyn SymbolSource>>,
187}
188
189impl SymbolReaders {
190    /// Add a reader. **Order does not matter** — each reader ranks itself.
191    pub fn register(&mut self, reader: Box<dyn SymbolSource>) {
192        self.readers.push(reader);
193    }
194
195    /// The best claimant's answer, falling to the next best on failure.
196    pub fn of_file(&self, path: &[u8], content: &[u8]) -> Option<FileSymbols> {
197        let mut ranked: Vec<(u8, &dyn SymbolSource)> = self
198            .readers
199            .iter()
200            .filter_map(|r| r.priority(path).map(|p| (p, r.as_ref())))
201            .collect();
202        // Stable, so equal priorities keep registration order and the answer
203        // stays deterministic across runs.
204        ranked.sort_by(|a, b| b.0.cmp(&a.0));
205        ranked
206            .into_iter()
207            .find_map(|(_, r)| r.file_symbols(path, content))
208    }
209
210    /// Every reader, in a stable order. Feeds the grouping cache key, so it
211    /// must change whenever any reader's behaviour does.
212    pub fn fingerprint(&self) -> String {
213        let mut parts: Vec<String> = self.readers.iter().map(|r| r.fingerprint()).collect();
214        parts.sort();
215        parts.join("+")
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    struct Fake {
224        id: &'static str,
225        priority: Option<u8>,
226        answer: Option<&'static str>,
227    }
228
229    impl SymbolSource for Fake {
230        fn priority(&self, _path: &[u8]) -> Option<u8> {
231            self.priority
232        }
233        fn file_symbols(&self, _path: &[u8], _content: &[u8]) -> Option<FileSymbols> {
234            self.answer.map(|a| FileSymbols {
235                namespace: b"test".to_vec(),
236                defines: vec![vec![Symbol::global(a)]],
237                references: vec![Vec::new()],
238            })
239        }
240        fn fingerprint(&self) -> String {
241            self.id.to_string()
242        }
243    }
244
245    fn readers(fakes: Vec<Fake>) -> SymbolReaders {
246        let mut r = SymbolReaders::default();
247        for f in fakes {
248            r.register(Box::new(f));
249        }
250        r
251    }
252
253    fn first_define(s: &FileSymbols) -> String {
254        String::from_utf8_lossy(&s.defines[0][0].name).into_owned()
255    }
256
257    fn low() -> Fake {
258        Fake {
259            id: "low",
260            priority: Some(1),
261            answer: Some("crude"),
262        }
263    }
264
265    fn high() -> Fake {
266        Fake {
267            id: "high",
268            priority: Some(9),
269            answer: Some("precise"),
270        }
271    }
272
273    #[test]
274    fn symbols_are_addressed_by_line_number_counting_from_one() {
275        let fs = FileSymbols {
276            namespace: b"test".to_vec(),
277            defines: vec![vec![Symbol::global("a")], Vec::new()],
278            references: vec![Vec::new(), vec![Symbol::local("b")]],
279        };
280        assert_eq!(fs.defines_at(1), [Symbol::global("a")]);
281        assert!(fs.defines_at(2).is_empty());
282        assert_eq!(fs.references_at(2), [Symbol::local("b")]);
283        // Line 0 does not exist, and neither does line 3. Both answer empty
284        // rather than panic: a reader that returns fewer lines than the diff
285        // expects loses those lines' symbols, it does not crash the run.
286        assert!(fs.defines_at(0).is_empty());
287        assert!(fs.references_at(3).is_empty());
288    }
289
290    #[test]
291    fn the_highest_priority_claimant_answers_whatever_the_registration_order() {
292        // The whole reason priority sits on the port: wiring cannot get the
293        // ranking wrong, because the readers rank themselves.
294        let forwards = readers(vec![low(), high()]);
295        let backwards = readers(vec![high(), low()]);
296        assert_eq!(
297            first_define(&forwards.of_file(b"x.rs", b"").unwrap()),
298            "precise"
299        );
300        assert_eq!(
301            first_define(&backwards.of_file(b"x.rs", b"").unwrap()),
302            "precise"
303        );
304    }
305
306    #[test]
307    fn a_claimant_that_fails_falls_to_the_next_best() {
308        // A parser can claim a file and still meet something it cannot parse.
309        // The file must not lose every symbol because of it.
310        let r = readers(vec![
311            Fake {
312                id: "high",
313                priority: Some(9),
314                answer: None,
315            },
316            Fake {
317                id: "low",
318                priority: Some(1),
319                answer: Some("crude"),
320            },
321        ]);
322        assert_eq!(first_define(&r.of_file(b"x.rs", b"").unwrap()), "crude");
323    }
324
325    #[test]
326    fn a_file_no_reader_claims_contributes_nothing() {
327        // The rule that removes 32% of the corpus's edges: a lockfile or a
328        // README gets silence, not guesses.
329        let r = readers(vec![Fake {
330            id: "ast",
331            priority: None,
332            answer: Some("never asked"),
333        }]);
334        assert!(r.of_file(b"Cargo.lock", b"").is_none());
335        assert!(SymbolReaders::default().of_file(b"x.rs", b"").is_none());
336    }
337
338    #[test]
339    fn the_fingerprint_covers_every_reader_and_ignores_their_order() {
340        let a = readers(vec![
341            Fake {
342                id: "ast-v1",
343                priority: Some(9),
344                answer: None,
345            },
346            Fake {
347                id: "naive-v1",
348                priority: Some(1),
349                answer: None,
350            },
351        ]);
352        let b = readers(vec![
353            Fake {
354                id: "naive-v1",
355                priority: Some(1),
356                answer: None,
357            },
358            Fake {
359                id: "ast-v1",
360                priority: Some(9),
361                answer: None,
362            },
363        ]);
364        assert_eq!(a.fingerprint(), "ast-v1+naive-v1");
365        assert_eq!(a.fingerprint(), b.fingerprint());
366    }
367}