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#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum IrError {
36    /// Input buffer is too short to contain the expected structure.
37    BufferTooShort { expected: usize, actual: usize },
38    /// Magic bytes do not match "FMIR".
39    BadMagic([u8; 4]),
40    /// IR version is not supported.
41    UnsupportedVersion(u16),
42    /// A section extends beyond the file boundary.
43    SectionOutOfBounds {
44        section: usize,
45        offset: u32,
46        size: u32,
47        file_len: usize,
48    },
49    /// An opcode byte does not map to a known opcode.
50    InvalidOpcode(u8),
51    /// A slot-type byte does not map to a known slot type.
52    InvalidSlotType(u8),
53    /// An island-trigger byte does not map to a known trigger.
54    InvalidIslandTrigger(u8),
55    /// A props-mode byte does not map to a known mode.
56    InvalidPropsMode(u8),
57    /// A slot-source byte does not map to a known source.
58    InvalidSlotSource(u8),
59    /// A string index is out of bounds.
60    StringIndexOutOfBounds { index: u32, len: usize },
61    /// A byte sequence is not valid UTF-8.
62    InvalidUtf8(String),
63    /// Nested LIST depth exceeded the maximum allowed.
64    ListDepthExceeded { max: u8 },
65    /// An island with the given id was not found in the island table.
66    IslandNotFound(u16),
67    /// Failed to parse JSON input.
68    JsonParseError(String),
69}
70
71impl fmt::Display for IrError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            IrError::BufferTooShort { expected, actual } => {
75                write!(
76                    f,
77                    "buffer too short: expected at least {expected} bytes, got {actual}"
78                )
79            }
80            IrError::BadMagic(got) => {
81                write!(f, "bad magic: expected FMIR, got {:?}", got)
82            }
83            IrError::UnsupportedVersion(v) => {
84                write!(f, "unsupported IR version: {v} (expected {IR_VERSION})")
85            }
86            IrError::SectionOutOfBounds {
87                section,
88                offset,
89                size,
90                file_len,
91            } => {
92                write!(
93                    f,
94                    "section {section} out of bounds: offset={offset}, size={size}, file_len={file_len}"
95                )
96            }
97            IrError::InvalidOpcode(b) => write!(f, "invalid opcode: 0x{b:02x}"),
98            IrError::InvalidSlotType(b) => write!(f, "invalid slot type: 0x{b:02x}"),
99            IrError::InvalidIslandTrigger(b) => {
100                write!(f, "invalid island trigger: 0x{b:02x}")
101            }
102            IrError::InvalidPropsMode(b) => write!(f, "invalid props mode: 0x{b:02x}"),
103            IrError::InvalidSlotSource(b) => write!(f, "invalid slot source: 0x{b:02x}"),
104            IrError::StringIndexOutOfBounds { index, len } => {
105                write!(f, "string index {index} out of bounds (table has {len} entries)")
106            }
107            IrError::InvalidUtf8(msg) => write!(f, "invalid UTF-8: {msg}"),
108            IrError::ListDepthExceeded { max } => {
109                write!(f, "nested LIST depth exceeded maximum of {max}")
110            }
111            IrError::IslandNotFound(id) => {
112                write!(f, "island with id {id} not found in island table")
113            }
114            IrError::JsonParseError(msg) => {
115                write!(f, "JSON parse error: {msg}")
116            }
117        }
118    }
119}
120
121impl std::error::Error for IrError {}
122
123// ---------------------------------------------------------------------------
124// Header (16 bytes)
125// ---------------------------------------------------------------------------
126
127/// FMIR file header — the first 16 bytes of every `.fmir` file.
128///
129/// Layout (little-endian):
130/// ```text
131/// [0..4)   magic        – b"FMIR"
132/// [4..6)   version      – u16
133/// [6..8)   flags        – u16 (reserved, must be 0)
134/// [8..16)  source_hash  – u64 (hash of original source)
135/// ```
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct IrHeader {
138    pub version: u16,
139    pub flags: u16,
140    pub source_hash: u64,
141}
142
143impl IrHeader {
144    /// Parse an `IrHeader` from the first 16 bytes of `data`.
145    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
146        if data.len() < HEADER_SIZE {
147            return Err(IrError::BufferTooShort {
148                expected: HEADER_SIZE,
149                actual: data.len(),
150            });
151        }
152
153        let magic: [u8; 4] = data[0..4].try_into().unwrap();
154        if &magic != MAGIC {
155            return Err(IrError::BadMagic(magic));
156        }
157
158        let version = u16::from_le_bytes(data[4..6].try_into().unwrap());
159        if version != IR_VERSION {
160            return Err(IrError::UnsupportedVersion(version));
161        }
162
163        let flags = u16::from_le_bytes(data[6..8].try_into().unwrap());
164        let source_hash = u64::from_le_bytes(data[8..16].try_into().unwrap());
165
166        Ok(IrHeader {
167            version,
168            flags,
169            source_hash,
170        })
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Section table (32 bytes)
176// ---------------------------------------------------------------------------
177
178/// A single section descriptor: offset + size (both u32, little-endian).
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct SectionDescriptor {
181    pub offset: u32,
182    pub size: u32,
183}
184
185/// Section table — four section descriptors immediately following the header.
186///
187/// Sections (in order):
188/// 0. Bytecode
189/// 1. String table
190/// 2. Slot table
191/// 3. Island table
192///
193/// Layout: 4 x (offset u32 + size u32) = 32 bytes.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct SectionTable {
196    pub sections: [SectionDescriptor; 4],
197}
198
199impl SectionTable {
200    /// Parse a `SectionTable` from `data` (must be at least 32 bytes).
201    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
202        if data.len() < SECTION_TABLE_SIZE {
203            return Err(IrError::BufferTooShort {
204                expected: SECTION_TABLE_SIZE,
205                actual: data.len(),
206            });
207        }
208
209        let mut sections = [SectionDescriptor { offset: 0, size: 0 }; 4];
210        for (i, section) in sections.iter_mut().enumerate() {
211            let base = i * 8;
212            let offset = u32::from_le_bytes(data[base..base + 4].try_into().unwrap());
213            let size = u32::from_le_bytes(data[base + 4..base + 8].try_into().unwrap());
214            *section = SectionDescriptor { offset, size };
215        }
216
217        Ok(SectionTable { sections })
218    }
219
220    /// Validate that every section falls within `file_len` bytes.
221    pub fn validate(&self, file_len: usize) -> Result<(), IrError> {
222        for (i, sec) in self.sections.iter().enumerate() {
223            let end = sec.offset as usize + sec.size as usize;
224            if end > file_len {
225                return Err(IrError::SectionOutOfBounds {
226                    section: i,
227                    offset: sec.offset,
228                    size: sec.size,
229                    file_len,
230                });
231            }
232        }
233        Ok(())
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Opcode enum (16 opcodes, 0x01–0x10)
239// ---------------------------------------------------------------------------
240
241/// Bytecode opcodes for the FMIR instruction stream.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243#[repr(u8)]
244pub enum Opcode {
245    OpenTag = 0x01,
246    CloseTag = 0x02,
247    VoidTag = 0x03,
248    Text = 0x04,
249    DynText = 0x05,
250    DynAttr = 0x06,
251    ShowIf = 0x07,
252    ShowElse = 0x08,
253    Switch = 0x09,
254    List = 0x0A,
255    IslandStart = 0x0B,
256    IslandEnd = 0x0C,
257    TryStart = 0x0D,
258    Fallback = 0x0E,
259    Preload = 0x0F,
260    Comment = 0x10,
261    ListItemKey = 0x11,
262    /// Extract a named property from an Object slot into a target slot.
263    /// Format: src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16)
264    Prop = 0x12,
265}
266
267impl Opcode {
268    /// Convert a raw byte to an `Opcode`.
269    pub fn from_byte(b: u8) -> Result<Self, IrError> {
270        match b {
271            0x01 => Ok(Opcode::OpenTag),
272            0x02 => Ok(Opcode::CloseTag),
273            0x03 => Ok(Opcode::VoidTag),
274            0x04 => Ok(Opcode::Text),
275            0x05 => Ok(Opcode::DynText),
276            0x06 => Ok(Opcode::DynAttr),
277            0x07 => Ok(Opcode::ShowIf),
278            0x08 => Ok(Opcode::ShowElse),
279            0x09 => Ok(Opcode::Switch),
280            0x0A => Ok(Opcode::List),
281            0x0B => Ok(Opcode::IslandStart),
282            0x0C => Ok(Opcode::IslandEnd),
283            0x0D => Ok(Opcode::TryStart),
284            0x0E => Ok(Opcode::Fallback),
285            0x0F => Ok(Opcode::Preload),
286            0x10 => Ok(Opcode::Comment),
287            0x11 => Ok(Opcode::ListItemKey),
288            0x12 => Ok(Opcode::Prop),
289            _ => Err(IrError::InvalidOpcode(b)),
290        }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// SlotType enum (5 types)
296// ---------------------------------------------------------------------------
297
298/// Data type hint for a slot entry.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300#[repr(u8)]
301pub enum SlotType {
302    Text = 0x01,
303    Bool = 0x02,
304    Number = 0x03,
305    Array = 0x04,
306    Object = 0x05,
307}
308
309impl SlotType {
310    /// Convert a raw byte to a `SlotType`.
311    pub fn from_byte(b: u8) -> Result<Self, IrError> {
312        match b {
313            0x01 => Ok(SlotType::Text),
314            0x02 => Ok(SlotType::Bool),
315            0x03 => Ok(SlotType::Number),
316            0x04 => Ok(SlotType::Array),
317            0x05 => Ok(SlotType::Object),
318            _ => Err(IrError::InvalidSlotType(b)),
319        }
320    }
321}
322
323// ---------------------------------------------------------------------------
324// IslandTrigger enum (4 triggers)
325// ---------------------------------------------------------------------------
326
327/// When an island should be hydrated.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329#[repr(u8)]
330pub enum IslandTrigger {
331    Load = 0x01,
332    Visible = 0x02,
333    Interaction = 0x03,
334    Idle = 0x04,
335}
336
337impl IslandTrigger {
338    /// Convert a raw byte to an `IslandTrigger`.
339    pub fn from_byte(b: u8) -> Result<Self, IrError> {
340        match b {
341            0x01 => Ok(IslandTrigger::Load),
342            0x02 => Ok(IslandTrigger::Visible),
343            0x03 => Ok(IslandTrigger::Interaction),
344            0x04 => Ok(IslandTrigger::Idle),
345            _ => Err(IrError::InvalidIslandTrigger(b)),
346        }
347    }
348}
349
350// ---------------------------------------------------------------------------
351// PropsMode enum (3 modes)
352// ---------------------------------------------------------------------------
353
354/// How island props are delivered.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356#[repr(u8)]
357pub enum PropsMode {
358    Inline = 0x01,
359    ScriptTag = 0x02,
360    Deferred = 0x03,
361}
362
363impl PropsMode {
364    /// Convert a raw byte to a `PropsMode`.
365    pub fn from_byte(b: u8) -> Result<Self, IrError> {
366        match b {
367            0x01 => Ok(PropsMode::Inline),
368            0x02 => Ok(PropsMode::ScriptTag),
369            0x03 => Ok(PropsMode::Deferred),
370            _ => Err(IrError::InvalidPropsMode(b)),
371        }
372    }
373}
374
375// ---------------------------------------------------------------------------
376// SlotSource enum (2 sources)
377// ---------------------------------------------------------------------------
378
379/// Where the slot value originates at runtime.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381#[repr(u8)]
382pub enum SlotSource {
383    Server = 0x00,
384    Client = 0x01,
385}
386
387impl SlotSource {
388    /// Convert a raw byte to a `SlotSource`.
389    pub fn from_byte(b: u8) -> Result<Self, IrError> {
390        match b {
391            0x00 => Ok(SlotSource::Server),
392            0x01 => Ok(SlotSource::Client),
393            _ => Err(IrError::InvalidSlotSource(b)),
394        }
395    }
396}
397
398// ---------------------------------------------------------------------------
399// SlotEntry
400// ---------------------------------------------------------------------------
401
402/// A slot declaration in the slot table.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct SlotEntry {
405    /// Unique slot identifier within the module.
406    pub slot_id: u16,
407    /// Index into the string table for the slot name.
408    pub name_str_idx: u32,
409    /// Expected data type for this slot.
410    pub type_hint: SlotType,
411    /// Where the slot value originates at runtime.
412    pub source: SlotSource,
413    /// Default value bytes (empty if no default).
414    pub default_bytes: Vec<u8>,
415}
416
417// ---------------------------------------------------------------------------
418// IslandEntry
419// ---------------------------------------------------------------------------
420
421/// An island declaration in the island table.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct IslandEntry {
424    /// Unique island identifier within the module.
425    pub id: u16,
426    /// When this island should hydrate.
427    pub trigger: IslandTrigger,
428    /// How props are delivered to the island.
429    pub props_mode: PropsMode,
430    /// Index into the string table for the island name.
431    pub name_str_idx: u32,
432    /// Byte offset of the ISLAND_START opcode in the bytecode stream.
433    pub byte_offset: u32,
434    /// Which slots belong to this island.
435    pub slot_ids: Vec<u16>,
436}
437
438// ---------------------------------------------------------------------------
439// Tests
440// ---------------------------------------------------------------------------
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    /// Build a valid 16-byte FMIR header.
447    fn make_header(version: u16, flags: u16, source_hash: u64) -> Vec<u8> {
448        let mut buf = Vec::with_capacity(HEADER_SIZE);
449        buf.extend_from_slice(MAGIC);
450        buf.extend_from_slice(&version.to_le_bytes());
451        buf.extend_from_slice(&flags.to_le_bytes());
452        buf.extend_from_slice(&source_hash.to_le_bytes());
453        buf
454    }
455
456    #[test]
457    fn parse_valid_header() {
458        let data = make_header(2, 0, 0xDEAD_BEEF_CAFE_BABE);
459        let hdr = IrHeader::parse(&data).unwrap();
460        assert_eq!(hdr.version, 2);
461        assert_eq!(hdr.flags, 0);
462        assert_eq!(hdr.source_hash, 0xDEAD_BEEF_CAFE_BABE);
463    }
464
465    #[test]
466    fn reject_bad_magic() {
467        let mut data = make_header(2, 0, 0);
468        data[0..4].copy_from_slice(b"NOPE");
469        let err = IrHeader::parse(&data).unwrap_err();
470        assert_eq!(err, IrError::BadMagic(*b"NOPE"));
471    }
472
473    #[test]
474    fn reject_unsupported_version() {
475        let data = make_header(99, 0, 0);
476        let err = IrHeader::parse(&data).unwrap_err();
477        assert_eq!(err, IrError::UnsupportedVersion(99));
478    }
479
480    /// Build a 32-byte section table from four (offset, size) pairs.
481    fn make_section_table(sections: [(u32, u32); 4]) -> Vec<u8> {
482        let mut buf = Vec::with_capacity(SECTION_TABLE_SIZE);
483        for (offset, size) in &sections {
484            buf.extend_from_slice(&offset.to_le_bytes());
485            buf.extend_from_slice(&size.to_le_bytes());
486        }
487        buf
488    }
489
490    #[test]
491    fn parse_section_table() {
492        let data = make_section_table([
493            (48, 100),  // bytecode
494            (148, 200), // string table
495            (348, 50),  // slot table
496            (398, 30),  // island table
497        ]);
498        let st = SectionTable::parse(&data).unwrap();
499        assert_eq!(st.sections[0], SectionDescriptor { offset: 48, size: 100 });
500        assert_eq!(st.sections[1], SectionDescriptor { offset: 148, size: 200 });
501        assert_eq!(st.sections[2], SectionDescriptor { offset: 348, size: 50 });
502        assert_eq!(st.sections[3], SectionDescriptor { offset: 398, size: 30 });
503    }
504
505    #[test]
506    fn validate_section_bounds() {
507        let data = make_section_table([
508            (48, 100),
509            (148, 200),
510            (348, 50),
511            (398, 9999), // way past end
512        ]);
513        let st = SectionTable::parse(&data).unwrap();
514        let err = st.validate(500).unwrap_err();
515        assert_eq!(
516            err,
517            IrError::SectionOutOfBounds {
518                section: 3,
519                offset: 398,
520                size: 9999,
521                file_len: 500,
522            }
523        );
524    }
525
526    #[test]
527    fn opcode_from_byte_all_valid() {
528        let expected = [
529            (0x01, Opcode::OpenTag),
530            (0x02, Opcode::CloseTag),
531            (0x03, Opcode::VoidTag),
532            (0x04, Opcode::Text),
533            (0x05, Opcode::DynText),
534            (0x06, Opcode::DynAttr),
535            (0x07, Opcode::ShowIf),
536            (0x08, Opcode::ShowElse),
537            (0x09, Opcode::Switch),
538            (0x0A, Opcode::List),
539            (0x0B, Opcode::IslandStart),
540            (0x0C, Opcode::IslandEnd),
541            (0x0D, Opcode::TryStart),
542            (0x0E, Opcode::Fallback),
543            (0x0F, Opcode::Preload),
544            (0x10, Opcode::Comment),
545            (0x11, Opcode::ListItemKey),
546            (0x12, Opcode::Prop),
547        ];
548        for (byte, op) in &expected {
549            assert_eq!(Opcode::from_byte(*byte).unwrap(), *op, "byte 0x{byte:02x}");
550        }
551    }
552
553    #[test]
554    fn opcode_from_byte_invalid() {
555        assert_eq!(Opcode::from_byte(0x00).unwrap_err(), IrError::InvalidOpcode(0x00));
556        assert_eq!(Opcode::from_byte(0x13).unwrap_err(), IrError::InvalidOpcode(0x13));
557        assert_eq!(Opcode::from_byte(0xFF).unwrap_err(), IrError::InvalidOpcode(0xFF));
558    }
559
560    #[test]
561    fn slot_type_from_byte() {
562        let expected = [
563            (0x01, SlotType::Text),
564            (0x02, SlotType::Bool),
565            (0x03, SlotType::Number),
566            (0x04, SlotType::Array),
567            (0x05, SlotType::Object),
568        ];
569        for (byte, st) in &expected {
570            assert_eq!(SlotType::from_byte(*byte).unwrap(), *st, "byte 0x{byte:02x}");
571        }
572        assert_eq!(
573            SlotType::from_byte(0x00).unwrap_err(),
574            IrError::InvalidSlotType(0x00)
575        );
576        assert_eq!(
577            SlotType::from_byte(0x06).unwrap_err(),
578            IrError::InvalidSlotType(0x06)
579        );
580    }
581
582    #[test]
583    fn island_trigger_from_byte() {
584        let expected = [
585            (0x01, IslandTrigger::Load),
586            (0x02, IslandTrigger::Visible),
587            (0x03, IslandTrigger::Interaction),
588            (0x04, IslandTrigger::Idle),
589        ];
590        for (byte, trigger) in &expected {
591            assert_eq!(
592                IslandTrigger::from_byte(*byte).unwrap(),
593                *trigger,
594                "byte 0x{byte:02x}"
595            );
596        }
597        assert_eq!(
598            IslandTrigger::from_byte(0x00).unwrap_err(),
599            IrError::InvalidIslandTrigger(0x00)
600        );
601        assert_eq!(
602            IslandTrigger::from_byte(0x05).unwrap_err(),
603            IrError::InvalidIslandTrigger(0x05)
604        );
605    }
606
607    #[test]
608    fn props_mode_from_byte() {
609        let expected = [
610            (0x01, PropsMode::Inline),
611            (0x02, PropsMode::ScriptTag),
612            (0x03, PropsMode::Deferred),
613        ];
614        for (byte, mode) in &expected {
615            assert_eq!(
616                PropsMode::from_byte(*byte).unwrap(),
617                *mode,
618                "byte 0x{byte:02x}"
619            );
620        }
621        assert_eq!(
622            PropsMode::from_byte(0x00).unwrap_err(),
623            IrError::InvalidPropsMode(0x00)
624        );
625        assert_eq!(
626            PropsMode::from_byte(0x04).unwrap_err(),
627            IrError::InvalidPropsMode(0x04)
628        );
629    }
630
631    #[test]
632    fn slot_source_from_byte() {
633        assert_eq!(SlotSource::from_byte(0x00).unwrap(), SlotSource::Server);
634        assert_eq!(SlotSource::from_byte(0x01).unwrap(), SlotSource::Client);
635        assert_eq!(
636            SlotSource::from_byte(0x02).unwrap_err(),
637            IrError::InvalidSlotSource(0x02)
638        );
639        assert_eq!(
640            SlotSource::from_byte(0xFF).unwrap_err(),
641            IrError::InvalidSlotSource(0xFF)
642        );
643    }
644}