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 (16 opcodes, 0x01–0x10)
396// ---------------------------------------------------------------------------
397
398/// Bytecode opcodes for the FMIR instruction stream.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400#[repr(u8)]
401pub enum Opcode {
402    /// Open an element. `str_idx(u32) + attr_count(u16) + [(key u32, val u32)]`
403    OpenTag = 0x01,
404    /// Close the named element. `str_idx(u32)`
405    CloseTag = 0x02,
406    /// A void element, which takes no CLOSE_TAG. Same operands as OPEN_TAG.
407    VoidTag = 0x03,
408    /// Static text from the string table. `str_idx(u32)`
409    Text = 0x04,
410    /// Slot-bound text wrapped in `<!--f:tN-->` markers.
411    /// `slot_id(u16) + marker_id(u16)`
412    DynText = 0x05,
413    /// Slot-bound attribute on the tag currently being opened.
414    /// `attr_str_idx(u32) + slot_id(u16)`
415    DynAttr = 0x06,
416    /// Conditional region wrapped in `<!--f:sN-->` markers.
417    /// `slot_id(u16) + then_len(u32) + else_len(u32)`, then the two bodies
418    /// separated by a [`Opcode::ShowElse`] byte.
419    ShowIf = 0x07,
420    /// Separator byte between a SHOW_IF's then- and else-bodies.
421    ShowElse = 0x08,
422    /// First-match-wins multi-way branch on a slot's text.
423    /// `slot_id(u16) + case_count(u16)`, then one
424    /// `(val_str_idx u32, body_len u32)` header per case, then the bodies.
425    Switch = 0x09,
426    /// Repeat a body once per element of an array slot, wrapped in
427    /// `<!--f:lN-->` markers. `slot_id(u16) + item_slot_id(u16) + body_len(u32)`
428    List = 0x0A,
429    /// Begin an island region. `island_id(u16)`
430    IslandStart = 0x0B,
431    /// End an island region. `island_id(u16)`
432    IslandEnd = 0x0C,
433    /// Begin a try block. `fallback_len(u32)`
434    TryStart = 0x0D,
435    /// Separator between a try block's main and fallback bodies.
436    Fallback = 0x0E,
437    /// Emit a `<link rel="preload">`. `resource_type(u8) + url_str_idx(u32)`
438    Preload = 0x0F,
439    /// An HTML comment. `str_idx(u32)`
440    Comment = 0x10,
441    /// Set `data-forma-key` on the next element opened in a LIST body.
442    /// `key_str_idx(u32)`
443    ///
444    /// LIMITATION: the operand is a *static* string-table index, so every item
445    /// in a list gets the same key. Per-item keys would need a slot-bound
446    /// operand, which is a v-bump to this opcode's layout and a coordinated
447    /// compiler change — not something the walker can work around.
448    ListItemKey = 0x11,
449    /// Extract a named property from an Object slot into a target slot.
450    /// Format: src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16)
451    Prop = 0x12,
452}
453
454impl Opcode {
455    /// Convert a raw byte to an `Opcode`.
456    pub fn from_byte(b: u8) -> Result<Self, IrError> {
457        match b {
458            0x01 => Ok(Opcode::OpenTag),
459            0x02 => Ok(Opcode::CloseTag),
460            0x03 => Ok(Opcode::VoidTag),
461            0x04 => Ok(Opcode::Text),
462            0x05 => Ok(Opcode::DynText),
463            0x06 => Ok(Opcode::DynAttr),
464            0x07 => Ok(Opcode::ShowIf),
465            0x08 => Ok(Opcode::ShowElse),
466            0x09 => Ok(Opcode::Switch),
467            0x0A => Ok(Opcode::List),
468            0x0B => Ok(Opcode::IslandStart),
469            0x0C => Ok(Opcode::IslandEnd),
470            0x0D => Ok(Opcode::TryStart),
471            0x0E => Ok(Opcode::Fallback),
472            0x0F => Ok(Opcode::Preload),
473            0x10 => Ok(Opcode::Comment),
474            0x11 => Ok(Opcode::ListItemKey),
475            0x12 => Ok(Opcode::Prop),
476            _ => Err(IrError::InvalidOpcode(b)),
477        }
478    }
479}
480
481// ---------------------------------------------------------------------------
482// SlotType enum (5 types)
483// ---------------------------------------------------------------------------
484
485/// Data type hint for a slot entry.
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487#[repr(u8)]
488pub enum SlotType {
489    /// A string.
490    Text = 0x01,
491    /// A boolean.
492    Bool = 0x02,
493    /// An IEEE-754 double, formatted the way ECMAScript formats it.
494    Number = 0x03,
495    /// An array, the only type a LIST may iterate.
496    Array = 0x04,
497    /// A key/value object, the only type PROP may read from.
498    Object = 0x05,
499}
500
501impl SlotType {
502    /// Convert a raw byte to a `SlotType`.
503    pub fn from_byte(b: u8) -> Result<Self, IrError> {
504        match b {
505            0x01 => Ok(SlotType::Text),
506            0x02 => Ok(SlotType::Bool),
507            0x03 => Ok(SlotType::Number),
508            0x04 => Ok(SlotType::Array),
509            0x05 => Ok(SlotType::Object),
510            _ => Err(IrError::InvalidSlotType(b)),
511        }
512    }
513}
514
515// ---------------------------------------------------------------------------
516// IslandTrigger enum (4 triggers)
517// ---------------------------------------------------------------------------
518
519/// When an island should be hydrated.
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521#[repr(u8)]
522pub enum IslandTrigger {
523    /// Hydrate as soon as the page's JS runs.
524    Load = 0x01,
525    /// Hydrate when the island scrolls into view.
526    Visible = 0x02,
527    /// Hydrate on the first user interaction with the island.
528    Interaction = 0x03,
529    /// Hydrate when the main thread next goes idle.
530    Idle = 0x04,
531}
532
533impl IslandTrigger {
534    /// Convert a raw byte to an `IslandTrigger`.
535    pub fn from_byte(b: u8) -> Result<Self, IrError> {
536        match b {
537            0x01 => Ok(IslandTrigger::Load),
538            0x02 => Ok(IslandTrigger::Visible),
539            0x03 => Ok(IslandTrigger::Interaction),
540            0x04 => Ok(IslandTrigger::Idle),
541            _ => Err(IrError::InvalidIslandTrigger(b)),
542        }
543    }
544}
545
546// ---------------------------------------------------------------------------
547// PropsMode enum (3 modes)
548// ---------------------------------------------------------------------------
549
550/// How island props are delivered.
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552#[repr(u8)]
553pub enum PropsMode {
554    /// Props serialized into a `data-forma-props` attribute on the island root.
555    Inline = 0x01,
556    /// Props accumulated into a shared `<script id="__forma_islands">` JSON
557    /// block at the end of the page.
558    ///
559    /// NOT YET EMITTED BY THE COMPILER — `@getforma/compiler` hardcodes
560    /// `Inline` at every `addIsland` call site, so nothing in the toolchain
561    /// produces this value today. The walker supports it because `forma-ir`
562    /// accepts FMIR from any producer, but treat it as a dormant branch rather
563    /// than a shipping feature until the compiler opts into it (the intended
564    /// trigger being props too large for an attribute).
565    ScriptTag = 0x02,
566    /// Reserved. NOT IMPLEMENTED on either side of the stack.
567    ///
568    /// The name promises props fetched by the client after hydration, but
569    /// there is no mechanism behind it: the walker emits no props and no fetch
570    /// hint (no `data-forma-props-src`, nothing), and `@getforma/core`'s
571    /// `loadIslandProps` knows only the inline attribute and the shared block.
572    /// An island declared `Deferred` is therefore indistinguishable from an
573    /// island with no props at all. Treat it as reserved wire space, not as a
574    /// feature — the compiler never emits it.
575    Deferred = 0x03,
576}
577
578impl PropsMode {
579    /// Convert a raw byte to a `PropsMode`.
580    pub fn from_byte(b: u8) -> Result<Self, IrError> {
581        match b {
582            0x01 => Ok(PropsMode::Inline),
583            0x02 => Ok(PropsMode::ScriptTag),
584            0x03 => Ok(PropsMode::Deferred),
585            _ => Err(IrError::InvalidPropsMode(b)),
586        }
587    }
588}
589
590// ---------------------------------------------------------------------------
591// SlotSource enum (2 sources)
592// ---------------------------------------------------------------------------
593
594/// Where the slot value originates at runtime.
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596#[repr(u8)]
597pub enum SlotSource {
598    /// Filled by the page handler before the walk. Still `Null` at render
599    /// time means the handler probably has a bug, and the walker warns.
600    Server = 0x00,
601    /// Owned by the client runtime; the slot table's default is what SSR
602    /// renders.
603    Client = 0x01,
604}
605
606impl SlotSource {
607    /// Convert a raw byte to a `SlotSource`.
608    pub fn from_byte(b: u8) -> Result<Self, IrError> {
609        match b {
610            0x00 => Ok(SlotSource::Server),
611            0x01 => Ok(SlotSource::Client),
612            _ => Err(IrError::InvalidSlotSource(b)),
613        }
614    }
615}
616
617// ---------------------------------------------------------------------------
618// SlotEntry
619// ---------------------------------------------------------------------------
620
621/// A slot declaration in the slot table.
622#[derive(Debug, Clone, PartialEq, Eq)]
623pub struct SlotEntry {
624    /// Unique slot identifier within the module.
625    pub slot_id: u16,
626    /// Index into the string table for the slot name.
627    pub name_str_idx: u32,
628    /// Expected data type for this slot.
629    pub type_hint: SlotType,
630    /// Where the slot value originates at runtime.
631    pub source: SlotSource,
632    /// Default value bytes (empty if no default).
633    pub default_bytes: Vec<u8>,
634}
635
636// ---------------------------------------------------------------------------
637// IslandEntry
638// ---------------------------------------------------------------------------
639
640/// An island declaration in the island table.
641#[derive(Debug, Clone, PartialEq, Eq)]
642pub struct IslandEntry {
643    /// Unique island identifier within the module.
644    pub id: u16,
645    /// When this island should hydrate.
646    pub trigger: IslandTrigger,
647    /// How props are delivered to the island.
648    pub props_mode: PropsMode,
649    /// Index into the string table for the island name.
650    pub name_str_idx: u32,
651    /// Byte offset of the ISLAND_START opcode in the bytecode stream.
652    pub byte_offset: u32,
653    /// Which slots belong to this island.
654    pub slot_ids: Vec<u16>,
655}
656
657// ---------------------------------------------------------------------------
658// Tests
659// ---------------------------------------------------------------------------
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    /// Build a valid 16-byte FMIR header.
666    fn make_header(version: u16, flags: u16, source_hash: u64) -> Vec<u8> {
667        let mut buf = Vec::with_capacity(HEADER_SIZE);
668        buf.extend_from_slice(MAGIC);
669        buf.extend_from_slice(&version.to_le_bytes());
670        buf.extend_from_slice(&flags.to_le_bytes());
671        buf.extend_from_slice(&source_hash.to_le_bytes());
672        buf
673    }
674
675    #[test]
676    fn parse_valid_header() {
677        let data = make_header(2, 0, 0xDEAD_BEEF_CAFE_BABE);
678        let hdr = IrHeader::parse(&data).unwrap();
679        assert_eq!(hdr.version, 2);
680        assert_eq!(hdr.flags, 0);
681        assert_eq!(hdr.source_hash, 0xDEAD_BEEF_CAFE_BABE);
682    }
683
684    #[test]
685    fn reject_bad_magic() {
686        let mut data = make_header(2, 0, 0);
687        data[0..4].copy_from_slice(b"NOPE");
688        let err = IrHeader::parse(&data).unwrap_err();
689        assert_eq!(err, IrError::BadMagic(*b"NOPE"));
690    }
691
692    #[test]
693    fn reject_unsupported_version() {
694        let data = make_header(99, 0, 0);
695        let err = IrHeader::parse(&data).unwrap_err();
696        assert_eq!(err, IrError::UnsupportedVersion(99));
697    }
698
699    #[test]
700    fn reject_nonzero_flags() {
701        // `flags` is reserved and must be 0 — a future extension bit must fail
702        // closed on this reader rather than being silently ignored.
703        let data = make_header(2, 1, 0);
704        let err = IrHeader::parse(&data).unwrap_err();
705        assert_eq!(err, IrError::UnsupportedFlags(1));
706
707        let data = make_header(2, 0x8000, 0);
708        let err = IrHeader::parse(&data).unwrap_err();
709        assert_eq!(err, IrError::UnsupportedFlags(0x8000));
710    }
711
712    /// Build a 32-byte section table from four (offset, size) pairs.
713    fn make_section_table(sections: [(u32, u32); 4]) -> Vec<u8> {
714        let mut buf = Vec::with_capacity(SECTION_TABLE_SIZE);
715        for (offset, size) in &sections {
716            buf.extend_from_slice(&offset.to_le_bytes());
717            buf.extend_from_slice(&size.to_le_bytes());
718        }
719        buf
720    }
721
722    #[test]
723    fn parse_section_table() {
724        let data = make_section_table([
725            (48, 100),  // bytecode
726            (148, 200), // string table
727            (348, 50),  // slot table
728            (398, 30),  // island table
729        ]);
730        let st = SectionTable::parse(&data).unwrap();
731        assert_eq!(
732            st.sections[0],
733            SectionDescriptor {
734                offset: 48,
735                size: 100
736            }
737        );
738        assert_eq!(
739            st.sections[1],
740            SectionDescriptor {
741                offset: 148,
742                size: 200
743            }
744        );
745        assert_eq!(
746            st.sections[2],
747            SectionDescriptor {
748                offset: 348,
749                size: 50
750            }
751        );
752        assert_eq!(
753            st.sections[3],
754            SectionDescriptor {
755                offset: 398,
756                size: 30
757            }
758        );
759    }
760
761    #[test]
762    fn validate_section_bounds() {
763        let data = make_section_table([
764            (48, 100),
765            (148, 200),
766            (348, 50),
767            (398, 9999), // way past end
768        ]);
769        let st = SectionTable::parse(&data).unwrap();
770        let err = st.validate(500).unwrap_err();
771        assert_eq!(
772            err,
773            IrError::SectionOutOfBounds {
774                section: 3,
775                offset: 398,
776                size: 9999,
777                file_len: 500,
778            }
779        );
780    }
781
782    #[test]
783    fn opcode_from_byte_all_valid() {
784        let expected = [
785            (0x01, Opcode::OpenTag),
786            (0x02, Opcode::CloseTag),
787            (0x03, Opcode::VoidTag),
788            (0x04, Opcode::Text),
789            (0x05, Opcode::DynText),
790            (0x06, Opcode::DynAttr),
791            (0x07, Opcode::ShowIf),
792            (0x08, Opcode::ShowElse),
793            (0x09, Opcode::Switch),
794            (0x0A, Opcode::List),
795            (0x0B, Opcode::IslandStart),
796            (0x0C, Opcode::IslandEnd),
797            (0x0D, Opcode::TryStart),
798            (0x0E, Opcode::Fallback),
799            (0x0F, Opcode::Preload),
800            (0x10, Opcode::Comment),
801            (0x11, Opcode::ListItemKey),
802            (0x12, Opcode::Prop),
803        ];
804        for (byte, op) in &expected {
805            assert_eq!(Opcode::from_byte(*byte).unwrap(), *op, "byte 0x{byte:02x}");
806        }
807    }
808
809    #[test]
810    fn opcode_from_byte_invalid() {
811        assert_eq!(
812            Opcode::from_byte(0x00).unwrap_err(),
813            IrError::InvalidOpcode(0x00)
814        );
815        assert_eq!(
816            Opcode::from_byte(0x13).unwrap_err(),
817            IrError::InvalidOpcode(0x13)
818        );
819        assert_eq!(
820            Opcode::from_byte(0xFF).unwrap_err(),
821            IrError::InvalidOpcode(0xFF)
822        );
823    }
824
825    #[test]
826    fn slot_type_from_byte() {
827        let expected = [
828            (0x01, SlotType::Text),
829            (0x02, SlotType::Bool),
830            (0x03, SlotType::Number),
831            (0x04, SlotType::Array),
832            (0x05, SlotType::Object),
833        ];
834        for (byte, st) in &expected {
835            assert_eq!(
836                SlotType::from_byte(*byte).unwrap(),
837                *st,
838                "byte 0x{byte:02x}"
839            );
840        }
841        assert_eq!(
842            SlotType::from_byte(0x00).unwrap_err(),
843            IrError::InvalidSlotType(0x00)
844        );
845        assert_eq!(
846            SlotType::from_byte(0x06).unwrap_err(),
847            IrError::InvalidSlotType(0x06)
848        );
849    }
850
851    #[test]
852    fn island_trigger_from_byte() {
853        let expected = [
854            (0x01, IslandTrigger::Load),
855            (0x02, IslandTrigger::Visible),
856            (0x03, IslandTrigger::Interaction),
857            (0x04, IslandTrigger::Idle),
858        ];
859        for (byte, trigger) in &expected {
860            assert_eq!(
861                IslandTrigger::from_byte(*byte).unwrap(),
862                *trigger,
863                "byte 0x{byte:02x}"
864            );
865        }
866        assert_eq!(
867            IslandTrigger::from_byte(0x00).unwrap_err(),
868            IrError::InvalidIslandTrigger(0x00)
869        );
870        assert_eq!(
871            IslandTrigger::from_byte(0x05).unwrap_err(),
872            IrError::InvalidIslandTrigger(0x05)
873        );
874    }
875
876    #[test]
877    fn props_mode_from_byte() {
878        let expected = [
879            (0x01, PropsMode::Inline),
880            (0x02, PropsMode::ScriptTag),
881            (0x03, PropsMode::Deferred),
882        ];
883        for (byte, mode) in &expected {
884            assert_eq!(
885                PropsMode::from_byte(*byte).unwrap(),
886                *mode,
887                "byte 0x{byte:02x}"
888            );
889        }
890        assert_eq!(
891            PropsMode::from_byte(0x00).unwrap_err(),
892            IrError::InvalidPropsMode(0x00)
893        );
894        assert_eq!(
895            PropsMode::from_byte(0x04).unwrap_err(),
896            IrError::InvalidPropsMode(0x04)
897        );
898    }
899
900    #[test]
901    fn slot_source_from_byte() {
902        assert_eq!(SlotSource::from_byte(0x00).unwrap(), SlotSource::Server);
903        assert_eq!(SlotSource::from_byte(0x01).unwrap(), SlotSource::Client);
904        assert_eq!(
905            SlotSource::from_byte(0x02).unwrap_err(),
906            IrError::InvalidSlotSource(0x02)
907        );
908        assert_eq!(
909            SlotSource::from_byte(0xFF).unwrap_err(),
910            IrError::InvalidSlotSource(0xFF)
911        );
912    }
913}