Skip to main content

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}