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 §ions {
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}