Skip to main content

backhopper_core/model/
snapshot.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! `Snapshot<S>` with the type-state pattern.
6//!
7//! The two states `Unsorted` and `Canonical` enforce that:
8//!  * the on-disk format writer accepts only `Snapshot<Canonical>`
9//!  * the parser produces `Snapshot<Canonical>` and rejects any
10//!    non-canonical input
11
12use std::collections::BTreeMap;
13use std::marker::PhantomData;
14
15use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17
18use crate::compat::arg_shape::ArgShape;
19use crate::model::names::{
20    ApplicationName, Arity, CommitSha, DependencyName, DependencyVersion, FieldName, FunctionName,
21    MacroName, ModuleName, ProjectName, RecordName, TagName, TypeName,
22};
23use crate::snapshot::sort::canonicalize;
24
25pub mod state {
26    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27    pub struct Unsorted;
28
29    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30    pub struct Canonical;
31}
32
33pub const FORMAT_VERSION: u32 = 4;
34
35/// Older snapshot file versions the parser still accepts. New writers
36/// always emit `FORMAT_VERSION`. `snapshots migrate` regenerates each
37/// stored snapshot at the current version.
38pub const SUPPORTED_FORMAT_VERSIONS: &[u32] = &[1, 2, 3, 4];
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct SnapshotHeader {
42    pub project: ProjectName,
43    pub tag: TagName,
44    pub branch: Option<String>,
45    pub commit: CommitSha,
46    pub scanned_paths: Vec<String>,
47    /// Names of Erlang/OTP applications included in this snapshot. Populated
48    /// for `multi_app` and `erlang_otp` projects: empty for `single_app`.
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub apps_scanned: Vec<ApplicationName>,
51    pub generated_by: String,
52    #[serde(with = "time::serde::rfc3339")]
53    pub generated_at: OffsetDateTime,
54    /// Version of the extractor that produced this snapshot. Compared against
55    /// the running binary's `EXTRACTOR_VERSION` by `snapshots verify --all`.
56    /// Empty string when reading pre-extractor-versioning snapshots.
57    #[serde(default)]
58    pub extractor_version: String,
59    /// Vendored dependency pins captured from the project's components file
60    /// (e.g. `rabbitmq-components.mk`). Populated for projects whose family
61    /// declares a components-file shape; empty otherwise. Format version 4
62    /// and later.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub dep_pins: Vec<VendoredDep>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum Visibility {
70    Public,
71    Hidden,
72    TestOnly,
73}
74
75impl Visibility {
76    pub fn keyword(self) -> &'static str {
77        match self {
78            Self::Public => "public",
79            Self::Hidden => "hidden",
80            Self::TestOnly => "test_only",
81        }
82    }
83
84    pub fn from_keyword(s: &str) -> Option<Self> {
85        match s {
86            "public" => Some(Self::Public),
87            "hidden" => Some(Self::Hidden),
88            "test_only" => Some(Self::TestOnly),
89            _ => None,
90        }
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct Module {
96    pub name: ModuleName,
97    pub visibility: Visibility,
98    pub behaviours: Vec<ModuleName>,
99    pub exports: Vec<FunArity>,
100    pub export_types: Vec<TypeArity>,
101    pub callbacks: Vec<CallbackSig>,
102    pub optional_callbacks: Vec<FunArity>,
103    pub specs: Vec<SpecSig>,
104    pub types: Vec<TypeDecl>,
105    pub opaques: Vec<TypeArity>,
106    pub deprecations: Vec<Deprecation>,
107    /// Per-export clause-head patterns: the disjunction of clauses that
108    /// define this function. Used by the analyzer to flag calls whose
109    /// argument shape doesn't satisfy any clause head at the pin.
110    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
111    pub clause_heads: BTreeMap<FunArity, Vec<Vec<ArgShape>>>,
112    /// Source path relative to the project root. `None` on format
113    /// version 1 snapshots; populated on v2.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub path: Option<String>,
116    /// Application this module belongs to. `None` on v1 and for
117    /// single-app projects.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub app: Option<ApplicationName>,
120    /// Records declared inline in the `.erl` file. Distinct from
121    /// `HrlFile.records`. v2 and later.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub records: Vec<RecordDecl>,
124    /// Exports declared inside an `-ifdef(TEST)` block. Test-only
125    /// exports drive cascade planning for the unconditional-exports
126    /// refactor: they are not part of the public API at the tag but the
127    /// planner needs them recorded for this branch.
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub test_only_exports: Vec<TestOnlyExport>,
130    /// `-define(...)` macros inside `-ifdef(TEST)` *body*
131    /// blocks (the macro itself is only visible under the guard).
132    /// Needed by the Variant B unwrap planner: when the body block
133    /// becomes unconditional, every macro it depends on must move
134    /// out together with the function bodies.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub ifdef_macros: Vec<IfdefMacro>,
137    /// `-ifndef(TEST)/-else/-endif` regions: a function with two
138    /// bodies, a production one and a test one. Recorded for
139    /// surfacing; no verdict rule fires on them.
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub variant_c_blocks: Vec<VariantCBlock>,
142    /// Extracted body of the version function for modules that implement
143    /// a `versioned_machine_impl` declared by the project family (e.g.
144    /// `rabbit_fifo:version/0`). `None` when the module is not a declared
145    /// versioned-machine impl or when the body did not resolve. Format
146    /// version 4 and later.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub versioned_machine_version: Option<VersionedMachineVersion>,
149    /// Bound values of the wire-bearing macros declared by the project
150    /// family for this module (e.g. `RA_PROTO_VERSION` on `ra`). Empty
151    /// when the module declares no tracked wire constants. Format
152    /// version 4 and later.
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub wire_constants: Vec<WireConstantBinding>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
158pub struct TestOnlyExport {
159    pub function: FunctionName,
160    pub arity: Arity,
161    /// 1-based line of the `-export` directive.
162    pub export_line: usize,
163    /// 1-based line where the function body starts. `None` for
164    /// Variant A (body unconditional), `Some` for Variant B (body
165    /// inside its own `-ifdef(TEST)` block, where the function
166    /// itself is absent from a non-TEST beam).
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub body_line: Option<usize>,
169    pub variant: TestExportVariant,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum TestExportVariant {
175    /// Body is compiled unconditionally; only the `-export` is
176    /// guarded. Stale non-TEST `.beam` still has the function code
177    /// but not in its export table.
178    A,
179    /// Body is also inside an `-ifdef(TEST)` block. The function
180    /// does not exist in a non-TEST `.beam` at all.
181    B,
182}
183
184impl TestExportVariant {
185    pub fn as_label(self) -> &'static str {
186        match self {
187            Self::A => "a",
188            Self::B => "b",
189        }
190    }
191
192    pub fn from_label(s: &str) -> Option<Self> {
193        match s {
194            "a" => Some(Self::A),
195            "b" => Some(Self::B),
196            _ => None,
197        }
198    }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
202pub struct IfdefMacro {
203    pub name: String,
204    /// 1-based line of the `-define` directive.
205    pub line: usize,
206    pub guard_kind: IfdefGuardKind,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum IfdefGuardKind {
212    /// The macro is inside `-ifdef(TEST)`.
213    Test,
214    /// The macro is inside `-ifndef(TEST)`.
215    NotTest,
216    /// The macro is inside some other `-ifdef` or `-if` block.
217    Other,
218}
219
220impl IfdefGuardKind {
221    pub fn as_label(self) -> &'static str {
222        match self {
223            Self::Test => "test",
224            Self::NotTest => "not_test",
225            Self::Other => "other",
226        }
227    }
228
229    pub fn from_label(s: &str) -> Option<Self> {
230        match s {
231            "test" => Some(Self::Test),
232            "not_test" => Some(Self::NotTest),
233            "other" => Some(Self::Other),
234            _ => None,
235        }
236    }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
240pub struct VariantCBlock {
241    /// The `-ifndef(...)` identifier (typically `TEST`).
242    pub guard: String,
243    /// 1-based line of the `-ifndef` directive.
244    pub start_line: usize,
245    /// 1-based line of the matching `-endif.`.
246    pub end_line: usize,
247    /// 1-based line of the `-else.`, when present.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub else_line: Option<usize>,
250}
251
252/// Snapshot-level record of a versioned state-machine's `version/0`
253/// function value, captured from the source at snapshot time. Populated
254/// only for modules the project family declares as versioned-machine
255/// impls (e.g. `rabbit_fifo`).
256#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
257pub struct VersionedMachineVersion {
258    pub function: FunctionName,
259    pub arity: Arity,
260    /// Resolved integer value of the version-function body. `None` when
261    /// the body did not parse to an integer literal (directly or through
262    /// macro expansion).
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub value: Option<u64>,
265    pub provenance: Provenance,
266}
267
268/// How a versioned-machine `version/0` value was resolved at snapshot
269/// time.
270#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case", tag = "kind")]
272pub enum Provenance {
273    /// The body was an integer literal directly in the function clause.
274    Literal,
275    /// The body referenced a `?MACRO` that resolved to an integer.
276    /// `defined_in` is the file path of the source that bound the macro
277    /// (own `.erl`, own `.hrl`, or a transitive include).
278    MacroBody {
279        macro_name: MacroName,
280        #[serde(default, skip_serializing_if = "Option::is_none")]
281        defined_in: Option<String>,
282    },
283}
284
285/// Snapshot-level record of a wire-bearing macro's bound value (e.g.
286/// `RA_PROTO_VERSION`, segment-file `VERSION` and `MAGIC`). Captured
287/// per module declared by the project family as wire-bearing.
288#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
289pub struct WireConstantBinding {
290    pub macro_name: MacroName,
291    pub value: WireValue,
292    /// Source file where the macro was `-define`'d. May be the module's
293    /// own `.erl`, a sibling `.hrl`, or a transitive include.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub defined_in: Option<String>,
296}
297
298/// Type-discriminated wire-constant value. Integers and ASCII bytestrings
299/// are typed; anything else is preserved verbatim as `Opaque`.
300#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
302pub enum WireValue {
303    /// Unsigned integer literal (`-define(VERSION, 2)`).
304    U64(u64),
305    /// Bytestring literal (`-define(MAGIC, <<"RASG">>)`). Stored as
306    /// the inner bytes without the `<<"...">>` wrapper. The text-format
307    /// round-trip assumes ASCII-printable bytes: non-printable byte
308    /// content should land in `Opaque` instead.
309    Bytes(Vec<u8>),
310    /// Anything else, preserved as the raw token text.
311    Opaque(String),
312}
313
314/// Pinned dependency captured from the project's components file (e.g.
315/// `rabbitmq-components.mk`).
316#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
317pub struct VendoredDep {
318    pub name: DependencyName,
319    pub version: DependencyVersion,
320    pub source: VendoredDepSource,
321}
322
323/// How a vendored dependency is sourced. Mirrors the three real
324/// `rabbitmq-components.mk` line shapes.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
326#[serde(rename_all = "snake_case")]
327pub enum VendoredDepSource {
328    /// `dep_NAME = hex VERSION`.
329    Hex,
330    /// `dep_NAME = git URL TAG`.
331    Git,
332    /// `dep_NAME = git_rmq NAME TAG` (vendored fork).
333    GitRmq,
334}
335
336impl VendoredDepSource {
337    pub fn as_label(self) -> &'static str {
338        match self {
339            Self::Hex => "hex",
340            Self::Git => "git",
341            Self::GitRmq => "git_rmq",
342        }
343    }
344
345    pub fn from_label(s: &str) -> Option<Self> {
346        match s {
347            "hex" => Some(Self::Hex),
348            "git" => Some(Self::Git),
349            "git_rmq" => Some(Self::GitRmq),
350            _ => None,
351        }
352    }
353}
354
355impl Module {
356    pub fn new(name: ModuleName) -> Self {
357        Self {
358            name,
359            visibility: Visibility::Public,
360            behaviours: Vec::new(),
361            exports: Vec::new(),
362            export_types: Vec::new(),
363            callbacks: Vec::new(),
364            optional_callbacks: Vec::new(),
365            specs: Vec::new(),
366            types: Vec::new(),
367            opaques: Vec::new(),
368            deprecations: Vec::new(),
369            clause_heads: BTreeMap::new(),
370            path: None,
371            app: None,
372            records: Vec::new(),
373            test_only_exports: Vec::new(),
374            ifdef_macros: Vec::new(),
375            variant_c_blocks: Vec::new(),
376            versioned_machine_version: None,
377            wire_constants: Vec::new(),
378        }
379    }
380
381    /// Exports that have no matching clause head. An empty
382    /// `clause_heads` (older snapshots without the field populated)
383    /// returns an empty list so this never false-fires on legacy data.
384    pub fn missing_clause_bodies(&self) -> Vec<FunArity> {
385        if self.clause_heads.is_empty() {
386            return Vec::new();
387        }
388        self.exports
389            .iter()
390            .filter(|fa| !self.clause_heads.contains_key(fa))
391            .cloned()
392            .collect()
393    }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397pub struct HrlFile {
398    pub path: String,
399    pub types: Vec<TypeDecl>,
400    pub opaques: Vec<TypeArity>,
401    pub records: Vec<RecordDecl>,
402}
403
404impl HrlFile {
405    pub fn new(path: impl Into<String>) -> Self {
406        Self {
407            path: path.into(),
408            types: Vec::new(),
409            opaques: Vec::new(),
410            records: Vec::new(),
411        }
412    }
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
416pub struct FunArity {
417    pub name: FunctionName,
418    pub arity: Arity,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
422pub struct TypeArity {
423    pub name: TypeName,
424    pub arity: Arity,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428pub struct CallbackSig {
429    pub name: FunctionName,
430    pub arity: Arity,
431    pub signature: String,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct SpecSig {
436    pub name: FunctionName,
437    pub arity: Arity,
438    pub signature: String,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442pub struct TypeDecl {
443    pub name: TypeName,
444    pub arity: Arity,
445    pub rhs: String,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449pub struct RecordDecl {
450    pub name: RecordName,
451    pub fields: Vec<RecordField>,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455pub struct RecordField {
456    pub name: FieldName,
457    pub type_repr: Option<String>,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub struct Deprecation {
462    pub function: Option<FunctionName>,
463    pub arity_match: ArityMatch,
464    pub since: Option<TagName>,
465    pub replacement: Option<DeprecationReplacement>,
466    pub reason: Option<String>,
467    pub module_wide: bool,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case", tag = "kind")]
472pub enum ArityMatch {
473    Exact { arity: Arity },
474    Any,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478pub struct DeprecationReplacement {
479    pub function: FunctionName,
480    pub arity: Arity,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(bound = "")]
485pub struct Snapshot<S = state::Unsorted> {
486    pub header: SnapshotHeader,
487    pub modules: Vec<Module>,
488    pub headers: Vec<HrlFile>,
489    #[serde(skip)]
490    _state: PhantomData<S>,
491}
492
493impl Snapshot<state::Unsorted> {
494    pub fn from_extracted(
495        header: SnapshotHeader,
496        modules: Vec<Module>,
497        headers: Vec<HrlFile>,
498    ) -> Self {
499        Self {
500            header,
501            modules,
502            headers,
503            _state: PhantomData,
504        }
505    }
506
507    #[must_use]
508    pub fn into_canonical(mut self) -> Snapshot<state::Canonical> {
509        canonicalize(&mut self.modules, &mut self.headers);
510        self.header.apps_scanned.sort();
511        self.header.apps_scanned.dedup();
512        self.header.dep_pins.sort_by(|a, b| a.name.cmp(&b.name));
513        self.header.dep_pins.dedup_by(|a, b| a.name == b.name);
514        Snapshot {
515            header: self.header,
516            modules: self.modules,
517            headers: self.headers,
518            _state: PhantomData,
519        }
520    }
521}
522
523impl<S> Snapshot<S> {
524    pub fn header(&self) -> &SnapshotHeader {
525        &self.header
526    }
527
528    pub fn modules(&self) -> &[Module] {
529        &self.modules
530    }
531
532    pub fn headers(&self) -> &[HrlFile] {
533        &self.headers
534    }
535
536    /// Records are declared in headers (.hrl) and inline in modules (.erl): both.
537    pub fn records(&self) -> impl Iterator<Item = &RecordDecl> {
538        self.headers
539            .iter()
540            .flat_map(|h| h.records.iter())
541            .chain(self.modules.iter().flat_map(|m| m.records.iter()))
542    }
543}
544
545impl Snapshot<state::Canonical> {
546    /// True when `module` exports `function/arity`. O(log n): canonical
547    /// snapshots keep `modules` and each module's `exports` sorted.
548    pub fn lookup_export(
549        &self,
550        module: &ModuleName,
551        function: &FunctionName,
552        arity: Arity,
553    ) -> bool {
554        let Some(m) = self.module_named(module) else {
555            return false;
556        };
557        let target = FunArity {
558            name: function.clone(),
559            arity,
560        };
561        m.exports.binary_search(&target).is_ok()
562    }
563
564    /// Look up a module by name in O(log n): canonical snapshots keep
565    /// `modules` sorted by `name`.
566    pub fn module_named(&self, name: &ModuleName) -> Option<&Module> {
567        self.modules
568            .binary_search_by(|m| m.name.cmp(name))
569            .ok()
570            .map(|idx| &self.modules[idx])
571    }
572
573    /// Every module whose `-behaviour(B).` list contains `behaviour`.
574    /// Reverse index over `Module.behaviours`; built per call (one
575    /// linear scan over `self.modules`). Used by behaviour-conformance
576    /// checks and the R7 suite-implementer sweep.
577    pub fn implementers_of(&self, behaviour: &ModuleName) -> Vec<&Module> {
578        self.modules
579            .iter()
580            .filter(|m| m.behaviours.contains(behaviour))
581            .collect()
582    }
583
584    pub(crate) fn from_canonical_parts(
585        header: SnapshotHeader,
586        modules: Vec<Module>,
587        headers: Vec<HrlFile>,
588    ) -> Self {
589        Self {
590            header,
591            modules,
592            headers,
593            _state: PhantomData,
594        }
595    }
596}
597
598impl Default for Snapshot<state::Unsorted> {
599    fn default() -> Self {
600        Self {
601            header: SnapshotHeader {
602                project: ProjectName::new("placeholder").expect("valid"),
603                tag: TagName::new("v0.0.0").expect("valid"),
604                branch: None,
605                commit: CommitSha::new("0".repeat(40)).expect("valid"),
606                scanned_paths: Vec::new(),
607                apps_scanned: Vec::new(),
608                generated_by: format!("backhopper {}", env!("CARGO_PKG_VERSION")),
609                generated_at: OffsetDateTime::UNIX_EPOCH,
610                extractor_version: String::new(),
611                dep_pins: Vec::new(),
612            },
613            modules: Vec::new(),
614            headers: Vec::new(),
615            _state: PhantomData,
616        }
617    }
618}