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