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