pub use crate::contracts::state::IslandState;
use crate::contracts::state::StateBuilder;
#[cfg(feature = "tracing")]
use crate::format::SlotSource;
use crate::format::{IrError, IslandTrigger, ListKeyReason, ListKeyViolation, Opcode};
use crate::parser::IrModule;
use crate::slot::{SlotData, SlotValue};
use crate::text::{normalize_text, normalized_chars};
use std::collections::HashMap;
use std::fmt;
use std::rc::Rc;
struct IslandPending {
id: u16,
component_name: String,
occurrence: usize,
hash: String,
trigger: IslandTrigger,
}
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WalkLimits {
pub max_output_bytes: usize,
pub max_list_iterations: usize,
}
impl Default for WalkLimits {
fn default() -> Self {
WalkLimits {
max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
}
}
}
struct KeyedRow {
list_offset: u32,
ordinal: usize,
path: Rc<str>,
key: Option<Rc<str>>,
entry_depth: usize,
roots: usize,
stray_content: bool,
envelope: Option<u16>,
}
impl KeyedRow {
fn refuse(&self, reason: ListKeyReason) -> IrError {
list_key_error(self.list_offset, self.ordinal, &self.path, reason)
}
}
struct WalkState {
list_depth: u8,
pending_island: Option<IslandPending>,
element_depth: usize,
keyed_rows: Vec<KeyedRow>,
open_tag_is_row_root: bool,
island_state: StateBuilder,
pending_tag_close: bool,
pending_tag_name: String,
pre_boundary: Option<usize>,
max_output_bytes: usize,
list_iterations: usize,
max_list_iterations: usize,
slot_journal: Vec<(u16, SlotValue)>,
text_only: Option<TextOnlyParent>,
pending_control: PendingControl,
select: Option<SelectBinding>,
option_by_text: Option<OptionByText>,
nodes: usize,
islands: Vec<u16>,
#[cfg(any(test, feature = "__fault-injection"))]
fault_armed: bool,
}
impl WalkState {
fn new(limits: WalkLimits) -> Self {
WalkState {
list_depth: 0,
pending_island: None,
element_depth: 0,
keyed_rows: Vec::new(),
open_tag_is_row_root: false,
island_state: StateBuilder::new(),
pending_tag_close: false,
pending_tag_name: String::new(),
pre_boundary: None,
max_output_bytes: limits.max_output_bytes,
list_iterations: 0,
max_list_iterations: limits.max_list_iterations,
slot_journal: Vec::new(),
text_only: None,
pending_control: PendingControl::None,
select: None,
option_by_text: None,
nodes: 0,
islands: Vec::new(),
#[cfg(any(test, feature = "__fault-injection"))]
fault_armed: false,
}
}
fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
if self.list_depth > 0 {
self.slot_journal.push((slot_id, previous));
}
}
fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
while self.slot_journal.len() > mark {
if let Some((slot_id, previous)) = self.slot_journal.pop() {
slots.set(slot_id, previous);
}
}
}
fn keyed_row_at_entry_depth(&mut self) -> Option<&mut KeyedRow> {
let depth = self.element_depth;
self.keyed_rows
.last_mut()
.filter(|row| row.entry_depth == depth)
}
fn open_row_root(&mut self) -> Option<Rc<str>> {
let row = self.keyed_row_at_entry_depth()?;
row.roots += 1;
row.key.take()
}
fn note_row_level_content(&mut self) {
if let Some(row) = self.keyed_row_at_entry_depth() {
row.stray_content = true;
}
}
fn note_row_level_island_start(&mut self, island_id: u16) {
if let Some(row) = self.keyed_row_at_entry_depth() {
if row.roots == 0 && row.envelope.is_none() && !row.stray_content {
row.envelope = Some(island_id);
} else {
row.stray_content = true;
}
}
}
fn note_row_level_island_end(&mut self, island_id: u16) {
if let Some(row) = self.keyed_row_at_entry_depth() {
if row.envelope == Some(island_id) && row.roots == 1 {
row.envelope = None;
} else {
row.stray_content = true;
}
}
}
fn closes_outside_keyed_row(&self) -> bool {
self.keyed_rows
.last()
.is_some_and(|row| row.entry_depth == self.element_depth)
}
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
#[cfg(feature = "tracing")]
if let Some(entry) = module.slot_entry(slot_id) {
if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
tracing::warn!(
slot_id = slot_id,
"Server-sourced slot has no value at render time — handler may have a bug"
);
}
}
}
pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
walk_to_html_with_limits(module, slots, WalkLimits::default())
}
pub fn walk_to_html_with_limits(
module: &IrModule,
slots: &SlotData,
limits: WalkLimits,
) -> Result<String, IrError> {
let ops = &module.opcodes;
let len = ops.len();
let mut out = String::with_capacity(initial_capacity(len));
let mut state = WalkState::new(limits);
let mut slots_mut = slots.clone();
slots_mut.apply_module_constants(module);
walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
Ok(out)
}
fn initial_capacity(opcodes_len: usize) -> usize {
opcodes_len.saturating_mul(4).min(1 << 20)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkCounts {
pub nodes: usize,
pub bytes: usize,
pub list_iterations: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkOutput {
pub html: String,
pub island_state: IslandState,
pub counts: WalkCounts,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkFailure {
pub error: IrError,
pub component_path: Vec<String>,
pub counts: WalkCounts,
}
impl fmt::Display for WalkFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.error)?;
if !self.component_path.is_empty() {
write!(f, " (in {})", self.component_path.join(" > "))?;
}
Ok(())
}
}
impl std::error::Error for WalkFailure {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
pub fn walk_to_html_counted(
module: &IrModule,
slots: &SlotData,
limits: WalkLimits,
) -> Result<WalkOutput, WalkFailure> {
let ops = &module.opcodes;
let len = ops.len();
let mut out = String::with_capacity(initial_capacity(len));
let mut state = WalkState::new(limits);
#[cfg(any(test, feature = "__fault-injection"))]
{
state.fault_armed = true;
}
let mut slots_mut = slots.clone();
slots_mut.apply_module_constants(module);
let walked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)
}));
let counts = WalkCounts {
nodes: state.nodes,
bytes: out.len(),
list_iterations: state.list_iterations,
};
let error = match walked {
Ok(Ok(())) => match state.island_state.finish() {
Ok(island_state) => {
return Ok(WalkOutput {
html: out,
island_state,
counts,
})
}
Err(error) => {
return Err(WalkFailure {
error,
component_path: Vec::new(),
counts,
})
}
},
Ok(Err(error)) => error,
Err(payload) => IrError::Panicked {
message: panic_message(payload.as_ref()),
},
};
let component_path = state
.islands
.iter()
.map(|&id| {
module
.island_entry(id)
.and_then(|entry| module.strings.get(entry.name_str_idx).ok())
.map_or_else(|| format!("island {id}"), str::to_string)
})
.collect();
Err(WalkFailure {
error,
component_path,
counts,
})
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"a panic with no message".to_string()
}
}
#[allow(dead_code)] pub(crate) fn walk_island(
module: &IrModule,
slots: &SlotData,
island_id: u16,
) -> Result<String, IrError> {
walk_island_with_limits(module, slots, island_id, WalkLimits::default())
}
#[allow(dead_code)]
pub(crate) fn walk_island_with_limits(
module: &IrModule,
slots: &SlotData,
island_id: u16,
limits: WalkLimits,
) -> Result<String, IrError> {
let entry = module
.island_entry(island_id)
.ok_or(IrError::IslandNotFound(island_id))?;
let byte_offset = entry.byte_offset as usize;
let ops = &module.opcodes;
if byte_offset >= ops.len() {
return Err(IrError::BufferTooShort {
expected: byte_offset.saturating_add(1),
actual: ops.len(),
});
}
let opcode = Opcode::from_byte(ops[byte_offset])?;
if opcode != Opcode::IslandStart {
return Err(IrError::InvalidOpcode(ops[byte_offset]));
}
let content_start = byte_offset.saturating_add(3);
if content_start > ops.len() {
return Err(IrError::BufferTooShort {
expected: content_start,
actual: ops.len(),
});
}
let mut out = String::with_capacity(256);
let mut state = WalkState::new(limits);
let mut slots_mut = slots.clone();
slots_mut.apply_module_constants(module);
let component_name = module.strings.get(entry.name_str_idx)?.to_string();
let (occurrence, hash) = state.island_state.snapshot(module, entry, &slots_mut)?;
state.pending_island = Some(IslandPending {
id: island_id,
component_name,
occurrence,
hash,
trigger: entry.trigger,
});
walk_range_until_island_end(
module,
&mut slots_mut,
ops,
content_start,
island_id,
&mut out,
&mut state,
0,
)?;
Ok(out)
}
const MAX_LIST_DEPTH: u8 = crate::format::MAX_LIST_DEPTH as u8;
const MAX_RECURSION_DEPTH: usize = crate::format::MAX_CONTROL_DEPTH;
enum WalkMode {
Range { end: usize },
UntilIslandEnd { target_island_id: u16 },
}
#[allow(clippy::too_many_arguments)]
fn walk_range(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
end: usize,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::Range { end },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_until_island_end(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
target_island_id: u16,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::UntilIslandEnd { target_island_id },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_impl(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
mode: WalkMode,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
if depth > MAX_RECURSION_DEPTH {
return Err(IrError::RecursionLimitExceeded);
}
let strings = &module.strings;
let mut pos = start;
let limit = state.max_output_bytes;
let loop_bound = match mode {
WalkMode::Range { end } => end,
WalkMode::UntilIslandEnd { .. } => usize::MAX, };
let region_limit = match mode {
WalkMode::Range { end } => end.min(ops.len()),
WalkMode::UntilIslandEnd { .. } => ops.len(),
};
while pos < loop_bound {
if out.len() > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
if pos >= ops.len() {
match mode {
WalkMode::Range { .. } => {
return Err(IrError::BufferTooShort {
expected: pos + 1,
actual: ops.len(),
});
}
WalkMode::UntilIslandEnd { .. } => break, }
}
let opcode = Opcode::from_byte(ops[pos])?;
pos += 1;
if state.pending_tag_close && opcode != Opcode::DynAttr {
close_start_tag(out, state, limit)?;
}
match opcode {
Opcode::OpenTag | Opcode::VoidTag => {
let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
let tag = strings.get(tag_str_idx)?;
let has_content = opcode == Opcode::OpenTag;
emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
state.nodes += 1;
pos = new_pos;
}
Opcode::CloseTag => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let tag = strings.get(str_idx)?;
if !is_valid_name(tag) {
return Err(IrError::InvalidName(tag.to_string()));
}
if state.closes_outside_keyed_row() {
return Err(innermost_row_error(
state,
ListKeyReason::UnbalancedRow,
IrError::InvalidSection,
));
}
leave_element(out, tag, state, limit)?;
state.element_depth = state.element_depth.saturating_sub(1);
push_checked(out, "</", limit)?;
push_checked(out, tag, limit)?;
push_checked(out, ">", limit)?;
}
Opcode::Text => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let text = strings.get(str_idx)?;
state.note_row_level_content();
if let Some(parent) = state.text_only.as_mut() {
if !parent.value_bound {
write_text_only(out, parent, text, limit)?;
}
} else {
if state.pre_boundary == Some(out.len())
&& normalized_chars(text).next() == Some('\n')
{
push_checked(out, "\n", limit)?;
}
push_escaped_html(out, text, limit)?;
if let Some(option) = state.option_by_text.as_mut() {
option.text.extend(normalized_chars(text));
}
}
state.nodes += 1;
}
Opcode::DynText => {
let slot_id = read_u16(ops, pos)?;
pos += 2;
let marker_id = read_u16(ops, pos)?;
pos += 2;
check_slot_source(module, slot_id, slots.get(slot_id));
state.note_row_level_content();
let value = slots.get(slot_id).to_text();
#[cfg(any(test, feature = "__fault-injection"))]
#[allow(clippy::panic)]
if state.fault_armed && value == crate::fault_injection::FAULT_TRIGGER {
panic!("stand-in component panicked (FAULT_TRIGGER)");
}
if let Some(parent) = state.text_only.as_mut() {
if !parent.value_bound {
write_text_only(out, parent, &value, limit)?;
}
} else {
push_checked(out, "<!--f:t", limit)?;
push_u16(out, marker_id, limit)?;
push_checked(out, "-->", limit)?;
let written = if value.is_empty() {
"\u{200B}"
} else {
value.as_str()
};
push_escaped_html(out, written, limit)?;
push_checked(out, "<!--/f:t", limit)?;
push_u16(out, marker_id, limit)?;
push_checked(out, "-->", limit)?;
if let Some(option) = state.option_by_text.as_mut() {
option.text.extend(normalized_chars(written));
}
}
state.nodes += 1;
}
Opcode::DynAttr => {
let attr_str_idx = read_u32(ops, pos)?;
let slot_id = read_u16(ops, pos + 4)?;
pos += 6;
check_slot_source(module, slot_id, slots.get(slot_id));
if state.pending_tag_close {
let attr_name = strings.get(attr_str_idx)?;
if state.open_tag_is_row_root
&& attr_name.eq_ignore_ascii_case("data-forma-key")
{
return Err(innermost_row_error(
state,
ListKeyReason::AuthoredKey,
IrError::InvalidName(attr_name.to_string()),
));
}
if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
continue;
}
let text;
let value = match slots.get(slot_id) {
SlotValue::Bool(false) | SlotValue::Null => None,
other if is_html_boolean_attribute(attr_name) => {
other.as_bool().then_some(AttrValue::Bare)
}
SlotValue::Bool(true) => Some(AttrValue::Valued("true")),
other => {
text = other.to_text();
Some(AttrValue::Valued(text.as_str()))
}
};
if let Some(value) = value {
let tag = std::mem::take(&mut state.pending_tag_name);
emit_attr(out, &tag, attr_name, value, limit)?;
state.pending_tag_name = tag;
}
}
}
Opcode::IslandStart => {
let island_id = read_u16(ops, pos)?;
pos += 2;
if let Some(outer) = &state.pending_island {
return Err(IrError::NestedIslandRoot {
outer: outer.id,
inner: island_id,
});
}
state.islands.push(island_id);
state.note_row_level_island_start(island_id);
if state.text_only.is_none() {
push_checked(out, "<!--f:i", limit)?;
push_u16(out, island_id, limit)?;
push_checked(out, "-->", limit)?;
}
if let Some(entry) = module.island_entry(island_id) {
let component_name = strings.get(entry.name_str_idx)?.to_string();
let (occurrence, hash) = state.island_state.snapshot(module, entry, slots)?;
state.pending_island = Some(IslandPending {
id: island_id,
component_name,
occurrence,
hash,
trigger: entry.trigger,
});
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
island_id = island_id,
"ISLAND_START names an island that is not in the island table — \
markers emitted but no data-forma-* attributes, so it will not hydrate \
(module was not validated)"
);
}
}
Opcode::IslandEnd => {
let island_id = read_u16(ops, pos)?;
pos += 2;
if state.islands.last() == Some(&island_id) {
state.islands.pop();
}
state.note_row_level_island_end(island_id);
if state.pending_island.take().is_some() {
#[cfg(feature = "tracing")]
tracing::warn!(
island_id = island_id,
"ISLAND_START was not followed by an element — island attributes dropped, \
this island will not hydrate (compiler should emit a shell element)"
);
}
if let WalkMode::UntilIslandEnd { target_island_id } = mode {
if island_id == target_island_id {
if state.pending_tag_close {
close_start_tag(out, state, limit)?;
}
return Ok(());
}
}
if state.text_only.is_none() {
push_checked(out, "<!--/f:i", limit)?;
push_u16(out, island_id, limit)?;
push_checked(out, "-->", limit)?;
}
}
Opcode::ShowIf => {
let slot_id = read_u16(ops, pos)?;
let then_len = read_u32(ops, pos + 2)? as usize;
let else_len = read_u32(ops, pos + 6)? as usize;
pos += 10;
state.note_row_level_content();
check_slot_source(module, slot_id, slots.get(slot_id));
let condition = slots.get(slot_id).as_bool();
let then_start = pos;
let then_end = bounded_offset(pos, then_len, region_limit)?;
if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
return Err(IrError::InvalidSection);
}
let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
let markers = state.text_only.is_none();
if markers {
push_checked(out, "<!--f:s", limit)?;
push_u16(out, slot_id, limit)?;
push_checked(out, "-->", limit)?;
}
if condition {
walk_range(
module,
slots,
ops,
then_start,
then_end,
out,
state,
depth + 1,
)?;
} else {
walk_range(
module,
slots,
ops,
else_start,
else_end,
out,
state,
depth + 1,
)?;
}
if markers {
push_checked(out, "<!--/f:s", limit)?;
push_u16(out, slot_id, limit)?;
push_checked(out, "-->", limit)?;
}
pos = else_end;
}
Opcode::ShowElse => {
}
Opcode::List => {
let list_offset = (pos - 1) as u32;
let slot_id = read_u16(ops, pos)?;
let item_slot_id = read_u16(ops, pos + 2)?;
let body_len = read_u32(ops, pos + 4)? as usize;
pos += 8;
check_slot_source(module, slot_id, slots.get(slot_id));
state.note_row_level_content();
let body_start = pos;
let body_end = bounded_offset(pos, body_len, region_limit)?;
let key_path = match module
.key_paths
.binary_search_by_key(&list_offset, |path| path.list_offset)
{
Ok(index) => Some(ListKey::resolve(
list_offset,
&module.key_paths[index].segments,
strings,
)?),
Err(_) => None,
};
let list_marker_id = slot_id;
let markers = state.text_only.is_none();
if markers {
push_checked(out, "<!--f:l", limit)?;
push_u16(out, list_marker_id, limit)?;
push_checked(out, "-->", limit)?;
}
let items = match slots.get(slot_id) {
SlotValue::Array(a) => Some(a.clone()),
_ => None,
};
if let Some(items) = items.filter(|a| !a.is_empty()) {
state.list_depth += 1;
let walked = if state.list_depth > MAX_LIST_DEPTH {
Err(IrError::ListDepthExceeded {
max: MAX_LIST_DEPTH,
})
} else {
walk_list_rows(
module,
slots,
ops,
ListRows {
items: &items,
item_slot_id,
body_start,
body_end,
key: key_path.as_ref(),
},
out,
state,
depth,
)
};
state.list_depth -= 1;
walked?;
}
if markers {
push_checked(out, "<!--/f:l", limit)?;
push_u16(out, list_marker_id, limit)?;
push_checked(out, "-->", limit)?;
}
pos = body_end;
}
Opcode::Coalesce => {
let source = read_u16(ops, pos)?;
let fallback = read_u16(ops, pos + 2)?;
let destination = read_u16(ops, pos + 4)?;
pos += 6;
let chosen = if matches!(slots.get(source), SlotValue::Null) {
slots.get(fallback)
} else {
slots.get(source)
};
let previous = slots.replace(destination, chosen.clone());
state.journal_write(destination, previous);
}
Opcode::Prop => {
let src_slot_id = read_u16(ops, pos)?;
let prop_str_idx = read_u32(ops, pos + 2)?;
let target_slot_id = read_u16(ops, pos + 6)?;
pos += 8;
let prop_name = strings.get(prop_str_idx)?;
let value = slots.get(src_slot_id).get_property(prop_name);
let previous = slots.replace(target_slot_id, value);
state.journal_write(target_slot_id, previous);
}
}
}
match mode {
WalkMode::Range { .. } => {
if state.pending_tag_close {
close_start_tag(out, state, limit)?;
}
Ok(())
}
WalkMode::UntilIslandEnd { target_island_id } => {
Err(IrError::IslandNotFound(target_island_id))
}
}
}
struct ListKey<'m> {
list_offset: u32,
segments: Vec<&'m str>,
path: Rc<str>,
}
impl<'m> ListKey<'m> {
fn resolve(
list_offset: u32,
segments: &[u32],
strings: &'m crate::parser::StringTable,
) -> Result<Self, IrError> {
let segments = segments
.iter()
.map(|segment| strings.get(*segment))
.collect::<Result<Vec<&str>, IrError>>()?;
let path = Rc::from(dotted_path(&segments));
Ok(ListKey {
list_offset,
segments,
path,
})
}
}
fn dotted_path(segments: &[&str]) -> String {
let mut path = String::new();
for segment in segments {
if segment.is_empty() || segment.contains(['.', '[', ']']) {
path.push_str(&format!("[{segment:?}]"));
} else {
if !path.is_empty() {
path.push('.');
}
path.push_str(segment);
}
}
path
}
struct ListRows<'a, 'm> {
items: &'a [SlotValue],
item_slot_id: u16,
body_start: usize,
body_end: usize,
key: Option<&'a ListKey<'m>>,
}
fn walk_list_rows(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
rows: ListRows<'_, '_>,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
let limit = state.max_output_bytes;
let remaining = state
.max_list_iterations
.saturating_sub(state.list_iterations);
if rows.items.len() > remaining {
return Err(IrError::ListBudgetExceeded {
max: state.max_list_iterations,
});
}
let mut seen_keys: HashMap<Rc<str>, usize> = HashMap::new();
for (ordinal, item) in rows.items.iter().enumerate() {
if state.list_iterations >= state.max_list_iterations {
return Err(IrError::ListBudgetExceeded {
max: state.max_list_iterations,
});
}
state.list_iterations += 1;
if let Some(key) = rows.key {
let refuse = |reason| list_key_error(key.list_offset, ordinal, &key.path, reason);
let value = crate::keys::key_value_at(item, &key.segments).map_err(refuse)?;
if crate::keys::normalized_len_exceeds(value, limit.saturating_sub(out.len())) {
return Err(IrError::OutputLimitExceeded { limit });
}
let text: Rc<str> = crate::keys::canonical_key_text(value)
.map_err(refuse)?
.into();
if let Some(&first_row) = seen_keys.get(&text) {
return Err(refuse(ListKeyReason::Duplicate { first_row }));
}
seen_keys.insert(Rc::clone(&text), ordinal);
state.keyed_rows.push(KeyedRow {
list_offset: key.list_offset,
ordinal,
path: Rc::clone(&key.path),
key: Some(text),
entry_depth: state.element_depth,
roots: 0,
stray_content: false,
envelope: None,
});
}
let mark = state.slot_journal.len();
let previous = slots.replace(rows.item_slot_id, item.clone());
state.slot_journal.push((rows.item_slot_id, previous));
let walked = walk_range(
module,
slots,
ops,
rows.body_start,
rows.body_end,
out,
state,
depth + 1,
);
state.unwind_slot_journal(slots, mark);
let keyed_row = if rows.key.is_some() {
state.keyed_rows.pop()
} else {
None
};
walked?;
if let Some(row) = keyed_row {
if state.element_depth != row.entry_depth {
return Err(row.refuse(ListKeyReason::UnbalancedRow));
}
if row.roots != 1 || row.stray_content || row.envelope.is_some() {
return Err(row.refuse(ListKeyReason::RowShape {
roots: row.roots,
stray_content: row.stray_content || row.envelope.is_some(),
}));
}
}
}
Ok(())
}
fn list_key_error(list_offset: u32, row: usize, path: &str, reason: ListKeyReason) -> IrError {
IrError::InvalidListKey(Box::new(ListKeyViolation::new(
list_offset,
row,
path,
reason,
)))
}
fn innermost_row_error(state: &WalkState, reason: ListKeyReason, fallback: IrError) -> IrError {
match state.keyed_rows.last() {
Some(row) => row.refuse(reason),
None => fallback,
}
}
fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
base.checked_add(len)
.filter(|e| *e <= limit)
.ok_or(IrError::InvalidSection)
}
fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
}
fn is_valid_attr_name(name: &str) -> bool {
is_valid_name(name)
&& name
.strip_prefix("xlink:")
.map_or(true, |local| is_valid_name(local) && !local.contains(':'))
}
fn is_event_handler_attr(name: &str) -> bool {
name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
}
const URL_ATTRS: [&str; 8] = [
"href",
"src",
"action",
"formaction",
"xlink:href",
"poster",
"background",
"data",
];
const IMAGE_CONTEXT_TAGS: [&str; 13] = [
"img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
"td", "th",
];
fn is_url_attr(name: &str) -> bool {
URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
}
fn is_dangerous_url(value: &str, tag: &str) -> bool {
if value.contains('\0') {
return true;
}
let normalized: String = value
.chars()
.filter(|c| {
let n = *c as u32;
!((n <= 0x20) || (0x7F..=0x9F).contains(&n))
})
.collect();
let lower = normalized.to_ascii_lowercase();
if lower.starts_with("javascript") || lower.starts_with("vbscript") {
return true;
}
if lower.starts_with("data:text/html") {
return true;
}
if lower.starts_with("data:image/svg+xml") {
return !IMAGE_CONTEXT_TAGS
.iter()
.any(|t| tag.eq_ignore_ascii_case(t));
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttrValue<'a> {
Bare,
Valued(&'a str),
}
const HTML_BOOLEAN_ATTRIBUTES: [&str; 22] = [
"async",
"autofocus",
"autoplay",
"checked",
"controls",
"default",
"defer",
"disabled",
"formnovalidate",
"hidden",
"ismap",
"loop",
"multiple",
"muted",
"nomodule",
"novalidate",
"open",
"playsinline",
"readonly",
"required",
"reversed",
"selected",
];
fn is_html_boolean_attribute(name: &str) -> bool {
HTML_BOOLEAN_ATTRIBUTES.contains(&name)
}
fn emit_attr(
out: &mut String,
tag: &str,
key: &str,
val: AttrValue<'_>,
limit: usize,
) -> Result<(), IrError> {
if !is_valid_attr_name(key) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
return Ok(());
}
if is_event_handler_attr(key) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
return Ok(());
}
if let AttrValue::Valued(v) = val {
if is_url_attr(key) && is_dangerous_url(v, tag) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
return Ok(());
}
}
push_checked(out, " ", limit)?;
push_checked(out, key, limit)?;
if let AttrValue::Valued(v) = val {
push_checked(out, "=\"", limit)?;
push_escaped_attr(out, v, limit)?;
push_checked(out, "\"", limit)?;
}
Ok(())
}
fn emit_open_tag(
out: &mut String,
tag: &str,
attrs: &[(&str, &str)],
has_content: bool,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
if !is_valid_name(tag) {
return Err(IrError::InvalidName(tag.to_string()));
}
let row_key = state.open_row_root();
state.open_tag_is_row_root = row_key.is_some();
if row_key.is_some()
&& attrs
.iter()
.any(|(key, _)| key.eq_ignore_ascii_case("data-forma-key"))
{
return Err(innermost_row_error(
state,
ListKeyReason::AuthoredKey,
IrError::InvalidName("data-forma-key".to_string()),
));
}
if has_content {
enter_element(out, tag, state, limit)?;
state.element_depth += 1;
}
push_checked(out, "<", limit)?;
push_checked(out, tag, limit)?;
for (key, val) in attrs {
if consume_control_attr(state, key, Prop::Static(val)) {
continue;
}
let value = if val.is_empty() {
AttrValue::Bare
} else {
AttrValue::Valued(val)
};
emit_attr(out, tag, key, value, limit)?;
}
if let Some(island) = state.pending_island.take() {
push_checked(out, " data-forma-island=\"", limit)?;
push_u16(out, island.id, limit)?;
push_checked(out, "\" data-forma-component=\"", limit)?;
push_escaped_attr(out, &island.component_name, limit)?;
push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
let trigger_str = match island.trigger {
IslandTrigger::Load => "load",
IslandTrigger::Visible => "visible",
IslandTrigger::Interaction => "interaction",
IslandTrigger::Idle => "idle",
};
push_checked(out, " data-forma-hydrate=\"", limit)?;
push_checked(out, trigger_str, limit)?;
push_checked(out, "\"", limit)?;
push_checked(out, " data-forma-state=\"", limit)?;
push_checked(out, &island.occurrence.to_string(), limit)?;
push_checked(out, "\" data-forma-h=\"", limit)?;
push_checked(out, &island.hash, limit)?;
push_checked(out, "\"", limit)?;
}
if let Some(key) = row_key {
push_checked(out, " data-forma-key=\"", limit)?;
push_escaped_attr(out, &key, limit)?;
push_checked(out, "\"", limit)?;
}
state.pending_tag_close = true;
state.pending_tag_name.clear();
state.pending_tag_name.push_str(tag);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextOnlyTag {
Textarea,
Title,
Style,
Script,
}
impl TextOnlyTag {
fn from_tag(tag: &str) -> Option<Self> {
[Self::Textarea, Self::Title, Self::Style, Self::Script]
.into_iter()
.find(|t| tag.eq_ignore_ascii_case(t.name()))
}
fn name(self) -> &'static str {
match self {
TextOnlyTag::Textarea => "textarea",
TextOnlyTag::Title => "title",
TextOnlyTag::Style => "style",
TextOnlyTag::Script => "script",
}
}
}
#[derive(Debug)]
struct TextOnlyParent {
tag: TextOnlyTag,
started: bool,
value_bound: bool,
}
fn write_text_only(
out: &mut String,
parent: &mut TextOnlyParent,
text: &str,
limit: usize,
) -> Result<(), IrError> {
if text.is_empty() {
return Ok(());
}
if parent.tag == TextOnlyTag::Textarea
&& !parent.started
&& normalized_chars(text).next() == Some('\n')
{
push_checked(out, "\n", limit)?;
}
parent.started = true;
push_escaped_html(out, text, limit)
}
#[derive(Debug, Default)]
enum PendingControl {
#[default]
None,
Textarea { value: Option<String> },
Select {
value: Option<SlotValue>,
multiple: bool,
},
OptionTag { value: Option<String> },
}
#[derive(Debug)]
struct SelectBinding {
wanted: SelectWanted,
matched: bool,
}
#[derive(Debug)]
enum SelectWanted {
One(String),
Any(Vec<String>),
}
impl SelectBinding {
fn new(value: &SlotValue, multiple: bool) -> Self {
let wanted = match value {
SlotValue::Array(items) if multiple => {
SelectWanted::Any(items.iter().map(js_string).collect())
}
other => SelectWanted::One(normalize_text(&other.to_text()).into_owned()),
};
SelectBinding {
wanted,
matched: false,
}
}
fn selects(&mut self, option_value: &str) -> bool {
match &self.wanted {
SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
SelectWanted::One(value) => {
let hit = !self.matched && value == option_value;
self.matched |= hit;
hit
}
}
}
}
fn js_string(value: &SlotValue) -> String {
match value {
SlotValue::Null => "null".to_string(),
other => normalize_text(&other.to_text()).into_owned(),
}
}
#[derive(Debug)]
struct OptionByText {
insert_at: usize,
text: String,
}
#[derive(Clone, Copy)]
enum Prop<'a> {
Static(&'a str),
Slot(&'a SlotValue),
}
impl Prop<'_> {
fn control_text(self) -> String {
match self {
Prop::Static(s) => normalize_text(s).into_owned(),
Prop::Slot(v) => normalize_text(&v.to_text()).into_owned(),
}
}
fn is_written(self) -> bool {
!matches!(
self,
Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
)
}
fn written(self) -> Option<String> {
match self {
_ if !self.is_written() => None,
Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
other => Some(other.control_text()),
}
}
fn to_slot(self) -> SlotValue {
match self {
Prop::Static(s) => SlotValue::Text(s.to_string()),
Prop::Slot(v) => v.clone(),
}
}
}
fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
match &mut state.pending_control {
PendingControl::None => false,
PendingControl::Textarea { value } => {
if key != "value" {
return false;
}
*value = Some(prop.control_text());
true
}
PendingControl::Select { value, multiple } => match key {
"value" => {
*value = Some(prop.to_slot());
true
}
"multiple" => {
*multiple |= match prop {
Prop::Static(_) => true,
Prop::Slot(value) => value.as_bool(),
};
false
}
_ => false,
},
PendingControl::OptionTag { value } => match key {
"selected" => true,
"value" => {
if value.is_none() {
*value = prop.written();
}
false
}
_ => false,
},
}
}
fn enter_element(
out: &mut String,
tag: &str,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
let is = |name: &str| tag.eq_ignore_ascii_case(name);
if is("option") || is("optgroup") {
finish_option_by_text(out, state, limit)?;
}
state.pending_control = if is("textarea") {
PendingControl::Textarea { value: None }
} else if is("select") {
PendingControl::Select {
value: None,
multiple: false,
}
} else if is("option") && state.select.is_some() {
PendingControl::OptionTag { value: None }
} else {
PendingControl::None
};
if state.text_only.is_none() {
state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
tag,
started: false,
value_bound: false,
});
}
Ok(())
}
fn leave_element(
out: &mut String,
tag: &str,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
let is = |name: &str| tag.eq_ignore_ascii_case(name);
if is("option") || is("optgroup") || is("select") {
finish_option_by_text(out, state, limit)?;
}
if is("select") {
state.select = None;
}
state.pre_boundary = None;
if state
.text_only
.as_ref()
.is_some_and(|parent| is(parent.tag.name()))
{
state.text_only = None;
}
Ok(())
}
fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
state.pending_tag_close = false;
state.open_tag_is_row_root = false;
match std::mem::take(&mut state.pending_control) {
PendingControl::None => {}
PendingControl::Textarea { value } => {
push_checked(out, ">", limit)?;
if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
if parent.tag == TextOnlyTag::Textarea {
parent.value_bound = true;
write_text_only(out, parent, &value, limit)?;
}
}
return Ok(());
}
PendingControl::Select { value, multiple } => {
state.select = value.map(|value| SelectBinding::new(&value, multiple));
}
PendingControl::OptionTag { value: Some(value) } => {
if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
push_checked(out, " selected", limit)?;
}
}
PendingControl::OptionTag { value: None } => {
state.option_by_text = Some(OptionByText {
insert_at: out.len(),
text: String::new(),
});
}
}
push_checked(out, ">", limit)?;
let tag = &state.pending_tag_name;
if tag.eq_ignore_ascii_case("pre") || tag.eq_ignore_ascii_case("listing") {
state.pre_boundary = Some(out.len());
}
Ok(())
}
fn finish_option_by_text(
out: &mut String,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
let Some(option) = state.option_by_text.take() else {
return Ok(());
};
let value = strip_and_collapse_ascii_whitespace(&option.text);
if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
const SELECTED: &str = " selected";
if out.len().saturating_add(SELECTED.len()) > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.insert_str(option.insert_at, SELECTED);
}
Ok(())
}
fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for word in text.split_ascii_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
out
}
pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
if pos + 2 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 2,
actual: data.len(),
});
}
Ok(u16::from_le_bytes([data[pos], data[pos + 1]]))
}
pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
if pos + 4 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 4,
actual: data.len(),
});
}
Ok(u32::from_le_bytes([
data[pos],
data[pos + 1],
data[pos + 2],
data[pos + 3],
]))
}
pub(crate) fn read_tag_with_attrs<'a>(
ops: &[u8],
pos: usize,
strings: &'a crate::parser::StringTable,
) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
let tag_str_idx = read_u32(ops, pos)?;
let attr_count = read_u16(ops, pos + 4)? as usize;
let mut cursor = pos + 6;
let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
for _ in 0..attr_count {
let key_idx = read_u32(ops, cursor)?;
let val_idx = read_u32(ops, cursor + 4)?;
let key = strings.get(key_idx)?;
let val = strings.get(val_idx)?;
attrs.push((key, val));
cursor += 8;
}
Ok((tag_str_idx, attrs, cursor))
}
const MAX_ESCAPE_EXPANSION: usize = 6;
fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
if out.len().saturating_add(s.len()) > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push_str(s);
Ok(())
}
fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
let mut buf = [0u8; 5];
let mut remaining = value;
let mut start = buf.len();
loop {
start -= 1;
buf[start] = b'0' + (remaining % 10) as u8;
remaining /= 10;
if remaining == 0 {
break;
}
}
match core::str::from_utf8(&buf[start..]) {
Ok(digits) => push_checked(out, digits, limit),
Err(e) => Err(IrError::InvalidUtf8(e.to_string())),
}
}
fn push_char_checked(out: &mut String, ch: char, limit: usize) -> Result<(), IrError> {
if out.len().saturating_add(ch.len_utf8()) > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push(ch);
Ok(())
}
fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
if out
.len()
.saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
<= limit
{
for ch in normalized_chars(text) {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
return Ok(());
}
for ch in normalized_chars(text) {
match ch {
'&' => push_checked(out, "&", limit)?,
'<' => push_checked(out, "<", limit)?,
'>' => push_checked(out, ">", limit)?,
_ => push_char_checked(out, ch, limit)?,
}
}
Ok(())
}
fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
if out
.len()
.saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
<= limit
{
for ch in normalized_chars(text) {
match ch {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
return Ok(());
}
for ch in normalized_chars(text) {
match ch {
'&' => push_checked(out, "&", limit)?,
'"' => push_checked(out, """, limit)?,
'<' => push_checked(out, "<", limit)?,
'>' => push_checked(out, ">", limit)?,
_ => push_char_checked(out, ch, limit)?,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::test_helpers::{
build_declared_fixture, encode_close_tag, encode_list, encode_open_tag, encode_show_if,
encode_text, encode_void_tag,
};
use crate::slot::{SlotData, SlotValue};
fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&marker_id.to_le_bytes());
buf
}
fn encode_island_start(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0B];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn encode_island_end(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0C];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
let data = build_declared_fixture(strings, &[], opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
walk_to_html(&module, &slots).unwrap()
}
fn walk_with_slots(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_declared_fixture(strings, slot_decls, opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn walk_with_islands(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_declared_fixture(strings, slot_decls, opcodes, island_decls);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn walk_with_islands_output(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
opcodes: &[u8],
slots: &SlotData,
) -> WalkOutput {
let data = build_declared_fixture(strings, slot_decls, opcodes, island_decls);
let module = IrModule::parse(&data).unwrap();
walk_to_html_counted(&module, slots, WalkLimits::default()).unwrap()
}
fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
bytes.extend_from_slice(&slot_id.to_le_bytes());
bytes
}
#[test]
fn walk_static_div() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_text(3));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
assert_eq!(html, r#"<div class="container">Hello</div>"#);
}
#[test]
fn walk_void_tag() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "type", "email"], &opcodes);
assert_eq!(html, r#"<input type="email">"#);
}
#[test]
fn walk_nested_elements() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_text(2));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "span", "Hi"], &opcodes);
assert_eq!(html, "<div><span>Hi</span></div>");
}
#[test]
fn walk_text_escaping() {
let opcodes = encode_text(0);
let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
assert_eq!(html, "<script>alert('xss')</script>");
}
#[test]
fn walk_attr_escaping() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let html = walk_static(&["div", "title", "a\"b"], &opcodes);
assert_eq!(html, r#"<div title="a"b">"#);
}
#[test]
fn security_probe_bounded_text_escaping() {
let data = build_declared_fixture(&["xxxxxxxxxx<"], &[], &encode_text(0), &[]);
let module = IrModule::parse(&data).unwrap();
let expected = "xxxxxxxxxx<";
let limits = WalkLimits {
max_output_bytes: expected.len(),
..WalkLimits::default()
};
assert_eq!(
walk_to_html_with_limits(&module, &SlotData::new(0), limits).as_deref(),
Ok(expected)
);
let short = WalkLimits {
max_output_bytes: expected.len() - 1,
..limits
};
assert!(matches!(
walk_to_html_with_limits(&module, &SlotData::new(0), short),
Err(IrError::OutputLimitExceeded { .. })
));
}
#[test]
fn security_probe_bounded_attribute_escaping() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_declared_fixture(&["div", "title", "xxxxxxxxxx\""], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let expected = "<div title=\"xxxxxxxxxx"\">";
let limits = WalkLimits {
max_output_bytes: expected.len(),
..WalkLimits::default()
};
assert_eq!(
walk_to_html_with_limits(&module, &SlotData::new(0), limits).as_deref(),
Ok(expected)
);
let short = WalkLimits {
max_output_bytes: expected.len() - 1,
..limits
};
assert!(matches!(
walk_to_html_with_limits(&module, &SlotData::new(0), short),
Err(IrError::OutputLimitExceeded { .. })
));
}
#[test]
fn walk_empty_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div"], &opcodes);
assert_eq!(html, "<div></div>");
}
#[test]
fn walk_dyn_text() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_null_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let slots = SlotData::new(1);
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_empty_string_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(String::new()));
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_escaping() {
let opcodes = encode_dyn_text(0, 1);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
}
#[test]
fn walk_island_markers() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_text(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["Hello"],
&[],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
}
#[test]
fn walk_boolean_attr() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "disabled", ""], &opcodes);
assert_eq!(html, "<input disabled>");
}
fn walk_dyn_attr_with(value: SlotValue) -> String {
let mut opcodes = encode_void_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let mut slots = SlotData::new(1);
slots.set(0, value);
walk_with_slots(
&["input", "disabled"],
&[(0, 1, 0x02, 0x00, &[])],
&opcodes,
&slots,
)
}
#[test]
fn dyn_attr_bool_matches_the_client_renderer() {
assert_eq!(
walk_dyn_attr_with(SlotValue::Bool(true)),
"<input disabled>",
"true must render the bare attribute"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Bool(false)),
"<input>",
"false must omit the attribute, not write disabled=\"false\""
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Null),
"<input>",
"null must omit the attribute"
);
}
#[test]
fn dyn_attr_empty_string_and_zero_follow_boolean_presence() {
assert_eq!(
walk_dyn_attr_with(SlotValue::Text(String::new())),
"<input>"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Text("yes".into())),
"<input disabled>"
);
assert_eq!(walk_dyn_attr_with(SlotValue::Number(0.0)), "<input>");
assert_eq!(
walk_dyn_attr_with(SlotValue::Number(1.0)),
"<input disabled>"
);
}
#[test]
fn walk_multiple_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(
&["div", "id", "main", "class", "container", "data-x", "42"],
&opcodes,
);
assert_eq!(
html,
r#"<div id="main" class="container" data-x="42"></div>"#
);
}
#[test]
fn walk_show_if_true_branch() {
let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Yes"), "should contain then-branch text");
assert!(!html.contains("No"), "should NOT contain else-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_false_branch() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("No"), "should contain else-branch text");
assert!(!html.contains("Yes"), "should NOT contain then-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_truthy_text() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["greeting", "Shown", "Hidden"],
&[(0, 0, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(
html.contains("Shown"),
"truthy text should render then branch"
);
assert!(
!html.contains("Hidden"),
"truthy text should NOT render else branch"
);
}
#[test]
fn walk_show_if_falsy_null() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["maybe", "Present", "Missing"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("Missing"), "null should render else branch");
assert!(
!html.contains("Present"),
"null should NOT render then branch"
);
}
#[test]
fn walk_show_if_no_else() {
let then_ops = encode_text(1);
let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["flag", "Content"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><!--/f:s0-->",
"empty else should produce only markers"
);
}
#[test]
fn walk_show_if_nested() {
let inner_then = encode_text(4);
let inner_else = encode_text(5);
let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
let outer_then = inner_show_if; let outer_else = encode_text(3);
let opcodes = encode_show_if(0, &outer_then, &outer_else);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false));
let html = walk_with_slots(
&[
"outer",
"inner",
"outer-yes",
"outer-no",
"inner-yes",
"inner-no",
],
&[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("inner-no"),
"nested: inner false should render inner else"
);
assert!(
!html.contains("inner-yes"),
"nested: inner true branch should not render"
);
assert!(
!html.contains("outer-no"),
"nested: outer else should not render"
);
assert!(
html.contains("<!--f:s0-->"),
"should have outer opening marker"
);
assert!(
html.contains("<!--/f:s0-->"),
"should have outer closing marker"
);
assert!(
html.contains("<!--f:s1-->"),
"should have inner opening marker"
);
assert!(
html.contains("<!--/f:s1-->"),
"should have inner closing marker"
);
}
#[test]
fn walk_show_if_with_elements() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[]));
then_ops.extend_from_slice(&encode_text(2));
then_ops.extend_from_slice(&encode_close_tag(1));
let else_ops = encode_text(3);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["flag", "span", "Hello", "Bye"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
"then branch should produce full HTML element"
);
}
#[test]
fn walk_list_renders_items() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("Alice".to_string()),
SlotValue::Text("Bob".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain first item");
assert!(html.contains("Bob"), "should contain second item");
assert!(
html.starts_with("<!--f:l0-->"),
"should start with list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should end with list close marker"
);
}
#[test]
fn walk_list_empty_array() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::array(vec![]));
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"empty array should produce only markers"
);
}
#[test]
fn walk_list_with_elements() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[]));
body.extend_from_slice(&encode_dyn_text(1, 0));
body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("A".to_string()),
SlotValue::Text("B".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
"should render li elements with content, got: {html}"
);
}
#[test]
fn walk_list_null_slot() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let slots = SlotData::new(2);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"null slot should produce only markers"
);
}
#[test]
fn walk_list_nested() {
let inner_body = encode_dyn_text(2, 0);
let inner_list = encode_list(1, 2, &inner_body);
let opcodes = encode_list(0, 1, &inner_list);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::array(vec![
SlotValue::array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Text("b".to_string()),
]),
SlotValue::array(vec![SlotValue::Text("c".to_string())]),
]),
);
let html = walk_with_slots(
&["outer", "inner", "item"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(html.contains("a"), "should contain nested item 'a'");
assert!(html.contains("b"), "should contain nested item 'b'");
assert!(html.contains("c"), "should contain nested item 'c'");
assert!(
html.starts_with("<!--f:l0-->"),
"should have outer list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should have outer list close marker"
);
assert!(
html.contains("<!--f:l1-->"),
"should have inner list open marker"
);
assert!(
html.contains("<!--/f:l1-->"),
"should have inner list close marker"
);
}
#[test]
fn walk_list_depth_exceeded() {
let innermost_body = encode_dyn_text(5, 0);
let level5 = encode_list(4, 5, &innermost_body);
let level4 = encode_list(3, 4, &level5);
let level3 = encode_list(2, 3, &level4);
let level2 = encode_list(1, 2, &level3);
let level1 = encode_list(0, 1, &level2);
let mut slots = SlotData::new(6);
slots.set(
0,
SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
"deep".to_string(),
)])]),
])])]),
);
let data = build_declared_fixture(
&["s0", "s1", "s2", "s3", "s4", "s5"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x04, 0x00, &[]),
(3, 3, 0x04, 0x00, &[]),
(4, 4, 0x04, 0x00, &[]),
(5, 5, 0x01, 0x00, &[]),
],
&level1,
&[],
);
let module = IrModule::parse(&data).unwrap();
let result = walk_to_html(&module, &slots);
assert!(result.is_err(), "should fail with depth exceeded");
match result.unwrap_err() {
IrError::ListDepthExceeded { max: 4 } => {}
other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
}
}
#[test]
fn roundtrip_page_structure() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_open_tag(3, &[])); opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); opcodes.extend_from_slice(&encode_open_tag(7, &[])); opcodes.extend_from_slice(&encode_text(8)); opcodes.extend_from_slice(&encode_close_tag(7)); opcodes.extend_from_slice(&encode_close_tag(3)); opcodes.extend_from_slice(&encode_open_tag(9, &[])); opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); opcodes.extend_from_slice(&encode_close_tag(10)); opcodes.extend_from_slice(&encode_close_tag(9)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_declared_fixture(
&[
"html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
"div", "id", "app",
],
&[],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let slots = SlotData::new(0);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
);
}
#[test]
fn roundtrip_dynamic_greeting() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_dyn_text(0, 0)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_declared_fixture(
&["div", "class", "greeting", "content"],
&[(0, 3, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("Hello, World".into()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
);
}
#[test]
fn roundtrip_conditional_content() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
then_ops.extend_from_slice(&encode_text(4));
then_ops.extend_from_slice(&encode_close_tag(1));
let mut else_ops = Vec::new();
else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
else_ops.extend_from_slice(&encode_text(5));
else_ops.extend_from_slice(&encode_close_tag(1));
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let ir = build_declared_fixture(
&["auth", "span", "class", "badge", "Logged In", "Guest"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&[],
);
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
);
}
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
);
}
}
#[test]
fn roundtrip_list_rendering() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[])); body.extend_from_slice(&encode_dyn_text(1, 0)); body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let ir = build_declared_fixture(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("Alice".into()),
SlotValue::Text("Bob".into()),
SlotValue::Text("Charlie".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
"<!--f:l0-->\
<li><!--f:t0-->Alice<!--/f:t0--></li>\
<li><!--f:t0-->Bob<!--/f:t0--></li>\
<li><!--f:t0-->Charlie<!--/f:t0--></li>\
<!--/f:l0-->"
);
}
#[test]
fn roundtrip_nested_structure() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(4, &[]));
then_ops.extend_from_slice(&encode_text(5));
then_ops.extend_from_slice(&encode_close_tag(4));
let show_if_ops = encode_show_if(0, &then_ops, &[]);
let mut list_body = Vec::new();
list_body.extend_from_slice(&encode_open_tag(9, &[]));
list_body.extend_from_slice(&encode_dyn_text(2, 0));
list_body.extend_from_slice(&encode_close_tag(9));
let list_ops = encode_list(1, 2, &list_body);
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&show_if_ops); opcodes.extend_from_slice(&encode_open_tag(8, &[])); opcodes.extend_from_slice(&list_ops); opcodes.extend_from_slice(&encode_close_tag(8)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_declared_fixture(
&[
"nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
],
&[
(0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::Bool(true));
slots.set(
1,
SlotValue::array(vec![
SlotValue::Text("Home".into()),
SlotValue::Text("About".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<nav class="main-nav"><!--f:s0--><span>Welcome</span><!--/f:s0--><ul><!--f:l1--><li><!--f:t0-->Home<!--/f:t0--></li><li><!--f:t0-->About<!--/f:t0--></li><!--/f:l1--></ul></nav>"#
);
}
#[test]
fn walk_island_attrs_on_open_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Hello", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 2, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load" data-forma-state="0" data-forma-h="0000000000000000">Hello</div><!--/f:i0-->"#
);
}
#[test]
fn walk_island_attrs_with_existing_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "class", "form", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 3, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><div class="form" data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load" data-forma-state="0" data-forma-h="0000000000000000"></div><!--/f:i0-->"#
);
}
#[test]
fn walk_island_attrs_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["input", "Widget"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load" data-forma-state="0" data-forma-h="0000000000000000"><!--/f:i0-->"#
);
}
#[test]
fn declared_props_are_separate_from_body_html() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("Hello".to_string()));
slots.set(1, SlotValue::Number(42.0));
let output = walk_with_islands_output(
&["div", "MyComponent", "title", "count"],
&[
(0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
&[(0, 0x01, 0x01, 1, 0, &[0, 1])],
&opcodes,
&slots,
);
assert!(!output.html.contains("data-forma-props"));
assert!(!output.html.contains("<script"));
assert!(output.html.contains("data-forma-component=\"MyComponent\""));
let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
assert_eq!(json["i"][0]["p"]["title"], "Hello");
assert_eq!(json["i"][0]["p"]["count"], 42);
}
#[test]
fn walk_island_no_entry_in_table() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(5));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(5));
let data = build_declared_fixture(&["div", "Hello"], &[], &opcodes, &[]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::UnknownIslandId(5)
);
}
#[test]
fn walk_nested_island_start_before_a_root_element_is_an_error() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let data = build_declared_fixture(
&["div", "Outer", "Inner"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
);
let module = IrModule::parse(&data).unwrap();
assert_eq!(
walk_to_html(&module, &slots).unwrap_err(),
IrError::NestedIslandRoot { outer: 0, inner: 1 }
);
}
#[test]
fn walk_island_nested_inside_another_islands_root_is_fine() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
let html = walk_with_islands(
&["div", "Outer", "Inner"],
&[],
&[
(0, 0x01, 0x01, 1, 0, &[]),
(1, 0x01, 0x01, 2, inner_offset, &[]),
],
&opcodes,
&slots,
);
assert!(html.contains(r#"data-forma-component="Outer""#));
assert!(html.contains(r#"data-forma-component="Inner""#));
}
#[test]
fn empty_declared_inputs_do_not_emit_inline_props() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Simple"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"empty slot_ids should not emit props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
}
fn walk_island_with_prop_payload(payload: &str) -> WalkOutput {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
walk_with_islands_output(
&["div", "Big", "blob"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x01, 1, 0, &[0])],
&opcodes,
&slots,
)
}
#[test]
fn duplicate_slot_names_do_not_invent_declared_island_props() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("first".into()));
slots.set(1, SlotValue::Text("second".into()));
let output = walk_with_islands_output(
&["div", "Comp", "dup"],
&[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
assert_eq!(json["i"][0]["p"], serde_json::json!({}));
assert!(!output.html.contains("data-forma-props"));
}
#[test]
fn small_props_use_the_owned_state_channel() {
let output = walk_island_with_prop_payload("small");
assert!(output.island_state.as_json().contains("\"blob\":\"small\""));
assert!(!output.html.contains("data-forma-props"));
assert!(!output.html.contains("__forma_islands"));
}
#[test]
fn larger_props_use_the_same_owned_state_channel() {
let payload = "x".repeat(1025);
let output = walk_island_with_prop_payload(&payload);
assert!(!output.html.contains("data-forma-props"));
assert!(!output.html.contains("__forma_islands"));
assert!(output.island_state.as_json().contains(&payload));
}
#[test]
fn diagnostic_fragment_does_not_restore_an_inline_state_channel() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let payload = "x".repeat(1024 + 1);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload));
let data = build_declared_fixture(
&["div", "Big", "blob"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x01, 0x01, 1, 0, &[0])],
);
let module = IrModule::parse(&data).unwrap();
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(!fragment.contains("data-forma-props"));
assert!(!fragment.contains("__forma_islands"));
}
#[test]
fn multiple_islands_share_one_owned_envelope() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let island1_offset = opcodes.len() as u32;
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(1));
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::Text("hello".to_string()));
slots.set(1, SlotValue::Bool(true));
slots.set(2, SlotValue::Text("x".repeat(1024)));
let output = walk_with_islands_output(
&["div", "span", "CompA", "CompB", "msg", "flag", "padding"],
&[
(0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]),
(2, 6, 0x01, 0, &[]), ],
&[
(0, 0x01, 0x02, 2, 0, &[0, 2]), (1, 0x01, 0x02, 3, island1_offset, &[1, 2]), ],
&opcodes,
&slots,
);
let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
assert_eq!(json["i"][0]["p"]["msg"], "hello");
assert_eq!(json["i"][1]["p"]["flag"], true);
assert_eq!(output.island_state.len(), 2);
assert!(!output.html.contains("<script"));
}
#[test]
fn slot_value_to_json_all_types() {
assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
assert_eq!(
SlotValue::Text("hello".to_string()).to_json(),
serde_json::Value::String("hello".to_string())
);
assert_eq!(
SlotValue::Bool(true).to_json(),
serde_json::Value::Bool(true)
);
assert_eq!(
SlotValue::Bool(false).to_json(),
serde_json::Value::Bool(false)
);
assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
assert_eq!(
SlotValue::array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Number(1.0)
])
.to_json(),
serde_json::json!(["a", 1])
);
assert_eq!(
SlotValue::object(vec![(
"key".to_string(),
SlotValue::Text("val".to_string())
),])
.to_json(),
serde_json::json!({"key": "val"})
);
}
#[test]
fn dyn_attr_emits_attribute_with_value() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("password".to_string()));
let html = walk_with_slots(
&["input", "type", "input_type"],
&[(0, 2, 0x01, 0x01, b"password")], &opcodes,
&slots,
);
assert_eq!(html, r#"<input type="password"></input>"#);
}
#[test]
fn dyn_attr_skips_when_slot_null() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let slots = SlotData::new(1);
let html = walk_with_slots(
&["div", "class", "cls"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "<div></div>");
}
#[test]
fn dyn_attr_multiple_on_same_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("email".to_string()));
slots.set(1, SlotValue::Text("form-input".to_string()));
let html = walk_with_slots(
&["input", "type", "class", "slot_type", "slot_class"],
&[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
}
#[test]
fn dyn_attr_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("checkbox".to_string()));
let html = walk_with_slots(
&["input", "type", "slot_type"],
&[(0, 2, 0x01, 0x01, b"text")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="checkbox">"#);
}
#[test]
fn walk_island_basic() {
let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
let close_p = encode_close_tag(2);
let island_end = encode_island_end(0);
let close_div = encode_close_tag(1);
let byte_offset = pre_island.len() as u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&pre_island);
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_p);
opcodes.extend_from_slice(&text_hello);
opcodes.extend_from_slice(&close_p);
opcodes.extend_from_slice(&island_end);
opcodes.extend_from_slice(&close_div);
let data = build_declared_fixture(
&["MyIsland", "div", "p", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[])], );
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(
html,
"<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
data-forma-status=\"pending\" data-forma-hydrate=\"load\" data-forma-state=\"0\" data-forma-h=\"0000000000000000\">hello</p>"
);
assert!(!html.contains("<!--f:i0-->"));
assert!(!html.contains("<!--/f:i0-->"));
}
#[test]
fn walk_island_attrs_match_walk_to_html() {
let island_start = encode_island_start(0);
let open_div = encode_open_tag(1, &[]);
let dyn_text = encode_dyn_text(0, 0);
let close_div = encode_close_tag(1);
let island_end = encode_island_end(0);
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_div);
opcodes.extend_from_slice(&dyn_text);
opcodes.extend_from_slice(&close_div);
opcodes.extend_from_slice(&island_end);
let data = build_declared_fixture(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x02, 0x01, 0, 0, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let page = walk_to_html(&module, &slots).unwrap();
let fragment = walk_island(&module, &slots, 0).unwrap();
for attr in [
"data-forma-island=\"0\"",
"data-forma-component=\"IslandComp\"",
"data-forma-status=\"pending\"",
"data-forma-hydrate=\"visible\"",
"data-forma-state=\"0\"",
"data-forma-h=\"0000000000000000\"",
] {
assert!(page.contains(attr), "page missing {attr}: {page}");
assert!(
fragment.contains(attr),
"fragment missing {attr}: {fragment}"
);
}
}
#[test]
fn obsolete_mode_cannot_restore_fragment_inline_props() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let data = build_declared_fixture(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x01, 0x02, 0, 0, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(
!fragment.contains("data-forma-props="),
"diagnostic fragment must not inline props: {fragment}"
);
assert!(
!fragment.contains("__forma_islands"),
"fragment must not emit a second islands block: {fragment}"
);
}
#[test]
fn nested_diagnostic_fragment_has_occurrence_markers() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
let inner_offset = opcodes.len() as u32;
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let data = build_declared_fixture(
&["Outer", "div", "greeting", "Inner"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[
(0, 0x01, 0x01, 0, 0, &[]),
(1, 0x01, 0x02, 3, inner_offset, &[0]), ],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".into()));
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(
fragment.contains("data-forma-component=\"Inner\""),
"nested island must render: {fragment}"
);
assert!(
fragment.contains("data-forma-state=\"1\""),
"nested island must retain its own occurrence: {fragment}"
);
assert!(!fragment.contains("__forma_islands"), "{fragment}");
}
#[test]
fn walk_island_not_found() {
let opcodes = encode_text(0);
let data = build_declared_fixture(&["Hello"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let result = walk_island(&module, &slots, 5);
assert!(result.is_err());
match result.unwrap_err() {
IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
}
}
#[test]
fn walk_island_with_dyn_text() {
let island_start = encode_island_start(0);
let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
let island_end = encode_island_end(0);
let byte_offset = 0u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_div);
opcodes.extend_from_slice(&dyn_text);
opcodes.extend_from_slice(&close_div);
opcodes.extend_from_slice(&island_end);
let data = build_declared_fixture(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])], &opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(
html,
"<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
data-forma-state=\"0\" data-forma-h=\"0000000000000000\">\
<!--f:t0-->World<!--/f:t0--></div>"
);
}
#[test]
fn walk_island_byte_offset_out_of_bounds() {
let opcodes = encode_text(0);
let data = build_declared_fixture(
&["BadIsland", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, 9999, &[])], );
let err = IrModule::parse(&data).unwrap_err();
assert_eq!(
err,
IrError::IslandOffsetMismatch {
island_id: 0,
byte_offset: 9999
}
);
}
fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
buf.extend_from_slice(&prop_str_idx.to_le_bytes());
buf.extend_from_slice(&target_slot_id.to_le_bytes());
buf
}
#[test]
fn walk_prop_in_list_body() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_dyn_text(2, 0)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::array(vec![
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Bob".to_string()),
)]),
]),
);
let html = walk_with_slots(
&["items", "item", "name"],
&[
(0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
&opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain Alice, got: {html}");
assert!(html.contains("Bob"), "should contain Bob, got: {html}");
}
#[test]
fn walk_prop_multiple_properties() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_prop(1, 3, 3)); body.extend_from_slice(&encode_open_tag(4, &[])); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(2, 0)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(3, 1)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_close_tag(4)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(4);
slots.set(
0,
SlotValue::array(vec![
SlotValue::object(vec![
("name".to_string(), SlotValue::Text("Alice".to_string())),
(
"email".to_string(),
SlotValue::Text("alice@test.com".to_string()),
),
]),
SlotValue::object(vec![
("name".to_string(), SlotValue::Text("Bob".to_string())),
(
"email".to_string(),
SlotValue::Text("bob@test.com".to_string()),
),
]),
]),
);
let html = walk_with_slots(
&["rows", "row", "name", "email", "tr", "td"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x05, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
(3, 3, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(
html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
"should contain Alice td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
"should contain alice email, got: {html}"
);
assert!(
html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
"should contain Bob td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
"should contain bob email, got: {html}"
);
}
#[test]
fn walk_prop_on_non_object() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 1, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["src", "name"],
&[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"non-object PROP should produce empty text, got: {html}"
);
}
#[test]
fn walk_prop_missing_property() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 2, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
);
let html = walk_with_slots(
&["src", "target", "missing"],
&[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"missing property should produce empty text, got: {html}"
);
}
#[test]
fn walk_nested_show_if_within_limit() {
let inner = encode_text(5);
let level4 = encode_show_if(4, &inner, &[]);
let level3 = encode_show_if(3, &level4, &[]);
let level2 = encode_show_if(2, &level3, &[]);
let level1 = encode_show_if(1, &level2, &[]);
let level0 = encode_show_if(0, &level1, &[]);
let mut slots = SlotData::new(5);
for i in 0..5 {
slots.set(i, SlotValue::Bool(true));
}
let html = walk_with_slots(
&["s0", "s1", "s2", "s3", "s4", "deep"],
&[
(0, 0, 0x02, 0x00, &[]),
(1, 1, 0x02, 0x00, &[]),
(2, 2, 0x02, 0x00, &[]),
(3, 3, 0x02, 0x00, &[]),
(4, 4, 0x02, 0x00, &[]),
],
&level0,
&slots,
);
assert!(
html.contains("deep"),
"5 levels of nesting should succeed, got: {html}"
);
}
fn script_tag_props_html(payload: &str) -> (String, String) {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text(payload.to_string()));
slots.set(1, SlotValue::Text("x".repeat(1024)));
let output = walk_with_islands_output(
&["div", "Comp", "payload", "padding"],
&[(0, 2, 0x01, 0x00, &[]), (1, 3, 0x01, 0, &[])],
&[(0, 0x01, 0x01, 1, 0, &[0, 1])],
&opcodes,
&slots,
);
(output.html, output.island_state.as_json().to_owned())
}
#[test]
fn owned_state_has_no_raw_script_openers() {
let (_html, body) = script_tag_props_html("</script>alert(1)");
assert!(
!body.contains('<'),
"state envelope must contain no raw script openers, got: {body}"
);
assert!(body.contains("\\u003c/script>"), "got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["i"][0]["p"]["payload"], "</script>alert(1)");
}
#[test]
fn owned_state_escapes_existing_comment_script_regression() {
let payload = "<!--<script>PAYLOAD";
let (html, body) = script_tag_props_html(payload);
assert!(
!html.contains("<!--<script"),
"must not emit the script-data-double-escape trigger, got: {html}"
);
assert!(!body.contains('<'), "got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["i"][0]["p"]["payload"], payload);
}
#[test]
#[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
fn walk_real_benchmark_ir() {
let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
let ir_path = std::fs::read_dir(&dist_dir)
.expect("admin/dist must exist")
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with("platform-benchmark.") && s.ends_with(".ir")
})
.map(|e| e.path())
.expect("platform-benchmark.*.ir must exist in admin/dist");
let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
let module = IrModule::parse(&data).expect("IR must parse");
let slots = SlotData::new_from_defaults(&module.slots);
let html = walk_to_html(&module, &slots).expect("walk must succeed");
assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
assert!(
html.contains("data-forma-island=\"0\""),
"missing island 0 attr"
);
assert!(
html.contains("Search"),
"FilterBar must contain 'Search' label text in SSR"
);
assert!(
html.contains("filter-group"),
"FilterBar must contain filter-group divs in SSR"
);
assert!(
html.contains("Sort by"),
"FilterBar must contain 'Sort by' label text in SSR"
);
assert!(
html.contains("Performance"),
"PerfPanel must contain 'Performance' heading in SSR"
);
assert!(
html.contains("benchmark-data-table"),
"BenchmarkDataTable root class must exist"
);
let start = html
.find("<!--f:i0-->")
.expect("FilterBar island start marker");
let end = html
.find("<!--/f:i0-->")
.expect("FilterBar island end marker");
assert!(end > start, "island end marker must follow its start");
}
#[test]
fn walk_output_limit_trips_inside_one_opcode() {
let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
let opcodes = encode_open_tag(0, &attrs);
let data = build_declared_fixture(&["div", "data-x", &big], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let limits = WalkLimits {
max_output_bytes: 64 * 1024,
..WalkLimits::default()
};
let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
let ok = WalkLimits {
max_output_bytes: 64 * 1024 * 1024,
..WalkLimits::default()
};
assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
}
#[test]
fn walk_output_limit_trips_on_a_single_dyn_text_value() {
let opcodes = encode_dyn_text(0, 0);
let data = build_declared_fixture(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("x".repeat(100_000)));
let limits = WalkLimits {
max_output_bytes: 4096,
..WalkLimits::default()
};
assert!(matches!(
walk_to_html_with_limits(&module, &slots, limits),
Err(IrError::OutputLimitExceeded { .. })
));
}
#[test]
fn walk_list_budget_exceeded() {
let body = encode_text(2);
let inner = encode_list(0, 1, &body);
let opcodes = encode_list(0, 1, &inner);
let data = build_declared_fixture(
&["items", "item", "x"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
);
let err = walk_to_html(&module, &slots).unwrap_err();
assert_eq!(
err,
IrError::ListBudgetExceeded {
max: DEFAULT_MAX_LIST_ITERATIONS
}
);
assert_ne!(
err,
IrError::ListDepthExceeded {
max: MAX_LIST_DEPTH
}
);
}
fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
}
#[test]
fn parse_rejects_show_if_then_len_past_the_stream() {
let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data =
build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
patch_u32(&mut opcodes, 3, 4); let data =
build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_list_body_len_past_the_stream() {
let mut opcodes = encode_list(0, 1, &encode_text(2));
patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
let data = build_declared_fixture(
&["items", "item", "x"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&[],
);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_accepts_zero_length_else_branch_known_residual() {
let opcodes = encode_show_if(0, &encode_text(1), &[]);
let data =
build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(IrModule::parse(&data).is_ok());
}
#[test]
fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(1);
let html = walk_with_islands(
&["Comp", "div", "flag"],
&[(0, 2, 0x02, 0x00, &[])],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<div data-forma-island=\"0\""),
"island root must keep its attributes, got: {html}"
);
}
#[test]
fn walk_island_attrs_do_not_leak_past_the_island_end() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_island_end(0));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["Comp", "div"],
&[],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-island"),
"island attributes must not leak onto an element outside the island, got: {html}"
);
}
#[test]
fn walk_rejects_hostile_tag_name() {
let opcodes = encode_open_tag(0, &[]);
let data = build_declared_fixture(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert!(matches!(
walk_to_html(&module, &slots),
Err(IrError::InvalidName(_))
));
}
#[test]
fn walk_rejects_hostile_close_tag_name() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_close_tag(1));
let data = build_declared_fixture(&["div", "div><script"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert!(matches!(
walk_to_html(&module, &slots),
Err(IrError::InvalidName(_))
));
}
#[test]
fn walk_drops_hostile_attribute_names() {
let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
let data =
build_declared_fixture(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<div ok-attr=\"v\">");
}
#[test]
fn walk_drops_hostile_boolean_attribute_name() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_declared_fixture(&["div", "y\"><script", ""], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_hostile_dyn_attr_name() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_declared_fixture(
&["div", "z\"><script", "val"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("x".into()));
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_javascript_url_in_dyn_attr() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_declared_fixture(
&["a", "href", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
for payload in [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"java\tscript:alert(1)",
" javascript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
] {
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
}
}
#[test]
fn walk_keeps_safe_urls_and_data_images() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_declared_fixture(
&["img", "src", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
for payload in [
"/about",
"https://example.com/x?a=1",
"data:image/png;base64,AAAA",
"data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
] {
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_to_html(&module, &slots).unwrap();
assert!(html.contains("src="), "payload {payload:?} was dropped");
}
}
#[test]
fn walk_blocks_svg_data_url_outside_image_context() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_declared_fixture(
&["a", "href", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(
0,
SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
);
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
}
#[test]
fn walk_drops_event_handler_attributes() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_declared_fixture(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
assert_eq!(
walk_to_html(&module, &SlotData::new(0)).unwrap(),
"<div>",
"static on* attribute must be dropped"
);
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_declared_fixture(
&["div", "ONCLICK", "h"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("alert(1)".into()));
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_javascript_url_in_static_attr() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data =
build_declared_fixture(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
}
struct Page {
strings: Vec<String>,
ops: Vec<u8>,
slots: Vec<(u8, SlotValue)>,
islands: Vec<(u16, u32, u32)>,
}
impl Page {
fn new() -> Self {
Page {
strings: Vec::new(),
ops: Vec::new(),
slots: Vec::new(),
islands: Vec::new(),
}
}
fn intern(&mut self, s: &str) -> u32 {
let index = match self.strings.iter().position(|x| x == s) {
Some(i) => i,
None => {
self.strings.push(s.to_string());
self.strings.len() - 1
}
};
u32::try_from(index).unwrap()
}
fn slot(&mut self, value: SlotValue) -> u16 {
let type_byte = match &value {
SlotValue::Null | SlotValue::Text(_) => 0x01,
SlotValue::Bool(_) => 0x02,
SlotValue::Number(_) => 0x03,
SlotValue::Array(_) => 0x04,
SlotValue::Object(_) => 0x05,
};
self.slots.push((type_byte, value));
u16::try_from(self.slots.len() - 1).unwrap()
}
fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
let tag = self.intern(tag);
let attrs: Vec<(u32, u32)> = attrs
.iter()
.map(|(k, v)| (self.intern(k), self.intern(v)))
.collect();
self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
self
}
fn close(&mut self, tag: &str) -> &mut Self {
let tag = self.intern(tag);
self.ops.extend_from_slice(&encode_close_tag(tag));
self
}
fn text(&mut self, text: &str) -> &mut Self {
let text = self.intern(text);
self.ops.extend_from_slice(&encode_text(text));
self
}
fn dyn_text(&mut self, slot: u16) -> &mut Self {
self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
self
}
fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
let name = self.intern(name);
self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
self
}
fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
let name = self.intern(name);
self.ops
.extend_from_slice(&encode_prop(source, name, target));
self
}
fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
let outer = std::mem::take(&mut self.ops);
f(self);
std::mem::replace(&mut self.ops, outer)
}
fn show_if(
&mut self,
slot: u16,
then_ops: impl FnOnce(&mut Self),
else_ops: impl FnOnce(&mut Self),
) -> &mut Self {
let then_ops = self.body(then_ops);
let else_ops = self.body(else_ops);
self.ops
.extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
self
}
fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
let body = self.body(body);
self.ops.extend_from_slice(&encode_list(slot, item, &body));
self
}
fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
let name = self.intern(&format!("Island{id}"));
let offset = u32::try_from(self.ops.len()).unwrap();
self.islands.push((id, name, offset));
self.ops.extend_from_slice(&encode_island_start(id));
body(self);
self.ops.extend_from_slice(&encode_island_end(id));
self
}
fn module_and_slots(&self) -> (IrModule, SlotData) {
let mut strings = self.strings.clone();
let mut decls = Vec::new();
for (id, (type_byte, _)) in self.slots.iter().enumerate() {
strings.push(format!("slot{id}"));
let name = u32::try_from(strings.len() - 1).unwrap();
decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
}
let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
.islands
.iter()
.map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
.collect();
let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
let data = build_declared_fixture(&strings, &decls, &self.ops, &islands);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(self.slots.len());
for (id, (_, value)) in self.slots.iter().enumerate() {
slots.set(u16::try_from(id).unwrap(), value.clone());
}
(module, slots)
}
fn render(&self) -> String {
let (module, slots) = self.module_and_slots();
walk_to_html(&module, &slots).unwrap()
}
fn render_island(&self, id: u16) -> String {
let (module, slots) = self.module_and_slots();
walk_island(&module, &slots, id).unwrap()
}
}
fn text(s: &str) -> SlotValue {
SlotValue::Text(s.to_string())
}
#[test]
fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
let mut page = Page::new();
let inside = page.slot(text("a<b&c"));
let outside = page.slot(text("world"));
page.open("div", &[])
.open(parent, &[])
.dyn_text(inside)
.close(parent)
.open("p", &[])
.dyn_text(outside)
.close("p")
.close("div");
assert_eq!(
page.render(),
format!(
"<div><{parent}>a<b&c</{parent}>\
<p><!--f:t1-->world<!--/f:t1--></p></div>"
),
"inside <{parent}>"
);
}
}
#[test]
fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
let mut page = Page::new();
let value = page.slot(text("hello"));
page.open(parent, &[]).dyn_text(value).close(parent);
assert_eq!(
page.render(),
format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
"inside <{parent}>"
);
}
}
#[test]
fn an_empty_value_in_a_text_only_parent_writes_nothing() {
let mut page = Page::new();
let inside = page.slot(text(""));
let outside = page.slot(text(""));
page.open("textarea", &[])
.dyn_text(inside)
.close("textarea")
.open("p", &[])
.dyn_text(outside)
.close("p");
assert_eq!(
page.render(),
"<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
);
}
#[test]
fn a_textarea_doubles_a_leading_line_feed() {
fn textarea(build: impl FnOnce(&mut Page)) -> String {
let mut page = Page::new();
page.open("textarea", &[]);
build(&mut page);
page.close("textarea");
page.render()
}
let cases: [(&str, String, &str); 5] = [
(
"a dynamic value",
textarea(|p| {
let v = p.slot(text("\nhello"));
p.dyn_text(v);
}),
"<textarea>\n\nhello</textarea>",
),
(
"a CRLF value",
textarea(|p| {
let v = p.slot(text("\r\nhello"));
p.dyn_text(v);
}),
"<textarea>\n\nhello</textarea>",
),
(
"an empty value first: the first written byte decides",
textarea(|p| {
let empty = p.slot(text(""));
let v = p.slot(text("\nx"));
p.dyn_text(empty).dyn_text(v);
}),
"<textarea>\n\nx</textarea>",
),
(
"an LF after other content is kept as is",
textarea(|p| {
let v = p.slot(text("\nb"));
p.text("a").dyn_text(v);
}),
"<textarea>a\nb</textarea>",
),
(
"static text",
textarea(|p| {
p.text("\nstatic");
}),
"<textarea>\n\nstatic</textarea>",
),
];
for (case, html, expected) in cases {
assert_eq!(html, expected, "{case}");
}
let mut page = Page::new();
let v = page.slot(text("\nhello"));
page.open("title", &[]).dyn_text(v).close("title");
assert_eq!(page.render(), "<title>\nhello</title>");
}
#[test]
fn no_marker_kind_is_written_inside_a_text_only_parent() {
let mut page = Page::new();
let loading = page.slot(SlotValue::Bool(false));
let name = page.slot(text("Ada"));
let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
let tag = page.slot(SlotValue::Null);
page.open("title", &[])
.show_if(
loading,
|p| {
p.text("Loading");
},
|p| {
p.dyn_text(name);
},
)
.text(" | ")
.list(tags, tag, |p| {
p.dyn_text(tag).text(";");
})
.close("title")
.open("p", &[])
.show_if(
loading,
|p| {
p.text("Loading");
},
|p| {
p.dyn_text(name);
},
)
.close("p");
assert_eq!(
page.render(),
"<title>Ada | x;y;</title>\
<p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
);
let mut page = Page::new();
page.open("textarea", &[])
.island(0, |p| {
p.text("x");
})
.close("textarea")
.island(1, |p| {
p.open("div", &[]).close("div");
});
assert_eq!(
page.render(),
"<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
data-forma-component=\"Island1\" data-forma-status=\"pending\" \
data-forma-hydrate=\"load\" data-forma-state=\"1\" data-forma-h=\"0101010101010101\"></div><!--/f:i1-->"
);
}
#[test]
fn a_textarea_value_binding_is_written_as_its_text() {
let cases = [
(text("hello"), "hello"),
(
text("</textarea><script>x</script>"),
"</textarea><script>x</script>",
),
(text("\nhello"), "\n\nhello"),
(SlotValue::Null, ""),
(SlotValue::Number(0.0), "0"),
(SlotValue::Bool(false), "false"),
];
for (value, inner) in cases {
let mut page = Page::new();
let v = page.slot(value.clone());
let note = page.slot(text("note"));
page.open("textarea", &[("name", "t")])
.dyn_attr("value", v)
.close("textarea")
.open("p", &[])
.dyn_text(note)
.close("p");
assert_eq!(
page.render(),
format!(
"<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
),
"value {value:?}"
);
}
let mut page = Page::new();
page.open("textarea", &[("name", "t"), ("value", "hello")])
.close("textarea");
assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
}
#[test]
fn a_textarea_value_binding_wins_over_its_children() {
let mut page = Page::new();
let v = page.slot(text("bound"));
let child = page.slot(text("child"));
page.open("textarea", &[])
.dyn_attr("value", v)
.dyn_text(child)
.text(" default")
.close("textarea")
.open("p", &[])
.dyn_text(child)
.close("p");
assert_eq!(
page.render(),
"<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
);
}
fn select_page(value: SlotValue) -> String {
let mut page = Page::new();
let v = page.slot(value);
page.open("select", &[("name", "c")]).dyn_attr("value", v);
for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
page.render()
}
fn option(value: &str, label: &str, selected: bool) -> String {
let selected = if selected { " selected" } else { "" };
format!("<option value=\"{value}\"{selected}>{label}</option>")
}
#[test]
fn a_select_value_binding_selects_the_matching_option() {
for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
let expected = format!(
"<select name=\"c\">{}{}{}</select>",
option("a", "Apple", chosen == "a"),
option("b", "Banana", chosen == "b"),
option("c", "Cherry", chosen == "c"),
);
assert_eq!(select_page(text(value)), expected, "value {value}");
}
}
#[test]
fn a_select_value_binding_compares_as_the_client_string() {
for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
let mut page = Page::new();
let v = page.slot(value.clone());
page.open("select", &[]).dyn_attr("value", v);
for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
let placeholder = if chosen.is_empty() {
"<option value selected>Choose</option>"
} else {
"<option value>Choose</option>"
};
let expected = format!(
"<select>{placeholder}{}{}</select>",
option("1", "One", chosen == "1"),
option("2", "Two", chosen == "2"),
);
assert_eq!(page.render(), expected, "value {value:?}");
}
}
#[test]
fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
let render = |value: &str| {
let mut page = Page::new();
let v = page.slot(text(value));
let label = page.slot(text("Blue"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[])
.text("Red")
.close("option")
.open("option", &[])
.text(" Dark \n green ")
.close("option")
.open("option", &[])
.dyn_text(label)
.close("option")
.close("select");
page.render()
};
assert_eq!(
render("Dark green"),
"<select><option>Red</option><option selected> Dark \n green </option>\
<option><!--f:t1-->Blue<!--/f:t1--></option></select>"
);
assert_eq!(
render("Blue"),
"<select><option>Red</option><option> Dark \n green </option>\
<option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
);
}
#[test]
fn a_select_value_binding_reaches_options_rendered_by_a_list() {
let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
.iter()
.map(|(id, label)| (id.to_string(), label.to_string()))
.collect();
let long: Vec<(String, String)> = (0..45)
.map(|i| (format!("r{i}"), format!("Row {i}")))
.collect();
let cases: [(&[(String, String)], &str, usize); 4] = [
(&short, "a", 0),
(&short, "c", 2),
(&long, "r40", 40),
(&long, "r44", 44),
];
for (rows_in, value, chosen) in cases {
let mut page = Page::new();
let v = page.slot(text(value));
let rows = rows_in
.iter()
.map(|(id, label)| {
SlotValue::object(vec![
("id".to_string(), text(id)),
("label".to_string(), text(label)),
])
})
.collect();
let items = page.slot(SlotValue::array(rows));
let item = page.slot(SlotValue::Null);
let id = page.slot(SlotValue::Null);
let label = page.slot(SlotValue::Null);
page.open("select", &[])
.dyn_attr("value", v)
.list(items, item, |p| {
p.prop(item, "id", id)
.prop(item, "label", label)
.open("option", &[])
.dyn_attr("value", id)
.dyn_text(label)
.close("option");
})
.close("select");
let expected: String = rows_in
.iter()
.enumerate()
.map(|(i, (id, label))| {
let selected = if i == chosen { " selected" } else { "" };
format!(
"<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
)
})
.collect();
assert_eq!(
page.render(),
format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
"value {value}"
);
}
}
#[test]
fn a_select_binding_ends_with_its_select() {
let mut page = Page::new();
let v = page.slot(text("b"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[("value", "a")])
.text("A")
.close("option")
.close("select")
.open("datalist", &[])
.open("option", &[("value", "b")])
.text("B")
.close("option")
.close("datalist");
assert_eq!(
page.render(),
format!(
"<select>{}</select><datalist>{}</datalist>",
option("a", "A", false),
option("b", "B", false)
)
);
}
#[test]
fn an_options_first_value_attribute_is_its_value() {
for (bound, selected) in [("a", " selected"), ("b", "")] {
let mut page = Page::new();
let v = page.slot(text(bound));
let second = page.slot(text("b"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[("value", "a")])
.dyn_attr("value", second)
.text("Only")
.close("option")
.close("select");
assert_eq!(
page.render(),
format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
"bound to {bound}"
);
}
}
#[test]
fn multiple_counts_only_when_the_attribute_is_written() {
for (multiple, attr, chosen) in [
(SlotValue::Bool(false), "", false),
(SlotValue::Bool(true), " multiple", true),
(SlotValue::Text(String::new()), "", false),
(SlotValue::Number(0.0), "", false),
(SlotValue::Number(-0.0), "", false),
(SlotValue::Text("false".into()), " multiple", true),
(SlotValue::Number(1.0), " multiple", true),
] {
let mut page = Page::new();
let flag = page.slot(multiple.clone());
let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
page.open("select", &[])
.dyn_attr("multiple", flag)
.dyn_attr("value", v);
for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
assert_eq!(
page.render(),
format!(
"<select{attr}>{}{}{}</select>",
option("a", "Apple", chosen),
option("b", "Banana", false),
option("c", "Cherry", chosen)
),
"multiple {multiple:?}"
);
}
}
#[test]
fn a_null_member_of_a_multiple_value_compares_as_null() {
let mut page = Page::new();
let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
page.open("select", &[("multiple", "")])
.dyn_attr("value", v);
for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
assert_eq!(
page.render(),
format!(
"<select multiple>{}{}{}</select>",
option("null", "Null", true),
option("x", "Empty", false),
option("b", "Banana", true)
)
);
}
#[test]
fn an_option_ends_at_the_next_option_or_optgroup() {
let mut page = Page::new();
let v = page.slot(text("A"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[])
.text("A")
.open("option", &[])
.text("B")
.close("select");
assert_eq!(
page.render(),
"<select><option selected>A<option>B</select>",
"ended by <option>"
);
let mut page = Page::new();
let v = page.slot(text("A"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[])
.text("A")
.open("optgroup", &[("label", "g")])
.open("option", &[("value", "x")])
.text("X")
.close("option")
.close("optgroup")
.close("select");
assert_eq!(
page.render(),
"<select><option selected>A<optgroup label=\"g\">\
<option value=\"x\">X</option></optgroup></select>",
"ended by <optgroup>"
);
}
#[test]
fn a_single_select_selects_only_the_first_matching_option() {
let mut page = Page::new();
let v = page.slot(text("b"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[("value", "b")])
.text("First")
.close("option")
.open("option", &[("value", "b")])
.text("Second")
.close("option")
.close("select");
assert_eq!(
page.render(),
format!(
"<select>{}{}</select>",
option("b", "First", true),
option("b", "Second", false)
)
);
}
#[test]
fn a_multiple_select_selects_every_option_in_an_array_value() {
let render = |attrs: &[(&str, &str)]| {
let mut page = Page::new();
let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
page.open("select", attrs).dyn_attr("value", v);
for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
page.render()
};
assert_eq!(
render(&[("multiple", "")]),
format!(
"<select multiple>{}{}{}</select>",
option("a", "Apple", true),
option("b", "Banana", false),
option("c", "Cherry", true)
)
);
assert_eq!(
render(&[]),
format!(
"<select>{}{}{}</select>",
option("a", "Apple", false),
option("b", "Banana", false),
option("c", "Cherry", false)
)
);
}
#[test]
fn a_bound_select_overrides_authored_selected_on_its_options() {
let render = |bound: bool| {
let mut page = Page::new();
let v = page.slot(text("a"));
let on = page.slot(SlotValue::Bool(true));
page.open("select", &[]);
if bound {
page.dyn_attr("value", v);
}
page.open("option", &[("value", "a")])
.text("A")
.close("option")
.open("option", &[("value", "b"), ("selected", "")])
.text("B")
.close("option")
.open("option", &[("value", "c")])
.dyn_attr("selected", on)
.text("C")
.close("option")
.close("select");
page.render()
};
assert_eq!(
render(true),
format!(
"<select>{}{}{}</select>",
option("a", "A", true),
option("b", "B", false),
option("c", "C", false)
)
);
assert_eq!(
render(false),
format!(
"<select>{}{}{}</select>",
option("a", "A", false),
option("b", "B", true),
option("c", "C", true)
)
);
}
#[test]
fn a_static_select_value_attribute_selects_its_option() {
let mut page = Page::new();
page.open("select", &[("value", "b")])
.open("option", &[("value", "a")])
.text("A")
.close("option")
.open("option", &[("value", "b")])
.text("B")
.close("option")
.close("select");
assert_eq!(
page.render(),
format!(
"<select>{}{}</select>",
option("a", "A", false),
option("b", "B", true)
)
);
}
#[test]
fn the_context_rules_hold_in_an_island_fragment() {
let mut page = Page::new();
let body = page.slot(text("hello"));
let choice = page.slot(text("b"));
page.island(0, |p| {
p.open("form", &[])
.open("textarea", &[])
.dyn_text(body)
.close("textarea")
.open("select", &[])
.dyn_attr("value", choice)
.open("option", &[("value", "a")])
.text("A")
.close("option")
.open("option", &[("value", "b")])
.text("B")
.close("option")
.close("select")
.close("form");
});
let inner = format!(
"<textarea>hello</textarea><select>{}{}</select>",
option("a", "A", false),
option("b", "B", true)
);
let full = page.render();
let fragment = page.render_island(0);
for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
assert!(html.contains(&inner), "{path}: {html}");
}
assert!(
full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
"{full}"
);
}
fn branding_module() -> IrModule {
let mut row = encode_open_tag(2, &[]);
row.extend_from_slice(&encode_dyn_text(2, 1));
row.extend_from_slice(&encode_close_tag(2));
let mut ops = encode_island_start(0);
ops.extend_from_slice(&encode_open_tag(0, &[]));
ops.extend_from_slice(&encode_dyn_text(0, 0));
ops.extend_from_slice(&encode_list(1, 2, &row));
ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(0));
let data = build_declared_fixture(
&["div", "Branding", "i", "name", "colors", "color"],
&[
(0, 3, 0x01, 0x00, &[]),
(1, 4, 0x04, 0x00, &[]),
(2, 5, 0x01, 0x01, &[]),
],
&ops,
&[(0, 0x01, 0x01, 1, 0, &[])],
);
IrModule::parse(&data).unwrap()
}
fn branding(name: &str, colors: usize) -> SlotData {
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::Text(name.to_string()));
slots.set(
1,
SlotValue::array(
(0..colors)
.map(|i| SlotValue::Text(format!("c{i}")))
.collect(),
),
);
slots
}
#[test]
fn counted_walk_writes_what_walk_to_html_writes_and_counts_it() {
let module = branding_module();
let slots = branding("Acme", 3);
let out = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
assert_eq!(out.html, walk_to_html(&module, &slots).unwrap());
assert_eq!(out.counts.bytes, out.html.len());
assert_eq!(out.counts.nodes, 8);
assert_eq!(out.counts.list_iterations, 3);
}
#[test]
fn counted_walk_returns_the_counts_reached_when_a_budget_stops_it() {
let module = branding_module();
let limits = WalkLimits {
max_list_iterations: 5,
..WalkLimits::default()
};
let failure = walk_to_html_counted(&module, &branding("Acme", 9), limits).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 5 });
assert_eq!(failure.counts.list_iterations, 0);
assert_eq!(failure.counts.nodes, 2);
assert_eq!(failure.component_path, vec!["Branding".to_string()]);
assert!(failure.counts.bytes > 0);
let limits = WalkLimits {
max_output_bytes: 400,
..WalkLimits::default()
};
let failure = walk_to_html_counted(&module, &branding("Acme", 50), limits).unwrap_err();
assert_eq!(failure.error, IrError::OutputLimitExceeded { limit: 400 });
let counts = failure.counts;
assert!(counts.bytes <= 400 && counts.bytes > 300, "{counts:?}");
assert!(
counts.list_iterations > 1 && counts.list_iterations < 50,
"{counts:?}"
);
assert!(
counts.nodes >= 2 + 2 * (counts.list_iterations - 1),
"{counts:?}"
);
assert_eq!(failure.component_path, vec!["Branding".to_string()]);
}
#[test]
fn counted_walk_names_no_component_for_a_failure_outside_every_island() {
let mut ops = encode_open_tag(0, &[]);
ops.extend_from_slice(&encode_open_tag(1, &[]));
let data = build_declared_fixture(&["p", "bad name"], &[], &ops, &[]);
let module = IrModule::parse(&data).unwrap();
let failure =
walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
assert_eq!(failure.error, IrError::InvalidName("bad name".into()));
assert!(failure.component_path.is_empty());
assert_eq!(failure.counts.nodes, 1);
}
#[test]
fn counted_walk_names_every_island_around_a_failure_outermost_first() {
let mut ops = encode_island_start(0); ops.extend_from_slice(&encode_open_tag(0, &[]));
let done_at = ops.len() as u32;
ops.extend_from_slice(&encode_island_start(2)); ops.extend_from_slice(&encode_open_tag(0, &[]));
ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(2));
let card_at = ops.len() as u32;
ops.extend_from_slice(&encode_island_start(1)); ops.extend_from_slice(&encode_open_tag(0, &[]));
ops.extend_from_slice(&encode_open_tag(4, &[])); ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(1));
ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(0));
let data = build_declared_fixture(
&["div", "Shell", "Card", "Done", "bad name"],
&[],
&ops,
&[
(0, 0x01, 0x01, 1, 0, &[]),
(1, 0x01, 0x01, 2, card_at, &[]),
(2, 0x01, 0x01, 3, done_at, &[]),
],
);
let module = IrModule::parse(&data).unwrap();
let failure =
walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
assert_eq!(
failure.component_path,
vec!["Shell".to_string(), "Card".to_string()]
);
}
#[test]
fn the_fault_trigger_is_armed_only_by_the_counted_walk() {
let module = branding_module();
let slots = branding(crate::fault_injection::FAULT_TRIGGER, 1);
let html = walk_to_html(&module, &slots).expect("walk_to_html never arms it");
assert!(html.contains("forma-test-fault"), "{html}");
let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
assert!(matches!(failure.error, IrError::Panicked { .. }));
}
#[test]
fn counted_walk_catches_a_panic_and_names_its_island() {
let module = branding_module();
let slots = branding(crate::fault_injection::FAULT_TRIGGER, 2);
let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
match &failure.error {
IrError::Panicked { message } => {
assert!(message.contains("stand-in"), "{message}")
}
other => panic!("expected Panicked, got {other:?}"),
}
assert_eq!(failure.component_path, vec!["Branding".to_string()]);
let out =
walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
assert!(out.html.contains("Acme"));
}
fn lists_module(lists: usize, nested: bool) -> IrModule {
let mut ops = encode_open_tag(0, &[]);
let mut slots: Vec<(u16, u32, u8, u8, &[u8])> = Vec::new();
for k in 0..lists as u16 {
let (list_slot, item_slot) = (2 * k, 2 * k + 1);
let mut row = encode_open_tag(1, &[]);
if nested {
let inner_item = 2 * lists as u16;
let mut inner_row = encode_open_tag(1, &[]);
inner_row.extend_from_slice(&encode_close_tag(1));
row.extend_from_slice(&encode_list(item_slot, inner_item, &inner_row));
}
row.extend_from_slice(&encode_close_tag(1));
ops.extend_from_slice(&encode_list(list_slot, item_slot, &row));
slots.push((list_slot, 2, 0x04, 0x00, &[]));
slots.push((item_slot, 3, if nested { 0x04 } else { 0x01 }, 0x00, &[]));
}
if nested {
slots.push((2 * lists as u16, 3, 0x01, 0x00, &[]));
}
ops.extend_from_slice(&encode_close_tag(0));
let data = build_declared_fixture(&["div", "i", "items", "item"], &slots, &ops, &[]);
IrModule::parse(&data).unwrap()
}
fn rows(n: usize) -> SlotValue {
SlotValue::array((0..n).map(|_| SlotValue::Text("x".into())).collect())
}
fn limits(max_list_iterations: usize) -> WalkLimits {
WalkLimits {
max_list_iterations,
..WalkLimits::default()
}
}
#[test]
fn a_list_longer_than_the_remaining_budget_fails_before_its_first_row() {
let module = lists_module(1, false);
let mut slots = SlotData::new(2);
slots.set(0, rows(51));
let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
assert_eq!(failure.counts.list_iterations, 0);
assert_eq!(failure.counts.nodes, 1, "only the wrapping div was written");
slots.set(0, rows(50));
let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
assert_eq!(out.counts.list_iterations, 50);
assert_eq!(out.html.matches("<i>").count(), 50);
}
#[test]
fn a_list_exactly_at_the_remaining_budget_still_renders() {
let module = lists_module(2, false);
let mut slots = SlotData::new(4);
slots.set(0, rows(20));
slots.set(2, rows(30));
let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
assert_eq!(out.counts.list_iterations, 50);
assert_eq!(out.html.matches("<i>").count(), 50);
slots.set(2, rows(31));
let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
assert_eq!(
failure.counts.list_iterations, 20,
"the first list, and no more"
);
assert_eq!(failure.counts.nodes, 1 + 20);
}
#[test]
fn a_nested_list_is_checked_against_what_its_parents_left() {
let module = lists_module(1, true);
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::array((0..3).map(|_| rows(20)).collect()));
let failure = walk_to_html_counted(&module, &slots, limits(59)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 59 });
assert_eq!(failure.counts.list_iterations, 43);
let out = walk_to_html_counted(&module, &slots, limits(63)).unwrap();
assert_eq!(out.counts.list_iterations, 63);
assert!(walk_to_html_counted(&module, &slots, limits(62)).is_err());
}
#[test]
fn the_per_row_check_still_stops_a_list_whose_nested_rows_spend_the_budget() {
let module = lists_module(1, true);
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::array((0..3).map(|_| rows(11)).collect()));
let failure = walk_to_html_counted(&module, &slots, limits(24)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 24 });
assert_eq!(failure.counts.list_iterations, 24);
}
mod normalization_vectors {
use super::*;
use serde_json::Value;
const VECTORS: &str = include_str!("../tests/fixtures/parity/normalization.json");
fn cases(section: &str) -> Vec<Value> {
let all: Value = serde_json::from_str(VECTORS).unwrap();
all[section].as_array().unwrap().clone()
}
fn field<'a>(case: &'a Value, key: &str) -> &'a str {
case[key].as_str().unwrap()
}
fn children(case: &Value) -> Vec<String> {
case["children"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
}
fn escape_html(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn escape_attr(text: &str) -> String {
escape_html(text).replace('"', """)
}
fn marker(id: u16, text: &str) -> String {
let inner = if text.is_empty() {
"\u{200B}".to_string()
} else {
escape_html(text)
};
format!("<!--f:t{id}-->{inner}<!--/f:t{id}-->")
}
fn visible(rendered: &str) -> String {
let mut out = String::new();
let mut rest = rendered;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
let end = rest[start..].find("-->").unwrap() + start + 3;
rest = &rest[end..];
}
out.push_str(rest);
out.replace('\u{200B}', "")
}
fn check(failures: &mut Vec<String>, label: &str, actual: &str, expected: &str) {
if actual != expected {
failures.push(format!(
"{label}: expected {expected:?}, walker wrote {actual:?}"
));
}
}
fn finish(failures: Vec<String>) {
assert!(
failures.is_empty(),
"{} disagreeing vector(s):\n{}",
failures.len(),
failures.join("\n")
);
}
#[test]
fn text_and_dyn_text_write_each_value_normalized() {
let mut failures = Vec::new();
for case in cases("values") {
let (id, input, expected) = (
field(&case, "id"),
field(&case, "input"),
field(&case, "expected"),
);
let mut page = Page::new();
let slot = page.slot(text(input));
page.open("p", &[])
.text(input)
.close("p")
.open("p", &[])
.dyn_text(slot)
.close("p");
let want = format!(
"<p>{}</p><p>{}</p>",
escape_html(expected),
marker(slot, expected)
);
check(
&mut failures,
&format!("values/{id} text+dyn_text"),
&page.render(),
&want,
);
}
finish(failures);
}
#[test]
fn static_and_dynamic_attributes_write_each_value_normalized() {
let mut failures = Vec::new();
for case in cases("values") {
let (id, input, expected) = (
field(&case, "id"),
field(&case, "input"),
field(&case, "expected"),
);
let mut page = Page::new();
let slot = page.slot(text(input));
page.open("p", &[("title", input), ("id", "ok")])
.close("p")
.open("p", &[("id", "ok")])
.dyn_attr("title", slot)
.close("p");
let e = escape_attr(expected);
let static_attr = if expected.is_empty() {
" title".to_string()
} else {
format!(" title=\"{e}\"")
};
let want = format!("<p{static_attr} id=\"ok\"></p><p id=\"ok\" title=\"{e}\"></p>");
check(
&mut failures,
&format!("values/{id} attr+dyn_attr"),
&page.render(),
&want,
);
}
finish(failures);
}
#[test]
fn adjacent_values_normalize_each_before_composition() {
let mut failures = Vec::new();
for case in cases("children") {
let (id, expected) = (field(&case, "id"), field(&case, "expected"));
let values = children(&case);
let mut page = Page::new();
page.open("p", &[]);
for value in &values {
page.text(value);
}
page.close("p");
let want = format!("<p>{}</p>", escape_html(expected));
check(
&mut failures,
&format!("children/{id} adjacent TEXT"),
&page.render(),
&want,
);
let mut page = Page::new();
let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
page.open("p", &[]);
for slot in &slots {
page.dyn_text(*slot);
}
page.close("p");
check(
&mut failures,
&format!("children/{id} adjacent DYN_TEXT (visible text)"),
&visible(&page.render()),
&want,
);
let mut page = Page::new();
let slots: Vec<u16> = values[1..].iter().map(|v| page.slot(text(v))).collect();
page.open("p", &[]).text(&values[0]);
for slot in &slots {
page.dyn_text(*slot);
}
page.close("p");
check(
&mut failures,
&format!("children/{id} TEXT then DYN_TEXT (visible text)"),
&visible(&page.render()),
&want,
);
}
finish(failures);
}
#[test]
fn textarea_children_protect_only_an_initial_normalized_line_feed() {
let mut failures = Vec::new();
for case in cases("textarea") {
let (id, html) = (field(&case, "id"), field(&case, "html"));
let values = children(&case);
let mut page = Page::new();
page.open("textarea", &[]);
for value in &values {
page.text(value);
}
page.close("textarea");
check(
&mut failures,
&format!("textarea/{id} static"),
&page.render(),
html,
);
let mut page = Page::new();
let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
page.open("textarea", &[]);
for slot in &slots {
page.dyn_text(*slot);
}
page.close("textarea");
check(
&mut failures,
&format!("textarea/{id} dynamic"),
&page.render(),
html,
);
}
finish(failures);
}
#[test]
fn url_attributes_are_checked_as_the_normalized_string_that_is_written() {
let mut failures = Vec::new();
for case in cases("unsafeUrlNearMiss") {
let (id, input) = (field(&case, "id"), field(&case, "input"));
let attribute = case["attribute"].as_str().unwrap_or("href");
let tag = case["tag"].as_str().unwrap_or("a");
let refused = case["refused"].as_bool().unwrap();
let mut page = Page::new();
let slot = page.slot(text(input));
page.open(tag, &[(attribute, input), ("id", "ok")])
.close(tag)
.open(tag, &[("id", "ok")])
.dyn_attr(attribute, slot)
.close(tag);
let want = if refused {
format!("<{tag} id=\"ok\"></{tag}><{tag} id=\"ok\"></{tag}>")
} else {
let written = escape_attr(field(&case, "written"));
format!(
"<{tag} {attribute}=\"{written}\" id=\"ok\"></{tag}>\
<{tag} id=\"ok\" {attribute}=\"{written}\"></{tag}>"
)
};
check(
&mut failures,
&format!("unsafeUrlNearMiss/{id}"),
&page.render(),
&want,
);
}
finish(failures);
}
#[test]
fn pre_children_protect_only_an_initial_normalized_line_feed() {
let mut failures = Vec::new();
for case in cases("pre") {
let (id, html, dynamic) = (
field(&case, "id"),
field(&case, "html"),
field(&case, "htmlDynamic"),
);
let values = children(&case);
for tag in ["pre", "listing", "PRE"] {
let mut page = Page::new();
page.open(tag, &[]);
for value in &values {
page.text(value);
}
page.close(tag);
let want = html
.replace("<pre>", &format!("<{tag}>"))
.replace("</pre>", &format!("</{tag}>"));
check(
&mut failures,
&format!("pre/{id} static <{tag}>"),
&page.render(),
&want,
);
}
let mut page = Page::new();
let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
page.open("pre", &[]);
for slot in &slots {
page.dyn_text(*slot);
}
page.close("pre");
check(
&mut failures,
&format!("pre/{id} dynamic"),
&page.render(),
dynamic,
);
}
finish(failures);
}
#[test]
fn the_pre_boundary_is_consumed_by_any_token_but_not_by_a_value_only_opcode() {
let mut page = Page::new();
page.open("pre", &[])
.open("b", &[])
.close("b")
.text("\nx")
.close("pre");
assert_eq!(page.render(), "<pre><b></b>\nx</pre>");
let mut page = Page::new();
let shown = page.slot(SlotValue::Bool(true));
page.open("pre", &[])
.show_if(
shown,
|p| {
p.text("\nx");
},
|_| {},
)
.close("pre");
let rendered = page.render();
assert!(rendered.starts_with("<pre><!--f:s"), "{rendered:?}");
assert!(rendered.contains("-->\nx<!--/f:s"), "{rendered:?}");
assert!(!rendered.contains("\n\n"), "{rendered:?}");
let mut page = Page::new();
let object = page.slot(SlotValue::object(vec![]));
let target = page.slot(text(""));
page.open("pre", &[])
.prop(object, "name", target)
.text("\nx")
.close("pre")
.open("pre", &[])
.text("\ny")
.close("pre")
.open("p", &[])
.text("\nz")
.close("p");
assert_eq!(page.render(), "<pre>\n\nx</pre><pre>\n\ny</pre><p>\nz</p>");
}
#[test]
fn the_output_budget_counts_the_normalized_and_escaped_bytes_actually_written() {
for (input, written) in [
("xxxxxxxxxx\0", "xxxxxxxxxx\u{FFFD}"),
("xxxxxxxxxx😀", "xxxxxxxxxx😀"),
("xxxxxxxxxx&", "xxxxxxxxxx&"),
("xxxxxxxxxx\r\n", "xxxxxxxxxx\n"),
] {
let mut page = Page::new();
page.text(input);
let (module, slots) = page.module_and_slots();
let limits = |max_output_bytes| WalkLimits {
max_output_bytes,
..WalkLimits::default()
};
let exact = written.len();
assert_eq!(
walk_to_html_with_limits(&module, &slots, limits(exact)).as_deref(),
Ok(written),
"{input:?} at its exact budget"
);
assert_eq!(
walk_to_html_with_limits(&module, &slots, limits(exact - 1)),
Err(IrError::OutputLimitExceeded { limit: exact - 1 }),
"{input:?} one byte under its budget"
);
}
}
}
mod inv1_vectors {
use super::*;
use serde_json::Value;
#[test]
fn names_and_urls_agree_for_static_and_dynamic_attributes() {
let vectors: Value =
serde_json::from_str(include_str!("../tests/fixtures/parity/inv1.json")).unwrap();
let mut failures = Vec::new();
for (section, tag, value) in [("names", "div", "value"), ("urls", "", "")] {
for case in vectors[section].as_array().unwrap() {
let name = case["name"].as_str().unwrap();
let tag = case["tag"].as_str().unwrap_or(tag);
let value = case["value"].as_str().unwrap_or(value);
let written = case["written"].as_bool().unwrap();
if section == "names" {
assert_eq!(
is_valid_attr_name(name),
case["valid"].as_bool().unwrap(),
"{}",
case["id"]
);
}
let mut escaped = String::new();
push_escaped_attr(&mut escaped, value, usize::MAX).unwrap();
let attribute = if written {
format!(" {name}=\"{escaped}\"")
} else {
String::new()
};
let want = format!("<{tag} id=\"ok\"{attribute}></{tag}>");
let mut static_page = Page::new();
static_page
.open(tag, &[("id", "ok"), (name, value)])
.close(tag);
let mut dynamic_page = Page::new();
let slot = dynamic_page.slot(SlotValue::Text(value.to_string()));
dynamic_page
.open(tag, &[("id", "ok")])
.dyn_attr(name, slot)
.close(tag);
for (writer, actual) in [
("static", static_page.render()),
("dynamic", dynamic_page.render()),
] {
let expected = if writer == "static" && written && value.is_empty() {
format!("<{tag} id=\"ok\" {name}></{tag}>")
} else {
want.clone()
};
if actual != expected {
failures.push(format!(
"{} {writer}: {actual:?} != {expected:?}",
case["id"]
));
}
}
}
}
let names: Vec<_> = vectors["urlAttributes"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(names, URL_ATTRS);
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn ordinary_payloads_are_escaped_in_both_output_contexts() {
let vectors: Value =
serde_json::from_str(include_str!("../tests/fixtures/parity/inv1.json")).unwrap();
for payload in vectors["payloads"].as_array().unwrap() {
let text = payload["text"].as_str().unwrap();
let attr = payload["attribute"].as_str().unwrap();
let payload = payload["value"].as_str().unwrap();
let mut page = Page::new();
page.open("p", &[("title", payload)])
.text(payload)
.close("p");
let html = page.render();
assert!(!html.contains("<script"));
assert!(!html.contains("<img"));
assert!(!html.contains("</textarea>"));
assert_eq!(html, format!("<p title=\"{attr}\">{text}</p>"));
}
}
}
mod show_text_branch_vectors {
use super::*;
use serde_json::Value;
const VECTORS: &str = include_str!("../tests/fixtures/parity/show-text-branch.json");
#[test]
fn a_dyn_text_branch_is_written_between_the_conditional_markers() {
let all: Value = serde_json::from_str(VECTORS).unwrap();
let mut failures = Vec::new();
let mut walked = 0;
for case in all["values"].as_array().unwrap() {
if case["clientOnly"].as_bool() == Some(true) {
continue;
}
walked += 1;
let id = case["id"].as_str().unwrap();
let mut page = Page::new();
let shown = page.slot(SlotValue::Bool(true));
let value = page.slot(crate::slot::json_to_slot_value(&case["value"]));
page.show_if(
shown,
|p| {
p.dyn_text(value);
},
|_| {},
);
let want = format!("<!--f:s0-->{}<!--/f:s0-->", case["html"].as_str().unwrap());
let got = page.render();
if got != want {
failures.push(format!("{id}: expected {want:?}, walker wrote {got:?}"));
}
}
assert!(walked >= 9, "the vectors were not read");
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
}
mod value_rule_vectors {
use super::*;
use serde_json::Value;
const NUMBERS: &str = include_str!("../tests/fixtures/parity/numbers.json");
const INTEGERS: &str = include_str!("../tests/fixtures/parity/integers.json");
const NULL: &str = include_str!("../tests/fixtures/parity/null.json");
const BOOLEAN_TEXT: &str = include_str!("../tests/fixtures/parity/boolean-text.json");
const CONDITIONS: &str = include_str!("../tests/fixtures/parity/conditions.json");
const ATTRIBUTES: &str = include_str!("../tests/fixtures/parity/attributes.json");
fn cases(file: &str, section: &str) -> Vec<Value> {
let all: Value = serde_json::from_str(file).unwrap();
all[section].as_array().unwrap().clone()
}
fn field<'a>(case: &'a Value, key: &str) -> &'a str {
case[key].as_str().unwrap()
}
fn marker(id: u16, text: &str) -> String {
let inner = if text.is_empty() {
"\u{200B}".to_string()
} else {
text.to_string()
};
format!("<!--f:t{id}-->{inner}<!--/f:t{id}-->")
}
fn visible(rendered: &str) -> String {
let mut out = String::new();
let mut rest = rendered;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
let end = rest[start..].find("-->").unwrap() + start + 3;
rest = &rest[end..];
}
out.push_str(rest);
out.replace('\u{200B}', "")
}
fn check(failures: &mut Vec<String>, label: &str, actual: &str, expected: &str) {
if actual != expected {
failures.push(format!(
"{label}: expected {expected:?}, walker wrote {actual:?}"
));
}
}
fn finish(failures: Vec<String>) {
assert!(
failures.is_empty(),
"{} disagreeing vector(s):\n{}",
failures.len(),
failures.join("\n")
);
}
fn vector_text(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn slot_value(value: &Value) -> SlotValue {
match value {
Value::Null => SlotValue::Null,
Value::Bool(b) => SlotValue::Bool(*b),
Value::Number(n) => SlotValue::Number(n.as_f64().unwrap()),
Value::String(s) => SlotValue::Text(s.clone()),
other => panic!("unsupported vector value {other}"),
}
}
#[test]
fn rule_2_numbers_are_written_in_ecmascript_formatting_as_text_and_attribute() {
let mut failures = Vec::new();
for case in cases(NUMBERS, "values") {
let (id, lexeme, expected) = (
field(&case, "id"),
field(&case, "lexeme"),
field(&case, "expected"),
);
let number: f64 = lexeme.parse().unwrap();
let mut page = Page::new();
let slot = page.slot(SlotValue::Number(number));
page.open("p", &[]).dyn_text(slot).close("p");
page.open("span", &[("id", "ok")])
.dyn_attr("title", slot)
.close("span");
let want = format!(
"<p>{}</p><span id=\"ok\" title=\"{expected}\"></span>",
marker(slot, expected)
);
check(
&mut failures,
&format!("numbers/{id}"),
&page.render(),
&want,
);
}
finish(failures);
}
#[test]
fn rule_3_an_admitted_exact_integer_is_written_as_its_decimal_digits() {
let mut failures = Vec::new();
for case in cases(INTEGERS, "exact") {
if !case["admitted"].as_bool().unwrap() {
continue;
}
let (id, lexeme) = (field(&case, "id"), field(&case, "lexeme"));
let number: f64 = lexeme.parse().unwrap();
let mut page = Page::new();
let slot = page.slot(SlotValue::Number(number));
page.open("p", &[]).dyn_text(slot).close("p");
let want = format!("<p>{}</p>", marker(slot, lexeme));
check(
&mut failures,
&format!("integers/exact/{id}"),
&page.render(),
&want,
);
}
for case in cases(INTEGERS, "floatOrigin") {
let (id, lexeme, expected) = (
field(&case, "id"),
field(&case, "lexeme"),
field(&case, "expected"),
);
let number: f64 = lexeme.parse().unwrap();
let mut page = Page::new();
let slot = page.slot(SlotValue::Number(number));
page.open("p", &[]).dyn_text(slot).close("p");
let want = format!("<p>{}</p>", marker(slot, expected));
check(
&mut failures,
&format!("integers/floatOrigin/{id}"),
&page.render(),
&want,
);
}
finish(failures);
}
#[test]
fn rule_5_null_writes_nothing_as_text_attribute_and_textarea_child() {
let mut failures = Vec::new();
for case in cases(NULL, "children") {
let (id, expected) = (field(&case, "id"), field(&case, "expected"));
let values = case["children"].as_array().unwrap();
let mut page = Page::new();
let slots: Vec<u16> = values.iter().map(|v| page.slot(slot_value(v))).collect();
page.open("p", &[]);
for slot in &slots {
page.dyn_text(*slot);
}
page.close("p");
let rendered = page.render();
check(
&mut failures,
&format!("null/children/{id} dynamic (visible text)"),
&visible(&rendered),
&format!("<p>{expected}</p>"),
);
let ranges: String = slots
.iter()
.zip(values)
.map(|(slot, value)| marker(*slot, &vector_text(value)))
.collect();
check(
&mut failures,
&format!("null/children/{id} dynamic"),
&rendered,
&format!("<p>{ranges}</p>"),
);
if rendered.contains("null") {
failures.push(format!(
"null/children/{id}: the word null was written: {rendered:?}"
));
}
let mut page = Page::new();
let slots: Vec<u16> = values.iter().map(|v| page.slot(slot_value(v))).collect();
page.open("textarea", &[]);
for slot in &slots {
page.dyn_text(*slot);
}
page.close("textarea");
check(
&mut failures,
&format!("null/children/{id} textarea"),
&page.render(),
&format!("<textarea>{expected}</textarea>"),
);
}
for case in cases(NULL, "attribute") {
let (id, attribute) = (field(&case, "id"), field(&case, "attribute"));
let mut page = Page::new();
let slot = page.slot(SlotValue::Null);
page.open("span", &[("id", "ok")])
.dyn_attr(attribute, slot)
.close("span");
check(
&mut failures,
&format!("null/attribute/{id}"),
&page.render(),
"<span id=\"ok\"></span>",
);
}
finish(failures);
}
#[test]
fn rule_6_the_string_near_miss_writes_the_boolean_word_and_the_idiom_its_range() {
let mut failures = Vec::new();
for case in cases(BOOLEAN_TEXT, "nearMissText") {
let (id, expected) = (field(&case, "id"), field(&case, "expected"));
let value = case["value"].as_bool().unwrap();
let mut page = Page::new();
let slot = page.slot(SlotValue::Bool(value));
page.open("p", &[]).dyn_text(slot).close("p");
check(
&mut failures,
&format!("boolean-text/nearMissText/{id}"),
&page.render(),
&format!("<p>{}</p>", marker(slot, expected)),
);
}
for case in cases(BOOLEAN_TEXT, "idiom") {
let (id, expected) = (field(&case, "id"), field(&case, "expected"));
let mut page = Page::new();
let slot = page.slot(if expected.is_empty() {
SlotValue::Null
} else {
SlotValue::Text(expected.to_string())
});
page.open("p", &[]).dyn_text(slot).close("p");
check(
&mut failures,
&format!("boolean-text/idiom/{id}"),
&page.render(),
&format!("<p>{}</p>", marker(slot, expected)),
);
}
finish(failures);
}
#[test]
fn rule_6_attribute_values_follow_one_table() {
let mut failures = Vec::new();
for case in cases(ATTRIBUTES, "values") {
let (id, attribute, html) = (
field(&case, "id"),
field(&case, "attribute"),
field(&case, "html"),
);
let mut page = Page::new();
let slot = page.slot(slot_value(&case["value"]));
page.open("input", &[("id", "ok")])
.dyn_attr(attribute, slot)
.close("input");
check(
&mut failures,
&format!("attributes/values/{id}"),
&page.render(),
&format!("<input id=\"ok\"{html}></input>"),
);
}
for name in cases(ATTRIBUTES, "booleanAttributes") {
let name = name.as_str().unwrap();
for (attribute, want) in [
(name.to_string(), format!(" {name}")),
(format!("data-{name}"), format!(" data-{name}=\"true\"")),
] {
let mut page = Page::new();
let slot = page.slot(SlotValue::Bool(true));
page.open("input", &[("id", "ok")])
.dyn_attr(&attribute, slot)
.close("input");
check(
&mut failures,
&format!("attributes/booleanAttributes/{attribute}"),
&page.render(),
&format!("<input id=\"ok\"{want}></input>"),
);
}
}
finish(failures);
}
#[test]
fn rule_7_a_boolean_condition_selects_the_branch() {
let mut failures = Vec::new();
for case in cases(CONDITIONS, "walker") {
let (id, branch) = (field(&case, "id"), field(&case, "branch"));
let value = case["value"].as_bool().unwrap();
let mut page = Page::new();
let slot = page.slot(SlotValue::Bool(value));
page.open("div", &[])
.show_if(
slot,
|p| {
p.open("b", &[]).text("then").close("b");
},
|p| {
p.open("i", &[]).text("else").close("i");
},
)
.close("div");
let inner = if branch == "then" {
"<b>then</b>"
} else {
"<i>else</i>"
};
check(
&mut failures,
&format!("conditions/walker/{id}"),
&page.render(),
&format!("<div><!--f:s{slot}-->{inner}<!--/f:s{slot}--></div>"),
);
}
finish(failures);
}
}
mod keyed_rows {
use super::*;
use crate::format::{ListKeyReason, ListKeyViolation};
use crate::parser::test_helpers::with_key_paths;
const STRINGS: &[&str] = &[
"rows", "row", "li", "id", "span", "label", "input", "type", "checkbox", "data-forma-key", "authored", "tags", "b", "Child", "ul", "DATA-FORMA-KEY", "text", "user", "tag", ];
const LI: u32 = 2;
const ID: u32 = 3;
const SPAN: u32 = 4;
const INPUT: u32 = 6;
const TYPE: u32 = 7;
const CHECKBOX: u32 = 8;
const KEY_ATTR: u32 = 9;
const AUTHORED: u32 = 10;
const TAGS: u32 = 11;
const B: u32 = 12;
const CHILD: u32 = 13;
const UL: u32 = 14;
const KEY_ATTR_UPPER: u32 = 15;
const TEXT: u32 = 16;
const USER: u32 = 17;
const SLOTS: &[(u16, u32, u8, u8, &[u8])] = &[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x05, 0x01, &[]),
(2, 11, 0x04, 0x01, &[]),
(3, 18, 0x05, 0x01, &[]),
(4, 5, 0x01, 0x00, &[]),
];
const LIST_HEADER: usize = 9;
fn keyed(opcodes: &[u8], paths: &[(u32, &[u32])]) -> IrModule {
let base = build_declared_fixture(STRINGS, SLOTS, opcodes, &[]);
IrModule::parse(&with_key_paths(&base, paths)).unwrap()
}
fn keyed_with_islands(
opcodes: &[u8],
paths: &[(u32, &[u32])],
islands: &[(u16, u8, u8, u32, u32, &[u16])],
) -> IrModule {
const ISLAND_SLOTS: &[(u16, u32, u8, u8, &[u8])] =
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x01, &[])];
let base = build_declared_fixture(STRINGS, ISLAND_SLOTS, opcodes, islands);
IrModule::parse(&with_key_paths(&base, paths)).unwrap()
}
fn unkeyed(opcodes: &[u8]) -> IrModule {
IrModule::parse(&build_declared_fixture(STRINGS, SLOTS, opcodes, &[])).unwrap()
}
fn rows(values: Vec<SlotValue>) -> SlotData {
let mut slots = SlotData::new(5);
slots.set(0, SlotValue::array(values));
slots.set(4, SlotValue::Text("x".into()));
slots
}
fn object(pairs: Vec<(&str, SlotValue)>) -> SlotValue {
SlotValue::object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
fn id(value: SlotValue) -> SlotValue {
object(vec![("id", value), ("name", text("secret"))])
}
fn text(value: &str) -> SlotValue {
SlotValue::Text(value.into())
}
fn num(value: f64) -> SlotValue {
SlotValue::Number(value)
}
fn refused(module: &IrModule, slots: &SlotData) -> ListKeyViolation {
match walk_to_html(module, slots) {
Err(IrError::InvalidListKey(violation)) => {
let message = IrError::InvalidListKey(violation.clone()).to_string();
assert!(message.starts_with("RENDER-503: LIST at bytecode offset "));
assert!(!message.contains("secret"), "{message}");
*violation
}
other => panic!("expected RENDER-503, got {other:?}"),
}
}
fn violation(
list_offset: u32,
row: usize,
path: &str,
reason: ListKeyReason,
) -> ListKeyViolation {
ListKeyViolation {
list_offset,
row,
path: path.into(),
reason,
}
}
fn li_body() -> Vec<u8> {
[
encode_open_tag(LI, &[]),
encode_open_tag(SPAN, &[]),
encode_close_tag(SPAN),
encode_close_tag(LI),
]
.concat()
}
#[test]
fn a_keyed_list_writes_each_rows_canonical_key_once_on_its_root() {
let ops = encode_list(0, 1, &li_body());
let data = rows(vec![
id(num(1.0)),
id(text("a&\"<b>'")),
id(num(-0.0)),
id(text("x\r\ny\0")),
id(num(-9_007_199_254_740_991.0)),
id(text("")),
]);
let html = walk_to_html(&keyed(&ops, &[(0, &[ID])]), &data).unwrap();
assert_eq!(
html,
concat!(
"<!--f:l0-->",
"<li data-forma-key=\"1\"><span></span></li>",
"<li data-forma-key=\"a&"<b>'\"><span></span></li>",
"<li data-forma-key=\"0\"><span></span></li>",
"<li data-forma-key=\"x\ny\u{FFFD}\"><span></span></li>",
"<li data-forma-key=\"-9007199254740991\"><span></span></li>",
"<li data-forma-key=\"\"><span></span></li>",
"<!--/f:l0-->"
)
);
}
#[test]
fn an_unkeyed_list_renders_byte_identically_with_no_key_and_no_key_rules() {
let ops = encode_list(0, 1, &li_body());
let data = rows(vec![id(num(1.0)), id(text("a"))]);
let keyed_out =
walk_to_html_counted(&keyed(&ops, &[(0, &[ID])]), &data, WalkLimits::default())
.unwrap();
let plain = walk_to_html_counted(&unkeyed(&ops), &data, WalkLimits::default()).unwrap();
assert_eq!(
plain.html,
"<!--f:l0--><li><span></span></li><li><span></span></li><!--/f:l0-->"
);
assert_eq!(
keyed_out
.html
.replace(" data-forma-key=\"1\"", "")
.replace(" data-forma-key=\"a\"", ""),
plain.html
);
assert_eq!(
keyed_out.counts.list_iterations,
plain.counts.list_iterations
);
assert_eq!(keyed_out.counts.nodes, plain.counts.nodes);
let anything = rows(vec![text("a"), text("a"), SlotValue::Null, object(vec![])]);
assert_eq!(
walk_to_html(&unkeyed(&ops), &anything).unwrap(),
format!(
"<!--f:l0-->{}<!--/f:l0-->",
"<li><span></span></li>".repeat(4)
)
);
let loose = [
encode_open_tag(LI, &[(KEY_ATTR, AUTHORED)]),
encode_close_tag(LI),
encode_text(TEXT),
encode_void_tag(INPUT, &[]),
]
.concat();
assert_eq!(
walk_to_html(&unkeyed(&encode_list(0, 1, &loose)), &rows(vec![text("a")])).unwrap(),
"<!--f:l0--><li data-forma-key=\"authored\"></li>text<input><!--/f:l0-->"
);
}
#[test]
fn a_void_root_carries_the_key_and_admits_nothing_beside_it() {
let void = encode_void_tag(INPUT, &[(TYPE, CHECKBOX)]);
let html = walk_to_html(
&keyed(&encode_list(0, 1, &void), &[(0, &[ID])]),
&rows(vec![id(num(7.0)), id(num(8.0))]),
)
.unwrap();
assert_eq!(
html,
"<!--f:l0--><input type=\"checkbox\" data-forma-key=\"7\"><input type=\"checkbox\" data-forma-key=\"8\"><!--/f:l0-->"
);
let beside = [void.clone(), encode_text(TEXT)].concat();
assert_eq!(
refused(
&keyed(&encode_list(0, 1, &beside), &[(0, &[ID])]),
&rows(vec![id(num(7.0))])
),
violation(
0,
0,
"id",
ListKeyReason::RowShape {
roots: 1,
stray_content: true
}
)
);
}
#[test]
fn a_registered_child_envelope_enclosing_the_root_puts_the_key_on_the_host() {
let body = [
encode_island_start(0),
encode_open_tag(B, &[]),
encode_close_tag(B),
encode_island_end(0),
]
.concat();
let ops = encode_list(0, 1, &body);
let island = (0, 0x01, 0x01, CHILD, LIST_HEADER as u32, &[][..]);
let module = keyed_with_islands(&ops, &[(0, &[ID])], &[island]);
let html = walk_to_html(&module, &rows(vec![id(num(1.0)), id(num(2.0))])).unwrap();
let host = |occurrence: usize, key: &str| {
format!("<!--f:i0--><b data-forma-island=\"0\" data-forma-component=\"Child\" data-forma-status=\"pending\" data-forma-hydrate=\"load\" data-forma-state=\"{occurrence}\" data-forma-h=\"0000000000000000\" data-forma-key=\"{key}\"></b><!--/f:i0-->")
};
assert_eq!(
html,
format!("<!--f:l0-->{}{}<!--/f:l0-->", host(0, "1"), host(1, "2"))
);
}
#[test]
fn an_island_envelope_that_does_not_wholly_enclose_the_root_refuses() {
let empty_island_then_root = [
encode_island_start(0),
encode_island_end(0),
encode_open_tag(LI, &[]),
encode_close_tag(LI),
]
.concat();
let root_then_child = [
encode_open_tag(LI, &[]),
encode_close_tag(LI),
encode_island_start(0),
encode_open_tag(B, &[]),
encode_close_tag(B),
encode_island_end(0),
]
.concat();
let cases: [(&[u8], usize, usize); 2] = [
(&empty_island_then_root, 0, 1),
(
&root_then_child,
encode_open_tag(LI, &[]).len() + encode_close_tag(LI).len(),
2,
),
];
for (body, at, roots) in cases {
let island = (0, 0x01, 0x01, CHILD, (LIST_HEADER + at) as u32, &[][..]);
let module = keyed_with_islands(&encode_list(0, 1, body), &[(0, &[ID])], &[island]);
assert_eq!(
refused(&module, &rows(vec![id(num(1.0))])),
violation(
0,
0,
"id",
ListKeyReason::RowShape {
roots,
stray_content: true
}
)
);
}
}
fn nested() -> (Vec<u8>, u32) {
let inner = encode_list(
2,
3,
&[encode_open_tag(B, &[]), encode_close_tag(B)].concat(),
);
let outer_body = [
encode_open_tag(LI, &[]),
encode_prop(1, TAGS, 2),
inner,
encode_close_tag(LI),
]
.concat();
let inner_offset =
LIST_HEADER + encode_open_tag(LI, &[]).len() + encode_prop(1, TAGS, 2).len();
(encode_list(0, 1, &outer_body), inner_offset as u32)
}
fn group(key: f64, tags: Vec<SlotValue>) -> SlotValue {
object(vec![("id", num(key)), ("tags", SlotValue::array(tags))])
}
#[test]
fn each_execution_of_a_list_is_its_own_duplicate_scope() {
let (ops, inner) = nested();
let module = keyed(&ops, &[(0, &[ID]), (inner, &[ID])]);
let data = rows(vec![
group(1.0, vec![id(text("a")), id(num(1.0))]),
group(2.0, vec![id(text("a"))]),
]);
assert_eq!(
walk_to_html(&module, &data).unwrap(),
concat!(
"<!--f:l0-->",
"<li data-forma-key=\"1\"><!--f:l2--><b data-forma-key=\"a\"></b><b data-forma-key=\"1\"></b><!--/f:l2--></li>",
"<li data-forma-key=\"2\"><!--f:l2--><b data-forma-key=\"a\"></b><!--/f:l2--></li>",
"<!--/f:l0-->"
)
);
let duplicate = rows(vec![
group(1.0, vec![id(text("a"))]),
group(2.0, vec![id(text("b")), id(text("a")), id(text("b"))]),
]);
assert_eq!(
refused(&module, &duplicate),
violation(inner, 2, "id", ListKeyReason::Duplicate { first_row: 0 })
);
let twice = [encode_list(0, 1, &li_body()), encode_list(0, 1, &li_body())].concat();
let second = encode_list(0, 1, &li_body()).len() as u32;
let html = walk_to_html(
&keyed(&twice, &[(0, &[ID]), (second, &[ID])]),
&rows(vec![id(num(1.0)), id(num(2.0))]),
)
.unwrap();
assert_eq!(html.matches("data-forma-key=\"1\"").count(), 2, "{html}");
assert_eq!(html.matches("data-forma-key=\"2\"").count(), 2, "{html}");
}
#[test]
fn keys_collide_on_their_canonical_text_not_on_their_type_or_spelling() {
let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
let collide = [
(num(1.0), text("1")),
(num(0.0), num(-0.0)),
(text("0"), num(-0.0)),
(text("a\r\nb"), text("a\nb")),
(text("a\rb"), text("a\nb")),
(text("\0"), text("\u{FFFD}")),
];
for (first, second) in collide {
let label = format!("{first:?} {second:?}");
assert_eq!(
refused(&module, &rows(vec![id(first), id(second)])),
violation(0, 1, "id", ListKeyReason::Duplicate { first_row: 0 }),
"{label}"
);
}
let distinct = [
(num(1.0), text("01")),
(text("A"), text("a")),
(text("a"), text("a ")),
(text("\u{e9}"), text("e\u{301}")),
(text("-0"), num(0.0)),
];
for (first, second) in distinct {
let label = format!("{first:?} {second:?}");
assert!(
walk_to_html(&module, &rows(vec![id(first), id(second)])).is_ok(),
"{label}"
);
}
}
#[test]
fn a_row_without_an_identity_at_the_path_refuses_with_its_reason() {
let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
let not_identity = |kind| ListKeyReason::NotAnIdentity { kind };
let twice = SlotValue::object(vec![("id".into(), num(1.0)), ("id".into(), num(2.0))]);
let cases = [
(
object(vec![("name", text("secret"))]),
ListKeyReason::MissingField {
segment: "id".into(),
},
),
(id(SlotValue::Null), not_identity("null")),
(id(SlotValue::Bool(true)), not_identity("a boolean")),
(
id(object(vec![("secret", num(1.0))])),
not_identity("an object"),
),
(
id(SlotValue::array(vec![text("secret")])),
not_identity("an array"),
),
(id(num(1.5)), ListKeyReason::NotASafeInteger),
(id(num(f64::NAN)), ListKeyReason::NotASafeInteger),
(id(num(f64::INFINITY)), ListKeyReason::NotASafeInteger),
(id(num(f64::NEG_INFINITY)), ListKeyReason::NotASafeInteger),
(
id(num(9_007_199_254_740_992.0)),
ListKeyReason::NotASafeInteger,
),
(
id(num(-9_007_199_254_740_992.0)),
ListKeyReason::NotASafeInteger,
),
(
text("secret"),
ListKeyReason::NotAnObject {
segment: "id".into(),
},
),
(
twice,
ListKeyReason::DuplicateField {
segment: "id".into(),
},
),
];
for (bad, reason) in cases {
assert_eq!(
refused(&module, &rows(vec![id(num(1.0)), bad])),
violation(0, 1, "id", reason)
);
}
let nested = keyed(&encode_list(0, 1, &li_body()), &[(0, &[USER, ID])]);
let user = |value| object(vec![("user", value)]);
assert_eq!(
walk_to_html(&nested, &rows(vec![user(id(text("u1")))])).unwrap(),
"<!--f:l0--><li data-forma-key=\"u1\"><span></span></li><!--/f:l0-->"
);
for value in [
text("u1"),
SlotValue::Null,
SlotValue::array(vec![id(num(1.0))]),
] {
assert_eq!(
refused(&nested, &rows(vec![user(value)])),
violation(
0,
0,
"user.id",
ListKeyReason::NotAnObject {
segment: "id".into()
}
)
);
}
}
#[test]
fn a_keyed_row_renders_exactly_one_balanced_root_and_nothing_beside_it() {
let shape = |roots, stray_content| ListKeyReason::RowShape {
roots,
stray_content,
};
let li = [encode_open_tag(LI, &[]), encode_close_tag(LI)].concat();
let cases: Vec<(&str, Vec<u8>, ListKeyReason)> = vec![
("empty row", Vec::new(), shape(0, false)),
(
"two roots",
[li.clone(), li.clone()].concat(),
shape(2, false),
),
(
"text beside",
[li.clone(), encode_text(TEXT)].concat(),
shape(1, true),
),
("text only", encode_text(TEXT), shape(0, true)),
("dynamic text only", encode_dyn_text(4, 0), shape(0, true)),
(
"conditional root",
encode_show_if(4, &li, &li),
shape(1, true),
),
(
"list at the row's level",
encode_list(2, 3, &li),
shape(1, true),
),
(
"closes the list's parent",
encode_close_tag(UL),
ListKeyReason::UnbalancedRow,
),
(
"closes the parent after its root",
[li.clone(), encode_close_tag(UL), encode_open_tag(UL, &[])].concat(),
ListKeyReason::UnbalancedRow,
),
(
"leaves its root open",
encode_open_tag(LI, &[]),
ListKeyReason::UnbalancedRow,
),
];
for (name, body, reason) in cases {
let ops = [
encode_open_tag(UL, &[]),
encode_list(0, 1, &body),
encode_close_tag(UL),
]
.concat();
let offset = encode_open_tag(UL, &[]).len() as u32;
let module = keyed(&ops, &[(offset, &[ID])]);
let mut data = rows(vec![id(num(1.0))]);
data.set(2, SlotValue::array(vec![text("t")]));
assert_eq!(
refused(&module, &data),
violation(offset, 0, "id", reason),
"{name}"
);
}
}
#[test]
fn an_authored_data_forma_key_on_the_root_refuses_and_on_a_descendant_renders() {
let authored = |body: Vec<u8>| {
refused(
&keyed(&encode_list(0, 1, &body), &[(0, &[ID])]),
&rows(vec![id(num(1.0))]),
)
};
let expected = violation(0, 0, "id", ListKeyReason::AuthoredKey);
for attr in [KEY_ATTR, KEY_ATTR_UPPER] {
assert_eq!(
authored(
[
encode_open_tag(LI, &[(attr, AUTHORED)]),
encode_close_tag(LI)
]
.concat()
),
expected
);
assert_eq!(
authored(encode_void_tag(INPUT, &[(attr, AUTHORED)])),
expected
);
assert_eq!(
authored(
[
encode_open_tag(LI, &[]),
encode_dyn_attr(attr, 4),
encode_close_tag(LI)
]
.concat()
),
expected
);
}
let descendant = [
encode_open_tag(LI, &[]),
encode_open_tag(SPAN, &[(KEY_ATTR, AUTHORED)]),
encode_close_tag(SPAN),
encode_close_tag(LI),
]
.concat();
assert_eq!(
walk_to_html(
&keyed(&encode_list(0, 1, &descendant), &[(0, &[ID])]),
&rows(vec![id(num(1.0))])
)
.unwrap(),
"<!--f:l0--><li data-forma-key=\"1\"><span data-forma-key=\"authored\"></span></li><!--/f:l0-->"
);
}
#[test]
fn a_refused_row_leaves_the_slot_journal_keyed_rows_and_list_depth_as_found() {
let (ops, inner) = nested();
let module = keyed(&ops, &[(0, &[ID]), (inner, &[ID])]);
let bad_inner_key = rows(vec![
group(1.0, vec![id(text("a"))]),
group(2.0, vec![id(text("a")), id(text("a"))]),
]);
let over_budget_inside = rows(vec![group(1.0, vec![id(text("a"))])]);
let cases = [
(
bad_inner_key,
WalkLimits::default(),
IrError::InvalidListKey(Box::new(violation(
inner,
1,
"id",
ListKeyReason::Duplicate { first_row: 0 },
))),
),
(
over_budget_inside,
WalkLimits {
max_output_bytes: 50,
..WalkLimits::default()
},
IrError::OutputLimitExceeded { limit: 50 },
),
];
for (data, limits, expected) in cases {
let mut slots = data.clone();
let before = format!("{slots:?}");
let mut state = WalkState::new(limits);
let mut out = String::new();
let error = walk_range(
&module,
&mut slots,
&module.opcodes,
0,
module.opcodes.len(),
&mut out,
&mut state,
0,
)
.unwrap_err();
assert_eq!(error, expected);
assert_eq!(state.list_depth, 0, "{error:?}");
assert!(state.keyed_rows.is_empty(), "{error:?}");
assert!(state.slot_journal.is_empty(), "{error:?}");
assert_eq!(format!("{slots:?}"), before, "{error:?}");
}
}
#[test]
fn a_key_is_charged_to_the_output_budget_before_it_is_built() {
let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
let long = "k".repeat(1000);
let opened = "<!--f:l0-->".len();
let limits = WalkLimits {
max_output_bytes: opened + 999,
..WalkLimits::default()
};
let failure =
walk_to_html_counted(&module, &rows(vec![id(text(&long))]), limits).unwrap_err();
assert_eq!(
failure.error,
IrError::OutputLimitExceeded {
limit: opened + 999
}
);
assert_eq!(failure.counts.bytes, opened);
let nul = "\0".repeat(400);
let limits = WalkLimits {
max_output_bytes: opened + 1000,
..WalkLimits::default()
};
let failure =
walk_to_html_counted(&module, &rows(vec![id(text(&nul))]), limits).unwrap_err();
assert_eq!(failure.counts.bytes, opened);
let html = walk_to_html(&module, &rows(vec![id(text("key"))])).unwrap();
let exact = WalkLimits {
max_output_bytes: html.len(),
..WalkLimits::default()
};
assert_eq!(
walk_to_html_with_limits(&module, &rows(vec![id(text("key"))]), exact).unwrap(),
html
);
}
#[test]
fn the_iteration_budget_refuses_a_keyed_list_before_any_key_is_read() {
let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
let limits = WalkLimits {
max_list_iterations: 1,
..WalkLimits::default()
};
let failure = walk_to_html_counted(
&module,
&rows(vec![id(SlotValue::Null), id(num(1.0))]),
limits,
)
.unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 1 });
assert_eq!(failure.counts.list_iterations, 0);
}
#[test]
fn every_shared_key_vector_is_written_as_its_served_markup() {
let vectors: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/parity/keys.json")).unwrap();
let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
let mut checked = 0;
for case in vectors["values"].as_array().unwrap() {
let Some(served) = case.get("served").and_then(serde_json::Value::as_str) else {
continue;
};
let row = object(vec![(
"id",
crate::slot::json_to_slot_value(&case["value"]),
)]);
assert_eq!(
walk_to_html(&module, &rows(vec![row])).unwrap(),
format!(
"<!--f:l0--><li data-forma-key=\"{served}\"><span></span></li><!--/f:l0-->"
),
"{}",
case["id"]
);
checked += 1;
}
assert!(
checked >= 20,
"the vector file lost its served markup: {checked}"
);
}
#[test]
fn a_diagnostic_path_quotes_a_segment_a_dot_would_make_ambiguous() {
assert_eq!(dotted_path(&["user", "id"]), "user.id");
assert_eq!(dotted_path(&["user.id"]), "[\"user.id\"]");
assert_eq!(dotted_path(&["a", "b.c", "d"]), "a[\"b.c\"].d");
assert_eq!(dotted_path(&["", "x[0]"]), "[\"\"][\"x[0]\"]");
}
}
}