Skip to main content

forma_ir/
format.rs

1//! FMIR binary format definition and parsers.
2//!
3//! Defines the on-disk layout for Forma Module IR files:
4//! - 16-byte header (magic, version, flags, source hash)
5//! - 32-byte section table (4 sections x 8 bytes each)
6//! - Opcode, SlotType, IslandTrigger, PropsMode enums
7//! - SlotEntry and IslandEntry structs
8//!
9//! All multi-byte integers are little-endian.
10
11use std::fmt;
12
13// ---------------------------------------------------------------------------
14// Constants
15// ---------------------------------------------------------------------------
16
17/// Magic bytes identifying an FMIR file.
18pub const MAGIC: &[u8; 4] = b"FMIR";
19
20/// Size of the file header in bytes.
21pub const HEADER_SIZE: usize = 16;
22
23/// Size of the section table in bytes (4 sections x 8 bytes).
24pub const SECTION_TABLE_SIZE: usize = 32;
25
26/// Current IR format version.
27pub const IR_VERSION: u16 = 2;
28
29// ---------------------------------------------------------------------------
30// Little-endian reads
31// ---------------------------------------------------------------------------
32//
33// Each call site has already checked that its slice is in bounds and of the
34// width read; these index rather than `try_into().unwrap()` so the crate
35// carries no `unwrap` (DR-12). The indexing cannot fail where the slice could
36// be taken.
37
38/// A `u16` from the first 2 bytes of `b`.
39pub(crate) fn le_u16(b: &[u8]) -> u16 {
40    u16::from_le_bytes([b[0], b[1]])
41}
42
43/// A `u32` from the first 4 bytes of `b`.
44pub(crate) fn le_u32(b: &[u8]) -> u32 {
45    u32::from_le_bytes([b[0], b[1], b[2], b[3]])
46}
47
48/// A `u64` from the first 8 bytes of `b`.
49pub(crate) fn le_u64(b: &[u8]) -> u64 {
50    u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
51}
52
53// ---------------------------------------------------------------------------
54// Error type
55// ---------------------------------------------------------------------------
56
57/// Errors that can occur when parsing FMIR binary data.
58///
59/// Marked `#[non_exhaustive]`: new failure modes are added as the parser is
60/// hardened, and downstream crates must not break when that happens. Match
61/// with a `_ =>` arm (or just use `Display`).
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum IrError {
65    /// Input buffer is too short to contain the expected structure.
66    BufferTooShort {
67        /// Bytes the structure needed.
68        expected: usize,
69        /// Bytes actually available.
70        actual: usize,
71    },
72    /// Magic bytes do not match "FMIR".
73    BadMagic([u8; 4]),
74    /// IR version is not supported.
75    UnsupportedVersion(u16),
76    /// A section extends beyond the file boundary.
77    SectionOutOfBounds {
78        /// Index of the offending section in the section table.
79        section: usize,
80        /// The section's declared byte offset.
81        offset: u32,
82        /// The section's declared byte length.
83        size: u32,
84        /// Length of the file the offsets were read from.
85        file_len: usize,
86    },
87    /// An opcode byte does not map to a known opcode.
88    InvalidOpcode(u8),
89    /// A slot-type byte does not map to a known slot type.
90    InvalidSlotType(u8),
91    /// An island-trigger byte does not map to a known trigger.
92    InvalidIslandTrigger(u8),
93    /// A props-mode byte does not map to a known mode.
94    InvalidPropsMode(u8),
95    /// A slot-source byte does not map to a known source.
96    InvalidSlotSource(u8),
97    /// A string index is out of bounds.
98    StringIndexOutOfBounds {
99        /// The index that was asked for.
100        index: u32,
101        /// Number of entries the string table actually holds.
102        len: usize,
103    },
104    /// A byte sequence is not valid UTF-8.
105    InvalidUtf8(String),
106    /// Nested LIST depth exceeded the maximum allowed.
107    ListDepthExceeded {
108        /// The nesting limit that was exceeded.
109        max: u8,
110    },
111    /// An island with the given id was not found in the island table.
112    IslandNotFound(u16),
113    /// Failed to parse JSON input.
114    JsonParseError(String),
115    /// A section descriptor has invalid values (e.g. integer overflow).
116    ///
117    /// Also returned for opcode-stream offsets that overflow or point outside
118    /// the region they were read from (branch/body lengths in SHOW_IF, SWITCH,
119    /// LIST and TRY_START/FALLBACK).
120    InvalidSection,
121    /// Recursive opcode nesting (SHOW_IF, LIST, SWITCH, etc.) exceeded the
122    /// maximum allowed depth.
123    RecursionLimitExceeded,
124    /// The rendered HTML exceeded the walker's configured output budget.
125    OutputLimitExceeded {
126        /// The byte budget that was exceeded.
127        limit: usize,
128    },
129    /// Total LIST item iterations exceeded the walker's per-render budget.
130    /// Distinct from [`IrError::ListDepthExceeded`], which bounds *nesting*.
131    ListBudgetExceeded {
132        /// The total-iteration budget that was exceeded.
133        max: usize,
134    },
135    /// The header's reserved `flags` field carried an unknown non-zero bit.
136    UnsupportedFlags(u16),
137    /// A tag or attribute name from the string table is not a legal HTML name.
138    InvalidName(String),
139    /// The slot table declares the same `slot_id` more than once.
140    DuplicateSlotId(u16),
141    /// The island table declares the same island `id` more than once.
142    DuplicateIslandId(u16),
143    /// An island's `byte_offset` does not point at an ISLAND_START opcode
144    /// carrying that island's own id.
145    IslandOffsetMismatch {
146        /// The island whose table entry is wrong.
147        island_id: u16,
148        /// The byte offset that entry declared.
149        byte_offset: u32,
150    },
151    /// A slot id referenced by an island entry or an opcode is not declared
152    /// in the slot table.
153    UnknownSlotId(u16),
154    /// The opcode stream's control-flow regions do not tile exactly: a
155    /// branch/body length does not line up with the opcodes it contains.
156    MalformedControlFlow {
157        /// Byte offset in the opcode stream where the mismatch was detected.
158        pos: usize,
159    },
160    /// An ISLAND_START opcode names an island id the island table does not
161    /// declare. Such an island emits its `<!--f:iN-->` markers but no
162    /// `data-forma-*` attributes, so the client can never hydrate it.
163    UnknownIslandId(u16),
164    /// Two ISLAND_START opcodes are pending on the same element: the outer
165    /// island's body opens no tag of its own before the inner island starts,
166    /// so only one of them can carry the `data-forma-island` attribute set and
167    /// the other would silently never hydrate.
168    NestedIslandRoot {
169        /// The island still waiting for a root element.
170        outer: u16,
171        /// The island that started before it got one.
172        inner: u16,
173    },
174    /// The walk panicked, and [`crate::walker::walk_to_html_counted`] caught
175    /// the unwind. No input is known to cause one (the parser and walker are
176    /// fuzzed), so this reports a defect in the walker, not in the module or
177    /// the data. Under `panic = "abort"` there is nothing to catch and the
178    /// process ends instead.
179    Panicked {
180        /// The panic's message, when it carried one.
181        message: String,
182    },
183}
184
185impl fmt::Display for IrError {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self {
188            IrError::BufferTooShort { expected, actual } => {
189                write!(
190                    f,
191                    "buffer too short: expected at least {expected} bytes, got {actual}"
192                )
193            }
194            IrError::BadMagic(got) => {
195                write!(f, "bad magic: expected FMIR, got {:?}", got)
196            }
197            IrError::UnsupportedVersion(v) => {
198                write!(f, "unsupported IR version: {v} (expected {IR_VERSION})")
199            }
200            IrError::SectionOutOfBounds {
201                section,
202                offset,
203                size,
204                file_len,
205            } => {
206                write!(
207                    f,
208                    "section {section} out of bounds: offset={offset}, size={size}, file_len={file_len}"
209                )
210            }
211            IrError::InvalidOpcode(b) => write!(f, "invalid opcode: 0x{b:02x}"),
212            IrError::InvalidSlotType(b) => write!(f, "invalid slot type: 0x{b:02x}"),
213            IrError::InvalidIslandTrigger(b) => {
214                write!(f, "invalid island trigger: 0x{b:02x}")
215            }
216            IrError::InvalidPropsMode(b) => write!(f, "invalid props mode: 0x{b:02x}"),
217            IrError::InvalidSlotSource(b) => write!(f, "invalid slot source: 0x{b:02x}"),
218            IrError::StringIndexOutOfBounds { index, len } => {
219                write!(
220                    f,
221                    "string index {index} out of bounds (table has {len} entries)"
222                )
223            }
224            IrError::InvalidUtf8(msg) => write!(f, "invalid UTF-8: {msg}"),
225            IrError::ListDepthExceeded { max } => {
226                write!(f, "nested LIST depth exceeded maximum of {max}")
227            }
228            IrError::IslandNotFound(id) => {
229                write!(f, "island with id {id} not found in island table")
230            }
231            IrError::JsonParseError(msg) => {
232                write!(f, "JSON parse error: {msg}")
233            }
234            IrError::InvalidSection => {
235                write!(f, "invalid section or opcode offset (out of range)")
236            }
237            IrError::RecursionLimitExceeded => {
238                write!(f, "recursion limit exceeded (maximum depth: 64)")
239            }
240            IrError::OutputLimitExceeded { limit } => {
241                write!(f, "rendered output exceeded the limit of {limit} bytes")
242            }
243            IrError::ListBudgetExceeded { max } => {
244                write!(f, "total LIST iterations exceeded the budget of {max}")
245            }
246            IrError::UnsupportedFlags(flags) => {
247                write!(f, "unsupported header flags: 0x{flags:04x} (must be 0)")
248            }
249            IrError::InvalidName(name) => {
250                write!(f, "invalid tag or attribute name: {name:?}")
251            }
252            IrError::DuplicateSlotId(id) => {
253                write!(f, "duplicate slot id {id} in slot table")
254            }
255            IrError::DuplicateIslandId(id) => {
256                write!(f, "duplicate island id {id} in island table")
257            }
258            IrError::IslandOffsetMismatch {
259                island_id,
260                byte_offset,
261            } => {
262                write!(
263                    f,
264                    "island {island_id} byte_offset {byte_offset} does not point at its ISLAND_START opcode"
265                )
266            }
267            IrError::UnknownSlotId(id) => {
268                write!(f, "slot id {id} is not declared in the slot table")
269            }
270            IrError::MalformedControlFlow { pos } => {
271                write!(
272                    f,
273                    "malformed control flow: region lengths do not tile at byte {pos}"
274                )
275            }
276            IrError::UnknownIslandId(id) => {
277                write!(f, "island id {id} is not declared in the island table")
278            }
279            IrError::NestedIslandRoot { outer, inner } => {
280                write!(
281                    f,
282                    "island {inner} starts before island {outer} has opened its root element — \
283                     one of them could never carry its data-forma-* attributes"
284                )
285            }
286            IrError::Panicked { message } => write!(f, "the walk panicked: {message}"),
287        }
288    }
289}
290
291impl std::error::Error for IrError {}
292
293// ---------------------------------------------------------------------------
294// Header (16 bytes)
295// ---------------------------------------------------------------------------
296
297/// FMIR file header — the first 16 bytes of every FMIR module (a `.ir` file).
298///
299/// Layout (little-endian):
300/// ```text
301/// [0..4)   magic        – b"FMIR"
302/// [4..6)   version      – u16
303/// [6..8)   flags        – u16 (reserved; MUST be 0 — any non-zero bit is a
304///                        hard parse error, so unknown future extensions fail
305///                        closed instead of being silently mis-parsed. A new
306///                        flag bit therefore requires a coordinated forma-ir
307///                        release before the compiler may emit it.)
308/// [8..16)  source_hash  – u64 (hash of original source; currently written as
309///                        0 by the JS compiler and not verified here)
310/// ```
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub struct IrHeader {
313    /// FMIR format version. Always [`IR_VERSION`] for a header that parsed.
314    pub version: u16,
315    /// Reserved bit field, validated to be zero (see the layout above).
316    pub flags: u16,
317    /// Hash of the original source module. Written as 0 by the JS compiler
318    /// today and not verified here.
319    pub source_hash: u64,
320}
321
322impl IrHeader {
323    /// Parse an `IrHeader` from the first 16 bytes of `data`.
324    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
325        if data.len() < HEADER_SIZE {
326            return Err(IrError::BufferTooShort {
327                expected: HEADER_SIZE,
328                actual: data.len(),
329            });
330        }
331
332        let magic: [u8; 4] = [data[0], data[1], data[2], data[3]];
333        if &magic != MAGIC {
334            return Err(IrError::BadMagic(magic));
335        }
336
337        let version = le_u16(&data[4..6]);
338        if version != IR_VERSION {
339            return Err(IrError::UnsupportedVersion(version));
340        }
341
342        // `flags` is reserved. Reject any non-zero bit so that a future
343        // extension (compression, extended section table, ...) fails closed on
344        // an old reader instead of being silently ignored and mis-parsed.
345        let flags = le_u16(&data[6..8]);
346        if flags != 0 {
347            return Err(IrError::UnsupportedFlags(flags));
348        }
349
350        let source_hash = le_u64(&data[8..16]);
351
352        Ok(IrHeader {
353            version,
354            flags,
355            source_hash,
356        })
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Section table (32 bytes)
362// ---------------------------------------------------------------------------
363
364/// A single section descriptor: offset + size (both u32, little-endian).
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub struct SectionDescriptor {
367    /// Byte offset of the section from the start of the file.
368    pub offset: u32,
369    /// Length of the section in bytes.
370    pub size: u32,
371}
372
373/// Section table — four section descriptors immediately following the header.
374///
375/// Sections (in order):
376/// 0. Bytecode
377/// 1. String table
378/// 2. Slot table
379/// 3. Island table
380///
381/// Layout: 4 x (offset u32 + size u32) = 32 bytes.
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub struct SectionTable {
384    /// Bytecode, string table, slot table, island table — in that order.
385    pub sections: [SectionDescriptor; 4],
386}
387
388impl SectionTable {
389    /// Parse a `SectionTable` from `data` (must be at least 32 bytes).
390    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
391        if data.len() < SECTION_TABLE_SIZE {
392            return Err(IrError::BufferTooShort {
393                expected: SECTION_TABLE_SIZE,
394                actual: data.len(),
395            });
396        }
397
398        let mut sections = [SectionDescriptor { offset: 0, size: 0 }; 4];
399        for (i, section) in sections.iter_mut().enumerate() {
400            let base = i * 8;
401            let offset = le_u32(&data[base..base + 4]);
402            let size = le_u32(&data[base + 4..base + 8]);
403            *section = SectionDescriptor { offset, size };
404        }
405
406        Ok(SectionTable { sections })
407    }
408
409    /// Validate that every section falls within `file_len` bytes.
410    pub fn validate(&self, file_len: usize) -> Result<(), IrError> {
411        for (i, sec) in self.sections.iter().enumerate() {
412            let end = (sec.offset as usize)
413                .checked_add(sec.size as usize)
414                .ok_or(IrError::InvalidSection)?;
415            if end > file_len {
416                return Err(IrError::SectionOutOfBounds {
417                    section: i,
418                    offset: sec.offset,
419                    size: sec.size,
420                    file_len,
421                });
422            }
423        }
424        Ok(())
425    }
426}
427
428// ---------------------------------------------------------------------------
429// Opcode enum (18 opcodes, 0x01–0x12)
430//
431// Six are decoded and walked but never emitted by @getforma/compiler today:
432// Switch, TryStart, Fallback, Preload, Comment, ListItemKey. They are reserved
433// wire space, not dead code — see docs/ARCHITECTURE.md §B1. ListItemKey in
434// particular cannot be emitted usefully as specified: its operand is a static
435// string index, so every row would carry the same key. Per-item keys need an
436// FMIR version bump.
437// ---------------------------------------------------------------------------
438
439/// Bytecode opcodes for the FMIR instruction stream.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441#[repr(u8)]
442pub enum Opcode {
443    /// Open an element. `str_idx(u32) + attr_count(u16) + [(key u32, val u32)]`
444    OpenTag = 0x01,
445    /// Close the named element. `str_idx(u32)`
446    CloseTag = 0x02,
447    /// A void element, which takes no CLOSE_TAG. Same operands as OPEN_TAG.
448    VoidTag = 0x03,
449    /// Static text from the string table. `str_idx(u32)`
450    Text = 0x04,
451    /// Slot-bound text wrapped in `<!--f:tN-->` markers.
452    /// `slot_id(u16) + marker_id(u16)`
453    DynText = 0x05,
454    /// Slot-bound attribute on the tag currently being opened.
455    /// `attr_str_idx(u32) + slot_id(u16)`
456    DynAttr = 0x06,
457    /// Conditional region wrapped in `<!--f:sN-->` markers.
458    /// `slot_id(u16) + then_len(u32) + else_len(u32)`, then the two bodies
459    /// separated by a [`Opcode::ShowElse`] byte.
460    ShowIf = 0x07,
461    /// Separator byte between a SHOW_IF's then- and else-bodies.
462    ShowElse = 0x08,
463    /// First-match-wins multi-way branch on a slot's text.
464    /// `slot_id(u16) + case_count(u16)`, then one
465    /// `(val_str_idx u32, body_len u32)` header per case, then the bodies.
466    Switch = 0x09,
467    /// Repeat a body once per element of an array slot, wrapped in
468    /// `<!--f:lN-->` markers. `slot_id(u16) + item_slot_id(u16) + body_len(u32)`
469    List = 0x0A,
470    /// Begin an island region. `island_id(u16)`
471    IslandStart = 0x0B,
472    /// End an island region. `island_id(u16)`
473    IslandEnd = 0x0C,
474    /// Begin a try block. `fallback_len(u32)`
475    TryStart = 0x0D,
476    /// Separator between a try block's main and fallback bodies.
477    Fallback = 0x0E,
478    /// Emit a `<link rel="preload">`. `resource_type(u8) + url_str_idx(u32)`
479    Preload = 0x0F,
480    /// An HTML comment. `str_idx(u32)`
481    Comment = 0x10,
482    /// Set `data-forma-key` on the next element opened in a LIST body.
483    /// `key_str_idx(u32)`
484    ///
485    /// LIMITATION: the operand is a *static* string-table index, so every item
486    /// in a list gets the same key. Per-item keys would need a slot-bound
487    /// operand, which is a v-bump to this opcode's layout and a coordinated
488    /// compiler change — not something the walker can work around.
489    ListItemKey = 0x11,
490    /// Extract a named property from an Object slot into a target slot.
491    /// Format: src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16)
492    Prop = 0x12,
493}
494
495impl Opcode {
496    /// Convert a raw byte to an `Opcode`.
497    pub fn from_byte(b: u8) -> Result<Self, IrError> {
498        match b {
499            0x01 => Ok(Opcode::OpenTag),
500            0x02 => Ok(Opcode::CloseTag),
501            0x03 => Ok(Opcode::VoidTag),
502            0x04 => Ok(Opcode::Text),
503            0x05 => Ok(Opcode::DynText),
504            0x06 => Ok(Opcode::DynAttr),
505            0x07 => Ok(Opcode::ShowIf),
506            0x08 => Ok(Opcode::ShowElse),
507            0x09 => Ok(Opcode::Switch),
508            0x0A => Ok(Opcode::List),
509            0x0B => Ok(Opcode::IslandStart),
510            0x0C => Ok(Opcode::IslandEnd),
511            0x0D => Ok(Opcode::TryStart),
512            0x0E => Ok(Opcode::Fallback),
513            0x0F => Ok(Opcode::Preload),
514            0x10 => Ok(Opcode::Comment),
515            0x11 => Ok(Opcode::ListItemKey),
516            0x12 => Ok(Opcode::Prop),
517            _ => Err(IrError::InvalidOpcode(b)),
518        }
519    }
520}
521
522// ---------------------------------------------------------------------------
523// SlotType enum (5 types)
524// ---------------------------------------------------------------------------
525
526/// Data type hint for a slot entry.
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528#[repr(u8)]
529pub enum SlotType {
530    /// A string.
531    Text = 0x01,
532    /// A boolean.
533    Bool = 0x02,
534    /// An IEEE-754 double, formatted the way ECMAScript formats it.
535    Number = 0x03,
536    /// An array, the only type a LIST may iterate.
537    Array = 0x04,
538    /// A key/value object, the only type PROP may read from.
539    Object = 0x05,
540}
541
542impl SlotType {
543    /// Convert a raw byte to a `SlotType`.
544    pub fn from_byte(b: u8) -> Result<Self, IrError> {
545        match b {
546            0x01 => Ok(SlotType::Text),
547            0x02 => Ok(SlotType::Bool),
548            0x03 => Ok(SlotType::Number),
549            0x04 => Ok(SlotType::Array),
550            0x05 => Ok(SlotType::Object),
551            _ => Err(IrError::InvalidSlotType(b)),
552        }
553    }
554}
555
556// ---------------------------------------------------------------------------
557// IslandTrigger enum (4 triggers)
558// ---------------------------------------------------------------------------
559
560/// When an island should be hydrated.
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562#[repr(u8)]
563pub enum IslandTrigger {
564    /// Hydrate as soon as the page's JS runs.
565    Load = 0x01,
566    /// Hydrate when the island scrolls into view.
567    Visible = 0x02,
568    /// Hydrate on the first user interaction with the island.
569    Interaction = 0x03,
570    /// Hydrate when the main thread next goes idle.
571    Idle = 0x04,
572}
573
574impl IslandTrigger {
575    /// Convert a raw byte to an `IslandTrigger`.
576    pub fn from_byte(b: u8) -> Result<Self, IrError> {
577        match b {
578            0x01 => Ok(IslandTrigger::Load),
579            0x02 => Ok(IslandTrigger::Visible),
580            0x03 => Ok(IslandTrigger::Interaction),
581            0x04 => Ok(IslandTrigger::Idle),
582            _ => Err(IrError::InvalidIslandTrigger(b)),
583        }
584    }
585}
586
587// ---------------------------------------------------------------------------
588// PropsMode enum (3 modes)
589// ---------------------------------------------------------------------------
590
591/// How island props are delivered.
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593#[repr(u8)]
594pub enum PropsMode {
595    /// Props serialized into a `data-forma-props` attribute on the island root.
596    Inline = 0x01,
597    /// Props accumulated into a shared `<script id="__forma_islands">` JSON
598    /// block at the end of the page.
599    ///
600    /// NOT YET EMITTED BY THE COMPILER — `@getforma/compiler` hardcodes
601    /// `Inline` at every `addIsland` call site, so nothing in the toolchain
602    /// produces this value today. The walker supports it because `forma-ir`
603    /// accepts FMIR from any producer, but treat it as a dormant branch rather
604    /// than a shipping feature until the compiler opts into it (the intended
605    /// trigger being props too large for an attribute).
606    ScriptTag = 0x02,
607    /// Reserved. NOT IMPLEMENTED on either side of the stack.
608    ///
609    /// The name promises props fetched by the client after hydration, but
610    /// there is no mechanism behind it: the walker emits no props and no fetch
611    /// hint (no `data-forma-props-src`, nothing), and `@getforma/core`'s
612    /// `loadIslandProps` knows only the inline attribute and the shared block.
613    /// An island declared `Deferred` is therefore indistinguishable from an
614    /// island with no props at all. Treat it as reserved wire space, not as a
615    /// feature — the compiler never emits it.
616    Deferred = 0x03,
617}
618
619impl PropsMode {
620    /// Convert a raw byte to a `PropsMode`.
621    pub fn from_byte(b: u8) -> Result<Self, IrError> {
622        match b {
623            0x01 => Ok(PropsMode::Inline),
624            0x02 => Ok(PropsMode::ScriptTag),
625            0x03 => Ok(PropsMode::Deferred),
626            _ => Err(IrError::InvalidPropsMode(b)),
627        }
628    }
629}
630
631// ---------------------------------------------------------------------------
632// SlotSource enum (2 sources)
633// ---------------------------------------------------------------------------
634
635/// Where the slot value originates at runtime.
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637#[repr(u8)]
638pub enum SlotSource {
639    /// Filled by the page handler before the walk. Still `Null` at render
640    /// time means the handler probably has a bug, and the walker warns.
641    Server = 0x00,
642    /// Owned by the client runtime; the slot table's default is what SSR
643    /// renders.
644    Client = 0x01,
645}
646
647impl SlotSource {
648    /// Convert a raw byte to a `SlotSource`.
649    pub fn from_byte(b: u8) -> Result<Self, IrError> {
650        match b {
651            0x00 => Ok(SlotSource::Server),
652            0x01 => Ok(SlotSource::Client),
653            _ => Err(IrError::InvalidSlotSource(b)),
654        }
655    }
656}
657
658// ---------------------------------------------------------------------------
659// SlotEntry
660// ---------------------------------------------------------------------------
661
662/// A slot declaration in the slot table.
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct SlotEntry {
665    /// Unique slot identifier within the module.
666    pub slot_id: u16,
667    /// Index into the string table for the slot name.
668    pub name_str_idx: u32,
669    /// Expected data type for this slot.
670    pub type_hint: SlotType,
671    /// Where the slot value originates at runtime.
672    pub source: SlotSource,
673    /// Default value bytes (empty if no default).
674    pub default_bytes: Vec<u8>,
675}
676
677// ---------------------------------------------------------------------------
678// IslandEntry
679// ---------------------------------------------------------------------------
680
681/// An island declaration in the island table.
682#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct IslandEntry {
684    /// Unique island identifier within the module.
685    pub id: u16,
686    /// When this island should hydrate.
687    pub trigger: IslandTrigger,
688    /// How props are delivered to the island.
689    pub props_mode: PropsMode,
690    /// Index into the string table for the island name.
691    pub name_str_idx: u32,
692    /// Byte offset of the ISLAND_START opcode in the bytecode stream.
693    pub byte_offset: u32,
694    /// Which slots belong to this island.
695    pub slot_ids: Vec<u16>,
696}
697
698// ---------------------------------------------------------------------------
699// Tests
700// ---------------------------------------------------------------------------
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    /// Build a valid 16-byte FMIR header.
707    fn make_header(version: u16, flags: u16, source_hash: u64) -> Vec<u8> {
708        let mut buf = Vec::with_capacity(HEADER_SIZE);
709        buf.extend_from_slice(MAGIC);
710        buf.extend_from_slice(&version.to_le_bytes());
711        buf.extend_from_slice(&flags.to_le_bytes());
712        buf.extend_from_slice(&source_hash.to_le_bytes());
713        buf
714    }
715
716    #[test]
717    fn parse_valid_header() {
718        let data = make_header(2, 0, 0xDEAD_BEEF_CAFE_BABE);
719        let hdr = IrHeader::parse(&data).unwrap();
720        assert_eq!(hdr.version, 2);
721        assert_eq!(hdr.flags, 0);
722        assert_eq!(hdr.source_hash, 0xDEAD_BEEF_CAFE_BABE);
723    }
724
725    #[test]
726    fn reject_bad_magic() {
727        let mut data = make_header(2, 0, 0);
728        data[0..4].copy_from_slice(b"NOPE");
729        let err = IrHeader::parse(&data).unwrap_err();
730        assert_eq!(err, IrError::BadMagic(*b"NOPE"));
731    }
732
733    #[test]
734    fn reject_unsupported_version() {
735        let data = make_header(99, 0, 0);
736        let err = IrHeader::parse(&data).unwrap_err();
737        assert_eq!(err, IrError::UnsupportedVersion(99));
738    }
739
740    #[test]
741    fn reject_nonzero_flags() {
742        // `flags` is reserved and must be 0 — a future extension bit must fail
743        // closed on this reader rather than being silently ignored.
744        let data = make_header(2, 1, 0);
745        let err = IrHeader::parse(&data).unwrap_err();
746        assert_eq!(err, IrError::UnsupportedFlags(1));
747
748        let data = make_header(2, 0x8000, 0);
749        let err = IrHeader::parse(&data).unwrap_err();
750        assert_eq!(err, IrError::UnsupportedFlags(0x8000));
751    }
752
753    /// Build a 32-byte section table from four (offset, size) pairs.
754    fn make_section_table(sections: [(u32, u32); 4]) -> Vec<u8> {
755        let mut buf = Vec::with_capacity(SECTION_TABLE_SIZE);
756        for (offset, size) in &sections {
757            buf.extend_from_slice(&offset.to_le_bytes());
758            buf.extend_from_slice(&size.to_le_bytes());
759        }
760        buf
761    }
762
763    #[test]
764    fn parse_section_table() {
765        let data = make_section_table([
766            (48, 100),  // bytecode
767            (148, 200), // string table
768            (348, 50),  // slot table
769            (398, 30),  // island table
770        ]);
771        let st = SectionTable::parse(&data).unwrap();
772        assert_eq!(
773            st.sections[0],
774            SectionDescriptor {
775                offset: 48,
776                size: 100
777            }
778        );
779        assert_eq!(
780            st.sections[1],
781            SectionDescriptor {
782                offset: 148,
783                size: 200
784            }
785        );
786        assert_eq!(
787            st.sections[2],
788            SectionDescriptor {
789                offset: 348,
790                size: 50
791            }
792        );
793        assert_eq!(
794            st.sections[3],
795            SectionDescriptor {
796                offset: 398,
797                size: 30
798            }
799        );
800    }
801
802    #[test]
803    fn validate_section_bounds() {
804        let data = make_section_table([
805            (48, 100),
806            (148, 200),
807            (348, 50),
808            (398, 9999), // way past end
809        ]);
810        let st = SectionTable::parse(&data).unwrap();
811        let err = st.validate(500).unwrap_err();
812        assert_eq!(
813            err,
814            IrError::SectionOutOfBounds {
815                section: 3,
816                offset: 398,
817                size: 9999,
818                file_len: 500,
819            }
820        );
821    }
822
823    #[test]
824    fn opcode_from_byte_all_valid() {
825        let expected = [
826            (0x01, Opcode::OpenTag),
827            (0x02, Opcode::CloseTag),
828            (0x03, Opcode::VoidTag),
829            (0x04, Opcode::Text),
830            (0x05, Opcode::DynText),
831            (0x06, Opcode::DynAttr),
832            (0x07, Opcode::ShowIf),
833            (0x08, Opcode::ShowElse),
834            (0x09, Opcode::Switch),
835            (0x0A, Opcode::List),
836            (0x0B, Opcode::IslandStart),
837            (0x0C, Opcode::IslandEnd),
838            (0x0D, Opcode::TryStart),
839            (0x0E, Opcode::Fallback),
840            (0x0F, Opcode::Preload),
841            (0x10, Opcode::Comment),
842            (0x11, Opcode::ListItemKey),
843            (0x12, Opcode::Prop),
844        ];
845        for (byte, op) in &expected {
846            assert_eq!(Opcode::from_byte(*byte).unwrap(), *op, "byte 0x{byte:02x}");
847        }
848    }
849
850    #[test]
851    fn opcode_from_byte_invalid() {
852        assert_eq!(
853            Opcode::from_byte(0x00).unwrap_err(),
854            IrError::InvalidOpcode(0x00)
855        );
856        assert_eq!(
857            Opcode::from_byte(0x13).unwrap_err(),
858            IrError::InvalidOpcode(0x13)
859        );
860        assert_eq!(
861            Opcode::from_byte(0xFF).unwrap_err(),
862            IrError::InvalidOpcode(0xFF)
863        );
864    }
865
866    #[test]
867    fn slot_type_from_byte() {
868        let expected = [
869            (0x01, SlotType::Text),
870            (0x02, SlotType::Bool),
871            (0x03, SlotType::Number),
872            (0x04, SlotType::Array),
873            (0x05, SlotType::Object),
874        ];
875        for (byte, st) in &expected {
876            assert_eq!(
877                SlotType::from_byte(*byte).unwrap(),
878                *st,
879                "byte 0x{byte:02x}"
880            );
881        }
882        assert_eq!(
883            SlotType::from_byte(0x00).unwrap_err(),
884            IrError::InvalidSlotType(0x00)
885        );
886        assert_eq!(
887            SlotType::from_byte(0x06).unwrap_err(),
888            IrError::InvalidSlotType(0x06)
889        );
890    }
891
892    #[test]
893    fn island_trigger_from_byte() {
894        let expected = [
895            (0x01, IslandTrigger::Load),
896            (0x02, IslandTrigger::Visible),
897            (0x03, IslandTrigger::Interaction),
898            (0x04, IslandTrigger::Idle),
899        ];
900        for (byte, trigger) in &expected {
901            assert_eq!(
902                IslandTrigger::from_byte(*byte).unwrap(),
903                *trigger,
904                "byte 0x{byte:02x}"
905            );
906        }
907        assert_eq!(
908            IslandTrigger::from_byte(0x00).unwrap_err(),
909            IrError::InvalidIslandTrigger(0x00)
910        );
911        assert_eq!(
912            IslandTrigger::from_byte(0x05).unwrap_err(),
913            IrError::InvalidIslandTrigger(0x05)
914        );
915    }
916
917    #[test]
918    fn props_mode_from_byte() {
919        let expected = [
920            (0x01, PropsMode::Inline),
921            (0x02, PropsMode::ScriptTag),
922            (0x03, PropsMode::Deferred),
923        ];
924        for (byte, mode) in &expected {
925            assert_eq!(
926                PropsMode::from_byte(*byte).unwrap(),
927                *mode,
928                "byte 0x{byte:02x}"
929            );
930        }
931        assert_eq!(
932            PropsMode::from_byte(0x00).unwrap_err(),
933            IrError::InvalidPropsMode(0x00)
934        );
935        assert_eq!(
936            PropsMode::from_byte(0x04).unwrap_err(),
937            IrError::InvalidPropsMode(0x04)
938        );
939    }
940
941    #[test]
942    fn slot_source_from_byte() {
943        assert_eq!(SlotSource::from_byte(0x00).unwrap(), SlotSource::Server);
944        assert_eq!(SlotSource::from_byte(0x01).unwrap(), SlotSource::Client);
945        assert_eq!(
946            SlotSource::from_byte(0x02).unwrap_err(),
947            IrError::InvalidSlotSource(0x02)
948        );
949        assert_eq!(
950            SlotSource::from_byte(0xFF).unwrap_err(),
951            IrError::InvalidSlotSource(0xFF)
952        );
953    }
954}