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}