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