Skip to main content

forma_ir/
parser.rs

1//! Parsers for FMIR data tables: string table, slot table, and island table.
2//!
3//! Each parser consumes a byte slice containing exactly one section's data
4//! and returns a typed, indexed collection.
5
6use crate::format::{
7    IrError, IrHeader, IslandEntry, IslandTrigger, PropsMode, SectionTable, SlotEntry, SlotSource,
8    SlotType, HEADER_SIZE, SECTION_TABLE_SIZE,
9};
10
11// ---------------------------------------------------------------------------
12// StringTable
13// ---------------------------------------------------------------------------
14
15/// Parsed string table — an indexed collection of UTF-8 strings.
16///
17/// Binary format: `count(u32)`, then `count` entries of `[len(u16), bytes([u8; len])]`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct StringTable {
20    strings: Vec<String>,
21}
22
23impl StringTable {
24    /// Parse a string table from its section data.
25    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
26        if data.len() < 4 {
27            return Err(IrError::BufferTooShort {
28                expected: 4,
29                actual: data.len(),
30            });
31        }
32
33        let count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
34        let mut offset = 4;
35        let mut strings = Vec::with_capacity(count);
36
37        for _ in 0..count {
38            // Need at least 2 bytes for the length prefix.
39            if offset + 2 > data.len() {
40                return Err(IrError::BufferTooShort {
41                    expected: offset + 2,
42                    actual: data.len(),
43                });
44            }
45
46            let str_len =
47                u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap()) as usize;
48            offset += 2;
49
50            if offset + str_len > data.len() {
51                return Err(IrError::BufferTooShort {
52                    expected: offset + str_len,
53                    actual: data.len(),
54                });
55            }
56
57            let s = std::str::from_utf8(&data[offset..offset + str_len])
58                .map_err(|e| IrError::InvalidUtf8(e.to_string()))?;
59            strings.push(s.to_owned());
60            offset += str_len;
61        }
62
63        Ok(StringTable { strings })
64    }
65
66    /// O(1) indexed lookup into the string table.
67    pub fn get(&self, idx: u32) -> Result<&str, IrError> {
68        self.strings
69            .get(idx as usize)
70            .map(|s| s.as_str())
71            .ok_or(IrError::StringIndexOutOfBounds {
72                index: idx,
73                len: self.strings.len(),
74            })
75    }
76
77    /// Number of strings in the table.
78    pub fn len(&self) -> usize {
79        self.strings.len()
80    }
81
82    /// Returns true if the string table is empty.
83    pub fn is_empty(&self) -> bool {
84        self.strings.is_empty()
85    }
86}
87
88// ---------------------------------------------------------------------------
89// SlotTable
90// ---------------------------------------------------------------------------
91
92/// Parsed slot table — an indexed collection of slot entries.
93///
94/// Binary format (v2): `count(u16)`, then `count` variable-length entries of
95/// `[slot_id(u16), name_str_idx(u32), type_hint(u8), source(u8), default_len(u16), default_bytes([u8; default_len])]`.
96/// Minimum entry size: 10 bytes (when default_len = 0).
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct SlotTable {
99    slots: Vec<SlotEntry>,
100}
101
102/// Minimum size of a single v2 slot entry in bytes (without default data).
103/// slot_id(2) + name_str_idx(4) + type_hint(1) + source(1) + default_len(2) = 10
104const SLOT_ENTRY_MIN_SIZE: usize = 10;
105
106impl SlotTable {
107    /// Parse a slot table from its section data.
108    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
109        if data.len() < 2 {
110            return Err(IrError::BufferTooShort {
111                expected: 2,
112                actual: data.len(),
113            });
114        }
115
116        let count = u16::from_le_bytes(data[0..2].try_into().unwrap()) as usize;
117        let mut slots = Vec::with_capacity(count);
118        let mut offset = 2;
119
120        for _ in 0..count {
121            // Check minimum entry size
122            if offset + SLOT_ENTRY_MIN_SIZE > data.len() {
123                return Err(IrError::BufferTooShort {
124                    expected: offset + SLOT_ENTRY_MIN_SIZE,
125                    actual: data.len(),
126                });
127            }
128
129            let slot_id =
130                u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap());
131            let name_str_idx =
132                u32::from_le_bytes(data[offset + 2..offset + 6].try_into().unwrap());
133            let type_hint = SlotType::from_byte(data[offset + 6])?;
134            let source = SlotSource::from_byte(data[offset + 7])?;
135            let default_len =
136                u16::from_le_bytes(data[offset + 8..offset + 10].try_into().unwrap()) as usize;
137            offset += SLOT_ENTRY_MIN_SIZE;
138
139            // Read default bytes
140            if offset + default_len > data.len() {
141                return Err(IrError::BufferTooShort {
142                    expected: offset + default_len,
143                    actual: data.len(),
144                });
145            }
146            let default_bytes = data[offset..offset + default_len].to_vec();
147            offset += default_len;
148
149            slots.push(SlotEntry {
150                slot_id,
151                name_str_idx,
152                type_hint,
153                source,
154                default_bytes,
155            });
156        }
157
158        Ok(SlotTable { slots })
159    }
160
161    /// Number of slots in the table.
162    pub fn len(&self) -> usize {
163        self.slots.len()
164    }
165
166    /// Returns true if the slot table is empty.
167    pub fn is_empty(&self) -> bool {
168        self.slots.is_empty()
169    }
170
171    /// Access the underlying slot entries.
172    pub fn entries(&self) -> &[SlotEntry] {
173        &self.slots
174    }
175}
176
177// ---------------------------------------------------------------------------
178// IslandTableParsed
179// ---------------------------------------------------------------------------
180
181/// Parsed island table — an indexed collection of island entries.
182///
183/// Binary format: `count(u16)`, then `count` variable-length entries of
184/// `[id(u16), trigger(u8), props_mode(u8), name_str_idx(u32), byte_offset(u32), slot_count(u16), [slot_id(u16) x slot_count]]`.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct IslandTableParsed {
187    islands: Vec<IslandEntry>,
188}
189
190/// Minimum size of a single island entry in bytes (without slot_ids).
191/// id(2) + trigger(1) + props_mode(1) + name_str_idx(4) + byte_offset(4) + slot_count(2) = 14
192const ISLAND_ENTRY_MIN_SIZE: usize = 14;
193
194impl IslandTableParsed {
195    /// Parse an island table from its section data.
196    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
197        if data.len() < 2 {
198            return Err(IrError::BufferTooShort {
199                expected: 2,
200                actual: data.len(),
201            });
202        }
203
204        let count = u16::from_le_bytes(data[0..2].try_into().unwrap()) as usize;
205        let mut islands = Vec::with_capacity(count);
206        let mut offset = 2;
207
208        for _ in 0..count {
209            // Minimum: id(2) + trigger(1) + props_mode(1) + name_str_idx(4) + byte_offset(4) + slot_count(2) = 14
210            if offset + ISLAND_ENTRY_MIN_SIZE > data.len() {
211                return Err(IrError::BufferTooShort {
212                    expected: offset + ISLAND_ENTRY_MIN_SIZE,
213                    actual: data.len(),
214                });
215            }
216
217            let id = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap());
218            let trigger = IslandTrigger::from_byte(data[offset + 2])?;
219            let props_mode = PropsMode::from_byte(data[offset + 3])?;
220            let name_str_idx =
221                u32::from_le_bytes(data[offset + 4..offset + 8].try_into().unwrap());
222            let byte_offset =
223                u32::from_le_bytes(data[offset + 8..offset + 12].try_into().unwrap());
224            let slot_count =
225                u16::from_le_bytes(data[offset + 12..offset + 14].try_into().unwrap()) as usize;
226            offset += ISLAND_ENTRY_MIN_SIZE;
227
228            // Read slot_ids
229            let needed = slot_count * 2;
230            if offset + needed > data.len() {
231                return Err(IrError::BufferTooShort {
232                    expected: offset + needed,
233                    actual: data.len(),
234                });
235            }
236            let mut slot_ids = Vec::with_capacity(slot_count);
237            for _ in 0..slot_count {
238                slot_ids.push(u16::from_le_bytes(
239                    data[offset..offset + 2].try_into().unwrap(),
240                ));
241                offset += 2;
242            }
243
244            islands.push(IslandEntry {
245                id,
246                trigger,
247                props_mode,
248                name_str_idx,
249                byte_offset,
250                slot_ids,
251            });
252        }
253
254        Ok(IslandTableParsed { islands })
255    }
256
257    /// Number of islands in the table.
258    pub fn len(&self) -> usize {
259        self.islands.len()
260    }
261
262    /// Returns true if the island table is empty.
263    pub fn is_empty(&self) -> bool {
264        self.islands.is_empty()
265    }
266
267    /// Access the underlying island entries.
268    pub fn entries(&self) -> &[IslandEntry] {
269        &self.islands
270    }
271}
272
273// ---------------------------------------------------------------------------
274// IrModule — full-file parser
275// ---------------------------------------------------------------------------
276
277/// A fully parsed and validated IR module, ready for walking.
278#[derive(Debug, Clone)]
279pub struct IrModule {
280    pub header: IrHeader,
281    pub strings: StringTable,
282    pub slots: SlotTable,
283    /// Raw opcode stream; the walker interprets it on the fly.
284    pub opcodes: Vec<u8>,
285    pub islands: IslandTableParsed,
286}
287
288impl IrModule {
289    /// Parse a complete FMIR binary into a validated `IrModule`.
290    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
291        // 1. Parse header (first 16 bytes).
292        let header = IrHeader::parse(data)?;
293
294        // 2. Parse section table (next 32 bytes, starting at HEADER_SIZE).
295        if data.len() < HEADER_SIZE + SECTION_TABLE_SIZE {
296            return Err(IrError::BufferTooShort {
297                expected: HEADER_SIZE + SECTION_TABLE_SIZE,
298                actual: data.len(),
299            });
300        }
301        let section_table = SectionTable::parse(&data[HEADER_SIZE..])?;
302
303        // 3. Validate all section bounds against the file length.
304        section_table.validate(data.len())?;
305
306        // Section indices: 0=Bytecode, 1=Strings, 2=Slots, 3=Islands
307        let sec_bytecode = &section_table.sections[0];
308        let sec_strings = &section_table.sections[1];
309        let sec_slots = &section_table.sections[2];
310        let sec_islands = &section_table.sections[3];
311
312        // 4. Parse string table.
313        let string_data = &data[sec_strings.offset as usize
314            ..(sec_strings.offset as usize + sec_strings.size as usize)];
315        let strings = StringTable::parse(string_data)?;
316
317        // 5. Parse slot table.
318        let slot_data =
319            &data[sec_slots.offset as usize..(sec_slots.offset as usize + sec_slots.size as usize)];
320        let slots = SlotTable::parse(slot_data)?;
321
322        // 6. Extract opcode stream (just clone into Vec<u8>).
323        let opcodes = data[sec_bytecode.offset as usize
324            ..(sec_bytecode.offset as usize + sec_bytecode.size as usize)]
325            .to_vec();
326
327        // 7. Parse island table.
328        let island_data = &data
329            [sec_islands.offset as usize..(sec_islands.offset as usize + sec_islands.size as usize)];
330        let islands = IslandTableParsed::parse(island_data)?;
331
332        let module = IrModule {
333            header,
334            strings,
335            slots,
336            opcodes,
337            islands,
338        };
339
340        // 8. Run cross-table validation.
341        module.validate()?;
342
343        Ok(module)
344    }
345
346    /// Validate cross-table references within the module.
347    pub fn validate(&self) -> Result<(), IrError> {
348        let str_count = self.strings.len();
349
350        // Validate all slot name_str_idx values are within string table bounds.
351        for slot in self.slots.entries() {
352            if slot.name_str_idx as usize >= str_count {
353                return Err(IrError::StringIndexOutOfBounds {
354                    index: slot.name_str_idx,
355                    len: str_count,
356                });
357            }
358        }
359
360        // Validate all island name_str_idx values are within string table bounds.
361        for island in self.islands.entries() {
362            if island.name_str_idx as usize >= str_count {
363                return Err(IrError::StringIndexOutOfBounds {
364                    index: island.name_str_idx,
365                    len: str_count,
366                });
367            }
368        }
369
370        Ok(())
371    }
372
373    /// Look up a slot ID by its name. Returns `None` if no slot with that name exists.
374    pub fn slot_id_by_name(&self, name: &str) -> Option<u16> {
375        for slot in self.slots.entries() {
376            if let Ok(slot_name) = self.strings.get(slot.name_str_idx) {
377                if slot_name == name {
378                    return Some(slot.slot_id);
379                }
380            }
381        }
382        None
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Test helpers — always compiled so external crates can use them in tests.
388// ---------------------------------------------------------------------------
389
390/// Binary encoding helpers for building FMIR test data.
391///
392/// These are always compiled (not behind `#[cfg(test)]`) so that other crates
393/// (e.g., `forma-server`) can use them in their own tests
394/// via `forma_ir::parser::test_helpers::build_minimal_ir`.
395pub mod test_helpers {
396    use crate::format::{HEADER_SIZE, SECTION_TABLE_SIZE};
397
398    /// Build valid string table binary data from a slice of strings.
399    pub fn build_string_table(strings: &[&str]) -> Vec<u8> {
400        let mut buf = Vec::new();
401        buf.extend_from_slice(&(strings.len() as u32).to_le_bytes());
402        for s in strings {
403            let bytes = s.as_bytes();
404            buf.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
405            buf.extend_from_slice(bytes);
406        }
407        buf
408    }
409
410    /// Build valid v2 slot table binary data from a slice of
411    /// (slot_id, name_str_idx, type_hint_byte, source_byte, default_bytes).
412    pub fn build_slot_table(entries: &[(u16, u32, u8, u8, &[u8])]) -> Vec<u8> {
413        let mut buf = Vec::new();
414        buf.extend_from_slice(&(entries.len() as u16).to_le_bytes());
415        for &(slot_id, name_str_idx, type_hint, source, default_bytes) in entries {
416            buf.extend_from_slice(&slot_id.to_le_bytes());
417            buf.extend_from_slice(&name_str_idx.to_le_bytes());
418            buf.push(type_hint);
419            buf.push(source);
420            buf.extend_from_slice(&(default_bytes.len() as u16).to_le_bytes());
421            buf.extend_from_slice(default_bytes);
422        }
423        buf
424    }
425
426    /// Build valid island table binary data with slot_ids support.
427    ///
428    /// Tuple: `(id, trigger, props_mode, name_str_idx, byte_offset, slot_ids)`
429    pub fn build_island_table(entries: &[(u16, u8, u8, u32, u32, &[u16])]) -> Vec<u8> {
430        let mut buf = Vec::new();
431        buf.extend_from_slice(&(entries.len() as u16).to_le_bytes());
432        for &(id, trigger, props_mode, name_str_idx, byte_offset, slot_ids) in entries {
433            buf.extend_from_slice(&id.to_le_bytes());
434            buf.push(trigger);
435            buf.push(props_mode);
436            buf.extend_from_slice(&name_str_idx.to_le_bytes());
437            buf.extend_from_slice(&byte_offset.to_le_bytes());
438            buf.extend_from_slice(&(slot_ids.len() as u16).to_le_bytes());
439            for &slot_id in slot_ids.iter() {
440                buf.extend_from_slice(&slot_id.to_le_bytes());
441            }
442        }
443        buf
444    }
445
446    // -- Opcode encoding helpers --------------------------------------------
447
448    /// Encode an OPEN_TAG opcode: opcode(1) + str_idx(4) + attr_count(2) + [(key(4), val(4))]
449    pub fn encode_open_tag(str_idx: u32, attrs: &[(u32, u32)]) -> Vec<u8> {
450        let mut buf = Vec::new();
451        buf.push(0x01); // Opcode::OpenTag
452        buf.extend_from_slice(&str_idx.to_le_bytes());
453        buf.extend_from_slice(&(attrs.len() as u16).to_le_bytes());
454        for &(key_idx, val_idx) in attrs {
455            buf.extend_from_slice(&key_idx.to_le_bytes());
456            buf.extend_from_slice(&val_idx.to_le_bytes());
457        }
458        buf
459    }
460
461    /// Encode a CLOSE_TAG opcode: opcode(1) + str_idx(4)
462    pub fn encode_close_tag(str_idx: u32) -> Vec<u8> {
463        let mut buf = Vec::new();
464        buf.push(0x02); // Opcode::CloseTag
465        buf.extend_from_slice(&str_idx.to_le_bytes());
466        buf
467    }
468
469    /// Encode a VOID_TAG opcode: opcode(1) + str_idx(4) + attr_count(2) + [(key(4), val(4))]
470    pub fn encode_void_tag(str_idx: u32, attrs: &[(u32, u32)]) -> Vec<u8> {
471        let mut buf = Vec::new();
472        buf.push(0x03); // Opcode::VoidTag
473        buf.extend_from_slice(&str_idx.to_le_bytes());
474        buf.extend_from_slice(&(attrs.len() as u16).to_le_bytes());
475        for &(key_idx, val_idx) in attrs {
476            buf.extend_from_slice(&key_idx.to_le_bytes());
477            buf.extend_from_slice(&val_idx.to_le_bytes());
478        }
479        buf
480    }
481
482    /// Encode a TEXT opcode: opcode(1) + str_idx(4)
483    pub fn encode_text(str_idx: u32) -> Vec<u8> {
484        let mut buf = Vec::new();
485        buf.push(0x04); // Opcode::Text
486        buf.extend_from_slice(&str_idx.to_le_bytes());
487        buf
488    }
489
490    /// Encode a SHOW_IF opcode with then/else branches.
491    ///
492    /// Binary layout:
493    /// ```text
494    /// [SHOW_IF(0x07)] [slot_id(u16)] [then_len(u32)] [else_len(u32)]
495    /// [then_ops bytes...] [SHOW_ELSE(0x08)] [else_ops bytes...]
496    /// ```
497    pub fn encode_show_if(slot_id: u16, then_ops: &[u8], else_ops: &[u8]) -> Vec<u8> {
498        let mut buf = Vec::new();
499        buf.push(0x07); // Opcode::ShowIf
500        buf.extend_from_slice(&slot_id.to_le_bytes());
501        buf.extend_from_slice(&(then_ops.len() as u32).to_le_bytes());
502        buf.extend_from_slice(&(else_ops.len() as u32).to_le_bytes());
503        buf.extend_from_slice(then_ops); // then branch body
504        buf.push(0x08); // SHOW_ELSE marker
505        buf.extend_from_slice(else_ops); // else branch body
506        buf
507    }
508
509    /// Encode a LIST opcode: opcode(1) + slot_id(2) + item_slot_id(2) + body_len(4) + body
510    pub fn encode_list(slot_id: u16, item_slot_id: u16, body_ops: &[u8]) -> Vec<u8> {
511        let mut buf = Vec::new();
512        buf.push(0x0A); // Opcode::List
513        buf.extend_from_slice(&slot_id.to_le_bytes());
514        buf.extend_from_slice(&item_slot_id.to_le_bytes());
515        buf.extend_from_slice(&(body_ops.len() as u32).to_le_bytes());
516        buf.extend_from_slice(body_ops);
517        buf
518    }
519
520    /// Encode a SWITCH opcode with case headers and bodies.
521    ///
522    /// Binary layout:
523    /// ```text
524    /// [SWITCH(0x09)] [slot_id(u16)] [case_count(u16)]
525    /// [val_str_idx(u32) body_len(u32)] x case_count   -- case headers
526    /// [...body opcodes for case 0...]
527    /// [...body opcodes for case 1...]
528    /// ...
529    /// ```
530    pub fn encode_switch(slot_id: u16, cases: &[(u32, &[u8])]) -> Vec<u8> {
531        let mut buf = Vec::new();
532        buf.push(0x09); // Opcode::Switch
533        buf.extend_from_slice(&slot_id.to_le_bytes());
534        buf.extend_from_slice(&(cases.len() as u16).to_le_bytes());
535        // Case headers
536        for (val_str_idx, body) in cases {
537            buf.extend_from_slice(&val_str_idx.to_le_bytes());
538            buf.extend_from_slice(&(body.len() as u32).to_le_bytes());
539        }
540        // Case bodies
541        for (_, body) in cases {
542            buf.extend_from_slice(body);
543        }
544        buf
545    }
546
547    /// Encode a TRY_START/FALLBACK block.
548    ///
549    /// Binary layout:
550    /// ```text
551    /// [TRY_START(0x0D)] [fallback_len(u32)]
552    /// [...main_ops...]
553    /// [FALLBACK(0x0E)]
554    /// [...fallback_ops...]
555    /// ```
556    pub fn encode_try(main_ops: &[u8], fallback_ops: &[u8]) -> Vec<u8> {
557        let mut buf = Vec::new();
558        buf.push(0x0D); // Opcode::TryStart
559        buf.extend_from_slice(&(fallback_ops.len() as u32).to_le_bytes());
560        buf.extend_from_slice(main_ops);
561        buf.push(0x0E); // Opcode::Fallback
562        buf.extend_from_slice(fallback_ops);
563        buf
564    }
565
566    /// Encode a PRELOAD opcode: opcode(1) + resource_type(1) + url_str_idx(4)
567    pub fn encode_preload(resource_type: u8, url_str_idx: u32) -> Vec<u8> {
568        let mut buf = Vec::new();
569        buf.push(0x0F); // Opcode::Preload
570        buf.push(resource_type);
571        buf.extend_from_slice(&url_str_idx.to_le_bytes());
572        buf
573    }
574
575    // -- Full-file builder --------------------------------------------------
576
577    /// Build a minimal valid FMIR v2 binary file for testing.
578    ///
579    /// * `strings` -- list of string table entries
580    /// * `slots` -- list of `(slot_id, name_str_idx, type_hint_byte, source_byte, default_bytes)` tuples
581    /// * `opcodes` -- raw opcode bytes (already assembled)
582    /// * `islands` -- list of `(id, trigger_byte, props_mode_byte, name_str_idx, byte_offset, slot_ids)` tuples
583    pub fn build_minimal_ir(
584        strings: &[&str],
585        slots: &[(u16, u32, u8, u8, &[u8])],
586        opcodes: &[u8],
587        islands: &[(u16, u8, u8, u32, u32, &[u16])],
588    ) -> Vec<u8> {
589        let string_section = build_string_table(strings);
590        let slot_section = build_slot_table(slots);
591        let island_section = build_island_table(islands);
592
593        // Section layout: header(16) + section_table(32) = 48 bytes before first section.
594        let data_start = HEADER_SIZE + SECTION_TABLE_SIZE;
595
596        // Order of sections in the file: bytecode, strings, slots, islands
597        let bytecode_offset = data_start;
598        let bytecode_size = opcodes.len();
599
600        let string_offset = bytecode_offset + bytecode_size;
601        let string_size = string_section.len();
602
603        let slot_offset = string_offset + string_size;
604        let slot_size = slot_section.len();
605
606        let island_offset = slot_offset + slot_size;
607        let island_size = island_section.len();
608
609        // Build header
610        let mut buf = Vec::new();
611        buf.extend_from_slice(b"FMIR");
612        buf.extend_from_slice(&2u16.to_le_bytes()); // version
613        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
614        buf.extend_from_slice(&0u64.to_le_bytes()); // source_hash
615
616        // Build section table (4 sections: bytecode, strings, slots, islands)
617        buf.extend_from_slice(&(bytecode_offset as u32).to_le_bytes());
618        buf.extend_from_slice(&(bytecode_size as u32).to_le_bytes());
619        buf.extend_from_slice(&(string_offset as u32).to_le_bytes());
620        buf.extend_from_slice(&(string_size as u32).to_le_bytes());
621        buf.extend_from_slice(&(slot_offset as u32).to_le_bytes());
622        buf.extend_from_slice(&(slot_size as u32).to_le_bytes());
623        buf.extend_from_slice(&(island_offset as u32).to_le_bytes());
624        buf.extend_from_slice(&(island_size as u32).to_le_bytes());
625
626        // Append section data
627        buf.extend_from_slice(opcodes);
628        buf.extend_from_slice(&string_section);
629        buf.extend_from_slice(&slot_section);
630        buf.extend_from_slice(&island_section);
631
632        buf
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Tests
638// ---------------------------------------------------------------------------
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use super::test_helpers::*;
644    use crate::format::{IrError, IslandTrigger, PropsMode, SlotSource, SlotType};
645
646    // -- StringTable tests --------------------------------------------------
647
648    #[test]
649    fn parse_string_table() {
650        let data = build_string_table(&["div", "class", "container"]);
651        let table = StringTable::parse(&data).unwrap();
652
653        assert_eq!(table.len(), 3);
654        assert_eq!(table.get(0).unwrap(), "div");
655        assert_eq!(table.get(1).unwrap(), "class");
656        assert_eq!(table.get(2).unwrap(), "container");
657
658        // Out of bounds
659        let err = table.get(3).unwrap_err();
660        assert_eq!(
661            err,
662            IrError::StringIndexOutOfBounds { index: 3, len: 3 }
663        );
664    }
665
666    #[test]
667    fn parse_string_table_empty() {
668        let data = build_string_table(&[]);
669        let table = StringTable::parse(&data).unwrap();
670        assert_eq!(table.len(), 0);
671    }
672
673    #[test]
674    fn parse_string_table_unicode() {
675        let data = build_string_table(&["héllo"]);
676        let table = StringTable::parse(&data).unwrap();
677
678        assert_eq!(table.len(), 1);
679        assert_eq!(table.get(0).unwrap(), "héllo");
680    }
681
682    #[test]
683    fn parse_string_table_truncated() {
684        // Count says 2 strings but data is truncated after the first string's length prefix.
685        let mut data = Vec::new();
686        data.extend_from_slice(&2u32.to_le_bytes()); // count = 2
687        data.extend_from_slice(&3u16.to_le_bytes()); // first string len = 3
688        data.extend_from_slice(b"div"); // first string bytes
689        // Second string is missing entirely
690
691        let err = StringTable::parse(&data).unwrap_err();
692        match err {
693            IrError::BufferTooShort { .. } => {} // expected
694            other => panic!("expected BufferTooShort, got {other:?}"),
695        }
696    }
697
698    // -- SlotTable tests ----------------------------------------------------
699
700    #[test]
701    fn parse_slot_table() {
702        let data = build_slot_table(&[
703            (1, 0, 0x01, 0x00, &[]),     // slot_id=1, name_str_idx=0, type=Text, source=Server, no default
704            (2, 1, 0x03, 0x01, &[0x42]), // slot_id=2, name_str_idx=1, type=Number, source=Client, 1-byte default
705        ]);
706        let table = SlotTable::parse(&data).unwrap();
707
708        assert_eq!(table.len(), 2);
709
710        let entries = table.entries();
711        assert_eq!(entries[0].slot_id, 1);
712        assert_eq!(entries[0].name_str_idx, 0);
713        assert_eq!(entries[0].type_hint, SlotType::Text);
714        assert_eq!(entries[0].source, SlotSource::Server);
715        assert_eq!(entries[0].default_bytes, Vec::<u8>::new());
716
717        assert_eq!(entries[1].slot_id, 2);
718        assert_eq!(entries[1].name_str_idx, 1);
719        assert_eq!(entries[1].type_hint, SlotType::Number);
720        assert_eq!(entries[1].source, SlotSource::Client);
721        assert_eq!(entries[1].default_bytes, vec![0x42]);
722    }
723
724    #[test]
725    fn parse_slot_table_empty() {
726        let data = build_slot_table(&[]);
727        let table = SlotTable::parse(&data).unwrap();
728        assert_eq!(table.len(), 0);
729    }
730
731    // -- IslandTableParsed tests --------------------------------------------
732
733    #[test]
734    fn parse_island_table() {
735        let data = build_island_table(&[
736            (1, 0x02, 0x01, 5, 0, &[]), // id=1, trigger=Visible, props=Inline, name_str_idx=5, byte_offset=0, no slots
737        ]);
738        let table = IslandTableParsed::parse(&data).unwrap();
739
740        assert_eq!(table.len(), 1);
741
742        let entry = &table.entries()[0];
743        assert_eq!(entry.id, 1);
744        assert_eq!(entry.trigger, IslandTrigger::Visible);
745        assert_eq!(entry.props_mode, PropsMode::Inline);
746        assert_eq!(entry.name_str_idx, 5);
747        assert_eq!(entry.slot_ids, Vec::<u16>::new());
748    }
749
750    #[test]
751    fn parse_island_table_with_slot_ids() {
752        let data = build_island_table(&[
753            (0, 0x01, 0x01, 0, 0, &[0, 1]), // id=0, Load, Inline, name=0, byte_offset=0, slots=[0, 1]
754        ]);
755        let table = IslandTableParsed::parse(&data).unwrap();
756        assert_eq!(table.len(), 1);
757        let entry = &table.entries()[0];
758        assert_eq!(entry.id, 0);
759        assert_eq!(entry.trigger, IslandTrigger::Load);
760        assert_eq!(entry.props_mode, PropsMode::Inline);
761        assert_eq!(entry.slot_ids, vec![0, 1]);
762    }
763
764    #[test]
765    fn parse_island_table_empty() {
766        let data = build_island_table(&[]);
767        let table = IslandTableParsed::parse(&data).unwrap();
768        assert_eq!(table.len(), 0);
769    }
770
771    // -- IrModule tests -----------------------------------------------------
772
773    #[test]
774    fn parse_minimal_ir_file() {
775        // 1 string ("div"), empty slots/islands, opcodes = [OPEN_TAG "div", CLOSE_TAG "div"]
776        let mut opcodes = Vec::new();
777        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
778        opcodes.extend_from_slice(&encode_close_tag(0));
779
780        let data = build_minimal_ir(&["div"], &[], &opcodes, &[]);
781        let module = IrModule::parse(&data).unwrap();
782
783        assert_eq!(module.header.version, 2);
784        assert_eq!(module.strings.get(0).unwrap(), "div");
785        assert_eq!(module.strings.len(), 1);
786        assert_eq!(module.slots.len(), 0);
787        assert_eq!(module.islands.len(), 0);
788        assert_eq!(module.opcodes.len(), opcodes.len());
789    }
790
791    #[test]
792    fn parse_ir_with_slots() {
793        let opcodes = encode_text(0);
794        let data = build_minimal_ir(
795            &["greeting", "count", "Hello"],
796            &[
797                (1, 0, 0x01, 0x00, &[]), // slot_id=1, name="greeting", type=Text, source=Server
798                (2, 1, 0x03, 0x00, &[]), // slot_id=2, name="count", type=Number, source=Server
799            ],
800            &opcodes,
801            &[],
802        );
803        let module = IrModule::parse(&data).unwrap();
804
805        assert_eq!(module.slots.len(), 2);
806        let entries = module.slots.entries();
807        assert_eq!(entries[0].slot_id, 1);
808        assert_eq!(entries[0].name_str_idx, 0);
809        assert_eq!(entries[0].type_hint, SlotType::Text);
810        assert_eq!(entries[1].slot_id, 2);
811        assert_eq!(entries[1].name_str_idx, 1);
812        assert_eq!(entries[1].type_hint, SlotType::Number);
813    }
814
815    #[test]
816    fn parse_ir_with_islands() {
817        let opcodes = encode_text(0);
818        let data = build_minimal_ir(
819            &["Counter", "Hello"],
820            &[],
821            &opcodes,
822            &[
823                (1, 0x01, 0x01, 0, 0, &[]), // id=1, trigger=Load, props=Inline, name="Counter", byte_offset=0, no slots
824            ],
825        );
826        let module = IrModule::parse(&data).unwrap();
827
828        assert_eq!(module.islands.len(), 1);
829        let entry = &module.islands.entries()[0];
830        assert_eq!(entry.id, 1);
831        assert_eq!(entry.trigger, IslandTrigger::Load);
832        assert_eq!(entry.props_mode, PropsMode::Inline);
833        assert_eq!(entry.name_str_idx, 0);
834        assert_eq!(module.strings.get(entry.name_str_idx).unwrap(), "Counter");
835    }
836
837    #[test]
838    fn parse_ir_rejects_truncated() {
839        // File shorter than HEADER_SIZE (16 bytes)
840        let data = b"FMIR\x02\x00";
841        let err = IrModule::parse(data).unwrap_err();
842        match err {
843            IrError::BufferTooShort { expected: 16, actual: 6 } => {}
844            other => panic!("expected BufferTooShort(16, 6), got {other:?}"),
845        }
846    }
847
848    #[test]
849    fn parse_ir_rejects_bad_section_bounds() {
850        // Build a valid file, then corrupt a section descriptor so it extends past EOF.
851        let opcodes = encode_text(0);
852        let mut data = build_minimal_ir(&["x"], &[], &opcodes, &[]);
853
854        // Corrupt section 3 (island table) size to be huge.
855        // Section table starts at offset 16. Section 3 is at 16 + 3*8 = 40.
856        // Size field is at offset 44 (40 + 4).
857        let big_size: u32 = 99999;
858        data[44..48].copy_from_slice(&big_size.to_le_bytes());
859
860        let err = IrModule::parse(&data).unwrap_err();
861        match err {
862            IrError::SectionOutOfBounds { section: 3, .. } => {}
863            other => panic!("expected SectionOutOfBounds for section 3, got {other:?}"),
864        }
865    }
866
867    #[test]
868    fn validate_catches_bad_slot_str_idx() {
869        // Slot references string index 99, but only 3 strings exist.
870        let opcodes = encode_text(0);
871        let data = build_minimal_ir(
872            &["a", "b", "c"],
873            &[(1, 99, 0x01, 0x00, &[])], // name_str_idx=99, out of bounds
874            &opcodes,
875            &[],
876        );
877        let err = IrModule::parse(&data).unwrap_err();
878        assert_eq!(
879            err,
880            IrError::StringIndexOutOfBounds { index: 99, len: 3 }
881        );
882    }
883
884    #[test]
885    fn validate_catches_bad_island_str_idx() {
886        // Island references string index 99, but only 3 strings exist.
887        let opcodes = encode_text(0);
888        let data = build_minimal_ir(
889            &["a", "b", "c"],
890            &[],
891            &opcodes,
892            &[(1, 0x01, 0x01, 99, 0, &[])],
893        );
894        let err = IrModule::parse(&data).unwrap_err();
895        assert_eq!(
896            err,
897            IrError::StringIndexOutOfBounds { index: 99, len: 3 }
898        );
899    }
900}