use crate::format::{
le_u16, le_u32, IrError, IrHeader, IslandEntry, IslandTrigger, Opcode, PropsMode, SectionTable,
SlotEntry, SlotSource, SlotType, HEADER_SIZE, SECTION_TABLE_SIZE,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StringTable {
strings: Vec<String>,
}
impl StringTable {
pub fn parse(data: &[u8]) -> Result<Self, IrError> {
if data.len() < 4 {
return Err(IrError::BufferTooShort {
expected: 4,
actual: data.len(),
});
}
let count = le_u32(&data[0..4]) as usize;
let max_entries = (data.len() - 4) / 2;
if count > max_entries {
return Err(IrError::BufferTooShort {
expected: 4usize.saturating_add(count.saturating_mul(2)),
actual: data.len(),
});
}
let mut offset = 4;
let mut strings = Vec::with_capacity(count);
for _ in 0..count {
if offset + 2 > data.len() {
return Err(IrError::BufferTooShort {
expected: offset + 2,
actual: data.len(),
});
}
let str_len = le_u16(&data[offset..offset + 2]) as usize;
offset += 2;
if offset + str_len > data.len() {
return Err(IrError::BufferTooShort {
expected: offset + str_len,
actual: data.len(),
});
}
let s = std::str::from_utf8(&data[offset..offset + str_len])
.map_err(|e| IrError::InvalidUtf8(e.to_string()))?;
strings.push(s.to_owned());
offset += str_len;
}
Ok(StringTable { strings })
}
pub fn get(&self, idx: u32) -> Result<&str, IrError> {
self.strings
.get(idx as usize)
.map(|s| s.as_str())
.ok_or(IrError::StringIndexOutOfBounds {
index: idx,
len: self.strings.len(),
})
}
pub fn len(&self) -> usize {
self.strings.len()
}
pub fn is_empty(&self) -> bool {
self.strings.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotTable {
slots: Vec<SlotEntry>,
}
const SLOT_ENTRY_MIN_SIZE: usize = 10;
impl SlotTable {
pub fn parse(data: &[u8]) -> Result<Self, IrError> {
if data.len() < 2 {
return Err(IrError::BufferTooShort {
expected: 2,
actual: data.len(),
});
}
let count = le_u16(&data[0..2]) as usize;
let max_entries = (data.len() - 2) / SLOT_ENTRY_MIN_SIZE;
if count > max_entries {
return Err(IrError::BufferTooShort {
expected: 2usize.saturating_add(count.saturating_mul(SLOT_ENTRY_MIN_SIZE)),
actual: data.len(),
});
}
let mut slots = Vec::with_capacity(count);
let mut offset = 2;
for _ in 0..count {
if offset + SLOT_ENTRY_MIN_SIZE > data.len() {
return Err(IrError::BufferTooShort {
expected: offset + SLOT_ENTRY_MIN_SIZE,
actual: data.len(),
});
}
let slot_id = le_u16(&data[offset..offset + 2]);
let name_str_idx = le_u32(&data[offset + 2..offset + 6]);
let type_hint = SlotType::from_byte(data[offset + 6])?;
let source = SlotSource::from_byte(data[offset + 7])?;
let default_len = le_u16(&data[offset + 8..offset + 10]) as usize;
offset += SLOT_ENTRY_MIN_SIZE;
if offset + default_len > data.len() {
return Err(IrError::BufferTooShort {
expected: offset + default_len,
actual: data.len(),
});
}
let default_bytes = data[offset..offset + default_len].to_vec();
offset += default_len;
slots.push(SlotEntry {
slot_id,
name_str_idx,
type_hint,
source,
default_bytes,
});
}
Ok(SlotTable { slots })
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
pub fn entries(&self) -> &[SlotEntry] {
&self.slots
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IslandTableParsed {
islands: Vec<IslandEntry>,
}
const ISLAND_ENTRY_MIN_SIZE: usize = 14;
impl IslandTableParsed {
pub fn parse(data: &[u8]) -> Result<Self, IrError> {
if data.len() < 2 {
return Err(IrError::BufferTooShort {
expected: 2,
actual: data.len(),
});
}
let count = le_u16(&data[0..2]) as usize;
let max_entries = (data.len() - 2) / ISLAND_ENTRY_MIN_SIZE;
if count > max_entries {
return Err(IrError::BufferTooShort {
expected: 2usize.saturating_add(count.saturating_mul(ISLAND_ENTRY_MIN_SIZE)),
actual: data.len(),
});
}
let mut islands = Vec::with_capacity(count);
let mut offset = 2;
for _ in 0..count {
if offset + ISLAND_ENTRY_MIN_SIZE > data.len() {
return Err(IrError::BufferTooShort {
expected: offset + ISLAND_ENTRY_MIN_SIZE,
actual: data.len(),
});
}
let id = le_u16(&data[offset..offset + 2]);
let trigger = IslandTrigger::from_byte(data[offset + 2])?;
let props_mode = PropsMode::from_byte(data[offset + 3])?;
let name_str_idx = le_u32(&data[offset + 4..offset + 8]);
let byte_offset = le_u32(&data[offset + 8..offset + 12]);
let slot_count = le_u16(&data[offset + 12..offset + 14]) as usize;
offset += ISLAND_ENTRY_MIN_SIZE;
let needed = slot_count * 2;
if offset + needed > data.len() {
return Err(IrError::BufferTooShort {
expected: offset + needed,
actual: data.len(),
});
}
let mut slot_ids = Vec::with_capacity(slot_count);
for _ in 0..slot_count {
slot_ids.push(le_u16(&data[offset..offset + 2]));
offset += 2;
}
islands.push(IslandEntry {
id,
trigger,
props_mode,
name_str_idx,
byte_offset,
slot_ids,
});
}
Ok(IslandTableParsed { islands })
}
pub fn len(&self) -> usize {
self.islands.len()
}
pub fn is_empty(&self) -> bool {
self.islands.is_empty()
}
pub fn entries(&self) -> &[IslandEntry] {
&self.islands
}
}
const NO_INDEX: u32 = u32::MAX;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct IrModule {
pub header: IrHeader,
pub strings: StringTable,
pub slots: SlotTable,
pub opcodes: Vec<u8>,
pub islands: IslandTableParsed,
slot_index: Vec<u32>,
island_index: Vec<u32>,
}
impl IrModule {
pub fn parse(data: &[u8]) -> Result<Self, IrError> {
let header = IrHeader::parse(data)?;
if data.len() < HEADER_SIZE + SECTION_TABLE_SIZE {
return Err(IrError::BufferTooShort {
expected: HEADER_SIZE + SECTION_TABLE_SIZE,
actual: data.len(),
});
}
let section_table = SectionTable::parse(&data[HEADER_SIZE..])?;
section_table.validate(data.len())?;
let sec_bytecode = §ion_table.sections[0];
let sec_strings = §ion_table.sections[1];
let sec_slots = §ion_table.sections[2];
let sec_islands = §ion_table.sections[3];
let string_data = &data[sec_strings.offset as usize
..(sec_strings.offset as usize + sec_strings.size as usize)];
let strings = StringTable::parse(string_data)?;
let slot_data =
&data[sec_slots.offset as usize..(sec_slots.offset as usize + sec_slots.size as usize)];
let slots = SlotTable::parse(slot_data)?;
let opcodes = data[sec_bytecode.offset as usize
..(sec_bytecode.offset as usize + sec_bytecode.size as usize)]
.to_vec();
let island_data = &data[sec_islands.offset as usize
..(sec_islands.offset as usize + sec_islands.size as usize)];
let islands = IslandTableParsed::parse(island_data)?;
let slot_index = build_id_index(slots.entries().iter().map(|e| e.slot_id));
let island_index = build_id_index(islands.entries().iter().map(|e| e.id));
let module = IrModule {
header,
strings,
slots,
opcodes,
islands,
slot_index,
island_index,
};
module.validate()?;
Ok(module)
}
pub fn slot_entry(&self, slot_id: u16) -> Option<&SlotEntry> {
match self.slot_index.get(slot_id as usize) {
Some(&idx) if idx != NO_INDEX => self.slots.entries().get(idx as usize),
_ => None,
}
}
pub fn island_entry(&self, island_id: u16) -> Option<&IslandEntry> {
match self.island_index.get(island_id as usize) {
Some(&idx) if idx != NO_INDEX => self.islands.entries().get(idx as usize),
_ => None,
}
}
pub fn validate(&self) -> Result<(), IrError> {
let str_count = self.strings.len();
let mut seen_slot = vec![false; self.slot_index.len()];
for slot in self.slots.entries() {
if slot.name_str_idx as usize >= str_count {
return Err(IrError::StringIndexOutOfBounds {
index: slot.name_str_idx,
len: str_count,
});
}
let idx = slot.slot_id as usize;
if seen_slot[idx] {
return Err(IrError::DuplicateSlotId(slot.slot_id));
}
seen_slot[idx] = true;
}
let mut seen_island = vec![false; self.island_index.len()];
for island in self.islands.entries() {
if island.name_str_idx as usize >= str_count {
return Err(IrError::StringIndexOutOfBounds {
index: island.name_str_idx,
len: str_count,
});
}
let idx = island.id as usize;
if seen_island[idx] {
return Err(IrError::DuplicateIslandId(island.id));
}
seen_island[idx] = true;
for &slot_id in &island.slot_ids {
if self.slot_entry(slot_id).is_none() {
return Err(IrError::UnknownSlotId(slot_id));
}
}
let off = island.byte_offset as usize;
let id_matches = self.opcodes.get(off) == Some(&(Opcode::IslandStart as u8))
&& crate::walker::read_u16(&self.opcodes, off + 1) == Ok(island.id);
if !id_matches {
return Err(IrError::IslandOffsetMismatch {
island_id: island.id,
byte_offset: island.byte_offset,
});
}
}
self.scan_region(0, self.opcodes.len(), 0)?;
Ok(())
}
fn scan_region(&self, start: usize, end: usize, depth: usize) -> Result<(), IrError> {
if depth > MAX_VALIDATION_DEPTH {
return Err(IrError::RecursionLimitExceeded);
}
let ops = &self.opcodes;
let mut pos = start;
let mut fallback_stack: Vec<usize> = Vec::new();
while pos < end {
let op_start = pos;
let opcode = Opcode::from_byte(ops[pos])?;
pos += 1;
match opcode {
Opcode::OpenTag | Opcode::VoidTag => {
let attr_count = read_u16_at(ops, pos + 4, end)? as usize;
pos = bounded(pos, 6 + attr_count * 8, end, op_start)?;
}
Opcode::CloseTag
| Opcode::Text
| Opcode::Comment
| Opcode::ListItemKey
| Opcode::DynText => {
if opcode == Opcode::DynText {
self.check_slot(read_u16_at(ops, pos, end)?)?;
}
pos = bounded(pos, 4, end, op_start)?;
}
Opcode::DynAttr => {
self.check_slot(read_u16_at(ops, pos + 4, end)?)?;
pos = bounded(pos, 6, end, op_start)?;
}
Opcode::IslandStart => {
let island_id = read_u16_at(ops, pos, end)?;
if self.island_entry(island_id).is_none() {
return Err(IrError::UnknownIslandId(island_id));
}
pos = bounded(pos, 2, end, op_start)?;
}
Opcode::IslandEnd => {
pos = bounded(pos, 2, end, op_start)?;
}
Opcode::ShowElse => {
}
Opcode::Preload => {
pos = bounded(pos, 5, end, op_start)?;
}
Opcode::Prop => {
self.check_slot(read_u16_at(ops, pos, end)?)?;
self.check_slot(read_u16_at(ops, pos + 6, end)?)?;
pos = bounded(pos, 8, end, op_start)?;
}
Opcode::ShowIf => {
self.check_slot(read_u16_at(ops, pos, end)?)?;
let then_len = read_u32_at(ops, pos + 2, end)? as usize;
let else_len = read_u32_at(ops, pos + 6, end)? as usize;
let then_start = bounded(pos, 10, end, op_start)?;
let then_end = bounded(then_start, then_len, end, op_start)?;
if then_end >= end || ops[then_end] != Opcode::ShowElse as u8 {
return Err(IrError::MalformedControlFlow { pos: then_end });
}
let else_start = then_end + 1;
let else_end = bounded(else_start, else_len, end, op_start)?;
self.scan_region(then_start, then_end, depth + 1)?;
self.scan_region(else_start, else_end, depth + 1)?;
pos = else_end;
}
Opcode::Switch => {
self.check_slot(read_u16_at(ops, pos, end)?)?;
let case_count = read_u16_at(ops, pos + 2, end)? as usize;
let mut header_pos = bounded(pos, 4, end, op_start)?;
let mut lens =
Vec::with_capacity(case_count.min(end.saturating_sub(header_pos) / 8));
for _ in 0..case_count {
let body_len = read_u32_at(ops, header_pos + 4, end)? as usize;
lens.push(body_len);
header_pos = bounded(header_pos, 8, end, op_start)?;
}
let mut body_pos = header_pos;
for body_len in lens {
let case_end = bounded(body_pos, body_len, end, op_start)?;
self.scan_region(body_pos, case_end, depth + 1)?;
body_pos = case_end;
}
pos = body_pos;
}
Opcode::List => {
self.check_slot(read_u16_at(ops, pos, end)?)?;
self.check_slot(read_u16_at(ops, pos + 2, end)?)?;
let body_len = read_u32_at(ops, pos + 4, end)? as usize;
let body_start = bounded(pos, 8, end, op_start)?;
let body_end = bounded(body_start, body_len, end, op_start)?;
self.scan_region(body_start, body_end, depth + 1)?;
pos = body_end;
}
Opcode::TryStart => {
let fallback_len = read_u32_at(ops, pos, end)? as usize;
pos = bounded(pos, 4, end, op_start)?;
fallback_stack.push(fallback_len);
}
Opcode::Fallback => {
match fallback_stack.pop() {
Some(skip) => pos = bounded(pos, skip, end, op_start)?,
None => return Err(IrError::MalformedControlFlow { pos: op_start }),
}
}
}
}
if let Some(_unmatched) = fallback_stack.pop() {
return Err(IrError::MalformedControlFlow { pos: end });
}
Ok(())
}
fn check_slot(&self, slot_id: u16) -> Result<(), IrError> {
if self.slot_entry(slot_id).is_none() {
return Err(IrError::UnknownSlotId(slot_id));
}
Ok(())
}
pub fn slot_id_by_name(&self, name: &str) -> Option<u16> {
for slot in self.slots.entries() {
if let Ok(slot_name) = self.strings.get(slot.name_str_idx) {
if slot_name == name {
return Some(slot.slot_id);
}
}
}
None
}
}
const MAX_VALIDATION_DEPTH: usize = 64;
fn build_id_index(ids: impl Iterator<Item = u16> + Clone) -> Vec<u32> {
let max_id = ids.clone().max();
let Some(max_id) = max_id else {
return Vec::new();
};
let mut index = vec![NO_INDEX; max_id as usize + 1];
for (i, id) in ids.enumerate() {
let entry = &mut index[id as usize];
if *entry == NO_INDEX {
*entry = i as u32;
}
}
index
}
fn bounded(base: usize, len: usize, end: usize, op_start: usize) -> Result<usize, IrError> {
base.checked_add(len)
.filter(|e| *e <= end)
.ok_or(IrError::MalformedControlFlow { pos: op_start })
}
fn read_u16_at(ops: &[u8], pos: usize, end: usize) -> Result<u16, IrError> {
if pos.checked_add(2).map(|e| e > end).unwrap_or(true) {
return Err(IrError::MalformedControlFlow { pos });
}
crate::walker::read_u16(ops, pos)
}
fn read_u32_at(ops: &[u8], pos: usize, end: usize) -> Result<u32, IrError> {
if pos.checked_add(4).map(|e| e > end).unwrap_or(true) {
return Err(IrError::MalformedControlFlow { pos });
}
crate::walker::read_u32(ops, pos)
}
#[cfg(any(test, feature = "test-helpers"))]
#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
pub mod test_helpers {
use crate::format::{HEADER_SIZE, SECTION_TABLE_SIZE};
fn to_u16(n: usize) -> [u8; 2] {
u16::try_from(n)
.unwrap_or_else(|_| panic!("{n} does not fit the u16 field this encoder writes"))
.to_le_bytes()
}
fn to_u32(n: usize) -> [u8; 4] {
u32::try_from(n)
.unwrap_or_else(|_| panic!("{n} does not fit the u32 field this encoder writes"))
.to_le_bytes()
}
pub fn build_string_table(strings: &[&str]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&to_u32(strings.len()));
for s in strings {
let bytes = s.as_bytes();
buf.extend_from_slice(&to_u16(bytes.len()));
buf.extend_from_slice(bytes);
}
buf
}
pub fn build_slot_table(entries: &[(u16, u32, u8, u8, &[u8])]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&to_u16(entries.len()));
for &(slot_id, name_str_idx, type_hint, source, default_bytes) in entries {
buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&name_str_idx.to_le_bytes());
buf.push(type_hint);
buf.push(source);
buf.extend_from_slice(&to_u16(default_bytes.len()));
buf.extend_from_slice(default_bytes);
}
buf
}
pub fn build_island_table(entries: &[(u16, u8, u8, u32, u32, &[u16])]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&to_u16(entries.len()));
for &(id, trigger, props_mode, name_str_idx, byte_offset, slot_ids) in entries {
buf.extend_from_slice(&id.to_le_bytes());
buf.push(trigger);
buf.push(props_mode);
buf.extend_from_slice(&name_str_idx.to_le_bytes());
buf.extend_from_slice(&byte_offset.to_le_bytes());
buf.extend_from_slice(&to_u16(slot_ids.len()));
for &slot_id in slot_ids.iter() {
buf.extend_from_slice(&slot_id.to_le_bytes());
}
}
buf
}
pub fn encode_open_tag(str_idx: u32, attrs: &[(u32, u32)]) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x01); buf.extend_from_slice(&str_idx.to_le_bytes());
buf.extend_from_slice(&to_u16(attrs.len()));
for &(key_idx, val_idx) in attrs {
buf.extend_from_slice(&key_idx.to_le_bytes());
buf.extend_from_slice(&val_idx.to_le_bytes());
}
buf
}
pub fn encode_close_tag(str_idx: u32) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x02); buf.extend_from_slice(&str_idx.to_le_bytes());
buf
}
pub fn encode_void_tag(str_idx: u32, attrs: &[(u32, u32)]) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x03); buf.extend_from_slice(&str_idx.to_le_bytes());
buf.extend_from_slice(&to_u16(attrs.len()));
for &(key_idx, val_idx) in attrs {
buf.extend_from_slice(&key_idx.to_le_bytes());
buf.extend_from_slice(&val_idx.to_le_bytes());
}
buf
}
pub fn encode_text(str_idx: u32) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x04); buf.extend_from_slice(&str_idx.to_le_bytes());
buf
}
pub fn encode_show_if(slot_id: u16, then_ops: &[u8], else_ops: &[u8]) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x07); buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&to_u32(then_ops.len()));
buf.extend_from_slice(&to_u32(else_ops.len()));
buf.extend_from_slice(then_ops); buf.push(0x08); buf.extend_from_slice(else_ops); buf
}
pub fn encode_list(slot_id: u16, item_slot_id: u16, body_ops: &[u8]) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x0A); buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&item_slot_id.to_le_bytes());
buf.extend_from_slice(&to_u32(body_ops.len()));
buf.extend_from_slice(body_ops);
buf
}
pub fn encode_switch(slot_id: u16, cases: &[(u32, &[u8])]) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x09); buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&to_u16(cases.len()));
for (val_str_idx, body) in cases {
buf.extend_from_slice(&val_str_idx.to_le_bytes());
buf.extend_from_slice(&to_u32(body.len()));
}
for (_, body) in cases {
buf.extend_from_slice(body);
}
buf
}
pub fn encode_try(main_ops: &[u8], fallback_ops: &[u8]) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x0D); buf.extend_from_slice(&to_u32(fallback_ops.len()));
buf.extend_from_slice(main_ops);
buf.push(0x0E); buf.extend_from_slice(fallback_ops);
buf
}
pub fn encode_preload(resource_type: u8, url_str_idx: u32) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x0F); buf.push(resource_type);
buf.extend_from_slice(&url_str_idx.to_le_bytes());
buf
}
pub fn build_minimal_ir(
strings: &[&str],
slots: &[(u16, u32, u8, u8, &[u8])],
opcodes: &[u8],
islands: &[(u16, u8, u8, u32, u32, &[u16])],
) -> Vec<u8> {
let string_section = build_string_table(strings);
let slot_section = build_slot_table(slots);
let island_section = build_island_table(islands);
let data_start = HEADER_SIZE + SECTION_TABLE_SIZE;
let bytecode_offset = data_start;
let bytecode_size = opcodes.len();
let string_offset = bytecode_offset + bytecode_size;
let string_size = string_section.len();
let slot_offset = string_offset + string_size;
let slot_size = slot_section.len();
let island_offset = slot_offset + slot_size;
let island_size = island_section.len();
let mut buf = Vec::new();
buf.extend_from_slice(b"FMIR");
buf.extend_from_slice(&2u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u64.to_le_bytes());
buf.extend_from_slice(&to_u32(bytecode_offset));
buf.extend_from_slice(&to_u32(bytecode_size));
buf.extend_from_slice(&to_u32(string_offset));
buf.extend_from_slice(&to_u32(string_size));
buf.extend_from_slice(&to_u32(slot_offset));
buf.extend_from_slice(&to_u32(slot_size));
buf.extend_from_slice(&to_u32(island_offset));
buf.extend_from_slice(&to_u32(island_size));
buf.extend_from_slice(opcodes);
buf.extend_from_slice(&string_section);
buf.extend_from_slice(&slot_section);
buf.extend_from_slice(&island_section);
buf
}
}
#[cfg(test)]
mod tests {
use super::test_helpers::*;
use super::*;
use crate::format::{IrError, IslandTrigger, PropsMode, SlotSource, SlotType};
#[test]
fn parse_string_table() {
let data = build_string_table(&["div", "class", "container"]);
let table = StringTable::parse(&data).unwrap();
assert_eq!(table.len(), 3);
assert_eq!(table.get(0).unwrap(), "div");
assert_eq!(table.get(1).unwrap(), "class");
assert_eq!(table.get(2).unwrap(), "container");
let err = table.get(3).unwrap_err();
assert_eq!(err, IrError::StringIndexOutOfBounds { index: 3, len: 3 });
}
#[test]
fn parse_string_table_empty() {
let data = build_string_table(&[]);
let table = StringTable::parse(&data).unwrap();
assert_eq!(table.len(), 0);
}
#[test]
fn parse_string_table_unicode() {
let data = build_string_table(&["héllo"]);
let table = StringTable::parse(&data).unwrap();
assert_eq!(table.len(), 1);
assert_eq!(table.get(0).unwrap(), "héllo");
}
#[test]
fn parse_string_table_truncated() {
let mut data = Vec::new();
data.extend_from_slice(&2u32.to_le_bytes()); data.extend_from_slice(&3u16.to_le_bytes()); data.extend_from_slice(b"div");
let err = StringTable::parse(&data).unwrap_err();
match err {
IrError::BufferTooShort { .. } => {} other => panic!("expected BufferTooShort, got {other:?}"),
}
}
#[test]
fn parse_slot_table() {
let data = build_slot_table(&[
(1, 0, 0x01, 0x00, &[]), (2, 1, 0x03, 0x01, &[0x42]), ]);
let table = SlotTable::parse(&data).unwrap();
assert_eq!(table.len(), 2);
let entries = table.entries();
assert_eq!(entries[0].slot_id, 1);
assert_eq!(entries[0].name_str_idx, 0);
assert_eq!(entries[0].type_hint, SlotType::Text);
assert_eq!(entries[0].source, SlotSource::Server);
assert_eq!(entries[0].default_bytes, Vec::<u8>::new());
assert_eq!(entries[1].slot_id, 2);
assert_eq!(entries[1].name_str_idx, 1);
assert_eq!(entries[1].type_hint, SlotType::Number);
assert_eq!(entries[1].source, SlotSource::Client);
assert_eq!(entries[1].default_bytes, vec![0x42]);
}
#[test]
fn parse_slot_table_empty() {
let data = build_slot_table(&[]);
let table = SlotTable::parse(&data).unwrap();
assert_eq!(table.len(), 0);
}
#[test]
fn parse_island_table() {
let data = build_island_table(&[
(1, 0x02, 0x01, 5, 0, &[]), ]);
let table = IslandTableParsed::parse(&data).unwrap();
assert_eq!(table.len(), 1);
let entry = &table.entries()[0];
assert_eq!(entry.id, 1);
assert_eq!(entry.trigger, IslandTrigger::Visible);
assert_eq!(entry.props_mode, PropsMode::Inline);
assert_eq!(entry.name_str_idx, 5);
assert_eq!(entry.slot_ids, Vec::<u16>::new());
}
#[test]
fn parse_island_table_with_slot_ids() {
let data = build_island_table(&[
(0, 0x01, 0x01, 0, 0, &[0, 1]), ]);
let table = IslandTableParsed::parse(&data).unwrap();
assert_eq!(table.len(), 1);
let entry = &table.entries()[0];
assert_eq!(entry.id, 0);
assert_eq!(entry.trigger, IslandTrigger::Load);
assert_eq!(entry.props_mode, PropsMode::Inline);
assert_eq!(entry.slot_ids, vec![0, 1]);
}
#[test]
fn parse_island_table_empty() {
let data = build_island_table(&[]);
let table = IslandTableParsed::parse(&data).unwrap();
assert_eq!(table.len(), 0);
}
#[test]
fn parse_minimal_ir_file() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
let data = build_minimal_ir(&["div"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
assert_eq!(module.header.version, 2);
assert_eq!(module.strings.get(0).unwrap(), "div");
assert_eq!(module.strings.len(), 1);
assert_eq!(module.slots.len(), 0);
assert_eq!(module.islands.len(), 0);
assert_eq!(module.opcodes.len(), opcodes.len());
}
#[test]
fn parse_ir_with_slots() {
let opcodes = encode_text(0);
let data = build_minimal_ir(
&["greeting", "count", "Hello"],
&[
(1, 0, 0x01, 0x00, &[]), (2, 1, 0x03, 0x00, &[]), ],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
assert_eq!(module.slots.len(), 2);
let entries = module.slots.entries();
assert_eq!(entries[0].slot_id, 1);
assert_eq!(entries[0].name_str_idx, 0);
assert_eq!(entries[0].type_hint, SlotType::Text);
assert_eq!(entries[1].slot_id, 2);
assert_eq!(entries[1].name_str_idx, 1);
assert_eq!(entries[1].type_hint, SlotType::Number);
}
#[test]
fn parse_ir_with_islands() {
let mut opcodes = vec![0x0B]; opcodes.extend_from_slice(&1u16.to_le_bytes());
opcodes.extend_from_slice(&encode_text(1));
opcodes.push(0x0C); opcodes.extend_from_slice(&1u16.to_le_bytes());
let data = build_minimal_ir(
&["Counter", "Hello"],
&[],
&opcodes,
&[
(1, 0x01, 0x01, 0, 0, &[]), ],
);
let module = IrModule::parse(&data).unwrap();
assert_eq!(module.islands.len(), 1);
let entry = &module.islands.entries()[0];
assert_eq!(entry.id, 1);
assert_eq!(entry.trigger, IslandTrigger::Load);
assert_eq!(entry.props_mode, PropsMode::Inline);
assert_eq!(entry.name_str_idx, 0);
assert_eq!(module.strings.get(entry.name_str_idx).unwrap(), "Counter");
}
#[test]
fn parse_ir_rejects_truncated() {
let data = b"FMIR\x02\x00";
let err = IrModule::parse(data).unwrap_err();
match err {
IrError::BufferTooShort {
expected: 16,
actual: 6,
} => {}
other => panic!("expected BufferTooShort(16, 6), got {other:?}"),
}
}
#[test]
fn parse_ir_rejects_bad_section_bounds() {
let opcodes = encode_text(0);
let mut data = build_minimal_ir(&["x"], &[], &opcodes, &[]);
let big_size: u32 = 99999;
data[44..48].copy_from_slice(&big_size.to_le_bytes());
let err = IrModule::parse(&data).unwrap_err();
match err {
IrError::SectionOutOfBounds { section: 3, .. } => {}
other => panic!("expected SectionOutOfBounds for section 3, got {other:?}"),
}
}
#[test]
fn validate_catches_bad_slot_str_idx() {
let opcodes = encode_text(0);
let data = build_minimal_ir(
&["a", "b", "c"],
&[(1, 99, 0x01, 0x00, &[])], &opcodes,
&[],
);
let err = IrModule::parse(&data).unwrap_err();
assert_eq!(err, IrError::StringIndexOutOfBounds { index: 99, len: 3 });
}
#[test]
fn validate_catches_bad_island_str_idx() {
let opcodes = encode_text(0);
let mut island_opcodes = vec![0x0B];
island_opcodes.extend_from_slice(&1u16.to_le_bytes());
island_opcodes.extend_from_slice(&opcodes);
island_opcodes.push(0x0C);
island_opcodes.extend_from_slice(&1u16.to_le_bytes());
let data = build_minimal_ir(
&["a", "b", "c"],
&[],
&island_opcodes,
&[(1, 0x01, 0x01, 99, 0, &[])],
);
let err = IrModule::parse(&data).unwrap_err();
assert_eq!(err, IrError::StringIndexOutOfBounds { index: 99, len: 3 });
}
#[test]
fn duplicate_slot_names_resolve_first_wins_everywhere() {
let opcodes = encode_text(0);
let data = build_minimal_ir(
&["dup"],
&[(0, 0, 0x01, 0x00, &[]), (1, 0, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
assert_eq!(module.slot_id_by_name("dup"), Some(0));
let slots = crate::slot::SlotData::from_json(r#"{"dup":"x"}"#, &module).unwrap();
assert_eq!(slots.get(0).to_text(), "x", "the first slot must be filled");
assert_eq!(slots.get(1).to_text(), "", "the second must stay untouched");
}
#[test]
fn validate_rejects_list_item_slot_not_in_table() {
let body = {
let mut b = vec![0x05]; b.extend_from_slice(&7u16.to_le_bytes());
b.extend_from_slice(&0u16.to_le_bytes());
b
};
let opcodes = encode_list(0, 7, &body);
let data = build_minimal_ir(&["items"], &[(0, 0, 0x04, 0x00, &[])], &opcodes, &[]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::UnknownSlotId(7)
);
}
#[test]
fn validate_rejects_island_start_missing_from_table() {
let mut opcodes = vec![0x0B]; opcodes.extend_from_slice(&3u16.to_le_bytes());
opcodes.extend_from_slice(&encode_text(0));
opcodes.push(0x0C); opcodes.extend_from_slice(&3u16.to_le_bytes());
let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::UnknownIslandId(3)
);
}
#[test]
fn string_table_rejects_count_the_section_cannot_hold() {
let mut data = Vec::new();
data.extend_from_slice(&1_000_000u32.to_le_bytes()); data.extend_from_slice(&[0u8; 16]); let err = StringTable::parse(&data).unwrap_err();
assert!(matches!(err, IrError::BufferTooShort { .. }));
}
#[test]
fn slot_table_rejects_count_the_section_cannot_hold() {
let data = 65535u16.to_le_bytes().to_vec();
let err = SlotTable::parse(&data).unwrap_err();
assert!(matches!(err, IrError::BufferTooShort { .. }));
}
#[test]
fn island_table_rejects_count_the_section_cannot_hold() {
let data = 65535u16.to_le_bytes().to_vec();
let err = IslandTableParsed::parse(&data).unwrap_err();
assert!(matches!(err, IrError::BufferTooShort { .. }));
}
#[test]
fn validate_rejects_duplicate_slot_ids() {
let opcodes = encode_text(0);
let data = build_minimal_ir(
&["a", "b"],
&[(0, 0, 0x01, 0x00, &[]), (0, 1, 0x01, 0x00, &[])],
&opcodes,
&[],
);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::DuplicateSlotId(0)
);
}
#[test]
fn validate_rejects_duplicate_island_ids() {
let mut opcodes = vec![0x0B];
opcodes.extend_from_slice(&0u16.to_le_bytes());
opcodes.push(0x0C);
opcodes.extend_from_slice(&0u16.to_le_bytes());
let data = build_minimal_ir(
&["Comp"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, 0, &[]), (0, 0x01, 0x01, 0, 0, &[])],
);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::DuplicateIslandId(0)
);
}
#[test]
fn validate_rejects_island_slot_id_absent_from_slot_table() {
let mut opcodes = vec![0x0B];
opcodes.extend_from_slice(&0u16.to_le_bytes());
opcodes.push(0x0C);
opcodes.extend_from_slice(&0u16.to_le_bytes());
let data = build_minimal_ir(&["Comp"], &[], &opcodes, &[(0, 0x01, 0x01, 0, 0, &[7])]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::UnknownSlotId(7)
);
}
#[test]
fn validate_rejects_island_offset_landing_mid_operand() {
let mut opcodes = vec![0x0B];
opcodes.extend_from_slice(&1u16.to_le_bytes());
opcodes.push(0x04);
opcodes.extend_from_slice(&0x0Bu32.to_le_bytes());
opcodes.push(0x0C);
opcodes.extend_from_slice(&1u16.to_le_bytes());
let data = build_minimal_ir(&["Comp"], &[], &opcodes, &[(1, 0x01, 0x01, 0, 4, &[])]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::IslandOffsetMismatch {
island_id: 1,
byte_offset: 4
}
);
}
#[test]
fn validate_rejects_opcode_slot_id_absent_from_slot_table() {
let mut opcodes = vec![0x05];
opcodes.extend_from_slice(&9u16.to_le_bytes());
opcodes.extend_from_slice(&0u16.to_le_bytes());
let data = build_minimal_ir(&["a"], &[], &opcodes, &[]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::UnknownSlotId(9)
);
}
#[test]
fn slot_and_island_lookups_are_indexed() {
let mut opcodes = vec![0x0B];
opcodes.extend_from_slice(&3u16.to_le_bytes());
opcodes.push(0x0C);
opcodes.extend_from_slice(&3u16.to_le_bytes());
let data = build_minimal_ir(
&["Comp", "title"],
&[(4, 1, 0x01, 0x00, &[])],
&opcodes,
&[(3, 0x01, 0x01, 0, 0, &[4])],
);
let module = IrModule::parse(&data).unwrap();
assert_eq!(module.slot_entry(4).unwrap().slot_id, 4);
assert!(module.slot_entry(0).is_none());
assert_eq!(module.island_entry(3).unwrap().id, 3);
assert!(module.island_entry(0).is_none());
assert!(module.slot_entry(u16::MAX).is_none());
assert!(module.island_entry(u16::MAX).is_none());
}
}