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