gonzalo_graph/model.rs
1//! The code-graph data model. Serializable so a graph can be persisted as a
2//! gonzalo record and shared/synced like any other data.
3
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// What kind of Rust item a symbol is.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum SymbolKind {
11 Function,
12 Struct,
13 Enum,
14 Trait,
15 Impl,
16 Module,
17 Const,
18 Static,
19 TypeAlias,
20 /// A class (Python `class`, and other languages that have classes).
21 Class,
22 /// An interface (TypeScript `interface`, and similar constructs).
23 Interface,
24}
25
26impl SymbolKind {
27 /// Lowercase name, used as a stable key when bucketing symbols by kind.
28 /// Matches the `snake_case` serde representation.
29 pub fn as_str(self) -> &'static str {
30 match self {
31 Self::Function => "function",
32 Self::Struct => "struct",
33 Self::Enum => "enum",
34 Self::Trait => "trait",
35 Self::Impl => "impl",
36 Self::Module => "module",
37 Self::Const => "const",
38 Self::Static => "static",
39 Self::TypeAlias => "type_alias",
40 Self::Class => "class",
41 Self::Interface => "interface",
42 }
43 }
44}
45
46/// A defined symbol with its in-file location (1-based line numbers).
47///
48/// **Path-agnostic** (ADR 0012): a symbol carries no file path, so the same
49/// file content produces byte-identical slices regardless of where it lives,
50/// and content-addressed storage dedups them across paths/worktrees. The path
51/// is supplied at assembly from the manifest — see [`Located`].
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Symbol {
54 pub name: String,
55 pub kind: SymbolKind,
56 pub start_line: usize,
57 pub end_line: usize,
58 /// The type or module this symbol is defined inside — `Foo` for a method in
59 /// `impl Foo`, `Widget` for a method in `class Widget`, `util` for a
60 /// function in an inline `mod util`.
61 ///
62 /// Without it `Foo::get` and `Bar::get` are one node, because a symbol's
63 /// identity is its bare name. Paired with [`Reference::qualifier`] it lets
64 /// the resolver keep two same-named methods apart with no type inference
65 /// (#248). Omitted from the serialized slice when absent, so a file of
66 /// plain free functions keeps the byte-identical slice — and therefore the
67 /// same content hash — it had before this field existed.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub owner: Option<String>,
70}
71
72/// A name-based reference (e.g. a call) from within `from` (the enclosing
73/// function symbol, if any) to `name`. References are unresolved: they match
74/// by name, not by a resolved definition. This is a heuristic call graph,
75/// suitable for navigation; true name resolution is a later milestone.
76///
77/// Path-agnostic like [`Symbol`]; the path comes from assembly.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Reference {
80 pub name: String,
81 pub from: Option<String>,
82 pub line: usize,
83 /// How the callee was written at the call site. Defaults to
84 /// [`RefKind::Free`] and is omitted from the serialized slice when free, so
85 /// a file of plain calls keeps the byte-identical slice — and therefore the
86 /// same content hash — it had before this field existed.
87 #[serde(default, skip_serializing_if = "RefKind::is_free")]
88 pub kind: RefKind,
89 /// The path segment immediately before the callee name at the call site —
90 /// `Language` for `Language::from_extension()`, `b` for `a::b::c()`.
91 ///
92 /// Recorded only for path-shaped calls. A method call's receiver is a
93 /// *value*, not a type, so it is deliberately left `None`: `x` says nothing
94 /// about which `helper` is meant, and storing it here would assert
95 /// something the graph does not know (#248).
96 ///
97 /// Omitted from the serialized slice when absent, like [`kind`](Self::kind).
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub qualifier: Option<String>,
100 /// What [`from`](Self::from) names — a function, or a module-level binding.
101 ///
102 /// Before #268 `from` was always a function, and a call at module level
103 /// simply had none. A great deal of modern TypeScript lives in module-level
104 /// builder objects (a Zod schema, a tRPC router), so those calls reached
105 /// neither `callers` nor `impact`.
106 ///
107 /// Attributing them to the binding widens what `from` means, so the scope
108 /// travels *with* it rather than the meaning changing silently: "who calls
109 /// this" and "which module-level declaration mentions this" are different
110 /// questions and a consumer must be able to tell them apart. Defaults to
111 /// [`Function`](FromScope::Function) and is omitted from the serialized
112 /// slice then, so a file with no module-level attribution keeps a
113 /// byte-identical slice.
114 #[serde(default, skip_serializing_if = "FromScope::is_function")]
115 pub from_scope: FromScope,
116}
117
118/// What a [`Reference`]'s `from` names.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum FromScope {
122 /// A function: a declaration, a method, or a function bound to a name. The
123 /// only kind before #268, and the default — so a slice written before it
124 /// deserializes to exactly what it meant.
125 #[default]
126 Function,
127 /// A module-level binding that is not a function — `const schema =
128 /// z.object({..})`, a tRPC handler keyed in a router object. The call sits
129 /// in that binding's initializer at module level (#268).
130 ///
131 /// Deliberately over-inclusive in the same way [`RefKind::Value`] is: a
132 /// module-level `const rows = items.map(..)` attributes its lambda's calls
133 /// to `rows`. At module level the alternative was no attribution at all,
134 /// and this field is what lets a consumer filter rather than guess.
135 Module,
136}
137
138impl FromScope {
139 /// Whether this is the default, [`Function`](FromScope::Function) scope.
140 pub fn is_function(&self) -> bool {
141 matches!(self, Self::Function)
142 }
143
144 /// Lowercase name, matching the `snake_case` serde representation. The
145 /// stored value in the persistent graph.
146 pub fn as_str(self) -> &'static str {
147 match self {
148 Self::Function => "function",
149 Self::Module => "module",
150 }
151 }
152
153 /// Parse from [`as_str`](FromScope::as_str). Anything unrecognized —
154 /// including a row written before the column existed — reads as
155 /// `Function`, the pre-existing behaviour.
156 pub fn from_str_or_function(raw: &str) -> Self {
157 match raw {
158 "module" => Self::Module,
159 _ => Self::Function,
160 }
161 }
162}
163
164/// The syntactic shape of a call site.
165///
166/// A name alone cannot distinguish `chain()` from `x.chain()`, and conflating
167/// them makes the resolver attribute a std or dependency method to a same-named
168/// free function that happens to be the only one in the view (#223). Recording
169/// the shape keeps that judgement possible at resolution time.
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum RefKind {
173 /// A plain call — `foo()` — or a path call such as `a::b::foo()`.
174 #[default]
175 Free,
176 /// A call through a receiver whose type is unknown — `x.foo()`. The callee
177 /// belongs to whatever `x` is, which the graph does not know, so it may well
178 /// be defined outside the view entirely.
179 Method,
180 /// A name used as a *value* rather than called — `and_then(helper)`.
181 ///
182 /// Not a call edge, and deliberately kept out of `callers`/`callees` and the
183 /// impact walk. It exists so `unreferenced` can tell a live callback from a
184 /// dead function: a path expression records no call, so a function only ever
185 /// passed as a value looked unused and was reported as deletable (#250).
186 ///
187 /// Over-inclusive by construction. Extraction is per-file, so it cannot tell
188 /// an identifier naming a function from one naming a local; both are
189 /// recorded. For `unreferenced` that errs towards not calling something
190 /// dead, which is the safe direction.
191 Value,
192}
193
194impl RefKind {
195 /// Whether this is the default, [`Free`](RefKind::Free) shape.
196 pub fn is_free(&self) -> bool {
197 matches!(self, Self::Free)
198 }
199
200 /// Lowercase name, matching the `snake_case` serde representation. Used as
201 /// the stored value in the persistent graph.
202 pub fn as_str(self) -> &'static str {
203 match self {
204 Self::Free => "free",
205 Self::Method => "method",
206 Self::Value => "value",
207 }
208 }
209
210 /// Parse from [`as_str`](RefKind::as_str). Anything unrecognized — including
211 /// a row written before the column existed — reads as `Free`, the
212 /// pre-existing behaviour.
213 pub fn from_str_or_free(raw: &str) -> Self {
214 match raw {
215 "method" => Self::Method,
216 "value" => Self::Value,
217 _ => Self::Free,
218 }
219 }
220}
221
222/// A query result carried with the assembly path it was found under. The path
223/// is not stored in the slice ([`Symbol`]/[`Reference`] are path-agnostic); it
224/// is re-attached at assembly from the manifest, so navigation still resolves
225/// to a concrete file.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct Located<T> {
228 pub path: String,
229 pub item: T,
230}
231
232/// A code graph: the symbols defined and references found in a single file's
233/// slice. Path-agnostic; a whole view is assembled from many of these keyed by
234/// path in a [`GraphStore`](crate::GraphStore).
235#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
236pub struct CodeGraph {
237 pub symbols: Vec<Symbol>,
238 pub references: Vec<Reference>,
239 /// Names this file brings into scope. Omitted from the serialized slice
240 /// when empty, so a file with no imports keeps the byte-identical slice —
241 /// and therefore the same content hash — it had before this existed.
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub imports: Vec<Import>,
244}
245
246/// A name brought into a file's scope, and the module path it came from.
247///
248/// Recorded as a **lexical scoping signal, not a resolved path** (#252).
249/// `use crate::model::Symbol` says that in this file `Symbol` came from a module
250/// called `model`, which is enough to prefer a definition under `model.rs` over
251/// a same-named one elsewhere — without needing to know where a crate root is,
252/// and so without extraction depending on any file but this one (ADR 0012).
253///
254/// Relative markers (`crate`, `self`, `super`, `.`, `..`) are stripped: they
255/// position the path rather than naming a module.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct Import {
258 /// The name as this file will write it — the alias when there is one.
259 ///
260 /// **Empty** when the import brings in a whole file rather than one name: a
261 /// C/C++ `#include` names a path, not a symbol (#267). Test that with
262 /// [`brings_whole_file`](Self::brings_whole_file) rather than comparing
263 /// against the empty string, so the intent is visible at the call site.
264 pub name: String,
265 /// The module path segments before the name, outermost first.
266 pub path: Vec<String>,
267 /// How many dots a relative import leads with; 0 when absolute.
268 ///
269 /// A Python relative import is the one module path resolvable with no
270 /// project root at all: `from ..pkg import X` means "my parent package,
271 /// then `pkg`", which is arithmetic on the referencing file's own path.
272 /// Dropping the dots threw that away and left the module to be matched as a
273 /// free-floating suffix against every candidate (#261).
274 ///
275 /// Omitted from the serialized slice when zero, so a file with only
276 /// absolute imports keeps a byte-identical slice.
277 #[serde(default, skip_serializing_if = "is_zero")]
278 pub depth: usize,
279 pub line: usize,
280}
281
282impl Import {
283 /// Whether this import brings in a whole file rather than one name.
284 ///
285 /// A `#include` names a path — every declaration in that file becomes
286 /// visible — so it has no single name to key on the way every other import
287 /// does. Resolution matches it by path instead (#267).
288 pub fn brings_whole_file(&self) -> bool {
289 self.name.is_empty()
290 }
291}
292
293/// Whether a count is zero, for `skip_serializing_if`.
294fn is_zero(n: &usize) -> bool {
295 *n == 0
296}
297
298impl CodeGraph {
299 /// Serialize this slice to its content-addressed blob bytes (ADR 0012).
300 /// Byte-stable for equal content, since the model carries no path.
301 pub fn to_slice_bytes(&self) -> Vec<u8> {
302 serde_json::to_vec(self).expect("CodeGraph serializes")
303 }
304
305 /// Deserialize a slice from its blob bytes.
306 pub fn from_slice_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
307 serde_json::from_slice(bytes)
308 }
309}
310
311/// A file and the number of symbols defined in it.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct FileSummary {
314 pub path: String,
315 pub symbols: usize,
316}
317
318/// The aggregate shape of a whole view — what is here, rather than facts about
319/// one symbol. `by_kind` and `by_language` are keyed by the lowercase names from
320/// [`SymbolKind::as_str`] and [`Language::as_str`](crate::Language::as_str);
321/// symbols in files with an unrecognized extension bucket under `"unknown"`, so
322/// `by_language` always sums to `symbols`.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct ViewOverview {
325 /// Distinct paths contributing symbols or references.
326 pub files: usize,
327 pub symbols: usize,
328 pub references: usize,
329 pub by_kind: BTreeMap<String, usize>,
330 pub by_language: BTreeMap<String, usize>,
331 /// Files with the most symbols, descending. Bounded by the caller's limit;
332 /// `files` above is the untruncated count.
333 pub largest_files: Vec<FileSummary>,
334}
335
336/// A symbol name ranked by some score, with the paths that define it. `paths`
337/// is empty when the name is referenced but never defined in this view (a call
338/// into a dependency, or a name the parser saw but no slice declares).
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct RankedSymbol {
341 pub name: String,
342 pub score: usize,
343 pub paths: Vec<String>,
344}
345
346/// What [`GraphStore::top`](crate::GraphStore::top) ranks by.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "snake_case")]
349pub enum Ranking {
350 /// Number of references to the name — how heavily it is called.
351 FanIn,
352 /// Number of distinct names called from within it.
353 FanOut,
354 /// Number of definitions of the name. A score above 1 means the name is
355 /// ambiguous, which is what makes name-matched traversal unreliable.
356 Definitions,
357}
358
359/// A conjunctive filter for [`GraphStore::list`](crate::GraphStore::list) — every
360/// set field must match. All fields unset matches every symbol.
361#[derive(Debug, Clone, Default, PartialEq, Eq)]
362pub struct SymbolFilter {
363 pub path_prefix: Option<String>,
364 pub kind: Option<SymbolKind>,
365 pub name_contains: Option<String>,
366}
367
368impl SymbolFilter {
369 /// Restrict to symbols whose path starts with `prefix` (scopes to a crate
370 /// or directory).
371 #[must_use]
372 pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
373 self.path_prefix = Some(prefix.into());
374 self
375 }
376
377 /// Restrict to one [`SymbolKind`].
378 #[must_use]
379 pub fn kind(mut self, kind: SymbolKind) -> Self {
380 self.kind = Some(kind);
381 self
382 }
383
384 /// Restrict to symbols whose name contains `needle`.
385 #[must_use]
386 pub fn name_contains(mut self, needle: impl Into<String>) -> Self {
387 self.name_contains = Some(needle.into());
388 self
389 }
390
391 /// Whether `located` satisfies every set field.
392 pub fn matches(&self, located: &Located<Symbol>) -> bool {
393 self.path_prefix
394 .as_ref()
395 .is_none_or(|p| located.path.starts_with(p.as_str()))
396 && self.kind.is_none_or(|k| located.item.kind == k)
397 && self
398 .name_contains
399 .as_ref()
400 .is_none_or(|n| located.item.name.contains(n.as_str()))
401 }
402}
403
404/// A bounded slice of a larger result set. `total` is the untruncated match
405/// count, so a caller can always tell what it did not see.
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct Page<T> {
408 pub items: Vec<T>,
409 pub total: usize,
410 pub truncated: bool,
411}
412
413impl<T> Page<T> {
414 /// Take at most `limit` of `items`, recording the pre-truncation total.
415 pub fn new(items: Vec<T>, limit: usize) -> Self {
416 let total = items.len();
417 let mut items = items;
418 items.truncate(limit);
419 Self {
420 truncated: items.len() < total,
421 items,
422 total,
423 }
424 }
425}