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