Skip to main content

forma_ir/
walker.rs

1//! Static HTML walker — walks the IR opcode stream to produce HTML output.
2//!
3//! This is the core SSR rendering engine. It takes a parsed `IrModule` plus
4//! populated `SlotData`, walks the opcode stream linearly, and produces an
5//! HTML string suitable for embedding in the page.
6
7pub use crate::contracts::state::IslandState;
8use crate::contracts::state::StateBuilder;
9#[cfg(feature = "tracing")]
10use crate::format::SlotSource;
11use crate::format::{IrError, IslandTrigger, ListKeyReason, ListKeyViolation, Opcode};
12use crate::parser::IrModule;
13use crate::slot::{SlotData, SlotValue};
14use crate::text::{normalize_text, normalized_chars};
15use std::collections::HashMap;
16use std::fmt;
17use std::rc::Rc;
18
19// ---------------------------------------------------------------------------
20// Walk state
21// ---------------------------------------------------------------------------
22
23/// Pending island info to be injected on the next OPEN_TAG or VOID_TAG.
24struct IslandPending {
25    id: u16,
26    component_name: String,
27    occurrence: usize,
28    hash: String,
29    trigger: IslandTrigger,
30}
31
32/// Default cap on the HTML a single walk may produce.
33///
34/// A structurally valid module can otherwise force an arbitrarily large
35/// `String` allocation inside one request handler, and Rust answers allocation
36/// failure with `handle_alloc_error` → process abort, which kills every
37/// in-flight request. Raise it via [`WalkLimits`] for an app with a
38/// legitimately large data-driven page rather than accepting a silent
39/// degradation.
40pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
41
42/// Default cap on total LIST item iterations per render, across all depths.
43///
44/// `MAX_LIST_DEPTH` bounds nesting but not volume: four LISTs bound to the
45/// same array give n^4 body walks from one small module plus one large
46/// request array.
47pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
48
49/// Resource budgets for a single walk.
50///
51/// Both are per-render totals, not per-nesting-level.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct WalkLimits {
54    /// Maximum bytes of HTML the walk may emit.
55    pub max_output_bytes: usize,
56    /// Maximum total LIST item iterations across all nesting depths.
57    pub max_list_iterations: usize,
58}
59
60impl Default for WalkLimits {
61    fn default() -> Self {
62        WalkLimits {
63            max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
64            max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
65        }
66    }
67}
68
69/// Whether the HTML is intended for client adoption.
70///
71/// Existing walker entry points always use [`RenderMode::Adoptable`]. The server
72/// selects [`RenderMode::Static`] only for a route with no client JavaScript and
73/// no declared islands. Both modes retain the same value rules and refusals.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum RenderMode {
76    /// Write the complete marker vocabulary consumed by strict adoption.
77    Adoptable,
78    /// Omit adoption markers, empty-text holders and injected row keys.
79    /// Modules containing islands are refused with [`IrError::InvalidSection`].
80    Static,
81}
82
83/// One row of a keyed LIST while its body is walked (FORMA-181, DR-8).
84///
85/// The row-owned guard for `data-forma-key`: the key is attached to the
86/// first element opened at the depth the row was entered at, which is the
87/// row's own root, never a descendant and never an element a later row or
88/// the code after the list opens. At its own level the row admits exactly
89/// that one root, optionally wholly enclosed by one registered child's
90/// `<!--f:iN-->` envelope (the child's host is the root); text, a
91/// conditional or list range, a second element, an envelope that does not
92/// enclose the root, a close of an element the row did not open, or an
93/// element left open refuse the render. The compiler proves the shape from
94/// the source (BUILD-613), so this is the defence for a hand-built module,
95/// under the same code the key itself refuses with (RENDER-503).
96struct KeyedRow {
97    /// Byte offset of the LIST, as section `0x0006` names it.
98    list_offset: u32,
99    /// Zero-based position of the row in the array this execution iterates.
100    ordinal: usize,
101    /// The key path, dotted, for the row's diagnostics.
102    path: Rc<str>,
103    /// The canonical key, until the row's root element takes it.
104    key: Option<Rc<str>>,
105    /// [`WalkState::element_depth`] when the row's body started.
106    entry_depth: usize,
107    /// Elements opened at `entry_depth` so far.
108    roots: usize,
109    /// Whether anything but the root and its envelope was written at
110    /// `entry_depth`.
111    stray_content: bool,
112    /// The registered child whose island envelope opened at `entry_depth`
113    /// before the root and has not closed yet.
114    envelope: Option<u16>,
115}
116
117impl KeyedRow {
118    fn refuse(&self, reason: ListKeyReason) -> IrError {
119        list_key_error(self.list_offset, self.ordinal, &self.path, reason)
120    }
121}
122
123/// Mutable state threaded through recursive `walk_range` calls.
124///
125/// This avoids adding extra parameters every time we need new walker state
126/// (e.g., list depth, output budget).
127struct WalkState {
128    /// Whether to write the adoption vocabulary outside text-only elements.
129    mode: RenderMode,
130    /// Current nesting depth for LIST opcodes.
131    list_depth: u8,
132    /// Pending island info to inject on the next OPEN_TAG/VOID_TAG.
133    pending_island: Option<IslandPending>,
134    /// Elements opened (`OPEN_TAG`) and not yet closed (`CLOSE_TAG`) at this
135    /// point of the walk. A keyed row's root is the element opened at the
136    /// depth the row was entered at ([`KeyedRow::entry_depth`]).
137    element_depth: usize,
138    /// The keyed rows the walk is inside, outermost first (FORMA-181). Only
139    /// the innermost can still be waiting for its root: an inner row is
140    /// entered at a deeper element depth than the root its outer row already
141    /// opened. Pushed and popped by the LIST item loop, never cleared at a
142    /// range end.
143    keyed_rows: Vec<KeyedRow>,
144    /// Whether the start tag still open is a keyed row's root, so a DYN_ATTR
145    /// named `data-forma-key` on it is refused as an authored identity.
146    open_tag_is_row_root: bool,
147    /// Checked, owned snapshots in execution order, separate from body HTML.
148    island_state: StateBuilder,
149    /// True when an OpenTag or VoidTag has been opened but the `>` has not
150    /// been written yet. DYN_ATTR opcodes may still append attributes.
151    pending_tag_close: bool,
152    /// Tag name of the element currently being opened — needed to decide
153    /// whether a `data:image/svg+xml` URL is in an image context.
154    pending_tag_name: String,
155    /// The output length right after an HTML `pre` or `listing` start tag
156    /// closed, while nothing has been written since. The parser drops one
157    /// line feed that is the first content token of those elements, so the
158    /// first text written at exactly this length gets an extra LF when its
159    /// normalized text starts with one. Any serialized token (a marker
160    /// comment, a child element, a closing tag) moves the length past it and
161    /// so consumes the boundary; an empty text or a value-only opcode writes
162    /// nothing and leaves it. Selected by tag spelling, as `TextOnlyTag` is:
163    /// the walker has no namespace stack, and neither SVG nor MathML has an
164    /// element of either name.
165    ///
166    /// Verified by: crates/forma-ir/src/walker.rs > "pre_children_protect_only_an_initial_normalized_line_feed"
167    pre_boundary: Option<usize>,
168    /// Output-size budget. Lives on the state (not as a `walk_range`
169    /// parameter) so every recursive call sharing the same `out` enforces the
170    /// same cumulative budget by construction.
171    max_output_bytes: usize,
172    /// Running total of LIST item iterations, counted across all depths and
173    /// never decremented.
174    list_iterations: usize,
175    /// Cap for `list_iterations`.
176    max_list_iterations: usize,
177    /// Undo log for slot writes made inside a LIST body.
178    ///
179    /// A list row must render from its own scope: the item binding and any
180    /// PROP the body extracts from it are visible to that row and to nothing
181    /// else. The loop used to buy that by deep-cloning the whole `SlotData`
182    /// per row, which costs O(rows x slots) — the walk was measurably
183    /// quadratic in row count. Instead each write swaps the new value in and
184    /// pushes the displaced one here; the row's `Drop`-equivalent pops back to
185    /// the mark it took on entry, restoring exactly the state the caller had.
186    ///
187    /// Entries are strictly LIFO across nesting: an inner list unwinds its own
188    /// entries before its enclosing row unwinds. The `Vec` keeps its capacity
189    /// between rows, so steady-state pushes do not allocate.
190    slot_journal: Vec<(u16, SlotValue)>,
191    /// The text-only element (`textarea`, `title`, `style`, `script`) the
192    /// walk is inside, if any. Inside one, no hydration marker is written; see
193    /// [`TextOnlyTag`].
194    text_only: Option<TextOnlyParent>,
195    /// Form-control state gathered from the attributes of the start tag that
196    /// is still open, resolved when its `>` is written ([`close_start_tag`]).
197    pending_control: PendingControl,
198    /// The bound `select` the walk is inside, if any.
199    select: Option<SelectBinding>,
200    /// An `option` of a bound select with no `value` attribute: its value is
201    /// its text, which is known only when the option ends.
202    option_by_text: Option<OptionByText>,
203    /// Elements and text nodes written so far ([`WalkCounts::nodes`]).
204    nodes: usize,
205    /// The islands open at this point of the walk, outermost first: the
206    /// component path a failure reports ([`WalkFailure::component_path`]).
207    islands: Vec<u16>,
208    /// Whether `fault_injection::FAULT_TRIGGER` may fire: set only by
209    /// [`walk_to_html_counted`], which catches the panic, so the uncaught
210    /// `walk_to_html` path can never take it.
211    #[cfg(any(test, feature = "__fault-injection"))]
212    fault_armed: bool,
213}
214
215impl WalkState {
216    fn new(limits: WalkLimits) -> Self {
217        WalkState {
218            mode: RenderMode::Adoptable,
219            list_depth: 0,
220            pending_island: None,
221            element_depth: 0,
222            keyed_rows: Vec::new(),
223            open_tag_is_row_root: false,
224            island_state: StateBuilder::new(),
225            pending_tag_close: false,
226            pending_tag_name: String::new(),
227            pre_boundary: None,
228            max_output_bytes: limits.max_output_bytes,
229            list_iterations: 0,
230            max_list_iterations: limits.max_list_iterations,
231            slot_journal: Vec::new(),
232            text_only: None,
233            pending_control: PendingControl::None,
234            select: None,
235            option_by_text: None,
236            nodes: 0,
237            islands: Vec::new(),
238            #[cfg(any(test, feature = "__fault-injection"))]
239            fault_armed: false,
240        }
241    }
242
243    /// Record a slot write so the enclosing LIST row can undo it.
244    ///
245    /// Outside a list there is nothing to undo — a PROP at depth 0 writes to
246    /// the walk's own `SlotData` copy for the rest of the page, which is what
247    /// the clone-per-row implementation did too.
248    fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
249        if self.list_depth > 0 {
250            self.slot_journal.push((slot_id, previous));
251        }
252    }
253
254    /// Undo every journalled write back to `mark`, restoring `slots`.
255    fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
256        while self.slot_journal.len() > mark {
257            // `len() > mark >= 0`, so the pop cannot be None.
258            if let Some((slot_id, previous)) = self.slot_journal.pop() {
259                slots.set(slot_id, previous);
260            }
261        }
262    }
263
264    /// The innermost keyed row, when the walk is at the depth it was
265    /// entered at: the level where its root element and nothing else belongs.
266    fn keyed_row_at_entry_depth(&mut self) -> Option<&mut KeyedRow> {
267        let depth = self.element_depth;
268        self.keyed_rows
269            .last_mut()
270            .filter(|row| row.entry_depth == depth)
271    }
272
273    /// An element is being opened: if it is the innermost keyed row's root,
274    /// count it and hand back the key it carries.
275    fn open_row_root(&mut self) -> Option<Rc<str>> {
276        let row = self.keyed_row_at_entry_depth()?;
277        row.roots += 1;
278        row.key.take()
279    }
280
281    /// Text or a marker is being written: at a keyed row's own level that is
282    /// content beside its root, which the row's shape does not admit.
283    fn note_row_level_content(&mut self) {
284        if let Some(row) = self.keyed_row_at_entry_depth() {
285            row.stray_content = true;
286        }
287    }
288
289    /// An island starts: at a keyed row's own level it is admitted only as
290    /// the envelope of a registered child whose host is the row's root, so
291    /// only before that root and only once.
292    fn note_row_level_island_start(&mut self, island_id: u16) {
293        if let Some(row) = self.keyed_row_at_entry_depth() {
294            if row.roots == 0 && row.envelope.is_none() && !row.stray_content {
295                row.envelope = Some(island_id);
296            } else {
297                row.stray_content = true;
298            }
299        }
300    }
301
302    /// An island ends: at a keyed row's own level it must close the envelope
303    /// that opened before the root, right after that one root.
304    fn note_row_level_island_end(&mut self, island_id: u16) {
305        if let Some(row) = self.keyed_row_at_entry_depth() {
306            if row.envelope == Some(island_id) && row.roots == 1 {
307                row.envelope = None;
308            } else {
309                row.stray_content = true;
310            }
311        }
312    }
313
314    /// A CLOSE_TAG at a keyed row's own level closes an element the row did
315    /// not open (the list's parent): the row is not balanced.
316    fn closes_outside_keyed_row(&self) -> bool {
317        self.keyed_rows
318            .last()
319            .is_some_and(|row| row.entry_depth == self.element_depth)
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Dev-mode slot source check
325// ---------------------------------------------------------------------------
326
327/// Log a warning when a server-sourced slot has no value at render time.
328///
329/// This is a dev-mode diagnostic: if a slot is declared as `SlotSource::Server`
330/// but the handler never populated it (still `SlotValue::Null`), the page
331/// handler likely has a bug. The warning is informational and does not affect
332/// output.
333#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
334fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
335    // Gated as a whole: with `tracing` off there is nothing to report, so the
336    // lookup should not run at all. (It is an O(1) index lookup now either
337    // way, but the source should say what it means.)
338    #[cfg(feature = "tracing")]
339    if let Some(entry) = module.slot_entry(slot_id) {
340        if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
341            tracing::warn!(
342                slot_id = slot_id,
343                "Server-sourced slot has no value at render time — handler may have a bug"
344            );
345        }
346    }
347}
348
349// ---------------------------------------------------------------------------
350// Public API
351// ---------------------------------------------------------------------------
352
353/// Walk the IR opcode stream and produce an HTML string.
354///
355/// This is the main entry point for SSR rendering. It processes every opcode
356/// in the module's bytecode section, looking up strings in the string table
357/// and slot values in `slots`.
358pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
359    walk_to_html_with_limits(module, slots, WalkLimits::default())
360}
361
362/// Walk the IR opcode stream with explicit resource budgets.
363///
364/// Use this when the default 8 MiB output cap or 50,000-iteration LIST budget
365/// is wrong for the page — raising the limit is the right answer for a
366/// legitimately large list, rather than letting the render fail and silently
367/// degrade to a client-mounted page.
368pub fn walk_to_html_with_limits(
369    module: &IrModule,
370    slots: &SlotData,
371    limits: WalkLimits,
372) -> Result<String, IrError> {
373    let ops = &module.opcodes;
374    let len = ops.len();
375    let mut out = String::with_capacity(initial_capacity(len));
376    let mut state = WalkState::new(limits);
377    let mut slots_mut = slots.clone();
378    slots_mut.apply_module_constants(module);
379    walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
380    Ok(out)
381}
382
383/// The output buffer's first reservation for a module's opcode stream.
384///
385/// Capped: `len * 4` is a fine estimate for a real page (measured modules are
386/// tens of bytes to low KiB) but a 4x reservation on an attacker-sized section
387/// is a free amplifier, and `len * 4` can itself overflow on wasm32 where
388/// `usize` is 32 bits. Beyond the cap, String's amortized doubling costs
389/// O(log n) reallocs.
390fn initial_capacity(opcodes_len: usize) -> usize {
391    opcodes_len.saturating_mul(4).min(1 << 20)
392}
393
394/// What a walk wrote, or what it had reached when it stopped.
395#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
396#[non_exhaustive]
397pub struct WalkCounts {
398    /// Elements and text nodes written from the module's opcodes. Hydration
399    /// markers and comments are not nodes. State is counted by the server.
400    pub nodes: usize,
401    /// Bytes of HTML written: what [`WalkLimits::max_output_bytes`] bounds.
402    pub bytes: usize,
403    /// LIST rows walked, across every nesting depth: what
404    /// [`WalkLimits::max_list_iterations`] bounds.
405    pub list_iterations: usize,
406}
407
408/// A whole page's HTML and what writing it took.
409#[derive(Debug, Clone, PartialEq, Eq)]
410#[non_exhaustive]
411pub struct WalkOutput {
412    /// The page's HTML, as [`walk_to_html_with_limits`] writes it.
413    pub html: String,
414    /// Declared inputs captured at each executed ISLAND_START. The server
415    /// places this envelope once in the head before sending body HTML.
416    pub island_state: IslandState,
417    /// What the walk wrote.
418    pub counts: WalkCounts,
419}
420
421/// A walk that stopped: why, where, and how far it had got.
422///
423/// The partial HTML is dropped: nothing a failed walk wrote leaves it.
424#[derive(Debug, Clone, PartialEq, Eq)]
425#[non_exhaustive]
426pub struct WalkFailure {
427    /// Why it stopped.
428    pub error: IrError,
429    /// The component names of the islands open where it stopped, outermost
430    /// first; empty when it stopped outside every island. The decoded v3
431    /// source-position metadata is available for later diagnostic integration;
432    /// this path continues to name declared islands.
433    pub component_path: Vec<String>,
434    /// What it had written by then.
435    pub counts: WalkCounts,
436}
437
438impl fmt::Display for WalkFailure {
439    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440        write!(f, "{}", self.error)?;
441        if !self.component_path.is_empty() {
442            write!(f, " (in {})", self.component_path.join(" > "))?;
443        }
444        Ok(())
445    }
446}
447
448impl std::error::Error for WalkFailure {
449    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
450        Some(&self.error)
451    }
452}
453
454/// [`walk_to_html_with_limits`], reporting what the walk wrote, and on
455/// failure where it stopped and how far it had got.
456///
457/// The same HTML, byte for byte, and the same errors, with two additions: the
458/// counts (nodes, bytes, list rows), returned on success and on failure alike
459/// so an over-budget page reports how far it got; and the component
460/// path, the islands open at the failure. A panic inside the walk is caught
461/// and returned as [`IrError::Panicked`] with the same path and counts, so
462/// one bad page cannot take its request's connection down with it.
463/// That holds only while the application unwinds on panic: under
464/// `panic = "abort"` the process ends instead.
465///
466/// Verified by: crates/forma-ir/src/walker.rs > "counted_walk_returns_the_counts_reached_when_a_budget_stops_it"
467/// Verified by: crates/forma-ir/src/walker.rs > "counted_walk_catches_a_panic_and_names_its_island"
468pub fn walk_to_html_counted(
469    module: &IrModule,
470    slots: &SlotData,
471    limits: WalkLimits,
472) -> Result<WalkOutput, WalkFailure> {
473    walk_to_html_counted_with_mode(module, slots, limits, RenderMode::Adoptable)
474}
475
476/// A counted walk with an explicit output mode.
477///
478/// [`RenderMode::Static`] is only for pages that will not be adopted by client
479/// code; modules with declared islands are refused before producing output.
480/// Key identity/shape checks, value rules, budgets and panic handling remain
481/// identical to [`walk_to_html_counted`].
482pub fn walk_to_html_counted_with_mode(
483    module: &IrModule,
484    slots: &SlotData,
485    limits: WalkLimits,
486    mode: RenderMode,
487) -> Result<WalkOutput, WalkFailure> {
488    if mode == RenderMode::Static && !module.islands.is_empty() {
489        return Err(WalkFailure {
490            error: IrError::InvalidSection,
491            component_path: Vec::new(),
492            counts: WalkCounts::default(),
493        });
494    }
495    let ops = &module.opcodes;
496    let len = ops.len();
497    let mut out = String::with_capacity(initial_capacity(len));
498    let mut state = WalkState::new(limits);
499    state.mode = mode;
500    #[cfg(any(test, feature = "__fault-injection"))]
501    {
502        state.fault_armed = true;
503    }
504    let mut slots_mut = slots.clone();
505    slots_mut.apply_module_constants(module);
506
507    // The closure only borrows: after an unwind, `state` and `out` still say
508    // where the walk was and how much it had written. Nothing they hold is
509    // used again except to read those counts.
510    let walked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
511        walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)
512    }));
513    let counts = WalkCounts {
514        nodes: state.nodes,
515        bytes: out.len(),
516        list_iterations: state.list_iterations,
517    };
518    let error = match walked {
519        Ok(Ok(())) => match state.island_state.finish() {
520            Ok(island_state) => {
521                return Ok(WalkOutput {
522                    html: out,
523                    island_state,
524                    counts,
525                })
526            }
527            Err(error) => {
528                return Err(WalkFailure {
529                    error,
530                    component_path: Vec::new(),
531                    counts,
532                })
533            }
534        },
535        Ok(Err(error)) => error,
536        Err(payload) => IrError::Panicked {
537            message: panic_message(payload.as_ref()),
538        },
539    };
540    let component_path = state
541        .islands
542        .iter()
543        .map(|&id| {
544            module
545                .island_entry(id)
546                .and_then(|entry| module.strings.get(entry.name_str_idx).ok())
547                .map_or_else(|| format!("island {id}"), str::to_string)
548        })
549        .collect();
550    Err(WalkFailure {
551        error,
552        component_path,
553        counts,
554    })
555}
556
557/// A panic payload's message: `panic!` with a literal gives a `&str`, with
558/// formatting a `String`.
559fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
560    if let Some(message) = payload.downcast_ref::<&str>() {
561        (*message).to_string()
562    } else if let Some(message) = payload.downcast_ref::<String>() {
563        message.clone()
564    } else {
565        "a panic with no message".to_string()
566    }
567}
568
569/// Walk a single island's content and produce an HTML string.
570///
571/// This internal fragment path has no supported caller (DR-10). It finds
572/// the island by `island_id` in the module's island table, seeks to its
573/// `byte_offset` in the opcode stream, and walks from after the ISLAND_START
574/// opcode until the matching ISLAND_END opcode.
575///
576/// Returns the island ROOT element carrying the full `data-forma-*` attribute
577/// set (`data-forma-island`, `-component`, `-status`, `-hydrate`, and
578/// the occurrence/hash markers). Internal diagnostics may inspect this root with
579/// `replaceWith`/`outerHTML` and MUST NOT use `innerHTML` — that would discard
580/// exactly the attributes the runtime selects on.
581///
582/// The `<!--f:iN-->` / `<!--/f:iN-->` marker comments are deliberately NOT
583/// emitted: they live outside the root element and survive an `outerHTML`
584/// swap, so re-emitting them would duplicate them.
585///
586/// This internal diagnostic fragment returns body HTML only; it is not a
587/// supported page-response path. The complete counted walk returns the state
588/// envelope required to activate its occurrence markers.
589#[allow(dead_code)] // Internal fragment path; no supported caller since the WASM walker retired.
590pub(crate) fn walk_island(
591    module: &IrModule,
592    slots: &SlotData,
593    island_id: u16,
594) -> Result<String, IrError> {
595    walk_island_with_limits(module, slots, island_id, WalkLimits::default())
596}
597
598/// [`walk_island`] with explicit resource budgets.
599#[allow(dead_code)]
600pub(crate) fn walk_island_with_limits(
601    module: &IrModule,
602    slots: &SlotData,
603    island_id: u16,
604    limits: WalkLimits,
605) -> Result<String, IrError> {
606    // 1. Find the island entry by id
607    let entry = module
608        .island_entry(island_id)
609        .ok_or(IrError::IslandNotFound(island_id))?;
610
611    let byte_offset = entry.byte_offset as usize;
612    let ops = &module.opcodes;
613
614    // 2. Validate byte_offset is in bounds. Saturating arithmetic: byte_offset
615    //    is u32-wide and `usize` is 32 bits on wasm32.
616    if byte_offset >= ops.len() {
617        return Err(IrError::BufferTooShort {
618            expected: byte_offset.saturating_add(1),
619            actual: ops.len(),
620        });
621    }
622
623    // 3. Verify we're at an ISLAND_START opcode
624    let opcode = Opcode::from_byte(ops[byte_offset])?;
625    if opcode != Opcode::IslandStart {
626        return Err(IrError::InvalidOpcode(ops[byte_offset]));
627    }
628
629    // 4. Skip ISLAND_START opcode(1) + island_id(2) = 3 bytes
630    let content_start = byte_offset.saturating_add(3);
631    if content_start > ops.len() {
632        return Err(IrError::BufferTooShort {
633            expected: content_start,
634            actual: ops.len(),
635        });
636    }
637
638    // 5. Replicate the ISLAND_START handler: seeking past the opcode means it
639    //    never runs, so without this the island root comes back with none of
640    //    its data-forma-* attributes and would never hydrate.
641    let mut out = String::with_capacity(256);
642    let mut state = WalkState::new(limits);
643    let mut slots_mut = slots.clone();
644    slots_mut.apply_module_constants(module);
645
646    let component_name = module.strings.get(entry.name_str_idx)?.to_string();
647    let (occurrence, hash) = state.island_state.snapshot(module, entry, &slots_mut)?;
648    state.pending_island = Some(IslandPending {
649        id: island_id,
650        component_name,
651        occurrence,
652        hash,
653        trigger: entry.trigger,
654    });
655
656    // 6. Walk until the matching ISLAND_END, which stops the range.
657    walk_range_until_island_end(
658        module,
659        &mut slots_mut,
660        ops,
661        content_start,
662        island_id,
663        &mut out,
664        &mut state,
665        0,
666    )?;
667
668    Ok(out)
669}
670
671// ---------------------------------------------------------------------------
672// Internal: recursive sub-range walker
673// ---------------------------------------------------------------------------
674
675/// Maximum nesting depth for LIST opcodes. Prevents stack overflow from
676/// deeply nested or malicious input.
677const MAX_LIST_DEPTH: u8 = crate::format::MAX_LIST_DEPTH as u8;
678
679/// Maximum recursion depth for control-flow opcodes (SHOW_IF, SWITCH, LIST,
680/// TRY/FALLBACK). Prevents stack overflow from deeply nested or malicious IR.
681const MAX_RECURSION_DEPTH: usize = crate::format::MAX_CONTROL_DEPTH;
682
683/// Controls how the walker decides when to stop processing opcodes.
684enum WalkMode {
685    /// Walk a fixed byte range `ops[start..end]`.  Used for the top-level
686    /// full-page walk **and** for all recursive sub-range calls inside
687    /// control-flow opcodes (ShowIf, Switch, List).
688    Range { end: usize },
689    /// Walk until we encounter an ISLAND_END opcode whose `island_id` matches
690    /// `target_island_id`.  Used by `walk_island()` for fragment rendering.
691    UntilIslandEnd { target_island_id: u16 },
692}
693
694/// Walk a sub-range of the opcode stream `ops[start..end]`, appending HTML to `out`.
695///
696/// This is structured as a sub-range function so that control-flow opcodes
697/// (ShowIf, List, Switch) can recurse into their body ranges.
698///
699/// `state` carries mutable walker context (list depth, output budget, etc.).
700#[allow(clippy::too_many_arguments)]
701fn walk_range(
702    module: &IrModule,
703    slots: &mut SlotData,
704    ops: &[u8],
705    start: usize,
706    end: usize,
707    out: &mut String,
708    state: &mut WalkState,
709    depth: usize,
710) -> Result<(), IrError> {
711    walk_range_impl(
712        module,
713        slots,
714        ops,
715        start,
716        WalkMode::Range { end },
717        out,
718        state,
719        depth,
720    )
721}
722
723/// Walk the opcode stream starting at `start` until we encounter an ISLAND_END
724/// with the matching `target_island_id`. Appends HTML output to `out`.
725///
726/// This is used by `walk_island()` to render just one island's content.
727/// It shares the same opcode interpretation as `walk_range` but has a different
728/// termination condition: it stops at ISLAND_END instead of a fixed end position.
729#[allow(clippy::too_many_arguments)]
730fn walk_range_until_island_end(
731    module: &IrModule,
732    slots: &mut SlotData,
733    ops: &[u8],
734    start: usize,
735    target_island_id: u16,
736    out: &mut String,
737    state: &mut WalkState,
738    depth: usize,
739) -> Result<(), IrError> {
740    walk_range_impl(
741        module,
742        slots,
743        ops,
744        start,
745        WalkMode::UntilIslandEnd { target_island_id },
746        out,
747        state,
748        depth,
749    )
750}
751
752/// Shared implementation for both `walk_range` and `walk_range_until_island_end`.
753///
754/// The `mode` parameter controls the termination condition:
755/// - `WalkMode::Range { end }` — stop when `pos >= end` (with bounds check)
756/// - `WalkMode::UntilIslandEnd { target_island_id }` — stop when we hit an
757///   ISLAND_END opcode matching the target id
758///
759/// All opcode handling is identical regardless of mode. The only differences
760/// are:
761/// 1. The loop termination condition
762/// 2. How ISLAND_END is handled (in Range mode it emits the comment marker
763///    and continues; in UntilIslandEnd mode a match causes an early return)
764/// 3. What happens when the loop ends without an explicit stop (Range returns
765///    Ok, UntilIslandEnd returns an error)
766#[allow(clippy::too_many_arguments)]
767fn walk_range_impl(
768    module: &IrModule,
769    slots: &mut SlotData,
770    ops: &[u8],
771    start: usize,
772    mode: WalkMode,
773    out: &mut String,
774    state: &mut WalkState,
775    depth: usize,
776) -> Result<(), IrError> {
777    if depth > MAX_RECURSION_DEPTH {
778        return Err(IrError::RecursionLimitExceeded);
779    }
780
781    let strings = &module.strings;
782    let mut pos = start;
783    let limit = state.max_output_bytes;
784    // Range-local, not shared on WalkState: a TRY_START and its FALLBACK are
785    // emitted in the same opcode range by construction, so a FALLBACK reached
786    // inside a nested range must never pop an entry pushed by an outer
787    // TRY_START and skip that many bytes of the wrong region. A stray FALLBACK
788    // then finds an empty stack and is a no-op, mirroring how a stray
789    // SHOW_ELSE is already tolerated. This also removes the unbounded growth
790    // when a TRY_START is walked but its FALLBACK never is.
791
792    // The loop bound depends on the mode:
793    // - Range: stop at `end`, but also guard against `pos >= ops.len()`
794    // - UntilIslandEnd: stop at `ops.len()` (or earlier via ISLAND_END match)
795    let loop_bound = match mode {
796        WalkMode::Range { end } => end,
797        WalkMode::UntilIslandEnd { .. } => usize::MAX, // guarded by ops.len() check below
798    };
799
800    // Upper bound every derived offset (branch/body ends) must respect. In
801    // Range mode that is the enclosing region; on the island path there is no
802    // `end`, so the opcode stream itself is the bound.
803    let region_limit = match mode {
804        WalkMode::Range { end } => end.min(ops.len()),
805        WalkMode::UntilIslandEnd { .. } => ops.len(),
806    };
807
808    while pos < loop_bound {
809        // Cheap redundancy: every append also goes through a checked push, so
810        // a single opcode cannot blow past the budget between iterations.
811        if out.len() > limit {
812            return Err(IrError::OutputLimitExceeded { limit });
813        }
814
815        // In Range mode, `end` could exceed ops.len()` for a malformed stream.
816        // In UntilIslandEnd mode, loop_bound is MAX so we always need this check.
817        if pos >= ops.len() {
818            match mode {
819                WalkMode::Range { .. } => {
820                    return Err(IrError::BufferTooShort {
821                        expected: pos + 1,
822                        actual: ops.len(),
823                    });
824                }
825                WalkMode::UntilIslandEnd { .. } => break, // fall through to IslandNotFound error
826            }
827        }
828
829        let opcode = Opcode::from_byte(ops[pos])?;
830        pos += 1; // advance past the opcode byte
831
832        // Flush pending tag close before any opcode that is NOT DYN_ATTR.
833        // DYN_ATTR needs the tag still open so it can append attributes.
834        if state.pending_tag_close && opcode != Opcode::DynAttr {
835            close_start_tag(out, state, limit)?;
836        }
837
838        match opcode {
839            // ----- Fully implemented opcodes -----
840            Opcode::OpenTag | Opcode::VoidTag => {
841                let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
842                let tag = strings.get(tag_str_idx)?;
843                let has_content = opcode == Opcode::OpenTag;
844                emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
845                state.nodes += 1;
846                pos = new_pos;
847            }
848
849            Opcode::CloseTag => {
850                let str_idx = read_u32(ops, pos)?;
851                pos += 4;
852                let tag = strings.get(str_idx)?;
853                // Names are written to the output stream unescaped, so an
854                // IR-authored name is markup injection unless it is validated.
855                if !is_valid_name(tag) {
856                    return Err(IrError::InvalidName(tag.to_string()));
857                }
858                // A keyed row closes only elements it opened (FORMA-181): a
859                // CLOSE_TAG at its own level would end the list's parent from
860                // inside one row.
861                if state.closes_outside_keyed_row() {
862                    return Err(innermost_row_error(
863                        state,
864                        ListKeyReason::UnbalancedRow,
865                        IrError::InvalidSection,
866                    ));
867                }
868                leave_element(out, tag, state, limit)?;
869                // Saturating: a CLOSE_TAG without its OPEN_TAG outside any
870                // keyed row is tolerated as before.
871                state.element_depth = state.element_depth.saturating_sub(1);
872                push_checked(out, "</", limit)?;
873                push_checked(out, tag, limit)?;
874                push_checked(out, ">", limit)?;
875            }
876
877            Opcode::Text => {
878                let str_idx = read_u32(ops, pos)?;
879                pos += 4;
880                let text = strings.get(str_idx)?;
881                state.note_row_level_content();
882                if let Some(parent) = state.text_only.as_mut() {
883                    if !parent.value_bound {
884                        write_text_only(out, parent, text, limit)?;
885                    }
886                } else {
887                    if state.pre_boundary == Some(out.len())
888                        && normalized_chars(text).next() == Some('\n')
889                    {
890                        // First content token of a `pre`/`listing` starting
891                        // with a line feed: the parser drops one, so write one
892                        // more (the textarea rule, by output position).
893                        push_checked(out, "\n", limit)?;
894                    }
895                    push_escaped_html(out, text, limit)?;
896                    if let Some(option) = state.option_by_text.as_mut() {
897                        // The text as the parser reads it: normalized.
898                        option.text.extend(normalized_chars(text));
899                    }
900                }
901                state.nodes += 1;
902            }
903
904            Opcode::DynText => {
905                // slot_id(u16) + marker_id(u16)
906                let slot_id = read_u16(ops, pos)?;
907                pos += 2;
908                let marker_id = read_u16(ops, pos)?;
909                pos += 2;
910
911                check_slot_source(module, slot_id, slots.get(slot_id));
912                state.note_row_level_content();
913                let mut number = ryu_js::Buffer::new();
914                let value = slots.get(slot_id).text_ref(&mut number);
915
916                // SR44's stand-in for a component that panics: this crate's
917                // tests and the internal `__fault-injection` feature only, and
918                // armed only inside `walk_to_html_counted`, which catches it.
919                #[cfg(any(test, feature = "__fault-injection"))]
920                #[allow(clippy::panic)]
921                if state.fault_armed && value == crate::fault_injection::FAULT_TRIGGER {
922                    panic!("stand-in component panicked (FAULT_TRIGGER)");
923                }
924
925                if let Some(parent) = state.text_only.as_mut() {
926                    // Inside a text-only element a marker comment is literal
927                    // text, so the value is written on its own (TextOnlyTag).
928                    if !parent.value_bound {
929                        write_text_only(out, parent, &value, limit)?;
930                    }
931                } else if state.mode == RenderMode::Static {
932                    // Without the opening marker a dynamic value can be the
933                    // first token of pre/listing: preserve the parser's LF rule.
934                    if state.pre_boundary == Some(out.len())
935                        && normalized_chars(&value).next() == Some('\n')
936                    {
937                        push_checked(out, "\n", limit)?;
938                    }
939                    push_escaped_html(out, &value, limit)?;
940                    if let Some(option) = state.option_by_text.as_mut() {
941                        option.text.extend(normalized_chars(&value));
942                    }
943                } else {
944                    push_checked(out, "<!--f:t", limit)?;
945                    push_u16(out, marker_id, limit)?;
946                    push_checked(out, "-->", limit)?;
947                    // Always emit at least a zero-width space so the browser
948                    // creates a Text node between the markers. Without this,
949                    // <!--f:t0--><!--/f:t0--> produces no Text node in the DOM
950                    // and the hydration walker has nothing to bind the
951                    // reactive effect to.
952                    let written = if value.is_empty() {
953                        "\u{200B}"
954                    } else {
955                        value.as_ref()
956                    };
957                    push_escaped_html(out, written, limit)?;
958                    push_checked(out, "<!--/f:t", limit)?;
959                    push_u16(out, marker_id, limit)?;
960                    push_checked(out, "-->", limit)?;
961                    if let Some(option) = state.option_by_text.as_mut() {
962                        option.text.extend(normalized_chars(written));
963                    }
964                }
965                state.nodes += 1;
966            }
967
968            Opcode::DynAttr => {
969                // attr_str_idx(u32) + slot_id(u16) = 6 bytes
970                let attr_str_idx = read_u32(ops, pos)?;
971                let slot_id = read_u16(ops, pos + 4)?;
972                pos += 6;
973                check_slot_source(module, slot_id, slots.get(slot_id));
974
975                if state.pending_tag_close {
976                    let attr_name = strings.get(attr_str_idx)?;
977                    if state.open_tag_is_row_root
978                        && attr_name.eq_ignore_ascii_case("data-forma-key")
979                    {
980                        return Err(innermost_row_error(
981                            state,
982                            ListKeyReason::AuthoredKey,
983                            IrError::InvalidName(attr_name.to_string()),
984                        ));
985                    }
986                    // `value` on a textarea or select, and `selected` on an
987                    // option of a bound select, are written another way.
988                    if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
989                        continue;
990                    }
991                    // `text` outlives the borrow only for the Valued case; it
992                    // is declared here so the &str can point into it.
993                    let text;
994                    let mut number = ryu_js::Buffer::new();
995                    let value = match slots.get(slot_id) {
996                        // An HTML boolean attribute is ON whenever it is
997                        // present, so the old `disabled="false"` meant
998                        // DISABLED: scalar truthiness selects presence. Any
999                        // other attribute takes the word `h()` writes for
1000                        // true, `data-live="true"` (FORMA-182 finding F2,
1001                        // docs/parity-rules.md rule 6's attribute table).
1002                        SlotValue::Bool(false) | SlotValue::Null => None,
1003                        other if is_html_boolean_attribute(attr_name) => {
1004                            other.as_bool().then_some(AttrValue::Bare)
1005                        }
1006                        SlotValue::Bool(true) => Some(AttrValue::Valued("true")),
1007                        other => {
1008                            text = other.text_ref(&mut number);
1009                            // An empty string is a value, not an absence:
1010                            // `renderAttr` emits `value=""`, and dropping the
1011                            // attribute instead is a hydration mismatch on the
1012                            // common `value=""` / `alt=""` case.
1013                            Some(AttrValue::Valued(text.as_ref()))
1014                        }
1015                    };
1016                    if let Some(value) = value {
1017                        // `pending_tag_name` is the element this attribute is
1018                        // being written to — it decides whether a
1019                        // data:image/svg+xml value is in an image context.
1020                        let tag = std::mem::take(&mut state.pending_tag_name);
1021                        emit_attr(out, &tag, attr_name, value, limit)?;
1022                        state.pending_tag_name = tag;
1023                    }
1024                }
1025                // If not inside a tag opening (shouldn't happen in well-formed IR), skip silently.
1026            }
1027
1028            Opcode::IslandStart => {
1029                // Also defend callers that constructed a module without validation.
1030                if state.mode == RenderMode::Static {
1031                    return Err(IrError::InvalidSection);
1032                }
1033                // island_id(u16)
1034                let island_id = read_u16(ops, pos)?;
1035                pos += 2;
1036
1037                // Only one element can carry a `data-forma-island` set. If an
1038                // island's body starts another island before opening a tag of
1039                // its own, the outer island's attributes are silently
1040                // discarded and it never hydrates, never disposes, and cannot
1041                // be found by `activateIslands`. Fail the render instead: the
1042                // compiler owes every island a shell element.
1043                if let Some(outer) = &state.pending_island {
1044                    return Err(IrError::NestedIslandRoot {
1045                        outer: outer.id,
1046                        inner: island_id,
1047                    });
1048                }
1049                state.islands.push(island_id);
1050                // A registered child's envelope may wrap a keyed row's root
1051                // (its host); anything else at the row's level is refused.
1052                state.note_row_level_island_start(island_id);
1053
1054                // No marker inside a text-only element, where it is text.
1055                if state.text_only.is_none() {
1056                    push_checked(out, "<!--f:i", limit)?;
1057                    push_u16(out, island_id, limit)?;
1058                    push_checked(out, "-->", limit)?;
1059                }
1060
1061                // Look up island entry from the island table (O(1) index).
1062                // `IrModule::validate` rejects an ISLAND_START naming an
1063                // island the table does not declare, so the `else` below is
1064                // reachable only for a module that skipped validation.
1065                // Verified by: crates/forma-ir/src/parser.rs > "validate_rejects_island_start_missing_from_table"
1066                if let Some(entry) = module.island_entry(island_id) {
1067                    let component_name = strings.get(entry.name_str_idx)?.to_string();
1068
1069                    // Capture ordered declared inputs before the island body or
1070                    // its surrounding list can mutate scratch/row slots.
1071                    let (occurrence, hash) = state.island_state.snapshot(module, entry, slots)?;
1072
1073                    state.pending_island = Some(IslandPending {
1074                        id: island_id,
1075                        component_name,
1076                        occurrence,
1077                        hash,
1078                        trigger: entry.trigger,
1079                    });
1080                } else {
1081                    #[cfg(feature = "tracing")]
1082                    tracing::warn!(
1083                        island_id = island_id,
1084                        "ISLAND_START names an island that is not in the island table — \
1085                         markers emitted but no data-forma-* attributes, so it will not hydrate \
1086                         (module was not validated)"
1087                    );
1088                }
1089            }
1090
1091            Opcode::IslandEnd => {
1092                // island_id(u16)
1093                let island_id = read_u16(ops, pos)?;
1094                pos += 2;
1095                if state.islands.last() == Some(&island_id) {
1096                    state.islands.pop();
1097                }
1098                state.note_row_level_island_end(island_id);
1099
1100                // The island is over: anything still pending belongs to no
1101                // element inside it. Without this, an ISLAND_START not
1102                // followed by a tag leaks its identity attributes onto
1103                // whatever element comes next — possibly one emitted after the
1104                // island's own closing marker.
1105                if state.pending_island.take().is_some() {
1106                    #[cfg(feature = "tracing")]
1107                    tracing::warn!(
1108                        island_id = island_id,
1109                        "ISLAND_START was not followed by an element — island attributes dropped, \
1110                         this island will not hydrate (compiler should emit a shell element)"
1111                    );
1112                }
1113
1114                // In UntilIslandEnd mode, a matching island_id means we are done.
1115                if let WalkMode::UntilIslandEnd { target_island_id } = mode {
1116                    if island_id == target_island_id {
1117                        // Flush any pending tag close before returning.
1118                        if state.pending_tag_close {
1119                            close_start_tag(out, state, limit)?;
1120                        }
1121                        return Ok(());
1122                    }
1123                }
1124
1125                // Either Range mode, or a nested (non-matching) island end —
1126                // emit the closing marker comment and continue (no marker
1127                // inside a text-only element, where it is text).
1128                if state.text_only.is_none() {
1129                    push_checked(out, "<!--/f:i", limit)?;
1130                    push_u16(out, island_id, limit)?;
1131                    push_checked(out, "-->", limit)?;
1132                }
1133            }
1134
1135            Opcode::ShowIf => {
1136                // slot_id(2) + then_len(4) + else_len(4) = 10 bytes header
1137                let slot_id = read_u16(ops, pos)?;
1138                let then_len = read_u32(ops, pos + 2)? as usize;
1139                let else_len = read_u32(ops, pos + 6)? as usize;
1140                pos += 10;
1141                // A conditional at a keyed row's own level is a range, not
1142                // the one stable root the key needs (BUILD-613 from source).
1143                state.note_row_level_content();
1144
1145                check_slot_source(module, slot_id, slots.get(slot_id));
1146                let condition = slots.get(slot_id).as_bool();
1147                let then_start = pos;
1148                // Checked, and bounded by the enclosing region: a corrupt
1149                // length would otherwise silently relocate content across the
1150                // hydration markers, and plain `+` can wrap on wasm32.
1151                let then_end = bounded_offset(pos, then_len, region_limit)?;
1152                // The SHOW_ELSE marker byte must itself be inside the region
1153                // (`<` not `<=`), or a SHOW_IF at the tail of a sub-range would
1154                // read a byte its parent never granted.
1155                if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
1156                    return Err(IrError::InvalidSection);
1157                }
1158                let else_start = then_end + 1; // skip SHOW_ELSE byte
1159                let else_end = bounded_offset(else_start, else_len, region_limit)?;
1160
1161                // Emit conditional marker — none inside a text-only element,
1162                // where it is text. Decided once, so the pair always matches.
1163                let markers = state.mode == RenderMode::Adoptable && state.text_only.is_none();
1164                if markers {
1165                    push_checked(out, "<!--f:s", limit)?;
1166                    push_u16(out, slot_id, limit)?;
1167                    push_checked(out, "-->", limit)?;
1168                }
1169
1170                if condition {
1171                    walk_range(
1172                        module,
1173                        slots,
1174                        ops,
1175                        then_start,
1176                        then_end,
1177                        out,
1178                        state,
1179                        depth + 1,
1180                    )?;
1181                } else {
1182                    walk_range(
1183                        module,
1184                        slots,
1185                        ops,
1186                        else_start,
1187                        else_end,
1188                        out,
1189                        state,
1190                        depth + 1,
1191                    )?;
1192                }
1193
1194                if markers {
1195                    push_checked(out, "<!--/f:s", limit)?;
1196                    push_u16(out, slot_id, limit)?;
1197                    push_checked(out, "-->", limit)?;
1198                }
1199
1200                // Advance pos past both branches + SHOW_ELSE marker byte
1201                pos = else_end;
1202            }
1203
1204            Opcode::ShowElse => {
1205                // SHOW_ELSE should never be reached during normal walking — it is
1206                // skipped over by the SHOW_IF handler. If we reach it, the opcode
1207                // stream is malformed, but we tolerate it gracefully by doing nothing.
1208            }
1209
1210            // LIMITATION, not an oversight: SWITCH emits no `<!--f:…-->`
1211            // marker pair, because the hydration wire contract has no switch
1212            // marker — it defines exactly `f:tN` (text), `f:sN` (conditional),
1213            // `f:lN` (list) and `f:iN` (island), and those bytes are shared
1214            // with @getforma/core and ksx Studio. A switched region is
1215            // therefore server-rendered only: the client cannot locate it to
1216            // re-render when the slot changes. Adding one is a coordinated
1217            // change to both renderers and the client walker, not a
1218            // walker-local fix. SWITCH also has no default case in the opcode
1219            // layout; a non-matching value renders nothing.
1220            Opcode::List => {
1221                // `pos` already passed the opcode byte: the LIST's own offset,
1222                // the one section 0x0006 names, is one before it.
1223                let list_offset = (pos - 1) as u32;
1224                // slot_id(2) + item_slot_id(2) + body_len(4) = 8 bytes header
1225                let slot_id = read_u16(ops, pos)?;
1226                let item_slot_id = read_u16(ops, pos + 2)?;
1227                let body_len = read_u32(ops, pos + 4)? as usize;
1228                pos += 8;
1229
1230                check_slot_source(module, slot_id, slots.get(slot_id));
1231                // A list at a keyed row's own level is a range beside the
1232                // row's root, not the root (BUILD-613 from source).
1233                state.note_row_level_content();
1234
1235                let body_start = pos;
1236                let body_end = bounded_offset(pos, body_len, region_limit)?;
1237
1238                // The key path section 0x0006 attaches to this LIST, if any,
1239                // resolved once per LIST execution, not per row: the entries
1240                // are sorted by offset (the parser refuses anything else), so
1241                // this is one binary search, and every segment index was
1242                // checked against the string table at load. A LIST without an
1243                // entry is unkeyed and renders exactly as before.
1244                let key_path = match module
1245                    .key_paths
1246                    .binary_search_by_key(&list_offset, |path| path.list_offset)
1247                {
1248                    Ok(index) => Some(ListKey::resolve(
1249                        list_offset,
1250                        &module.key_paths[index].segments,
1251                        strings,
1252                    )?),
1253                    Err(_) => None,
1254                };
1255
1256                // Emit list markers — none inside a text-only element, where
1257                // they are text. Decided once, so the pair always matches.
1258                let list_marker_id = slot_id;
1259                let markers = state.mode == RenderMode::Adoptable && state.text_only.is_none();
1260                if markers {
1261                    push_checked(out, "<!--f:l", limit)?;
1262                    push_u16(out, list_marker_id, limit)?;
1263                    push_checked(out, "-->", limit)?;
1264                }
1265
1266                // Take a cheap handle on the array to release the borrow on
1267                // `slots` before the loop mutates it. The payload is
1268                // Arc-backed, so this is a refcount bump — and holding it
1269                // means a body that writes to the list's own slot cannot
1270                // change what we are iterating.
1271                let items = match slots.get(slot_id) {
1272                    SlotValue::Array(a) => Some(a.clone()),
1273                    _ => None,
1274                };
1275
1276                if let Some(items) = items.filter(|a| !a.is_empty()) {
1277                    state.list_depth += 1;
1278                    let walked = if state.list_depth > MAX_LIST_DEPTH {
1279                        Err(IrError::ListDepthExceeded {
1280                            max: MAX_LIST_DEPTH,
1281                        })
1282                    } else {
1283                        walk_list_rows(
1284                            module,
1285                            slots,
1286                            ops,
1287                            ListRows {
1288                                items: &items,
1289                                item_slot_id,
1290                                body_start,
1291                                body_end,
1292                                key: key_path.as_ref(),
1293                            },
1294                            out,
1295                            state,
1296                            depth,
1297                        )
1298                    };
1299                    // Restored on every path, as the slot journal and the
1300                    // keyed-row stack are inside `walk_list_rows`: an error
1301                    // leaves the walk state as the LIST found it.
1302                    state.list_depth -= 1;
1303                    walked?;
1304                }
1305
1306                if markers {
1307                    push_checked(out, "<!--/f:l", limit)?;
1308                    push_u16(out, list_marker_id, limit)?;
1309                    push_checked(out, "-->", limit)?;
1310                }
1311
1312                pos = body_end;
1313            }
1314
1315            Opcode::Coalesce => {
1316                let source = read_u16(ops, pos)?;
1317                let fallback = read_u16(ops, pos + 2)?;
1318                let destination = read_u16(ops, pos + 4)?;
1319                pos += 6;
1320                let chosen = if matches!(slots.get(source), SlotValue::Null) {
1321                    slots.get(fallback)
1322                } else {
1323                    slots.get(source)
1324                };
1325                let previous = slots.replace(destination, chosen.clone());
1326                state.journal_write(destination, previous);
1327            }
1328
1329            Opcode::Prop => {
1330                // src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16) = 8 bytes
1331                let src_slot_id = read_u16(ops, pos)?;
1332                let prop_str_idx = read_u32(ops, pos + 2)?;
1333                let target_slot_id = read_u16(ops, pos + 6)?;
1334                pos += 8;
1335
1336                let prop_name = strings.get(prop_str_idx)?;
1337                let value = slots.get(src_slot_id).get_property(prop_name);
1338                // Mutate `slots` directly and journal the displaced value, so
1339                // the enclosing LIST row restores it when the row ends. A PROP
1340                // outside any list writes for the rest of the page, which is
1341                // what it did when each row got its own clone.
1342                let previous = slots.replace(target_slot_id, value);
1343                state.journal_write(target_slot_id, previous);
1344            }
1345        }
1346    }
1347
1348    // Post-loop handling depends on the mode:
1349    match mode {
1350        WalkMode::Range { .. } => {
1351            // Flush any pending tag close at the end of the opcode range.
1352            //
1353            // NOTE: `pending_island` / `keyed_rows` are deliberately NOT
1354            // cleared here. This function runs for every nested control-flow
1355            // body with the same state, so clearing at range end would wipe
1356            // the island attributes of a legitimate
1357            // ISLAND_START → SHOW_IF → OPEN_TAG stream (the island root would
1358            // silently never hydrate). They are cleared at their owning
1359            // boundaries instead: ISLAND_END and the LIST item loop.
1360            if state.pending_tag_close {
1361                close_start_tag(out, state, limit)?;
1362            }
1363            Ok(())
1364        }
1365        WalkMode::UntilIslandEnd { target_island_id } => {
1366            // If we reach the end of the opcode stream without finding ISLAND_END,
1367            // that means the IR is malformed. Return an error.
1368            Err(IrError::IslandNotFound(target_island_id))
1369        }
1370    }
1371}
1372
1373// ---------------------------------------------------------------------------
1374// Keyed list rows (FORMA-181, DR-8)
1375// ---------------------------------------------------------------------------
1376
1377/// A keyed LIST's identity source, resolved once per execution of the LIST.
1378struct ListKey<'m> {
1379    /// Byte offset of the LIST, as section `0x0006` names it.
1380    list_offset: u32,
1381    /// The key path's segments, as field names.
1382    segments: Vec<&'m str>,
1383    /// The path dotted, for diagnostics: shared by every row of the
1384    /// execution, so a row costs a refcount, not a copy.
1385    path: Rc<str>,
1386}
1387
1388impl<'m> ListKey<'m> {
1389    fn resolve(
1390        list_offset: u32,
1391        segments: &[u32],
1392        strings: &'m crate::parser::StringTable,
1393    ) -> Result<Self, IrError> {
1394        let segments = segments
1395            .iter()
1396            .map(|segment| strings.get(*segment))
1397            .collect::<Result<Vec<&str>, IrError>>()?;
1398        let path = Rc::from(dotted_path(&segments));
1399        Ok(ListKey {
1400            list_offset,
1401            segments,
1402            path,
1403        })
1404    }
1405}
1406
1407/// A key path for a diagnostic: segments joined by dots, and a segment that
1408/// a dot would make ambiguous (empty, or holding `.`, `[` or `]`) quoted in
1409/// brackets, so `user.id` is two fields and `["user.id"]` is one.
1410fn dotted_path(segments: &[&str]) -> String {
1411    let mut path = String::new();
1412    for segment in segments {
1413        if segment.is_empty() || segment.contains(['.', '[', ']']) {
1414            path.push_str(&format!("[{segment:?}]"));
1415        } else {
1416            if !path.is_empty() {
1417                path.push('.');
1418            }
1419            path.push_str(segment);
1420        }
1421    }
1422    path
1423}
1424
1425/// What one execution of a LIST iterates (see [`walk_list_rows`]).
1426struct ListRows<'a, 'm> {
1427    items: &'a [SlotValue],
1428    item_slot_id: u16,
1429    body_start: usize,
1430    body_end: usize,
1431    key: Option<&'a ListKey<'m>>,
1432}
1433
1434/// Walk every row of one LIST execution.
1435///
1436/// Before any error propagates, the slot journal is unwound to the row's mark
1437/// and a keyed row's guard is popped, and the caller restores the list depth.
1438/// The rest of the walk state (the element depth, an open start tag, the
1439/// island stack) is not restored: a `WalkState` serves one walk, and an error
1440/// ends that walk, which returns no partial page.
1441fn walk_list_rows(
1442    module: &IrModule,
1443    slots: &mut SlotData,
1444    ops: &[u8],
1445    rows: ListRows<'_, '_>,
1446    out: &mut String,
1447    state: &mut WalkState,
1448    depth: usize,
1449) -> Result<(), IrError> {
1450    let limit = state.max_output_bytes;
1451    // A list longer than what is left of the budget fails here, before its
1452    // first row, instead of walking up to the budget and failing there
1453    // (DR-13, SR38): a hostile 60,000-row list costs a comparison, not 50,000
1454    // rows. It fails exactly when the per-row check below would have, sooner,
1455    // and before any error the rows would have raised first (an output
1456    // budget, a malformed row, a keyed row's identity). That check stays: rows
1457    // of nested lists can spend the budget after this list was let in.
1458    //
1459    // Verified by: crates/forma-ir/src/walker.rs > "a_list_longer_than_the_remaining_budget_fails_before_its_first_row"
1460    // Verified by: crates/forma-ir/src/walker.rs > "a_list_exactly_at_the_remaining_budget_still_renders"
1461    let remaining = state
1462        .max_list_iterations
1463        .saturating_sub(state.list_iterations);
1464    if rows.items.len() > remaining {
1465        return Err(IrError::ListBudgetExceeded {
1466            max: state.max_list_iterations,
1467        });
1468    }
1469    // The keys this execution of the list has rendered, each with the row
1470    // that rendered it. Scoped to one execution: an inner list under two
1471    // outer rows, or two lists over the same array, may reuse a key. Bounded
1472    // by the output budget: a key is admitted only when its normalized text
1473    // fits in what is left of the budget, and every admitted key is then
1474    // written, escaped, on its row's root, so the keys held here never
1475    // outgrow the output.
1476    // One allocation per key: the set and the row's guard share it.
1477    let mut seen_keys: HashMap<Rc<str>, usize> = HashMap::new();
1478    for (ordinal, item) in rows.items.iter().enumerate() {
1479        // Total across all depths, never decremented: depth alone does not
1480        // bound volume, and four nested LISTs over the same array give n^4
1481        // body walks.
1482        //
1483        // Checked before counting, so at the error the count is the rows
1484        // actually walked (`WalkCounts`).
1485        if state.list_iterations >= state.max_list_iterations {
1486            return Err(IrError::ListBudgetExceeded {
1487                max: state.max_list_iterations,
1488            });
1489        }
1490        state.list_iterations += 1;
1491
1492        // A keyed row's identity is read from the row's own data before its
1493        // body runs, so a row without one writes nothing, and the whole render
1494        // is refused: no page carries a list the client could not adopt.
1495        if let Some(key) = rows.key {
1496            let refuse = |reason| list_key_error(key.list_offset, ordinal, &key.path, reason);
1497            let value = crate::keys::key_value_at(item, &key.segments).map_err(refuse)?;
1498            // The text is built only once it is known to fit: a key longer
1499            // than what is left of the output budget could never be written,
1500            // and copying it first would let one row allocate past the budget.
1501            if crate::keys::normalized_len_exceeds(value, limit.saturating_sub(out.len())) {
1502                return Err(IrError::OutputLimitExceeded { limit });
1503            }
1504            let text: Rc<str> = crate::keys::canonical_key_text(value)
1505                .map_err(refuse)?
1506                .into();
1507            if let Some(&first_row) = seen_keys.get(&text) {
1508                // The two ordinals, never the key: it is the row's data.
1509                return Err(refuse(ListKeyReason::Duplicate { first_row }));
1510            }
1511            seen_keys.insert(Rc::clone(&text), ordinal);
1512            state.keyed_rows.push(KeyedRow {
1513                list_offset: key.list_offset,
1514                ordinal,
1515                path: Rc::clone(&key.path),
1516                key: Some(text),
1517                entry_depth: state.element_depth,
1518                roots: 0,
1519                stray_content: false,
1520                envelope: None,
1521            });
1522        }
1523
1524        // Scoped binding instead of a per-row `SlotData` clone: swap the item
1525        // in, walk, swap back. Same isolation between rows, without copying
1526        // every other slot on the page once per row.
1527        let mark = state.slot_journal.len();
1528        let previous = slots.replace(rows.item_slot_id, item.clone());
1529        state.slot_journal.push((rows.item_slot_id, previous));
1530
1531        let walked = walk_range(
1532            module,
1533            slots,
1534            ops,
1535            rows.body_start,
1536            rows.body_end,
1537            out,
1538            state,
1539            depth + 1,
1540        );
1541        // Unwind before propagating: an aborted walk must still hand `slots`
1542        // back exactly as it was, because an enclosing row is about to unwind
1543        // on top of it.
1544        state.unwind_slot_journal(slots, mark);
1545        // The row's guard ends with the row, whatever the walk returned; a key
1546        // it still holds met no root element.
1547        let keyed_row = if rows.key.is_some() {
1548            state.keyed_rows.pop()
1549        } else {
1550            None
1551        };
1552        walked?;
1553
1554        if let Some(row) = keyed_row {
1555            if state.element_depth != row.entry_depth {
1556                // An element the row opened is still open: its root (or what
1557                // follows it) did not close where the row ends.
1558                return Err(row.refuse(ListKeyReason::UnbalancedRow));
1559            }
1560            if row.roots != 1 || row.stray_content || row.envelope.is_some() {
1561                return Err(row.refuse(ListKeyReason::RowShape {
1562                    roots: row.roots,
1563                    stray_content: row.stray_content || row.envelope.is_some(),
1564                }));
1565            }
1566        }
1567    }
1568    Ok(())
1569}
1570
1571/// The whole-render refusal for one keyed row (RENDER-503).
1572fn list_key_error(list_offset: u32, row: usize, path: &str, reason: ListKeyReason) -> IrError {
1573    IrError::InvalidListKey(Box::new(ListKeyViolation::new(
1574        list_offset,
1575        row,
1576        path,
1577        reason,
1578    )))
1579}
1580
1581/// A refusal inside the innermost keyed row, or `fallback` when the walk is in
1582/// none (unreachable through the callers, which act only inside a row).
1583fn innermost_row_error(state: &WalkState, reason: ListKeyReason, fallback: IrError) -> IrError {
1584    match state.keyed_rows.last() {
1585        Some(row) => row.refuse(reason),
1586        None => fallback,
1587    }
1588}
1589
1590// ---------------------------------------------------------------------------
1591// ---------------------------------------------------------------------------
1592// Offset arithmetic
1593// ---------------------------------------------------------------------------
1594
1595/// `base + len`, rejected if it overflows or leaves the enclosing region.
1596///
1597/// Plain `+` on `usize` can overflow on a 32-bit target. The parser accepts
1598/// arbitrary bytes, so the offset must be checked before indexing.
1599fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1600    base.checked_add(len)
1601        .filter(|e| *e <= limit)
1602        .ok_or(IrError::InvalidSection)
1603}
1604
1605// ---------------------------------------------------------------------------
1606// Name and URL safety
1607// ---------------------------------------------------------------------------
1608
1609/// True if `name` is safe to emit verbatim as a tag or attribute name.
1610///
1611/// Names are written to the output stream unescaped (only *values* go through
1612/// the escapers), so a string-table entry used as a tag name — `div onload=x`
1613/// — or as an attribute key — `x"><script` — is direct markup injection. The
1614/// accepted class is `[A-Za-z_:][-A-Za-z0-9_:.]*`, byte-identical to
1615/// core's `isSafeAttrName`/`isSafeTagName`. This deliberately admits an ASCII
1616/// subset (`data-*`, `aria-*`, `xlink:href`, `viewBox`, dotted names); the DOM
1617/// accepting a Unicode name does not expand this contract (docs/INV1.md).
1618fn is_valid_name(name: &str) -> bool {
1619    let mut chars = name.chars();
1620    match chars.next() {
1621        Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1622        _ => return false,
1623    }
1624    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1625}
1626
1627/// Attribute names also satisfy the qualified-name requirement of core's
1628/// xlink setAttributeNS writer. Tag-name admission remains unchanged.
1629fn is_valid_attr_name(name: &str) -> bool {
1630    is_valid_name(name)
1631        && name
1632            .strip_prefix("xlink:")
1633            .map_or(true, |local| is_valid_name(local) && !local.contains(':'))
1634}
1635
1636/// True for any `on…` event-handler attribute name (case-insensitive).
1637fn is_event_handler_attr(name: &str) -> bool {
1638    name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1639}
1640
1641/// Attributes whose value the browser resolves as a URL.
1642///
1643/// Mirrors formajs's `URL_ATTRS` exactly. `srcset` is deliberately absent (a
1644/// comma-separated candidate list that a single scheme test would misparse,
1645/// and always fetched in image mode) and so is `cite`; adding either would
1646/// make the Rust walker stricter than the JS renderer, which is itself a
1647/// hydration divergence.
1648const URL_ATTRS: [&str; 8] = [
1649    "href",
1650    "src",
1651    "action",
1652    "formaction",
1653    "xlink:href",
1654    "poster",
1655    "background",
1656    "data",
1657];
1658
1659/// Tags whose URL-bearing attributes are always fetched as an image or media
1660/// resource and never parsed as a document — the only places a
1661/// `data:image/svg+xml` value is safe. Mirrors formajs's `IMAGE_CONTEXT_TAGS`.
1662const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1663    "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1664    "td", "th",
1665];
1666
1667/// True if `name` is a URL-bearing attribute (case-insensitive).
1668fn is_url_attr(name: &str) -> bool {
1669    URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1670}
1671
1672/// True for `<set>` and every `<animate…>` element (FORMA-277): `set`, or
1673/// `animate` followed by ASCII letters, digits or `_` only (`animate`,
1674/// `animateTransform`, `animateMotion`, `animateColor`), in any ASCII case.
1675/// An animation element's `attributeName` can name a URL attribute (`href`),
1676/// so its [`ANIMATION_VALUE_ATTRS`] are judged as URLs. Mirrors the tag half
1677/// of core's `isDangerousUrlAttr` test, `/^(to|from|by|values) (set|animate\w*)$/i`.
1678fn is_animation_tag(tag: &str) -> bool {
1679    let bytes = tag.as_bytes();
1680    tag.eq_ignore_ascii_case("set")
1681        || (bytes.len() >= 7
1682            && bytes[..7].eq_ignore_ascii_case(b"animate")
1683            && bytes[7..]
1684                .iter()
1685                .all(|b| b.is_ascii_alphanumeric() || *b == b'_'))
1686}
1687
1688/// The value attributes of an animation element ([`is_animation_tag`]);
1689/// `values` is a `;`-separated list.
1690const ANIMATION_VALUE_ATTRS: [&str; 4] = ["to", "from", "by", "values"];
1691
1692/// True if writing `value` to `key` on `<tag>` would write a URL with a
1693/// script scheme: a URL attribute, judged against `tag`, or `to`, `from`,
1694/// `by` or `values` on `<set>` or an `<animate…>` element, any
1695/// `;`-separated item of which is one, judged with no image context.
1696///
1697/// An animation value is judged whatever `attributeName` says, so neither the
1698/// order of the two attributes nor a later change of `attributeName` matters.
1699/// `values` is the list; a split of `to`, `from` and `by` can only refuse
1700/// more. Matches core's `isDangerousUrlAttr` exactly, which the shared INV1
1701/// vectors check on both sides.
1702///
1703/// Verified by: crates/forma-ir/src/walker.rs > "walk_drops_a_script_url_in_an_svg_animation_value"
1704fn is_dangerous_url_attr(tag: &str, key: &str, value: &str) -> bool {
1705    if is_url_attr(key) {
1706        return is_dangerous_url(value, tag);
1707    }
1708    is_animation_tag(tag)
1709        && ANIMATION_VALUE_ATTRS
1710            .iter()
1711            .any(|a| key.eq_ignore_ascii_case(a))
1712        && value.split(';').any(|item| is_dangerous_url(item, ""))
1713}
1714
1715/// True if writing `value` to a URL attribute on `<tag>` would create an XSS
1716/// sink.
1717///
1718/// Browsers strip ASCII whitespace and C0/C1 control characters out of a URL
1719/// before resolving its scheme, so `java\tscript:alert(1)` executes even
1720/// though a naive prefix test does not match it. Forma uses a conservative
1721/// classification transform, shared with core's `isDangerousUrl`, so
1722/// the server emits exactly what the client would have produced.
1723fn is_dangerous_url(value: &str, tag: &str) -> bool {
1724    // A NUL anywhere in a URL attribute value is refused outright (FORMA-182
1725    // review S1): no author writes one, and the normalized U+FFFD it would
1726    // become is not ignored by the URL parser, so `java\0script:` would be
1727    // written as `java\u{FFFD}script:` and resolved and fetched as a
1728    // same-origin relative reference in every engine. The guard therefore
1729    // classifies the raw value; for every other character the strip below
1730    // makes the raw and normalized views agree.
1731    // Verified by: crates/forma-ir/src/walker.rs > "url_attributes_are_checked_as_the_normalized_string_that_is_written"
1732    if value.contains('\0') {
1733        return true;
1734    }
1735    let normalized: String = value
1736        .chars()
1737        .filter(|c| {
1738            let n = *c as u32;
1739            !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1740        })
1741        .collect();
1742    let lower = normalized.to_ascii_lowercase();
1743
1744    if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1745        return true;
1746    }
1747    if lower.starts_with("data:text/html") {
1748        return true;
1749    }
1750    // `data:image/svg+xml` is a *conditional* hazard: through an image sink it
1751    // decodes in image mode where script never runs; through a document sink
1752    // an `onload=` inside it fires. It is not blanket-blocked because the
1753    // stock CSP explicitly permits `img-src data:`.
1754    if lower.starts_with("data:image/svg+xml") {
1755        return !IMAGE_CONTEXT_TAGS
1756            .iter()
1757            .any(|t| tag.eq_ignore_ascii_case(t));
1758    }
1759    false
1760}
1761
1762/// How an attribute's value is written out.
1763///
1764/// The distinction exists because `key` and `key=""` are the same DOM but
1765/// `key` and `key="false"` are not: an HTML boolean attribute is ON whenever
1766/// it is present, whatever its value, so `disabled="false"` disables the
1767/// control. See [`AttrValue::from_slot`].
1768#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1769enum AttrValue<'a> {
1770    /// Write the bare name, no `="…"`.
1771    Bare,
1772    /// Write `key="escaped"`, including when `value` is empty.
1773    Valued(&'a str),
1774}
1775
1776/// Core's HTML boolean attribute set (`BOOLEAN_ATTRS` in
1777/// packages/core/src/dom/element.ts): the attributes a `true` writes bare.
1778/// Matched as written, as core matches its prop keys.
1779/// Verified by: crates/forma-ir/src/walker.rs > "rule_6_attribute_values_follow_one_table"
1780const HTML_BOOLEAN_ATTRIBUTES: [&str; 22] = [
1781    "async",
1782    "autofocus",
1783    "autoplay",
1784    "checked",
1785    "controls",
1786    "default",
1787    "defer",
1788    "disabled",
1789    "formnovalidate",
1790    "hidden",
1791    "ismap",
1792    "loop",
1793    "multiple",
1794    "muted",
1795    "nomodule",
1796    "novalidate",
1797    "open",
1798    "playsinline",
1799    "readonly",
1800    "required",
1801    "reversed",
1802    "selected",
1803];
1804
1805fn is_html_boolean_attribute(name: &str) -> bool {
1806    HTML_BOOLEAN_ATTRIBUTES.contains(&name)
1807}
1808
1809/// Emit one attribute, dropping it entirely if it is unsafe.
1810///
1811/// Dropping (rather than emitting an empty value) is what the client does:
1812/// `renderAttr` returns null and hydration calls `removeAttribute`. An
1813/// SSR-emitted `href=""` resolves to the current page — a real navigation —
1814/// and would not match the client DOM.
1815fn emit_attr(
1816    out: &mut String,
1817    tag: &str,
1818    key: &str,
1819    val: AttrValue<'_>,
1820    limit: usize,
1821) -> Result<(), IrError> {
1822    if !is_valid_attr_name(key) {
1823        // Skip silently (matching the walker's existing convention for
1824        // malformed attribute operands) rather than failing the whole page
1825        // for one bad key.
1826        #[cfg(feature = "tracing")]
1827        tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1828        return Ok(());
1829    }
1830    if is_event_handler_attr(key) {
1831        #[cfg(feature = "tracing")]
1832        tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1833        return Ok(());
1834    }
1835    if let AttrValue::Valued(v) = val {
1836        if is_dangerous_url_attr(tag, key, v) {
1837            #[cfg(feature = "tracing")]
1838            tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1839            return Ok(());
1840        }
1841    }
1842
1843    push_checked(out, " ", limit)?;
1844    push_checked(out, key, limit)?;
1845    if let AttrValue::Valued(v) = val {
1846        push_checked(out, "=\"", limit)?;
1847        push_escaped_attr(out, v, limit)?;
1848        push_checked(out, "\"", limit)?;
1849    }
1850    Ok(())
1851}
1852
1853/// Emit `<tag ...attrs`, plus any pending island / list-item attributes.
1854///
1855/// Shared by OPEN_TAG and VOID_TAG, which differ only in whether a matching
1856/// CLOSE_TAG follows (`has_content`). The `>` is deliberately not written yet:
1857/// DYN_ATTR opcodes may still append attributes.
1858fn emit_open_tag(
1859    out: &mut String,
1860    tag: &str,
1861    attrs: &[(&str, &str)],
1862    has_content: bool,
1863    state: &mut WalkState,
1864    limit: usize,
1865) -> Result<(), IrError> {
1866    if !is_valid_name(tag) {
1867        return Err(IrError::InvalidName(tag.to_string()));
1868    }
1869    // Decided before the depth moves: an element opened at the depth the
1870    // innermost keyed row was entered at is that row's root (FORMA-181).
1871    let row_key = state.open_row_root();
1872    state.open_tag_is_row_root = row_key.is_some();
1873    if row_key.is_some()
1874        && attrs
1875            .iter()
1876            .any(|(key, _)| key.eq_ignore_ascii_case("data-forma-key"))
1877    {
1878        // The identity attribute is the walker's to write, once, from the
1879        // key path; an authored one would be a second, silently first-wins
1880        // identity on the same root.
1881        return Err(innermost_row_error(
1882            state,
1883            ListKeyReason::AuthoredKey,
1884            IrError::InvalidName("data-forma-key".to_string()),
1885        ));
1886    }
1887    if has_content {
1888        enter_element(out, tag, state, limit)?;
1889        state.element_depth += 1;
1890    }
1891    push_checked(out, "<", limit)?;
1892    push_checked(out, tag, limit)?;
1893
1894    for (key, val) in attrs {
1895        // `value` on a textarea or select, and `selected` on an option of a
1896        // bound select, are written another way.
1897        if consume_control_attr(state, key, Prop::Static(val)) {
1898            continue;
1899        }
1900        // A static attribute is authored text, never a boolean. An empty value
1901        // stays the bare name: `key` and `key=""` parse to the same DOM, and
1902        // keeping the shorthand leaves already-compiled modules byte-stable.
1903        let value = if val.is_empty() {
1904            AttrValue::Bare
1905        } else {
1906            AttrValue::Valued(val)
1907        };
1908        emit_attr(out, tag, key, value, limit)?;
1909    }
1910
1911    // Inject pending island attributes
1912    if let Some(island) = state.pending_island.take() {
1913        push_checked(out, " data-forma-island=\"", limit)?;
1914        push_u16(out, island.id, limit)?;
1915        push_checked(out, "\" data-forma-component=\"", limit)?;
1916        push_escaped_attr(out, &island.component_name, limit)?;
1917        push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1918        let trigger_str = match island.trigger {
1919            IslandTrigger::Load => "load",
1920            IslandTrigger::Visible => "visible",
1921            IslandTrigger::Interaction => "interaction",
1922            IslandTrigger::Idle => "idle",
1923        };
1924        push_checked(out, " data-forma-hydrate=\"", limit)?;
1925        push_checked(out, trigger_str, limit)?;
1926        push_checked(out, "\"", limit)?;
1927        push_checked(out, " data-forma-state=\"", limit)?;
1928        push_checked(out, &island.occurrence.to_string(), limit)?;
1929        push_checked(out, "\" data-forma-h=\"", limit)?;
1930        push_checked(out, &island.hash, limit)?;
1931        push_checked(out, "\"", limit)?;
1932    }
1933
1934    // The keyed row's identity, on its root (FORMA-181, DR-8). The canonical
1935    // text is already normalized (value rule 1); `push_escaped_attr` applies
1936    // the same rule again, which is idempotent, and escapes for the
1937    // attribute, so the browser decodes exactly the canonical text.
1938    if let Some(key) = row_key.filter(|_| state.mode == RenderMode::Adoptable) {
1939        push_checked(out, " data-forma-key=\"", limit)?;
1940        push_escaped_attr(out, &key, limit)?;
1941        push_checked(out, "\"", limit)?;
1942    }
1943
1944    // Don't close the tag yet — DYN_ATTR opcodes may follow.
1945    state.pending_tag_close = true;
1946    state.pending_tag_name.clear();
1947    state.pending_tag_name.push_str(tag);
1948    Ok(())
1949}
1950
1951// ---------------------------------------------------------------------------
1952// Parsing context: text-only parents and form controls
1953// ---------------------------------------------------------------------------
1954
1955/// An element whose content the HTML tokenizer reads as text, not markup.
1956///
1957/// `textarea` and `title` are RCDATA: character references are decoded, but
1958/// a comment is not a comment. `style` (RAWTEXT) and `script` (script data)
1959/// decode nothing. A hydration marker inside any of them is literal text:
1960/// before JavaScript it is the control's value, what a form submits, and the
1961/// title the reader sees. So inside these four the walker writes no `f:`
1962/// marker of any kind (text, conditional, list or island) and no zero-width
1963/// space: the content is the element's static and dynamic text, in order.
1964///
1965/// The client adopts `textarea` and `title` text as a whole, by the element
1966/// (formajs, FORMA-003). A reactive child of `style` or `script` is not
1967/// supported for adoption in 0.2.x: its text is right before JavaScript, but
1968/// adoption may rewrite it. DR-7 makes it a build error in 3.0.0.
1969///
1970/// Dynamic text is still HTML-escaped. In RCDATA the parser decodes it back,
1971/// so the text is exact. In `style` and `script` nothing is decoded, so a
1972/// value holding `&`, `<` or `>` reads as the entity until the client writes
1973/// the element; that is the price of a hole that can neither close its
1974/// element (`</style>`) nor, in a script, swallow the rest of the page. A
1975/// hole there is still a code sink: the escaping does not make the value safe
1976/// as JavaScript or CSS.
1977///
1978/// Matched ASCII case-insensitively and in any namespace. An SVG `title` has
1979/// ordinary content, but its text written without markers is equally right,
1980/// and a single rule is one the client can match exactly.
1981///
1982/// Verified by: crates/forma-ir/src/walker.rs > "dyn_text_in_a_text_only_parent_is_written_without_markers"
1983/// Verified by: crates/forma-ir/src/walker.rs > "no_marker_kind_is_written_inside_a_text_only_parent"
1984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1985enum TextOnlyTag {
1986    Textarea,
1987    Title,
1988    Style,
1989    Script,
1990}
1991
1992impl TextOnlyTag {
1993    fn from_tag(tag: &str) -> Option<Self> {
1994        [Self::Textarea, Self::Title, Self::Style, Self::Script]
1995            .into_iter()
1996            .find(|t| tag.eq_ignore_ascii_case(t.name()))
1997    }
1998
1999    fn name(self) -> &'static str {
2000        match self {
2001            TextOnlyTag::Textarea => "textarea",
2002            TextOnlyTag::Title => "title",
2003            TextOnlyTag::Style => "style",
2004            TextOnlyTag::Script => "script",
2005        }
2006    }
2007}
2008
2009/// The text-only element the walk is inside.
2010#[derive(Debug)]
2011struct TextOnlyParent {
2012    tag: TextOnlyTag,
2013    /// Whether any content has been written: the leading-LF rule looks at the
2014    /// first byte only.
2015    started: bool,
2016    /// A `textarea` whose `value` is bound: the value is its whole content,
2017    /// as it is on the live control, so its children are not written.
2018    value_bound: bool,
2019}
2020
2021/// Write text into a text-only parent: escaped, with no marker and no
2022/// zero-width space, under the textarea's leading-LF rule.
2023///
2024/// The parser drops one line feed straight after `<textarea>`. A marker used
2025/// to stand between the tag and the first line; without one, content that
2026/// starts with a line break gets one more, so the parsed value is the value
2027/// written. Under rule 1 a leading CR or CRLF is already a line feed when it
2028/// is written, so only an initial LF of the normalized text is doubled. An
2029/// empty chunk does not consume the boundary.
2030///
2031/// Verified by: crates/forma-ir/src/walker.rs > "a_textarea_doubles_a_leading_line_feed"
2032/// Verified by: crates/forma-ir/src/walker.rs > "textarea_children_protect_only_an_initial_normalized_line_feed"
2033fn write_text_only(
2034    out: &mut String,
2035    parent: &mut TextOnlyParent,
2036    text: &str,
2037    limit: usize,
2038) -> Result<(), IrError> {
2039    if text.is_empty() {
2040        return Ok(());
2041    }
2042    if parent.tag == TextOnlyTag::Textarea
2043        && !parent.started
2044        && normalized_chars(text).next() == Some('\n')
2045    {
2046        push_checked(out, "\n", limit)?;
2047    }
2048    parent.started = true;
2049    push_escaped_html(out, text, limit)
2050}
2051
2052/// Form-control state gathered from a start tag that is still open.
2053///
2054/// A `value` on a `textarea` or a `select` is a property: HTML never reads it
2055/// as an attribute, so the server shipped an empty textarea and a select
2056/// showing its first option, and a form submitted those before JavaScript.
2057/// The client's writer (`writeFormControlProperty` in `@getforma/core`) sets
2058/// `value == null ? '' : String(value)`; the walker writes the HTML that
2059/// parses to the same control, and no `value` attribute.
2060#[derive(Debug, Default)]
2061enum PendingControl {
2062    #[default]
2063    None,
2064    /// A `textarea`; `value` is the text its bound value stands for.
2065    Textarea { value: Option<String> },
2066    /// A `select`; `multiple` records whether that attribute is written.
2067    Select {
2068        value: Option<SlotValue>,
2069        multiple: bool,
2070    },
2071    /// An `option` of a bound select; `value` is its `value` attribute as
2072    /// written, `None` when it has none.
2073    OptionTag { value: Option<String> },
2074}
2075
2076/// What a bound `select` selects.
2077///
2078/// Mirrors the client's writer. A `multiple` select given an array selects
2079/// every option whose value is in it (`value.map(String)`). Anything else is
2080/// `select.value = String(value)`, which selects the FIRST option with that
2081/// value; a second `selected` in the HTML would win instead, since the parser
2082/// keeps the last. An option's value is its `value` attribute or, without
2083/// one, its text, stripped and collapsed. A value no option has selects
2084/// nothing, and the browser then shows the first option: HTML cannot write
2085/// the client's `selectedIndex = -1`.
2086///
2087/// Verified by: crates/forma-ir/src/walker.rs > "a_select_value_binding_selects_the_matching_option"
2088/// Verified by: crates/forma-ir/src/walker.rs > "a_single_select_selects_only_the_first_matching_option"
2089/// Verified by: crates/forma-ir/src/walker.rs > "a_multiple_select_selects_every_option_in_an_array_value"
2090#[derive(Debug)]
2091struct SelectBinding {
2092    wanted: SelectWanted,
2093    /// A single select has selected its option.
2094    matched: bool,
2095}
2096
2097#[derive(Debug)]
2098enum SelectWanted {
2099    One(String),
2100    Any(Vec<String>),
2101}
2102
2103impl SelectBinding {
2104    fn new(value: &SlotValue, multiple: bool) -> Self {
2105        // Each wanted value is compared with option values that are written
2106        // normalized (rule 1), so it is normalized the same way: a requested
2107        // NUL matches the option whose attribute the browser reads as U+FFFD.
2108        let wanted = match value {
2109            SlotValue::Array(items) if multiple => {
2110                SelectWanted::Any(items.iter().map(js_string).collect())
2111            }
2112            // `value == null ? '' : String(value)` is exactly `to_text`.
2113            other => SelectWanted::One(normalize_text(&other.to_text()).into_owned()),
2114        };
2115        SelectBinding {
2116            wanted,
2117            matched: false,
2118        }
2119    }
2120
2121    /// Whether the option valued `option_value` is selected.
2122    fn selects(&mut self, option_value: &str) -> bool {
2123        match &self.wanted {
2124            SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
2125            SelectWanted::One(value) => {
2126                let hit = !self.matched && value == option_value;
2127                self.matched |= hit;
2128                hit
2129            }
2130        }
2131    }
2132}
2133
2134/// `String(value)` for one member of an array: unlike a text hole, `null` is
2135/// `"null"`.
2136fn js_string(value: &SlotValue) -> String {
2137    match value {
2138        SlotValue::Null => "null".to_string(),
2139        other => normalize_text(&other.to_text()).into_owned(),
2140    }
2141}
2142
2143/// An option of a bound select with no `value` attribute. Its value is its
2144/// text, known only when the option ends, so ` selected` is inserted then.
2145#[derive(Debug)]
2146struct OptionByText {
2147    /// Byte offset of the `>` that ends the option's start tag. The walker
2148    /// only ever appends to its output, so the offset stays valid.
2149    insert_at: usize,
2150    /// The option's text as the parser will read it.
2151    text: String,
2152}
2153
2154/// An attribute value as the IR gives it: authored text, or a slot.
2155#[derive(Clone, Copy)]
2156enum Prop<'a> {
2157    Static(&'a str),
2158    Slot(&'a SlotValue),
2159}
2160
2161impl Prop<'_> {
2162    /// The client's control text: `value == null ? '' : String(value)`, under
2163    /// rule 1, since it is compared with and written as DOM text.
2164    fn control_text(self) -> String {
2165        match self {
2166            Prop::Static(s) => normalize_text(s).into_owned(),
2167            Prop::Slot(v) => normalize_text(&v.to_text()).into_owned(),
2168        }
2169    }
2170
2171    /// Whether the generic path writes the attribute at all (DYN_ATTR's
2172    /// boolean rule: `false` and `null` omit it).
2173    fn is_written(self) -> bool {
2174        !matches!(
2175            self,
2176            Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
2177        )
2178    }
2179
2180    /// The value the attribute is written with: `None` when omitted, `""`
2181    /// when bare.
2182    fn written(self) -> Option<String> {
2183        match self {
2184            _ if !self.is_written() => None,
2185            Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
2186            other => Some(other.control_text()),
2187        }
2188    }
2189
2190    fn to_slot(self) -> SlotValue {
2191        match self {
2192            Prop::Static(s) => SlotValue::Text(s.to_string()),
2193            Prop::Slot(v) => v.clone(),
2194        }
2195    }
2196}
2197
2198/// Apply the form-control rules to one attribute of the open start tag.
2199/// Returns true when the attribute is consumed and must not be written.
2200///
2201/// Keys match exactly, as the client's `isFormControlProperty` does.
2202fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
2203    match &mut state.pending_control {
2204        PendingControl::None => false,
2205        PendingControl::Textarea { value } => {
2206            if key != "value" {
2207                return false;
2208            }
2209            *value = Some(prop.control_text());
2210            true
2211        }
2212        PendingControl::Select { value, multiple } => match key {
2213            "value" => {
2214                *value = Some(prop.to_slot());
2215                true
2216            }
2217            "multiple" => {
2218                *multiple |= match prop {
2219                    Prop::Static(_) => true,
2220                    Prop::Slot(value) => value.as_bool(),
2221                };
2222                false
2223            }
2224            _ => false,
2225        },
2226        PendingControl::OptionTag { value } => match key {
2227            // The binding decides, as on the client, where the select's
2228            // value is written after its options.
2229            "selected" => true,
2230            "value" => {
2231                // The parser keeps the first of two same-named attributes.
2232                if value.is_none() {
2233                    *value = prop.written();
2234                }
2235                false
2236            }
2237            _ => false,
2238        },
2239    }
2240}
2241
2242/// Set up the parsing context for an element whose start tag (OPEN_TAG) is
2243/// about to be written.
2244fn enter_element(
2245    out: &mut String,
2246    tag: &str,
2247    state: &mut WalkState,
2248    limit: usize,
2249) -> Result<(), IrError> {
2250    let is = |name: &str| tag.eq_ignore_ascii_case(name);
2251    // A new option or optgroup ends the open option: `</option>` is optional.
2252    if is("option") || is("optgroup") {
2253        finish_option_by_text(out, state, limit)?;
2254    }
2255    state.pending_control = if is("textarea") {
2256        PendingControl::Textarea { value: None }
2257    } else if is("select") {
2258        PendingControl::Select {
2259            value: None,
2260            multiple: false,
2261        }
2262    } else if is("option") && state.select.is_some() {
2263        PendingControl::OptionTag { value: None }
2264    } else {
2265        PendingControl::None
2266    };
2267    if state.text_only.is_none() {
2268        state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
2269            tag,
2270            started: false,
2271            value_bound: false,
2272        });
2273    }
2274    Ok(())
2275}
2276
2277/// Tear down the parsing context at a CLOSE_TAG, before `</tag>` is written.
2278fn leave_element(
2279    out: &mut String,
2280    tag: &str,
2281    state: &mut WalkState,
2282    limit: usize,
2283) -> Result<(), IrError> {
2284    let is = |name: &str| tag.eq_ignore_ascii_case(name);
2285    if is("option") || is("optgroup") || is("select") {
2286        finish_option_by_text(out, state, limit)?;
2287    }
2288    if is("select") {
2289        state.select = None;
2290    }
2291    // `</pre>` is a token too; the length check would already miss, but an
2292    // explicit clear keeps the state's meaning local to its element.
2293    state.pre_boundary = None;
2294    if state
2295        .text_only
2296        .as_ref()
2297        .is_some_and(|parent| is(parent.tag.name()))
2298    {
2299        state.text_only = None;
2300    }
2301    Ok(())
2302}
2303
2304/// Write the `>` that ends the open start tag, resolving the form-control
2305/// state gathered from its attributes.
2306///
2307/// Every place that used to write a bare `>` for `pending_tag_close` calls
2308/// this instead, so a control is resolved whichever opcode ends its tag.
2309fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
2310    state.pending_tag_close = false;
2311    state.open_tag_is_row_root = false;
2312    match std::mem::take(&mut state.pending_control) {
2313        PendingControl::None => {}
2314        PendingControl::Textarea { value } => {
2315            push_checked(out, ">", limit)?;
2316            if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
2317                if parent.tag == TextOnlyTag::Textarea {
2318                    parent.value_bound = true;
2319                    write_text_only(out, parent, &value, limit)?;
2320                }
2321            }
2322            return Ok(());
2323        }
2324        PendingControl::Select { value, multiple } => {
2325            state.select = value.map(|value| SelectBinding::new(&value, multiple));
2326        }
2327        PendingControl::OptionTag { value: Some(value) } => {
2328            if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2329                push_checked(out, " selected", limit)?;
2330            }
2331        }
2332        PendingControl::OptionTag { value: None } => {
2333            state.option_by_text = Some(OptionByText {
2334                insert_at: out.len(),
2335                text: String::new(),
2336            });
2337        }
2338    }
2339    push_checked(out, ">", limit)?;
2340    let tag = &state.pending_tag_name;
2341    if tag.eq_ignore_ascii_case("pre") || tag.eq_ignore_ascii_case("listing") {
2342        state.pre_boundary = Some(out.len());
2343    }
2344    Ok(())
2345}
2346
2347/// End an option whose value is its text, selecting it now if the text
2348/// matches. Runs at `</option>` and wherever the parser ends one implicitly.
2349///
2350/// Verified by: crates/forma-ir/src/walker.rs > "a_select_value_binding_matches_an_option_without_a_value_by_its_text"
2351fn finish_option_by_text(
2352    out: &mut String,
2353    state: &mut WalkState,
2354    limit: usize,
2355) -> Result<(), IrError> {
2356    let Some(option) = state.option_by_text.take() else {
2357        return Ok(());
2358    };
2359    let value = strip_and_collapse_ascii_whitespace(&option.text);
2360    if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2361        const SELECTED: &str = " selected";
2362        if out.len().saturating_add(SELECTED.len()) > limit {
2363            return Err(IrError::OutputLimitExceeded { limit });
2364        }
2365        out.insert_str(option.insert_at, SELECTED);
2366    }
2367    Ok(())
2368}
2369
2370/// HTML's "strip and collapse ASCII whitespace", which turns an option's text
2371/// into its value.
2372fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
2373    let mut out = String::with_capacity(text.len());
2374    for word in text.split_ascii_whitespace() {
2375        if !out.is_empty() {
2376            out.push(' ');
2377        }
2378        out.push_str(word);
2379    }
2380    out
2381}
2382
2383// ---------------------------------------------------------------------------
2384// Byte-reading helpers
2385// ---------------------------------------------------------------------------
2386
2387/// Read a little-endian u16 from `data` at `pos`.
2388pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
2389    if pos + 2 > data.len() {
2390        return Err(IrError::BufferTooShort {
2391            expected: pos + 2,
2392            actual: data.len(),
2393        });
2394    }
2395    Ok(u16::from_le_bytes([data[pos], data[pos + 1]]))
2396}
2397
2398/// Read a little-endian u32 from `data` at `pos`.
2399pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
2400    if pos + 4 > data.len() {
2401        return Err(IrError::BufferTooShort {
2402            expected: pos + 4,
2403            actual: data.len(),
2404        });
2405    }
2406    Ok(u32::from_le_bytes([
2407        data[pos],
2408        data[pos + 1],
2409        data[pos + 2],
2410        data[pos + 3],
2411    ]))
2412}
2413
2414/// Read an OPEN_TAG or VOID_TAG operand block:
2415/// `str_idx(u32) + attr_count(u16) + [(key_str_idx(u32), val_str_idx(u32))] x attr_count`
2416///
2417/// Returns `(tag_str_idx, vec of (key, val) string refs, new position)`.
2418pub(crate) fn read_tag_with_attrs<'a>(
2419    ops: &[u8],
2420    pos: usize,
2421    strings: &'a crate::parser::StringTable,
2422) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
2423    let tag_str_idx = read_u32(ops, pos)?;
2424    let attr_count = read_u16(ops, pos + 4)? as usize;
2425    let mut cursor = pos + 6; // past str_idx(4) + attr_count(2)
2426
2427    // `attr_count` is u16-bounded but each pair costs 8 bytes on disk and a
2428    // fat-pointer pair in memory — bound the reservation by what is actually
2429    // left. A valid stream still gets one exact allocation.
2430    let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
2431    for _ in 0..attr_count {
2432        let key_idx = read_u32(ops, cursor)?;
2433        let val_idx = read_u32(ops, cursor + 4)?;
2434        let key = strings.get(key_idx)?;
2435        let val = strings.get(val_idx)?;
2436        attrs.push((key, val));
2437        cursor += 8;
2438    }
2439
2440    Ok((tag_str_idx, attrs, cursor))
2441}
2442
2443// ---------------------------------------------------------------------------
2444// Budgeted output helpers
2445// ---------------------------------------------------------------------------
2446
2447/// Longest escape sequence any of the escapers can emit for one character
2448/// (`&amp;`, `&quot;`, `<`).
2449const MAX_ESCAPE_EXPANSION: usize = 6;
2450
2451/// Append `s`, refusing to exceed the walk's output budget.
2452///
2453/// Every append routes through here (or one of the escaping variants below)
2454/// because a *single* opcode can emit gigabytes: OPEN_TAG accepts up to 65,535
2455/// attributes, each with a 65,535-character value that escapes 5x, so a check
2456/// only at the top of the opcode loop never runs in time.
2457fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
2458    if out.len().saturating_add(s.len()) > limit {
2459        return Err(IrError::OutputLimitExceeded { limit });
2460    }
2461    out.push_str(s);
2462    Ok(())
2463}
2464
2465/// Append a `u16` in decimal, within the output budget, without allocating.
2466///
2467/// Marker ids are the hottest thing the walker writes — every DYN_TEXT emits
2468/// its id twice, every SHOW_IF and LIST likewise, and a list body repeats all
2469/// of that once per row. `to_string()` heap-allocated for each one.
2470fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
2471    // u16::MAX is 65535: five digits, never more.
2472    let mut buf = [0u8; 5];
2473    let mut remaining = value;
2474    let mut start = buf.len();
2475    loop {
2476        start -= 1;
2477        buf[start] = b'0' + (remaining % 10) as u8;
2478        remaining /= 10;
2479        if remaining == 0 {
2480            break;
2481        }
2482    }
2483    // ASCII digits are always UTF-8; the error arm is unreachable and costs
2484    // nothing, where `expect` would put a panic on the hottest write.
2485    match core::str::from_utf8(&buf[start..]) {
2486        Ok(digits) => push_checked(out, digits, limit),
2487        Err(e) => Err(IrError::InvalidUtf8(e.to_string())),
2488    }
2489}
2490
2491/// Append one normalized character, refusing to exceed the output budget by
2492/// its actual encoded length: U+FFFD (NUL's replacement) is three bytes, an
2493/// astral character four, so a check of `out.len() >= limit` before the push
2494/// could overshoot the budget by up to three bytes.
2495///
2496/// Verified by: crates/forma-ir/src/walker.rs > "the_output_budget_counts_the_normalized_and_escaped_bytes_actually_written"
2497fn push_char_checked(out: &mut String, ch: char, limit: usize) -> Result<(), IrError> {
2498    if out.len().saturating_add(ch.len_utf8()) > limit {
2499        return Err(IrError::OutputLimitExceeded { limit });
2500    }
2501    out.push(ch);
2502    Ok(())
2503}
2504
2505/// Escape text for HTML content (`& < >`), within the output budget.
2506///
2507/// The text is written under rule 1 (`crate::text`): CRLF and CR become LF
2508/// and NUL becomes U+FFFD, per value, before escaping. A NUL grows from one
2509/// byte to three, which the fast path's worst-case bound (six per input
2510/// byte) already covers.
2511///
2512/// Verified by: crates/forma-ir/src/walker.rs > "text_and_dyn_text_write_each_value_normalized"
2513fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2514    // Fast path: even worst-case expansion fits, so no per-character checks.
2515    if out
2516        .len()
2517        .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2518        <= limit
2519    {
2520        push_escaped_runs::<false>(out, text);
2521        return Ok(());
2522    }
2523    for ch in normalized_chars(text) {
2524        match ch {
2525            '&' => push_checked(out, "&amp;", limit)?,
2526            '<' => push_checked(out, "&lt;", limit)?,
2527            '>' => push_checked(out, "&gt;", limit)?,
2528            _ => push_char_checked(out, ch, limit)?,
2529        }
2530    }
2531    Ok(())
2532}
2533
2534/// Escape text for HTML attribute values (`& " < >`), within the output budget.
2535/// Normalized under rule 1 exactly as [`push_escaped_html`] is.
2536///
2537/// Verified by: crates/forma-ir/src/walker.rs > "static_and_dynamic_attributes_write_each_value_normalized"
2538fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2539    if out
2540        .len()
2541        .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2542        <= limit
2543    {
2544        push_escaped_runs::<true>(out, text);
2545        return Ok(());
2546    }
2547    for ch in normalized_chars(text) {
2548        match ch {
2549            '&' => push_checked(out, "&amp;", limit)?,
2550            '"' => push_checked(out, "&quot;", limit)?,
2551            '<' => push_checked(out, "&lt;", limit)?,
2552            '>' => push_checked(out, "&gt;", limit)?,
2553            _ => push_char_checked(out, ch, limit)?,
2554        }
2555    }
2556    Ok(())
2557}
2558
2559/// Copy unchanged UTF-8 runs at once after the caller proves the entire
2560/// worst-case expansion fits. Every split byte is ASCII, hence a UTF-8 boundary.
2561/// Keep the checked character loop near the limit: its partially written byte
2562/// count is part of `WalkFailure`, even when the complete value does not fit.
2563fn push_escaped_runs<const ATTRIBUTE: bool>(out: &mut String, text: &str) {
2564    let bytes = text.as_bytes();
2565    let mut start = 0;
2566    let mut i = 0;
2567    while i < bytes.len() {
2568        let replacement = match bytes[i] {
2569            b'&' => "&amp;",
2570            b'<' => "&lt;",
2571            b'>' => "&gt;",
2572            b'"' if ATTRIBUTE => "&quot;",
2573            b'\r' => "\n",
2574            0 => "\u{FFFD}",
2575            _ => {
2576                i += 1;
2577                continue;
2578            }
2579        };
2580        out.push_str(&text[start..i]);
2581        out.push_str(replacement);
2582        if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
2583            i += 1;
2584        }
2585        i += 1;
2586        start = i;
2587    }
2588    out.push_str(&text[start..]);
2589}
2590
2591// ---------------------------------------------------------------------------
2592// Tests
2593// ---------------------------------------------------------------------------
2594
2595#[cfg(test)]
2596mod tests {
2597    use super::*;
2598
2599    #[test]
2600    fn bulk_escaping_matches_character_reference_at_every_output_boundary() {
2601        fn reference(
2602            out: &mut String,
2603            text: &str,
2604            limit: usize,
2605            attr: bool,
2606        ) -> Result<(), IrError> {
2607            for ch in normalized_chars(text) {
2608                match ch {
2609                    '&' => push_checked(out, "&amp;", limit)?,
2610                    '<' => push_checked(out, "&lt;", limit)?,
2611                    '>' => push_checked(out, "&gt;", limit)?,
2612                    '"' if attr => push_checked(out, "&quot;", limit)?,
2613                    _ => push_char_checked(out, ch, limit)?,
2614                }
2615            }
2616            Ok(())
2617        }
2618        let mut cases = vec![
2619            String::new(),
2620            "plain UTF-8 é 🦀 text".into(),
2621            "\r\n\r\0<&>\"\r\r\n🦀\0".into(),
2622            "\r".into(),
2623            "\n".into(),
2624        ];
2625        // Reproducible mixed strings exercise ASCII split points next to
2626        // multibyte scalars, CRLF, lone CR and every escaping expansion.
2627        let alphabet = ['a', 'é', '🦀', '\0', '\r', '\n', '&', '<', '>', '"'];
2628        let mut seed = 258_u64;
2629        for length in 0..128 {
2630            let mut text = String::new();
2631            for _ in 0..length {
2632                seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
2633                text.push(alphabet[(seed >> 32) as usize % alphabet.len()]);
2634            }
2635            cases.push(text);
2636        }
2637        for text in cases {
2638            for attr in [false, true] {
2639                for limit in 0..=text.len() * MAX_ESCAPE_EXPANSION + 4 {
2640                    let mut expected = String::from("é");
2641                    let mut actual = expected.clone();
2642                    let expected_result = reference(&mut expected, &text, limit, attr);
2643                    let actual_result = if attr {
2644                        push_escaped_attr(&mut actual, &text, limit)
2645                    } else {
2646                        push_escaped_html(&mut actual, &text, limit)
2647                    };
2648                    assert_eq!(
2649                        actual_result.is_ok(),
2650                        expected_result.is_ok(),
2651                        "{text:?}, {limit}, {attr}"
2652                    );
2653                    assert_eq!(actual, expected, "partial bytes: {text:?}, {limit}, {attr}");
2654                }
2655            }
2656        }
2657    }
2658    use crate::parser::test_helpers::{
2659        build_declared_fixture, encode_close_tag, encode_list, encode_open_tag, encode_show_if,
2660        encode_text, encode_void_tag,
2661    };
2662    use crate::slot::{SlotData, SlotValue};
2663
2664    // -- Opcode encoding helpers local to walker tests ----------------------
2665
2666    /// Encode a DYN_TEXT opcode: opcode(1) + slot_id(2) + marker_id(2)
2667    fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
2668        let mut buf = Vec::new();
2669        buf.push(0x05); // Opcode::DynText
2670        buf.extend_from_slice(&slot_id.to_le_bytes());
2671        buf.extend_from_slice(&marker_id.to_le_bytes());
2672        buf
2673    }
2674
2675    /// Encode an ISLAND_START opcode: opcode(1) + island_id(2)
2676    fn encode_island_start(island_id: u16) -> Vec<u8> {
2677        let mut buf = vec![0x0B];
2678        buf.extend_from_slice(&island_id.to_le_bytes());
2679        buf
2680    }
2681
2682    /// Encode an ISLAND_END opcode: opcode(1) + island_id(2)
2683    fn encode_island_end(island_id: u16) -> Vec<u8> {
2684        let mut buf = vec![0x0C];
2685        buf.extend_from_slice(&island_id.to_le_bytes());
2686        buf
2687    }
2688
2689    /// Helper: build an IrModule, walk it with empty slots, return the HTML.
2690    fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
2691        let data = build_declared_fixture(strings, &[], opcodes, &[]);
2692        let module = IrModule::parse(&data).unwrap();
2693        let slots = SlotData::new(0);
2694        walk_to_html(&module, &slots).unwrap()
2695    }
2696
2697    /// Helper: build an IrModule with slot declarations, walk with given SlotData.
2698    fn walk_with_slots(
2699        strings: &[&str],
2700        slot_decls: &[(u16, u32, u8, u8, &[u8])],
2701        opcodes: &[u8],
2702        slots: &SlotData,
2703    ) -> String {
2704        let data = build_declared_fixture(strings, slot_decls, opcodes, &[]);
2705        let module = IrModule::parse(&data).unwrap();
2706        walk_to_html(&module, slots).unwrap()
2707    }
2708
2709    /// Helper: build an IrModule with slots AND islands, walk with given SlotData.
2710    fn walk_with_islands(
2711        strings: &[&str],
2712        slot_decls: &[(u16, u32, u8, u8, &[u8])],
2713        island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2714        opcodes: &[u8],
2715        slots: &SlotData,
2716    ) -> String {
2717        let data = build_declared_fixture(strings, slot_decls, opcodes, island_decls);
2718        let module = IrModule::parse(&data).unwrap();
2719        walk_to_html(&module, slots).unwrap()
2720    }
2721
2722    fn walk_with_islands_output(
2723        strings: &[&str],
2724        slot_decls: &[(u16, u32, u8, u8, &[u8])],
2725        island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2726        opcodes: &[u8],
2727        slots: &SlotData,
2728    ) -> WalkOutput {
2729        let data = build_declared_fixture(strings, slot_decls, opcodes, island_decls);
2730        let module = IrModule::parse(&data).unwrap();
2731        walk_to_html_counted(&module, slots, WalkLimits::default()).unwrap()
2732    }
2733
2734    /// Encode a DYN_ATTR opcode: opcode(1) + attr_str_idx(4) + slot_id(2)
2735    fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
2736        let mut bytes = vec![0x06]; // Opcode::DynAttr
2737        bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
2738        bytes.extend_from_slice(&slot_id.to_le_bytes());
2739        bytes
2740    }
2741
2742    // -- Test 1: walk_static_div -------------------------------------------
2743
2744    #[test]
2745    fn walk_static_div() {
2746        // <div class="container">Hello</div>
2747        // strings: 0="div", 1="class", 2="container", 3="Hello"
2748        let mut opcodes = Vec::new();
2749        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2750        opcodes.extend_from_slice(&encode_text(3));
2751        opcodes.extend_from_slice(&encode_close_tag(0));
2752
2753        let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
2754        assert_eq!(html, r#"<div class="container">Hello</div>"#);
2755    }
2756
2757    // -- Test 2: walk_void_tag ---------------------------------------------
2758
2759    #[test]
2760    fn walk_void_tag() {
2761        // <input type="email">
2762        // strings: 0="input", 1="type", 2="email"
2763        let opcodes = encode_void_tag(0, &[(1, 2)]);
2764
2765        let html = walk_static(&["input", "type", "email"], &opcodes);
2766        assert_eq!(html, r#"<input type="email">"#);
2767    }
2768
2769    // -- Test 3: walk_nested_elements --------------------------------------
2770
2771    #[test]
2772    fn walk_nested_elements() {
2773        // <div><span>Hi</span></div>
2774        // strings: 0="div", 1="span", 2="Hi"
2775        let mut opcodes = Vec::new();
2776        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2777        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
2778        opcodes.extend_from_slice(&encode_text(2));
2779        opcodes.extend_from_slice(&encode_close_tag(1));
2780        opcodes.extend_from_slice(&encode_close_tag(0));
2781
2782        let html = walk_static(&["div", "span", "Hi"], &opcodes);
2783        assert_eq!(html, "<div><span>Hi</span></div>");
2784    }
2785
2786    // -- Test 4: walk_text_escaping ----------------------------------------
2787
2788    #[test]
2789    fn walk_text_escaping() {
2790        // TEXT with "<script>alert('xss')</script>"
2791        let opcodes = encode_text(0);
2792
2793        let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
2794        assert_eq!(html, "&lt;script&gt;alert('xss')&lt;/script&gt;");
2795    }
2796
2797    // -- Test 5: walk_attr_escaping ----------------------------------------
2798
2799    #[test]
2800    fn walk_attr_escaping() {
2801        // OPEN_TAG with attr value containing a double quote
2802        // strings: 0="div", 1="title", 2=r#"a"b"#
2803        let opcodes = encode_open_tag(0, &[(1, 2)]);
2804
2805        let html = walk_static(&["div", "title", "a\"b"], &opcodes);
2806        assert_eq!(html, r#"<div title="a&quot;b">"#);
2807    }
2808
2809    #[test]
2810    fn security_probe_bounded_text_escaping() {
2811        let data = build_declared_fixture(&["xxxxxxxxxx<"], &[], &encode_text(0), &[]);
2812        let module = IrModule::parse(&data).unwrap();
2813        let expected = "xxxxxxxxxx&lt;";
2814        let limits = WalkLimits {
2815            max_output_bytes: expected.len(),
2816            ..WalkLimits::default()
2817        };
2818        assert_eq!(
2819            walk_to_html_with_limits(&module, &SlotData::new(0), limits).as_deref(),
2820            Ok(expected)
2821        );
2822        let short = WalkLimits {
2823            max_output_bytes: expected.len() - 1,
2824            ..limits
2825        };
2826        assert!(matches!(
2827            walk_to_html_with_limits(&module, &SlotData::new(0), short),
2828            Err(IrError::OutputLimitExceeded { .. })
2829        ));
2830    }
2831
2832    #[test]
2833    fn security_probe_bounded_attribute_escaping() {
2834        let opcodes = encode_open_tag(0, &[(1, 2)]);
2835        let data = build_declared_fixture(&["div", "title", "xxxxxxxxxx\""], &[], &opcodes, &[]);
2836        let module = IrModule::parse(&data).unwrap();
2837        let expected = "<div title=\"xxxxxxxxxx&quot;\">";
2838        let limits = WalkLimits {
2839            max_output_bytes: expected.len(),
2840            ..WalkLimits::default()
2841        };
2842        assert_eq!(
2843            walk_to_html_with_limits(&module, &SlotData::new(0), limits).as_deref(),
2844            Ok(expected)
2845        );
2846        let short = WalkLimits {
2847            max_output_bytes: expected.len() - 1,
2848            ..limits
2849        };
2850        assert!(matches!(
2851            walk_to_html_with_limits(&module, &SlotData::new(0), short),
2852            Err(IrError::OutputLimitExceeded { .. })
2853        ));
2854    }
2855
2856    // -- Test 6: walk_empty_element ----------------------------------------
2857
2858    #[test]
2859    fn walk_empty_element() {
2860        // <div></div> (no children)
2861        let mut opcodes = Vec::new();
2862        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2863        opcodes.extend_from_slice(&encode_close_tag(0));
2864
2865        let html = walk_static(&["div"], &opcodes);
2866        assert_eq!(html, "<div></div>");
2867    }
2868
2869    // -- Test 7: walk_dyn_text ---------------------------------------------
2870
2871    #[test]
2872    fn walk_dyn_text() {
2873        // DYN_TEXT with slot data produces marker comments + value
2874        // strings: 0="greeting" (slot name)
2875        // slot decl: slot_id=0, name_str_idx=0, type=Text(0x01)
2876        let opcodes = encode_dyn_text(0, 0);
2877
2878        let mut slots = SlotData::new(1);
2879        slots.set(0, SlotValue::Text("World".to_string()));
2880
2881        let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2882        assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
2883    }
2884
2885    #[test]
2886    fn walk_dyn_text_null_emits_zwsp() {
2887        // DYN_TEXT with null slot value must emit a zero-width space between
2888        // markers so the browser creates a Text node for hydration to bind to.
2889        let opcodes = encode_dyn_text(0, 0);
2890        let slots = SlotData::new(1); // slot 0 defaults to Null
2891
2892        let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2893        assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2894    }
2895
2896    #[test]
2897    fn walk_dyn_text_empty_string_emits_zwsp() {
2898        // DYN_TEXT with explicit empty string also needs the ZWSP placeholder.
2899        let opcodes = encode_dyn_text(0, 0);
2900        let mut slots = SlotData::new(1);
2901        slots.set(0, SlotValue::Text(String::new()));
2902
2903        let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2904        assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2905    }
2906
2907    // -- Test 8: walk_dyn_text_escaping ------------------------------------
2908
2909    #[test]
2910    fn walk_dyn_text_escaping() {
2911        // DYN_TEXT value with HTML chars is escaped
2912        let opcodes = encode_dyn_text(0, 1);
2913
2914        let mut slots = SlotData::new(1);
2915        slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
2916
2917        let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2918        assert_eq!(html, "<!--f:t1-->&lt;b&gt;bold&lt;/b&gt;<!--/f:t1-->");
2919    }
2920
2921    // -- Test 9: walk_island_markers ---------------------------------------
2922
2923    #[test]
2924    fn walk_island_markers() {
2925        // ISLAND_START(0) + TEXT content + ISLAND_END(0). The island body
2926        // opens no element, so the island contributes markers only — its
2927        // attributes are dropped at ISLAND_END with a warning.
2928        let mut opcodes = Vec::new();
2929        opcodes.extend_from_slice(&encode_island_start(0));
2930        opcodes.extend_from_slice(&encode_text(0));
2931        opcodes.extend_from_slice(&encode_island_end(0));
2932
2933        let slots = SlotData::new(0);
2934        let html = walk_with_islands(
2935            &["Hello"],
2936            &[],
2937            &[(0, 0x01, 0x01, 0, 0, &[])],
2938            &opcodes,
2939            &slots,
2940        );
2941        assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
2942    }
2943
2944    // -- Test 10: walk_comment ---------------------------------------------
2945
2946    // -- Test 11: walk_boolean_attr ----------------------------------------
2947
2948    #[test]
2949    fn walk_boolean_attr() {
2950        // <input disabled> — attribute with empty string value renders as valueless
2951        // strings: 0="input", 1="disabled", 2="" (empty string for boolean attr)
2952        let opcodes = encode_void_tag(0, &[(1, 2)]);
2953
2954        let html = walk_static(&["input", "disabled", ""], &opcodes);
2955        assert_eq!(html, "<input disabled>");
2956    }
2957
2958    // -- DYN_ATTR value semantics (W2 / F15) --------------------------------
2959
2960    /// Helper: `<input DYN_ATTR(disabled, slot 0)>` with slot 0 set to `value`.
2961    fn walk_dyn_attr_with(value: SlotValue) -> String {
2962        let mut opcodes = encode_void_tag(0, &[]);
2963        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
2964        let mut slots = SlotData::new(1);
2965        slots.set(0, value);
2966        walk_with_slots(
2967            &["input", "disabled"],
2968            &[(0, 1, 0x02, 0x00, &[])],
2969            &opcodes,
2970            &slots,
2971        )
2972    }
2973
2974    #[test]
2975    fn dyn_attr_bool_matches_the_client_renderer() {
2976        // An HTML boolean attribute is ON whenever it is PRESENT, whatever its
2977        // value — `disabled="false"` disables the control. The walker used to
2978        // emit exactly that, so SSR shipped a disabled/hidden/checked control
2979        // that only un-broke itself after hydration, and never at all for a
2980        // visible/idle/interaction island the user had not reached, or with JS
2981        // off. These are byte-for-byte what `renderAttr` in @getforma/core
2982        // produces for the same values.
2983        assert_eq!(
2984            walk_dyn_attr_with(SlotValue::Bool(true)),
2985            "<input disabled>",
2986            "true must render the bare attribute"
2987        );
2988        assert_eq!(
2989            walk_dyn_attr_with(SlotValue::Bool(false)),
2990            "<input>",
2991            "false must omit the attribute, not write disabled=\"false\""
2992        );
2993        assert_eq!(
2994            walk_dyn_attr_with(SlotValue::Null),
2995            "<input>",
2996            "null must omit the attribute"
2997        );
2998    }
2999
3000    #[test]
3001    fn dyn_attr_empty_string_and_zero_follow_boolean_presence() {
3002        // The original expected ordinary value semantics on `disabled`.
3003        // Its exact source is archived with the FORMA-182 review evidence.
3004        // Ordinary empty values remain covered by attributes.json.
3005        assert_eq!(
3006            walk_dyn_attr_with(SlotValue::Text(String::new())),
3007            "<input>"
3008        );
3009        assert_eq!(
3010            walk_dyn_attr_with(SlotValue::Text("yes".into())),
3011            "<input disabled>"
3012        );
3013        assert_eq!(walk_dyn_attr_with(SlotValue::Number(0.0)), "<input>");
3014        assert_eq!(
3015            walk_dyn_attr_with(SlotValue::Number(1.0)),
3016            "<input disabled>"
3017        );
3018    }
3019
3020    // -- Test 12: walk_multiple_attrs --------------------------------------
3021
3022    #[test]
3023    fn walk_multiple_attrs() {
3024        // <div id="main" class="container" data-x="42"></div>
3025        // strings: 0="div", 1="id", 2="main", 3="class", 4="container", 5="data-x", 6="42"
3026        let mut opcodes = Vec::new();
3027        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
3028        opcodes.extend_from_slice(&encode_close_tag(0));
3029
3030        let html = walk_static(
3031            &["div", "id", "main", "class", "container", "data-x", "42"],
3032            &opcodes,
3033        );
3034        assert_eq!(
3035            html,
3036            r#"<div id="main" class="container" data-x="42"></div>"#
3037        );
3038    }
3039
3040    // -- Test 13: walk_show_if_true_branch ---------------------------------
3041
3042    #[test]
3043    fn walk_show_if_true_branch() {
3044        // SHOW_IF(slot=0): then=TEXT("Yes"), else=TEXT("No")
3045        // slot 0 = Bool(true) → render "Yes"
3046        // strings: 0="visible", 1="Yes", 2="No"
3047        let then_ops = encode_text(1); // TEXT "Yes"
3048        let else_ops = encode_text(2); // TEXT "No"
3049        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3050
3051        let mut slots = SlotData::new(1);
3052        slots.set(0, SlotValue::Bool(true));
3053
3054        let html = walk_with_slots(
3055            &["visible", "Yes", "No"],
3056            &[(0, 0, 0x02, 0x00, &[])], // slot 0, name="visible", type=Bool
3057            &opcodes,
3058            &slots,
3059        );
3060        assert!(html.contains("Yes"), "should contain then-branch text");
3061        assert!(!html.contains("No"), "should NOT contain else-branch text");
3062        assert!(html.contains("<!--f:s0-->"), "should have opening marker");
3063        assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
3064    }
3065
3066    // -- Test 14: walk_show_if_false_branch --------------------------------
3067
3068    #[test]
3069    fn walk_show_if_false_branch() {
3070        // SHOW_IF(slot=0): then=TEXT("Yes"), else=TEXT("No")
3071        // slot 0 = Bool(false) → render "No"
3072        let then_ops = encode_text(1);
3073        let else_ops = encode_text(2);
3074        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3075
3076        let mut slots = SlotData::new(1);
3077        slots.set(0, SlotValue::Bool(false));
3078
3079        let html = walk_with_slots(
3080            &["visible", "Yes", "No"],
3081            &[(0, 0, 0x02, 0x00, &[])],
3082            &opcodes,
3083            &slots,
3084        );
3085        assert!(html.contains("No"), "should contain else-branch text");
3086        assert!(!html.contains("Yes"), "should NOT contain then-branch text");
3087        assert!(html.contains("<!--f:s0-->"), "should have opening marker");
3088        assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
3089    }
3090
3091    // -- Test 15: walk_show_if_truthy_text ---------------------------------
3092
3093    #[test]
3094    fn walk_show_if_truthy_text() {
3095        // slot 0 = Text("hello") → truthy → render then branch
3096        let then_ops = encode_text(1);
3097        let else_ops = encode_text(2);
3098        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3099
3100        let mut slots = SlotData::new(1);
3101        slots.set(0, SlotValue::Text("hello".to_string()));
3102
3103        let html = walk_with_slots(
3104            &["greeting", "Shown", "Hidden"],
3105            &[(0, 0, 0x01, 0x00, &[])], // type=Text
3106            &opcodes,
3107            &slots,
3108        );
3109        assert!(
3110            html.contains("Shown"),
3111            "truthy text should render then branch"
3112        );
3113        assert!(
3114            !html.contains("Hidden"),
3115            "truthy text should NOT render else branch"
3116        );
3117    }
3118
3119    // -- Test 16: walk_show_if_falsy_null ----------------------------------
3120
3121    #[test]
3122    fn walk_show_if_falsy_null() {
3123        // slot 0 = Null (default) → falsy → render else branch
3124        let then_ops = encode_text(1);
3125        let else_ops = encode_text(2);
3126        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3127
3128        let slots = SlotData::new(1); // slot 0 is Null by default
3129
3130        let html = walk_with_slots(
3131            &["maybe", "Present", "Missing"],
3132            &[(0, 0, 0x01, 0x00, &[])],
3133            &opcodes,
3134            &slots,
3135        );
3136        assert!(html.contains("Missing"), "null should render else branch");
3137        assert!(
3138            !html.contains("Present"),
3139            "null should NOT render then branch"
3140        );
3141    }
3142
3143    // -- Test 17: walk_show_if_no_else ------------------------------------
3144
3145    #[test]
3146    fn walk_show_if_no_else() {
3147        // SHOW_IF with empty else branch. When false, nothing rendered between markers.
3148        let then_ops = encode_text(1);
3149        let else_ops: Vec<u8> = vec![]; // empty else
3150        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3151
3152        let slots = SlotData::new(1); // Null → false
3153
3154        let html = walk_with_slots(
3155            &["flag", "Content"],
3156            &[(0, 0, 0x02, 0x00, &[])],
3157            &opcodes,
3158            &slots,
3159        );
3160        assert_eq!(
3161            html, "<!--f:s0--><!--/f:s0-->",
3162            "empty else should produce only markers"
3163        );
3164    }
3165
3166    // -- Test 18: walk_show_if_nested -------------------------------------
3167
3168    #[test]
3169    fn walk_show_if_nested() {
3170        // Outer SHOW_IF(slot=0, true) → then branch contains inner SHOW_IF(slot=1, false)
3171        // Inner then=TEXT("inner-yes"), inner else=TEXT("inner-no")
3172        // strings: 0="outer", 1="inner", 2="outer-yes", 3="outer-no", 4="inner-yes", 5="inner-no"
3173        let inner_then = encode_text(4);
3174        let inner_else = encode_text(5);
3175        let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
3176
3177        let outer_then = inner_show_if; // outer then = inner SHOW_IF
3178        let outer_else = encode_text(3);
3179        let opcodes = encode_show_if(0, &outer_then, &outer_else);
3180
3181        let mut slots = SlotData::new(2);
3182        slots.set(0, SlotValue::Bool(true)); // outer true
3183        slots.set(1, SlotValue::Bool(false)); // inner false
3184
3185        let html = walk_with_slots(
3186            &[
3187                "outer",
3188                "inner",
3189                "outer-yes",
3190                "outer-no",
3191                "inner-yes",
3192                "inner-no",
3193            ],
3194            &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
3195            &opcodes,
3196            &slots,
3197        );
3198        // Outer is true → walk inner. Inner is false → render "inner-no"
3199        assert!(
3200            html.contains("inner-no"),
3201            "nested: inner false should render inner else"
3202        );
3203        assert!(
3204            !html.contains("inner-yes"),
3205            "nested: inner true branch should not render"
3206        );
3207        assert!(
3208            !html.contains("outer-no"),
3209            "nested: outer else should not render"
3210        );
3211        // Verify correct nesting of markers
3212        assert!(
3213            html.contains("<!--f:s0-->"),
3214            "should have outer opening marker"
3215        );
3216        assert!(
3217            html.contains("<!--/f:s0-->"),
3218            "should have outer closing marker"
3219        );
3220        assert!(
3221            html.contains("<!--f:s1-->"),
3222            "should have inner opening marker"
3223        );
3224        assert!(
3225            html.contains("<!--/f:s1-->"),
3226            "should have inner closing marker"
3227        );
3228    }
3229
3230    // -- Test 19: walk_show_if_with_elements ------------------------------
3231
3232    #[test]
3233    fn walk_show_if_with_elements() {
3234        // Then branch contains OPEN_TAG("span") + TEXT("Hello") + CLOSE_TAG("span")
3235        // strings: 0="flag", 1="span", 2="Hello", 3="Bye"
3236        let mut then_ops = Vec::new();
3237        then_ops.extend_from_slice(&encode_open_tag(1, &[]));
3238        then_ops.extend_from_slice(&encode_text(2));
3239        then_ops.extend_from_slice(&encode_close_tag(1));
3240
3241        let else_ops = encode_text(3);
3242        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3243
3244        let mut slots = SlotData::new(1);
3245        slots.set(0, SlotValue::Bool(true));
3246
3247        let html = walk_with_slots(
3248            &["flag", "span", "Hello", "Bye"],
3249            &[(0, 0, 0x02, 0x00, &[])],
3250            &opcodes,
3251            &slots,
3252        );
3253        assert_eq!(
3254            html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
3255            "then branch should produce full HTML element"
3256        );
3257    }
3258
3259    // -- Test 20: walk_list_renders_items -----------------------------------
3260
3261    #[test]
3262    fn walk_list_renders_items() {
3263        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
3264        // slot 0 = Array([Text("Alice"), Text("Bob")])
3265        // strings: 0="items", 1="item"
3266        let body = encode_dyn_text(1, 0);
3267        let opcodes = encode_list(0, 1, &body);
3268
3269        let mut slots = SlotData::new(2);
3270        slots.set(
3271            0,
3272            SlotValue::array(vec![
3273                SlotValue::Text("Alice".to_string()),
3274                SlotValue::Text("Bob".to_string()),
3275            ]),
3276        );
3277
3278        let html = walk_with_slots(
3279            &["items", "item"],
3280            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], // slot 0=Array, slot 1=Text
3281            &opcodes,
3282            &slots,
3283        );
3284        assert!(html.contains("Alice"), "should contain first item");
3285        assert!(html.contains("Bob"), "should contain second item");
3286        assert!(
3287            html.starts_with("<!--f:l0-->"),
3288            "should start with list open marker"
3289        );
3290        assert!(
3291            html.ends_with("<!--/f:l0-->"),
3292            "should end with list close marker"
3293        );
3294    }
3295
3296    // -- Test 21: walk_list_empty_array ------------------------------------
3297
3298    #[test]
3299    fn walk_list_empty_array() {
3300        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
3301        // slot 0 = Array([])
3302        let body = encode_dyn_text(1, 0);
3303        let opcodes = encode_list(0, 1, &body);
3304
3305        let mut slots = SlotData::new(2);
3306        slots.set(0, SlotValue::array(vec![]));
3307
3308        let html = walk_with_slots(
3309            &["items", "item"],
3310            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3311            &opcodes,
3312            &slots,
3313        );
3314        assert_eq!(
3315            html, "<!--f:l0--><!--/f:l0-->",
3316            "empty array should produce only markers"
3317        );
3318    }
3319
3320    // -- Test 22: walk_list_with_elements ----------------------------------
3321
3322    #[test]
3323    fn walk_list_with_elements() {
3324        // LIST(slot=0, item_slot=1): body = <li>DYN_TEXT(slot=1)</li>
3325        // slot 0 = Array([Text("A"), Text("B")])
3326        // strings: 0="items", 1="item", 2="li"
3327        let mut body = Vec::new();
3328        body.extend_from_slice(&encode_open_tag(2, &[]));
3329        body.extend_from_slice(&encode_dyn_text(1, 0));
3330        body.extend_from_slice(&encode_close_tag(2));
3331
3332        let opcodes = encode_list(0, 1, &body);
3333
3334        let mut slots = SlotData::new(2);
3335        slots.set(
3336            0,
3337            SlotValue::array(vec![
3338                SlotValue::Text("A".to_string()),
3339                SlotValue::Text("B".to_string()),
3340            ]),
3341        );
3342
3343        let html = walk_with_slots(
3344            &["items", "item", "li"],
3345            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3346            &opcodes,
3347            &slots,
3348        );
3349        assert!(
3350            html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
3351            "should render li elements with content, got: {html}"
3352        );
3353    }
3354
3355    // -- Test 23: walk_list_null_slot --------------------------------------
3356
3357    #[test]
3358    fn walk_list_null_slot() {
3359        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
3360        // slot 0 = Null (not Array) → no iteration, empty markers
3361        let body = encode_dyn_text(1, 0);
3362        let opcodes = encode_list(0, 1, &body);
3363
3364        let slots = SlotData::new(2); // slot 0 remains Null
3365
3366        let html = walk_with_slots(
3367            &["items", "item"],
3368            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3369            &opcodes,
3370            &slots,
3371        );
3372        assert_eq!(
3373            html, "<!--f:l0--><!--/f:l0-->",
3374            "null slot should produce only markers"
3375        );
3376    }
3377
3378    // -- Test 24: walk_list_nested -----------------------------------------
3379
3380    #[test]
3381    fn walk_list_nested() {
3382        // Outer LIST(slot=0, item_slot=1): each item is an Array
3383        // Inner LIST(slot=1, item_slot=2): each sub-item is Text
3384        // Body of inner: DYN_TEXT(slot=2, marker=0)
3385        // strings: 0="outer", 1="inner", 2="item"
3386        let inner_body = encode_dyn_text(2, 0);
3387        let inner_list = encode_list(1, 2, &inner_body);
3388        let opcodes = encode_list(0, 1, &inner_list);
3389
3390        let mut slots = SlotData::new(3);
3391        slots.set(
3392            0,
3393            SlotValue::array(vec![
3394                SlotValue::array(vec![
3395                    SlotValue::Text("a".to_string()),
3396                    SlotValue::Text("b".to_string()),
3397                ]),
3398                SlotValue::array(vec![SlotValue::Text("c".to_string())]),
3399            ]),
3400        );
3401
3402        let html = walk_with_slots(
3403            &["outer", "inner", "item"],
3404            &[
3405                (0, 0, 0x04, 0x00, &[]),
3406                (1, 1, 0x04, 0x00, &[]),
3407                (2, 2, 0x01, 0x00, &[]),
3408            ],
3409            &opcodes,
3410            &slots,
3411        );
3412        // Verify all items rendered
3413        assert!(html.contains("a"), "should contain nested item 'a'");
3414        assert!(html.contains("b"), "should contain nested item 'b'");
3415        assert!(html.contains("c"), "should contain nested item 'c'");
3416        // Verify outer list markers
3417        assert!(
3418            html.starts_with("<!--f:l0-->"),
3419            "should have outer list open marker"
3420        );
3421        assert!(
3422            html.ends_with("<!--/f:l0-->"),
3423            "should have outer list close marker"
3424        );
3425        // Verify inner list markers present
3426        assert!(
3427            html.contains("<!--f:l1-->"),
3428            "should have inner list open marker"
3429        );
3430        assert!(
3431            html.contains("<!--/f:l1-->"),
3432            "should have inner list close marker"
3433        );
3434    }
3435
3436    // -- Test 25: walk_list_depth_exceeded ---------------------------------
3437
3438    #[test]
3439    fn walk_list_depth_exceeded() {
3440        // Nest 5 lists deep (exceeds MAX_LIST_DEPTH of 4)
3441        // Innermost body is a DYN_TEXT
3442        // strings: 0..=5 = "s0".."s5"
3443        let innermost_body = encode_dyn_text(5, 0);
3444        // Build from inside out: level 5 (innermost) → level 1 (outermost)
3445        let level5 = encode_list(4, 5, &innermost_body);
3446        let level4 = encode_list(3, 4, &level5);
3447        let level3 = encode_list(2, 3, &level4);
3448        let level2 = encode_list(1, 2, &level3);
3449        let level1 = encode_list(0, 1, &level2);
3450
3451        let mut slots = SlotData::new(6);
3452        // Each level needs an array with at least one item to trigger iteration
3453        slots.set(
3454            0,
3455            SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
3456                SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
3457                    "deep".to_string(),
3458                )])]),
3459            ])])]),
3460        );
3461        // Inner slots get shadowed during iteration
3462
3463        let data = build_declared_fixture(
3464            &["s0", "s1", "s2", "s3", "s4", "s5"],
3465            &[
3466                (0, 0, 0x04, 0x00, &[]),
3467                (1, 1, 0x04, 0x00, &[]),
3468                (2, 2, 0x04, 0x00, &[]),
3469                (3, 3, 0x04, 0x00, &[]),
3470                (4, 4, 0x04, 0x00, &[]),
3471                (5, 5, 0x01, 0x00, &[]),
3472            ],
3473            &level1,
3474            &[],
3475        );
3476        let module = IrModule::parse(&data).unwrap();
3477        let result = walk_to_html(&module, &slots);
3478        assert!(result.is_err(), "should fail with depth exceeded");
3479        match result.unwrap_err() {
3480            IrError::ListDepthExceeded { max: 4 } => {}
3481            other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
3482        }
3483    }
3484
3485    // -- Test 26: walk_switch_matching_case --------------------------------
3486
3487    // -- Test 27: walk_switch_no_match ------------------------------------
3488
3489    // -- Test 28: walk_switch_first_case ----------------------------------
3490
3491    // -- Test 29: walk_switch_with_elements -------------------------------
3492
3493    // -- Test 30: walk_try_renders_main -----------------------------------
3494
3495    // -- Test 31: walk_preload_font ---------------------------------------
3496
3497    // -- Test 32: walk_preload_style --------------------------------------
3498
3499    // =========================================================================
3500    // Hydration-roundtrip tests — verify walker produces exact expected HTML
3501    // from programmatically-built IR. These tests complement the TS-side
3502    // roundtrip tests (ir-roundtrip.test.ts) to ensure the binary contract
3503    // between the TS emitter and the Rust walker is correct.
3504    // =========================================================================
3505
3506    // -- Roundtrip 1: Full page skeleton ------------------------------------
3507
3508    #[test]
3509    fn roundtrip_page_structure() {
3510        // Build IR for a realistic page skeleton:
3511        // <html lang="en"><head><meta charset="utf-8"><title>Test</title></head>
3512        // <body><div id="app"></div></body></html>
3513        //
3514        // strings: 0="html", 1="lang", 2="en", 3="head", 4="meta",
3515        //          5="charset", 6="utf-8", 7="title", 8="Test",
3516        //          9="body", 10="div", 11="id", 12="app"
3517        let mut opcodes = Vec::new();
3518        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <html lang="en">
3519        opcodes.extend_from_slice(&encode_open_tag(3, &[])); // <head>
3520        opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); // <meta charset="utf-8">
3521        opcodes.extend_from_slice(&encode_open_tag(7, &[])); // <title>
3522        opcodes.extend_from_slice(&encode_text(8)); // Test
3523        opcodes.extend_from_slice(&encode_close_tag(7)); // </title>
3524        opcodes.extend_from_slice(&encode_close_tag(3)); // </head>
3525        opcodes.extend_from_slice(&encode_open_tag(9, &[])); // <body>
3526        opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); // <div id="app">
3527        opcodes.extend_from_slice(&encode_close_tag(10)); // </div>
3528        opcodes.extend_from_slice(&encode_close_tag(9)); // </body>
3529        opcodes.extend_from_slice(&encode_close_tag(0)); // </html>
3530
3531        let ir = build_declared_fixture(
3532            &[
3533                "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
3534                "div", "id", "app",
3535            ],
3536            &[],
3537            &opcodes,
3538            &[],
3539        );
3540        let module = IrModule::parse(&ir).unwrap();
3541        let slots = SlotData::new(0);
3542        let html = walk_to_html(&module, &slots).unwrap();
3543        assert_eq!(
3544            html,
3545            r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
3546        );
3547    }
3548
3549    // -- Roundtrip 2: Dynamic greeting with DYN_TEXT ------------------------
3550
3551    #[test]
3552    fn roundtrip_dynamic_greeting() {
3553        // Build IR for: <div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>
3554        // with slot 0 = Text("Hello, World")
3555        //
3556        // strings: 0="div", 1="class", 2="greeting", 3="content"
3557        // slot decl: slot_id=0, name_str_idx=3 ("content"), type=Text
3558        let mut opcodes = Vec::new();
3559        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <div class="greeting">
3560        opcodes.extend_from_slice(&encode_dyn_text(0, 0)); // DYN_TEXT slot=0 marker=0
3561        opcodes.extend_from_slice(&encode_close_tag(0)); // </div>
3562
3563        let ir = build_declared_fixture(
3564            &["div", "class", "greeting", "content"],
3565            &[(0, 3, 0x01, 0x00, &[])], // slot 0, name="content", type=Text
3566            &opcodes,
3567            &[],
3568        );
3569        let module = IrModule::parse(&ir).unwrap();
3570        let mut slots = SlotData::new(1);
3571        slots.set(0, SlotValue::Text("Hello, World".into()));
3572        let html = walk_to_html(&module, &slots).unwrap();
3573        assert_eq!(
3574            html,
3575            r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
3576        );
3577    }
3578
3579    // -- Roundtrip 3: Conditional content (SHOW_IF both branches) -----------
3580
3581    #[test]
3582    fn roundtrip_conditional_content() {
3583        // SHOW_IF slot=0
3584        //   then: <span class="badge">Logged In</span>
3585        //   else: <span class="badge">Guest</span>
3586        //
3587        // strings: 0="auth", 1="span", 2="class", 3="badge",
3588        //          4="Logged In", 5="Guest"
3589        let mut then_ops = Vec::new();
3590        then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3591        then_ops.extend_from_slice(&encode_text(4));
3592        then_ops.extend_from_slice(&encode_close_tag(1));
3593
3594        let mut else_ops = Vec::new();
3595        else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3596        else_ops.extend_from_slice(&encode_text(5));
3597        else_ops.extend_from_slice(&encode_close_tag(1));
3598
3599        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3600
3601        let ir = build_declared_fixture(
3602            &["auth", "span", "class", "badge", "Logged In", "Guest"],
3603            &[(0, 0, 0x02, 0x00, &[])], // slot 0, name="auth", type=Bool
3604            &opcodes,
3605            &[],
3606        );
3607
3608        // Test true branch
3609        {
3610            let module = IrModule::parse(&ir).unwrap();
3611            let mut slots = SlotData::new(1);
3612            slots.set(0, SlotValue::Bool(true));
3613            let html = walk_to_html(&module, &slots).unwrap();
3614            assert_eq!(
3615                html,
3616                r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
3617            );
3618        }
3619
3620        // Test false branch
3621        {
3622            let module = IrModule::parse(&ir).unwrap();
3623            let mut slots = SlotData::new(1);
3624            slots.set(0, SlotValue::Bool(false));
3625            let html = walk_to_html(&module, &slots).unwrap();
3626            assert_eq!(
3627                html,
3628                r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
3629            );
3630        }
3631    }
3632
3633    // -- Roundtrip 4: List rendering ----------------------------------------
3634
3635    #[test]
3636    fn roundtrip_list_rendering() {
3637        // LIST slot=0 item=1 → <li><!--f:t0-->{item}<!--/f:t0--></li>
3638        // Items: ["Alice", "Bob", "Charlie"]
3639        //
3640        // strings: 0="items", 1="item", 2="li"
3641        // slot decls: slot 0=Array, slot 1=Text (item)
3642        let mut body = Vec::new();
3643        body.extend_from_slice(&encode_open_tag(2, &[])); // <li>
3644        body.extend_from_slice(&encode_dyn_text(1, 0)); // DYN_TEXT slot=1 marker=0
3645        body.extend_from_slice(&encode_close_tag(2)); // </li>
3646
3647        let opcodes = encode_list(0, 1, &body);
3648
3649        let ir = build_declared_fixture(
3650            &["items", "item", "li"],
3651            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], // slot 0=Array, slot 1=Text
3652            &opcodes,
3653            &[],
3654        );
3655        let module = IrModule::parse(&ir).unwrap();
3656        let mut slots = SlotData::new(2);
3657        slots.set(
3658            0,
3659            SlotValue::array(vec![
3660                SlotValue::Text("Alice".into()),
3661                SlotValue::Text("Bob".into()),
3662                SlotValue::Text("Charlie".into()),
3663            ]),
3664        );
3665        let html = walk_to_html(&module, &slots).unwrap();
3666        assert_eq!(
3667            html,
3668            "<!--f:l0-->\
3669             <li><!--f:t0-->Alice<!--/f:t0--></li>\
3670             <li><!--f:t0-->Bob<!--/f:t0--></li>\
3671             <li><!--f:t0-->Charlie<!--/f:t0--></li>\
3672             <!--/f:l0-->"
3673        );
3674    }
3675
3676    // -- Roundtrip 5: Switch view -------------------------------------------
3677
3678    // -- Roundtrip 6: Nested realistic structure ----------------------------
3679
3680    #[test]
3681    fn roundtrip_nested_structure() {
3682        // Realistic page fragment: nav with conditional auth + list of links
3683        //
3684        // <nav class="main-nav">
3685        //   <!--f:s0-->  (SHOW_IF: logged in?)
3686        //     <span>Welcome</span>
3687        //   <!--/f:s0-->
3688        //   <ul>
3689        //     <!--f:l1-->  (LIST: nav items)
3690        //       <li><!--f:t0-->{item}<!--/f:t0--></li>
3691        //     <!--/f:l1-->
3692        //   </ul>
3693        // </nav>
3694        //
3695        // strings: 0="nav", 1="class", 2="main-nav", 3="auth",
3696        //          4="span", 5="Welcome", 6="items", 7="item",
3697        //          8="ul", 9="li"
3698        //
3699        // slots: 0=Bool (auth), 1=Array (items), 2=Text (item shadow)
3700
3701        // SHOW_IF then body: <span>Welcome</span>
3702        let mut then_ops = Vec::new();
3703        then_ops.extend_from_slice(&encode_open_tag(4, &[]));
3704        then_ops.extend_from_slice(&encode_text(5));
3705        then_ops.extend_from_slice(&encode_close_tag(4));
3706
3707        let show_if_ops = encode_show_if(0, &then_ops, &[]);
3708
3709        // LIST body: <li>DYN_TEXT(slot=2)</li>
3710        let mut list_body = Vec::new();
3711        list_body.extend_from_slice(&encode_open_tag(9, &[]));
3712        list_body.extend_from_slice(&encode_dyn_text(2, 0));
3713        list_body.extend_from_slice(&encode_close_tag(9));
3714
3715        let list_ops = encode_list(1, 2, &list_body);
3716
3717        // Full opcode stream
3718        let mut opcodes = Vec::new();
3719        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <nav class="main-nav">
3720        opcodes.extend_from_slice(&show_if_ops); // SHOW_IF auth
3721        opcodes.extend_from_slice(&encode_open_tag(8, &[])); // <ul>
3722        opcodes.extend_from_slice(&list_ops); // LIST items
3723        opcodes.extend_from_slice(&encode_close_tag(8)); // </ul>
3724        opcodes.extend_from_slice(&encode_close_tag(0)); // </nav>
3725
3726        let ir = build_declared_fixture(
3727            &[
3728                "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
3729            ],
3730            &[
3731                (0, 3, 0x02, 0x00, &[]), // slot 0 = Bool (auth)
3732                (1, 6, 0x04, 0x00, &[]), // slot 1 = Array (items)
3733                (2, 7, 0x01, 0x00, &[]), // slot 2 = Text (item)
3734            ],
3735            &opcodes,
3736            &[],
3737        );
3738        let module = IrModule::parse(&ir).unwrap();
3739        let mut slots = SlotData::new(3);
3740        slots.set(0, SlotValue::Bool(true));
3741        slots.set(
3742            1,
3743            SlotValue::array(vec![
3744                SlotValue::Text("Home".into()),
3745                SlotValue::Text("About".into()),
3746            ]),
3747        );
3748
3749        let html = walk_to_html(&module, &slots).unwrap();
3750        assert_eq!(
3751            html,
3752            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>"#
3753        );
3754    }
3755
3756    // =========================================================================
3757    // Island attribute injection tests
3758    // =========================================================================
3759
3760    // -- Test 33: walk_island_attrs_on_open_tag --------------------------------
3761
3762    #[test]
3763    fn walk_island_attrs_on_open_tag() {
3764        // ISLAND_START(0) + OPEN_TAG("div") + TEXT("Hello") + CLOSE_TAG("div") + ISLAND_END(0)
3765        // With island table entry: id=0, trigger=Load, props=Inline, name="AuthForm", no slots
3766        // strings: 0="div", 1="Hello", 2="AuthForm"
3767        let mut opcodes = Vec::new();
3768        opcodes.extend_from_slice(&encode_island_start(0));
3769        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3770        opcodes.extend_from_slice(&encode_text(1));
3771        opcodes.extend_from_slice(&encode_close_tag(0));
3772        opcodes.extend_from_slice(&encode_island_end(0));
3773
3774        let slots = SlotData::new(0);
3775        let html = walk_with_islands(
3776            &["div", "Hello", "AuthForm"],
3777            &[],
3778            // island: id=0, trigger=Load(0x01), props=Inline(0x01), name_str_idx=2, byte_offset=0, no slot_ids
3779            &[(0, 0x01, 0x01, 2, 0, &[])],
3780            &opcodes,
3781            &slots,
3782        );
3783        assert_eq!(
3784            html,
3785            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-->"#
3786        );
3787    }
3788
3789    // -- Test 34: walk_island_attrs_with_existing_attrs -----------------------
3790
3791    #[test]
3792    fn walk_island_attrs_with_existing_attrs() {
3793        // ISLAND_START(0) + OPEN_TAG("div" class="form") + CLOSE_TAG + ISLAND_END(0)
3794        // strings: 0="div", 1="class", 2="form", 3="AuthForm"
3795        let mut opcodes = Vec::new();
3796        opcodes.extend_from_slice(&encode_island_start(0));
3797        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3798        opcodes.extend_from_slice(&encode_close_tag(0));
3799        opcodes.extend_from_slice(&encode_island_end(0));
3800
3801        let slots = SlotData::new(0);
3802        let html = walk_with_islands(
3803            &["div", "class", "form", "AuthForm"],
3804            &[],
3805            &[(0, 0x01, 0x01, 3, 0, &[])],
3806            &opcodes,
3807            &slots,
3808        );
3809        assert_eq!(
3810            html,
3811            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-->"#
3812        );
3813    }
3814
3815    // -- Test 35: walk_island_attrs_on_void_tag -------------------------------
3816
3817    #[test]
3818    fn walk_island_attrs_on_void_tag() {
3819        // ISLAND_START(0) + VOID_TAG("input") + ISLAND_END(0)
3820        // strings: 0="input", 1="Widget"
3821        let mut opcodes = Vec::new();
3822        opcodes.extend_from_slice(&encode_island_start(0));
3823        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3824        opcodes.extend_from_slice(&encode_island_end(0));
3825
3826        let slots = SlotData::new(0);
3827        let html = walk_with_islands(
3828            &["input", "Widget"],
3829            &[],
3830            &[(0, 0x01, 0x01, 1, 0, &[])],
3831            &opcodes,
3832            &slots,
3833        );
3834        assert_eq!(
3835            html,
3836            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-->"#
3837        );
3838    }
3839
3840    // -- Declared state is separate from body markup -------------------------
3841
3842    #[test]
3843    fn declared_props_are_separate_from_body_html() {
3844        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
3845        // Island entry has ordered inputs [0, 1]; all props use typed state.
3846        // Slot 0 = "title" (Text), Slot 1 = "count" (Number)
3847        // strings: 0="div", 1="MyComponent", 2="title", 3="count"
3848        let mut opcodes = Vec::new();
3849        opcodes.extend_from_slice(&encode_island_start(0));
3850        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3851        opcodes.extend_from_slice(&encode_close_tag(0));
3852        opcodes.extend_from_slice(&encode_island_end(0));
3853
3854        let mut slots = SlotData::new(2);
3855        slots.set(0, SlotValue::Text("Hello".to_string()));
3856        slots.set(1, SlotValue::Number(42.0));
3857
3858        let output = walk_with_islands_output(
3859            &["div", "MyComponent", "title", "count"],
3860            &[
3861                (0, 2, 0x01, 0x00, &[]), // slot 0, name="title", Text, Server
3862                (1, 3, 0x03, 0x00, &[]), // slot 1, name="count", Number, Server
3863            ],
3864            // island: id=0, Load, Inline, name_str_idx=1, byte_offset=0, slot_ids=[0, 1]
3865            &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3866            &opcodes,
3867            &slots,
3868        );
3869        assert!(!output.html.contains("data-forma-props"));
3870        assert!(!output.html.contains("<script"));
3871        assert!(output.html.contains("data-forma-component=\"MyComponent\""));
3872        let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
3873        assert_eq!(json["i"][0]["p"]["title"], "Hello");
3874        assert_eq!(json["i"][0]["p"]["count"], 42);
3875    }
3876
3877    // -- Test 37: walk_list_item_key_on_first_open_tag -----------------------
3878
3879    // -- Test 38: walk_list_item_key_on_void_tag -----------------------------
3880
3881    // -- Test 39: walk_list_item_key_with_attrs ------------------------------
3882
3883    // -- Test 40: walk_script_tag_props_mode ---------------------------------
3884
3885    // -- Test 41: walk_deferred_props_mode -----------------------------------
3886
3887    // -- Test 42: walk_island_no_entry_in_table -------------------------------
3888
3889    #[test]
3890    fn walk_island_no_entry_in_table() {
3891        // An ISLAND_START whose id is absent from the island table used to
3892        // render happily: markers, but no `data-forma-*` attributes, so the
3893        // client could never hydrate that island and nothing said why. It is
3894        // now a load-time error instead of an invisible one at runtime.
3895        let mut opcodes = Vec::new();
3896        opcodes.extend_from_slice(&encode_island_start(5));
3897        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3898        opcodes.extend_from_slice(&encode_text(1));
3899        opcodes.extend_from_slice(&encode_close_tag(0));
3900        opcodes.extend_from_slice(&encode_island_end(5));
3901
3902        let data = build_declared_fixture(&["div", "Hello"], &[], &opcodes, &[]);
3903        assert_eq!(
3904            IrModule::parse(&data).unwrap_err(),
3905            IrError::UnknownIslandId(5)
3906        );
3907    }
3908
3909    #[test]
3910    fn walk_nested_island_start_before_a_root_element_is_an_error() {
3911        // Only one element can carry a data-forma-island set, so an island
3912        // that opens another island before its own root element would have
3913        // silently lost its identity — never hydrated, never disposed.
3914        let mut opcodes = Vec::new();
3915        opcodes.extend_from_slice(&encode_island_start(0));
3916        opcodes.extend_from_slice(&encode_island_start(1));
3917        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3918        opcodes.extend_from_slice(&encode_close_tag(0));
3919        opcodes.extend_from_slice(&encode_island_end(1));
3920        opcodes.extend_from_slice(&encode_island_end(0));
3921
3922        let slots = SlotData::new(0);
3923        let data = build_declared_fixture(
3924            &["div", "Outer", "Inner"],
3925            &[],
3926            &opcodes,
3927            &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3928        );
3929        let module = IrModule::parse(&data).unwrap();
3930        assert_eq!(
3931            walk_to_html(&module, &slots).unwrap_err(),
3932            IrError::NestedIslandRoot { outer: 0, inner: 1 }
3933        );
3934    }
3935
3936    #[test]
3937    fn walk_island_nested_inside_another_islands_root_is_fine() {
3938        // The legal shape: each island opens its own element first.
3939        let mut opcodes = Vec::new();
3940        opcodes.extend_from_slice(&encode_island_start(0));
3941        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3942        opcodes.extend_from_slice(&encode_island_start(1));
3943        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3944        opcodes.extend_from_slice(&encode_close_tag(0));
3945        opcodes.extend_from_slice(&encode_island_end(1));
3946        opcodes.extend_from_slice(&encode_close_tag(0));
3947        opcodes.extend_from_slice(&encode_island_end(0));
3948
3949        let slots = SlotData::new(0);
3950        let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3951        let html = walk_with_islands(
3952            &["div", "Outer", "Inner"],
3953            &[],
3954            &[
3955                (0, 0x01, 0x01, 1, 0, &[]),
3956                (1, 0x01, 0x01, 2, inner_offset, &[]),
3957            ],
3958            &opcodes,
3959            &slots,
3960        );
3961        assert!(html.contains(r#"data-forma-component="Outer""#));
3962        assert!(html.contains(r#"data-forma-component="Inner""#));
3963    }
3964
3965    // -- Empty declared props still have an occurrence ---------------------
3966
3967    #[test]
3968    fn empty_declared_inputs_do_not_emit_inline_props() {
3969        // An empty declaration carries identity but no inferred slot props.
3970        // strings: 0="div", 1="Simple"
3971        let mut opcodes = Vec::new();
3972        opcodes.extend_from_slice(&encode_island_start(0));
3973        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3974        opcodes.extend_from_slice(&encode_close_tag(0));
3975        opcodes.extend_from_slice(&encode_island_end(0));
3976
3977        let slots = SlotData::new(0);
3978        let html = walk_with_islands(
3979            &["div", "Simple"],
3980            &[],
3981            &[(0, 0x01, 0x01, 1, 0, &[])], // no slot_ids
3982            &opcodes,
3983            &slots,
3984        );
3985        assert!(
3986            !html.contains("data-forma-props"),
3987            "empty slot_ids should not emit props, got: {html}"
3988        );
3989        assert!(
3990            html.contains(r#"data-forma-island="0""#),
3991            "should contain island id"
3992        );
3993    }
3994
3995    // -- State always has one channel, independent of payload size -----------
3996
3997    /// Build a one-island page whose single prop slot holds `payload`.
3998    fn walk_island_with_prop_payload(payload: &str) -> WalkOutput {
3999        let mut opcodes = Vec::new();
4000        opcodes.extend_from_slice(&encode_island_start(0));
4001        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4002        opcodes.extend_from_slice(&encode_close_tag(0));
4003        opcodes.extend_from_slice(&encode_island_end(0));
4004
4005        let mut slots = SlotData::new(1);
4006        slots.set(0, SlotValue::Text(payload.to_string()));
4007        walk_with_islands_output(
4008            &["div", "Big", "blob"],
4009            &[(0, 2, 0x01, 0x00, &[])],
4010            &[(0, 0x01, 0x01, 1, 0, &[0])],
4011            &opcodes,
4012            &slots,
4013        )
4014    }
4015
4016    #[test]
4017    fn duplicate_slot_names_do_not_invent_declared_island_props() {
4018        // Both SERVER slots have the same lookup name. This island declares
4019        // no inputs, so neither slot is implicitly serialized by name.
4020        let mut opcodes = Vec::new();
4021        opcodes.extend_from_slice(&encode_island_start(0));
4022        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4023        opcodes.extend_from_slice(&encode_close_tag(0));
4024        opcodes.extend_from_slice(&encode_island_end(0));
4025
4026        let mut slots = SlotData::new(2);
4027        slots.set(0, SlotValue::Text("first".into()));
4028        slots.set(1, SlotValue::Text("second".into()));
4029
4030        let output = walk_with_islands_output(
4031            &["div", "Comp", "dup"],
4032            &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
4033            &[(0, 0x01, 0x01, 1, 0, &[])],
4034            &opcodes,
4035            &slots,
4036        );
4037        let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
4038        assert_eq!(json["i"][0]["p"], serde_json::json!({}));
4039        assert!(!output.html.contains("data-forma-props"));
4040    }
4041
4042    #[test]
4043    fn small_props_use_the_owned_state_channel() {
4044        let output = walk_island_with_prop_payload("small");
4045        assert!(output.island_state.as_json().contains("\"blob\":\"small\""));
4046        assert!(!output.html.contains("data-forma-props"));
4047        assert!(!output.html.contains("__forma_islands"));
4048    }
4049
4050    #[test]
4051    fn larger_props_use_the_same_owned_state_channel() {
4052        let payload = "x".repeat(1025);
4053        let output = walk_island_with_prop_payload(&payload);
4054        assert!(!output.html.contains("data-forma-props"));
4055        assert!(!output.html.contains("__forma_islands"));
4056        assert!(output.island_state.as_json().contains(&payload));
4057    }
4058
4059    #[test]
4060    fn diagnostic_fragment_does_not_restore_an_inline_state_channel() {
4061        // Diagnostic fragments cannot restore either retired props channel.
4062        // Supported page rendering consumes the counted walk's state separately.
4063        let mut opcodes = Vec::new();
4064        opcodes.extend_from_slice(&encode_island_start(0));
4065        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4066        opcodes.extend_from_slice(&encode_close_tag(0));
4067        opcodes.extend_from_slice(&encode_island_end(0));
4068
4069        let payload = "x".repeat(1024 + 1);
4070        let mut slots = SlotData::new(1);
4071        slots.set(0, SlotValue::Text(payload));
4072
4073        let data = build_declared_fixture(
4074            &["div", "Big", "blob"],
4075            &[(0, 2, 0x01, 0x00, &[])],
4076            &opcodes,
4077            &[(0, 0x01, 0x01, 1, 0, &[0])],
4078        );
4079        let module = IrModule::parse(&data).unwrap();
4080        let fragment = walk_island(&module, &slots, 0).unwrap();
4081        assert!(!fragment.contains("data-forma-props"));
4082        assert!(!fragment.contains("__forma_islands"));
4083    }
4084
4085    // -- Test 44: walk_island_and_list_key_combined --------------------------
4086
4087    // -- Test 45: walk_multiple_islands_script_tag ---------------------------
4088
4089    #[test]
4090    fn multiple_islands_share_one_owned_envelope() {
4091        // Two islands append entries to the same independently owned envelope.
4092        // strings: 0="div", 1="span", 2="CompA", 3="CompB", 4="msg", 5="flag"
4093        let mut opcodes = Vec::new();
4094        // Island 0
4095        opcodes.extend_from_slice(&encode_island_start(0));
4096        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4097        opcodes.extend_from_slice(&encode_close_tag(0));
4098        opcodes.extend_from_slice(&encode_island_end(0));
4099        // Island 1 — its byte_offset must point at its own ISLAND_START.
4100        let island1_offset = opcodes.len() as u32;
4101        opcodes.extend_from_slice(&encode_island_start(1));
4102        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4103        opcodes.extend_from_slice(&encode_close_tag(1));
4104        opcodes.extend_from_slice(&encode_island_end(1));
4105
4106        let mut slots = SlotData::new(3);
4107        slots.set(0, SlotValue::Text("hello".to_string()));
4108        slots.set(1, SlotValue::Bool(true));
4109        slots.set(2, SlotValue::Text("x".repeat(1024)));
4110
4111        let output = walk_with_islands_output(
4112            &["div", "span", "CompA", "CompB", "msg", "flag", "padding"],
4113            &[
4114                (0, 4, 0x01, 0x00, &[]), // slot 0, name="msg", Text
4115                (1, 5, 0x02, 0x00, &[]),
4116                (2, 6, 0x01, 0, &[]), // slot 1, name="flag", Bool
4117            ],
4118            &[
4119                (0, 0x01, 0x02, 2, 0, &[0, 2]), // island 0, ScriptTag, name="CompA", byte_offset=0, slots=[0]
4120                (1, 0x01, 0x02, 3, island1_offset, &[1, 2]), // island 1, ScriptTag, name="CompB", slots=[1]
4121            ],
4122            &opcodes,
4123            &slots,
4124        );
4125        let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
4126        assert_eq!(json["i"][0]["p"]["msg"], "hello");
4127        assert_eq!(json["i"][1]["p"]["flag"], true);
4128        assert_eq!(output.island_state.len(), 2);
4129        assert!(!output.html.contains("<script"));
4130    }
4131
4132    // -- Test 46: walk_list_item_key_consumed_once --------------------------
4133
4134    // -- Test 47: walk_slot_value_to_json -----------------------------------
4135
4136    #[test]
4137    fn slot_value_to_json_all_types() {
4138        // Verify SlotValue::to_json() for all variants
4139        assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
4140        assert_eq!(
4141            SlotValue::Text("hello".to_string()).to_json(),
4142            serde_json::Value::String("hello".to_string())
4143        );
4144        assert_eq!(
4145            SlotValue::Bool(true).to_json(),
4146            serde_json::Value::Bool(true)
4147        );
4148        assert_eq!(
4149            SlotValue::Bool(false).to_json(),
4150            serde_json::Value::Bool(false)
4151        );
4152        assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
4153        assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
4154        assert_eq!(
4155            SlotValue::array(vec![
4156                SlotValue::Text("a".to_string()),
4157                SlotValue::Number(1.0)
4158            ])
4159            .to_json(),
4160            serde_json::json!(["a", 1])
4161        );
4162        assert_eq!(
4163            SlotValue::object(vec![(
4164                "key".to_string(),
4165                SlotValue::Text("val".to_string())
4166            ),])
4167            .to_json(),
4168            serde_json::json!({"key": "val"})
4169        );
4170    }
4171
4172    // -- Test 48: dyn_attr_emits_attribute_with_value ----------------------
4173
4174    #[test]
4175    fn dyn_attr_emits_attribute_with_value() {
4176        // OPEN_TAG("input") + DYN_ATTR("type", slot=0) + CLOSE_TAG("input")
4177        // slot 0 = Text("password") → <input type="password"></input>
4178        // strings: 0="input", 1="type", 2="input_type" (slot name)
4179        let mut opcodes = Vec::new();
4180        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4181        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4182        opcodes.extend_from_slice(&encode_close_tag(0));
4183
4184        let mut slots = SlotData::new(1);
4185        slots.set(0, SlotValue::Text("password".to_string()));
4186
4187        let html = walk_with_slots(
4188            &["input", "type", "input_type"],
4189            &[(0, 2, 0x01, 0x01, b"password")], // slot_id=0, name_str_idx=2, Text, Client, default="password"
4190            &opcodes,
4191            &slots,
4192        );
4193        assert_eq!(html, r#"<input type="password"></input>"#);
4194    }
4195
4196    // -- Test 49: dyn_attr_skips_when_slot_null ----------------------------
4197
4198    #[test]
4199    fn dyn_attr_skips_when_slot_null() {
4200        // DYN_ATTR with Null slot value should emit no attribute at all.
4201        // <div> (no class attr) </div>
4202        // strings: 0="div", 1="class", 2="cls" (slot name)
4203        let mut opcodes = Vec::new();
4204        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4205        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4206        opcodes.extend_from_slice(&encode_close_tag(0));
4207
4208        let slots = SlotData::new(1); // slot 0 is Null by default
4209
4210        let html = walk_with_slots(
4211            &["div", "class", "cls"],
4212            &[(0, 2, 0x01, 0x00, &[])],
4213            &opcodes,
4214            &slots,
4215        );
4216        assert_eq!(html, "<div></div>");
4217    }
4218
4219    // -- Test 50: dyn_attr_multiple_on_same_element ------------------------
4220
4221    #[test]
4222    fn dyn_attr_multiple_on_same_element() {
4223        // OPEN_TAG("input") + DYN_ATTR("type", slot=0) + DYN_ATTR("class", slot=1) + CLOSE_TAG
4224        // strings: 0="input", 1="type", 2="class", 3="slot_type", 4="slot_class"
4225        let mut opcodes = Vec::new();
4226        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4227        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4228        opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
4229        opcodes.extend_from_slice(&encode_close_tag(0));
4230
4231        let mut slots = SlotData::new(2);
4232        slots.set(0, SlotValue::Text("email".to_string()));
4233        slots.set(1, SlotValue::Text("form-input".to_string()));
4234
4235        let html = walk_with_slots(
4236            &["input", "type", "class", "slot_type", "slot_class"],
4237            &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
4238            &opcodes,
4239            &slots,
4240        );
4241        assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
4242    }
4243
4244    // -- Test 51: dyn_attr_on_void_tag -------------------------------------
4245
4246    #[test]
4247    fn dyn_attr_on_void_tag() {
4248        // VOID_TAG("input") + DYN_ATTR("type", slot=0)
4249        // slot 0 = Text("checkbox") → <input type="checkbox">
4250        // strings: 0="input", 1="type", 2="slot_type"
4251        let mut opcodes = Vec::new();
4252        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
4253        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4254
4255        let mut slots = SlotData::new(1);
4256        slots.set(0, SlotValue::Text("checkbox".to_string()));
4257
4258        let html = walk_with_slots(
4259            &["input", "type", "slot_type"],
4260            &[(0, 2, 0x01, 0x01, b"text")],
4261            &opcodes,
4262            &slots,
4263        );
4264        assert_eq!(html, r#"<input type="checkbox">"#);
4265    }
4266
4267    // -- walk_island tests -------------------------------------------------
4268
4269    #[test]
4270    fn walk_island_basic() {
4271        // Build IR with an island containing <p>hello</p>
4272        // strings: 0="MyIsland", 1="div", 2="p", 3="hello"
4273        // We need a wrapper div before the island, the island content, and wrapper close.
4274        //
4275        // Opcodes layout:
4276        // OPEN_TAG "div" []       -> offset 0
4277        // ISLAND_START id=0       -> offset X (byte_offset for island table)
4278        // OPEN_TAG "p" []
4279        // TEXT "hello"
4280        // CLOSE_TAG "p"
4281        // ISLAND_END id=0
4282        // CLOSE_TAG "div"
4283
4284        let pre_island = encode_open_tag(1, &[]); // OPEN_TAG "div" -> str_idx=1
4285        let island_start = encode_island_start(0);
4286        let open_p = encode_open_tag(2, &[]); // OPEN_TAG "p" -> str_idx=2
4287        let text_hello = encode_text(3);
4288        let close_p = encode_close_tag(2);
4289        let island_end = encode_island_end(0);
4290        let close_div = encode_close_tag(1);
4291
4292        let byte_offset = pre_island.len() as u32; // island starts after the div open
4293
4294        let mut opcodes = Vec::new();
4295        opcodes.extend_from_slice(&pre_island);
4296        opcodes.extend_from_slice(&island_start);
4297        opcodes.extend_from_slice(&open_p);
4298        opcodes.extend_from_slice(&text_hello);
4299        opcodes.extend_from_slice(&close_p);
4300        opcodes.extend_from_slice(&island_end);
4301        opcodes.extend_from_slice(&close_div);
4302
4303        let data = build_declared_fixture(
4304            &["MyIsland", "div", "p", "hello"],
4305            &[],
4306            &opcodes,
4307            &[(0, 0x01, 0x01, 0, byte_offset, &[])], // island 0, Load, Inline, name="MyIsland"
4308        );
4309        let module = IrModule::parse(&data).unwrap();
4310        let slots = SlotData::new(0);
4311
4312        let html = walk_island(&module, &slots, 0).unwrap();
4313        // The fragment is the island ROOT carrying the full data-forma-* set —
4314        // seeking past ISLAND_START used to drop all of it, so a fragment
4315        // swapped in via outerHTML never hydrated.
4316        assert_eq!(
4317            html,
4318            "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
4319             data-forma-status=\"pending\" data-forma-hydrate=\"load\" data-forma-state=\"0\" data-forma-h=\"0000000000000000\">hello</p>"
4320        );
4321        // The f:iN markers live outside the root and survive an outerHTML
4322        // swap, so the fragment must not re-emit them.
4323        assert!(!html.contains("<!--f:i0-->"));
4324        assert!(!html.contains("<!--/f:i0-->"));
4325    }
4326
4327    #[test]
4328    fn walk_island_attrs_match_walk_to_html() {
4329        // Parity: the fragment's root must carry the same data-forma-*
4330        // attribute set that the full-page walk produces for the same island.
4331        let island_start = encode_island_start(0);
4332        let open_div = encode_open_tag(1, &[]);
4333        let dyn_text = encode_dyn_text(0, 0);
4334        let close_div = encode_close_tag(1);
4335        let island_end = encode_island_end(0);
4336
4337        let mut opcodes = Vec::new();
4338        opcodes.extend_from_slice(&island_start);
4339        opcodes.extend_from_slice(&open_div);
4340        opcodes.extend_from_slice(&dyn_text);
4341        opcodes.extend_from_slice(&close_div);
4342        opcodes.extend_from_slice(&island_end);
4343
4344        let data = build_declared_fixture(
4345            &["IslandComp", "div", "greeting"],
4346            &[(0, 2, 0x01, 0x00, &[])],
4347            &opcodes,
4348            &[(0, 0x02, 0x01, 0, 0, &[0])], // trigger=Visible, Inline props
4349        );
4350        let module = IrModule::parse(&data).unwrap();
4351        let mut slots = SlotData::new(1);
4352        slots.set(0, SlotValue::Text("World".to_string()));
4353
4354        let page = walk_to_html(&module, &slots).unwrap();
4355        let fragment = walk_island(&module, &slots, 0).unwrap();
4356
4357        for attr in [
4358            "data-forma-island=\"0\"",
4359            "data-forma-component=\"IslandComp\"",
4360            "data-forma-status=\"pending\"",
4361            "data-forma-hydrate=\"visible\"",
4362            "data-forma-state=\"0\"",
4363            "data-forma-h=\"0000000000000000\"",
4364        ] {
4365            assert!(page.contains(attr), "page missing {attr}: {page}");
4366            assert!(
4367                fragment.contains(attr),
4368                "fragment missing {attr}: {fragment}"
4369            );
4370        }
4371    }
4372
4373    #[test]
4374    fn obsolete_mode_cannot_restore_fragment_inline_props() {
4375        // The obsolete tuple mode is ignored by the declared fixture builder.
4376        // It cannot reactivate either retired body/inline props channel.
4377        let mut opcodes = Vec::new();
4378        opcodes.extend_from_slice(&encode_island_start(0));
4379        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4380        opcodes.extend_from_slice(&encode_close_tag(1));
4381        opcodes.extend_from_slice(&encode_island_end(0));
4382
4383        let data = build_declared_fixture(
4384            &["IslandComp", "div", "greeting"],
4385            &[(0, 2, 0x01, 0x00, &[])],
4386            &opcodes,
4387            &[(0, 0x01, 0x02, 0, 0, &[0])], // props_mode = ScriptTag
4388        );
4389        let module = IrModule::parse(&data).unwrap();
4390        let mut slots = SlotData::new(1);
4391        slots.set(0, SlotValue::Text("World".to_string()));
4392
4393        let fragment = walk_island(&module, &slots, 0).unwrap();
4394        assert!(
4395            !fragment.contains("data-forma-props="),
4396            "diagnostic fragment must not inline props: {fragment}"
4397        );
4398        assert!(
4399            !fragment.contains("__forma_islands"),
4400            "fragment must not emit a second islands block: {fragment}"
4401        );
4402    }
4403
4404    #[test]
4405    fn nested_diagnostic_fragment_has_occurrence_markers() {
4406        // Nested diagnostic roots retain distinct occurrence identities.
4407        // Actual page state is carried separately by the counted whole walk.
4408        let mut opcodes = encode_island_start(0);
4409        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4410        let inner_offset = opcodes.len() as u32;
4411        opcodes.extend_from_slice(&encode_island_start(1));
4412        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4413        opcodes.extend_from_slice(&encode_close_tag(1));
4414        opcodes.extend_from_slice(&encode_island_end(1));
4415        opcodes.extend_from_slice(&encode_close_tag(1));
4416        opcodes.extend_from_slice(&encode_island_end(0));
4417
4418        let data = build_declared_fixture(
4419            &["Outer", "div", "greeting", "Inner"],
4420            &[(0, 2, 0x01, 0x00, &[])],
4421            &opcodes,
4422            &[
4423                (0, 0x01, 0x01, 0, 0, &[]),
4424                (1, 0x01, 0x02, 3, inner_offset, &[0]), // nested, ScriptTag
4425            ],
4426        );
4427        let module = IrModule::parse(&data).unwrap();
4428        let mut slots = SlotData::new(1);
4429        slots.set(0, SlotValue::Text("World".into()));
4430
4431        let fragment = walk_island(&module, &slots, 0).unwrap();
4432        assert!(
4433            fragment.contains("data-forma-component=\"Inner\""),
4434            "nested island must render: {fragment}"
4435        );
4436        assert!(
4437            fragment.contains("data-forma-state=\"1\""),
4438            "nested island must retain its own occurrence: {fragment}"
4439        );
4440        assert!(!fragment.contains("__forma_islands"), "{fragment}");
4441    }
4442
4443    #[test]
4444    fn walk_island_not_found() {
4445        // Build IR with no islands, then request island_id=5
4446        let opcodes = encode_text(0);
4447        let data = build_declared_fixture(&["Hello"], &[], &opcodes, &[]);
4448        let module = IrModule::parse(&data).unwrap();
4449        let slots = SlotData::new(0);
4450
4451        let result = walk_island(&module, &slots, 5);
4452        assert!(result.is_err());
4453        match result.unwrap_err() {
4454            IrError::IslandNotFound(5) => {} // expected
4455            other => panic!("expected IslandNotFound(5), got {other:?}"),
4456        }
4457    }
4458
4459    #[test]
4460    fn walk_island_with_dyn_text() {
4461        // Island containing DYN_TEXT that references a slot
4462        // strings: 0="IslandComp", 1="div", 2="greeting"
4463        // slot 0: name_str_idx=2 ("greeting"), type=Text
4464
4465        let island_start = encode_island_start(0);
4466        let open_div = encode_open_tag(1, &[]); // str_idx=1 -> "div"
4467        let dyn_text = encode_dyn_text(0, 0); // slot_id=0, marker_id=0
4468        let close_div = encode_close_tag(1);
4469        let island_end = encode_island_end(0);
4470
4471        let byte_offset = 0u32; // island starts at beginning of opcodes
4472
4473        let mut opcodes = Vec::new();
4474        opcodes.extend_from_slice(&island_start);
4475        opcodes.extend_from_slice(&open_div);
4476        opcodes.extend_from_slice(&dyn_text);
4477        opcodes.extend_from_slice(&close_div);
4478        opcodes.extend_from_slice(&island_end);
4479
4480        let data = build_declared_fixture(
4481            &["IslandComp", "div", "greeting"],
4482            &[(0, 2, 0x01, 0x00, &[])], // slot_id=0, name="greeting", Text, Server
4483            &opcodes,
4484            &[(0, 0x01, 0x01, 0, byte_offset, &[0])], // island 0, slot_ids=[0]
4485        );
4486        let module = IrModule::parse(&data).unwrap();
4487
4488        let mut slots = SlotData::new(1);
4489        slots.set(0, SlotValue::Text("World".to_string()));
4490
4491        let html = walk_island(&module, &slots, 0).unwrap();
4492        // Root element keeps its island identity AND its props.
4493        assert_eq!(
4494            html,
4495            "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
4496             data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
4497             data-forma-state=\"0\" data-forma-h=\"0000000000000000\">\
4498             <!--f:t0-->World<!--/f:t0--></div>"
4499        );
4500    }
4501
4502    #[test]
4503    fn walk_island_byte_offset_out_of_bounds() {
4504        // Island table entry with byte_offset pointing past the opcode stream.
4505        // This is now rejected at parse time: a byte_offset must point at that
4506        // island's own ISLAND_START opcode, which is both a bounds check and a
4507        // structural one (an in-range offset landing mid-operand on a stray
4508        // 0x0B byte would pass a bare bounds check).
4509        let opcodes = encode_text(0); // just a small opcode stream
4510
4511        let data = build_declared_fixture(
4512            &["BadIsland", "hello"],
4513            &[],
4514            &opcodes,
4515            &[(0, 0x01, 0x01, 0, 9999, &[])], // byte_offset=9999, way past end
4516        );
4517        let err = IrModule::parse(&data).unwrap_err();
4518        assert_eq!(
4519            err,
4520            IrError::IslandOffsetMismatch {
4521                island_id: 0,
4522                byte_offset: 9999
4523            }
4524        );
4525    }
4526
4527    // -- PROP opcode tests -------------------------------------------------
4528
4529    /// Encode a PROP opcode: opcode(1) + src_slot_id(2) + prop_str_idx(4) + target_slot_id(2)
4530    fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
4531        let mut buf = vec![0x12]; // Opcode::Prop
4532        buf.extend_from_slice(&src_slot_id.to_le_bytes());
4533        buf.extend_from_slice(&prop_str_idx.to_le_bytes());
4534        buf.extend_from_slice(&target_slot_id.to_le_bytes());
4535        buf
4536    }
4537
4538    // -- Test: PROP extracts object property inside LIST body ----------------
4539
4540    #[test]
4541    fn walk_prop_in_list_body() {
4542        // LIST(slot=0, item_slot=1):
4543        //   PROP(src=1, "name", target=2)
4544        //   DYN_TEXT(slot=2, marker=0)
4545        //
4546        // Slot 0 = Array([Object{name:"Alice"}, Object{name:"Bob"}])
4547        // Strings: 0="items", 1="item", 2="name"
4548        let mut body = Vec::new();
4549        body.extend_from_slice(&encode_prop(1, 2, 2)); // PROP(item_slot=1, prop="name", target=2)
4550        body.extend_from_slice(&encode_dyn_text(2, 0)); // DYN_TEXT(slot=2)
4551        let opcodes = encode_list(0, 1, &body);
4552
4553        let mut slots = SlotData::new(3);
4554        slots.set(
4555            0,
4556            SlotValue::array(vec![
4557                SlotValue::object(vec![(
4558                    "name".to_string(),
4559                    SlotValue::Text("Alice".to_string()),
4560                )]),
4561                SlotValue::object(vec![(
4562                    "name".to_string(),
4563                    SlotValue::Text("Bob".to_string()),
4564                )]),
4565            ]),
4566        );
4567
4568        let html = walk_with_slots(
4569            &["items", "item", "name"],
4570            &[
4571                (0, 0, 0x04, 0x00, &[]), // slot 0: Array, Server
4572                (1, 1, 0x05, 0x00, &[]), // slot 1: Object, Server
4573                (2, 2, 0x01, 0x00, &[]), // slot 2: Text, Server
4574            ],
4575            &opcodes,
4576            &slots,
4577        );
4578        assert!(html.contains("Alice"), "should contain Alice, got: {html}");
4579        assert!(html.contains("Bob"), "should contain Bob, got: {html}");
4580    }
4581
4582    // -- Test: PROP with multiple properties ---------------------------------
4583
4584    #[test]
4585    fn walk_prop_multiple_properties() {
4586        // LIST(slot=0, item_slot=1):
4587        //   PROP(src=1, "name", target=2)
4588        //   PROP(src=1, "email", target=3)
4589        //   <tr><td>DYN_TEXT(2)</td><td>DYN_TEXT(3)</td></tr>
4590        //
4591        // Strings: 0="rows", 1="row", 2="name", 3="email", 4="tr", 5="td"
4592        let mut body = Vec::new();
4593        body.extend_from_slice(&encode_prop(1, 2, 2)); // PROP(1, "name", 2)
4594        body.extend_from_slice(&encode_prop(1, 3, 3)); // PROP(1, "email", 3)
4595        body.extend_from_slice(&encode_open_tag(4, &[])); // <tr>
4596        body.extend_from_slice(&encode_open_tag(5, &[])); // <td>
4597        body.extend_from_slice(&encode_dyn_text(2, 0)); // name
4598        body.extend_from_slice(&encode_close_tag(5)); // </td>
4599        body.extend_from_slice(&encode_open_tag(5, &[])); // <td>
4600        body.extend_from_slice(&encode_dyn_text(3, 1)); // email
4601        body.extend_from_slice(&encode_close_tag(5)); // </td>
4602        body.extend_from_slice(&encode_close_tag(4)); // </tr>
4603        let opcodes = encode_list(0, 1, &body);
4604
4605        let mut slots = SlotData::new(4);
4606        slots.set(
4607            0,
4608            SlotValue::array(vec![
4609                SlotValue::object(vec![
4610                    ("name".to_string(), SlotValue::Text("Alice".to_string())),
4611                    (
4612                        "email".to_string(),
4613                        SlotValue::Text("alice@test.com".to_string()),
4614                    ),
4615                ]),
4616                SlotValue::object(vec![
4617                    ("name".to_string(), SlotValue::Text("Bob".to_string())),
4618                    (
4619                        "email".to_string(),
4620                        SlotValue::Text("bob@test.com".to_string()),
4621                    ),
4622                ]),
4623            ]),
4624        );
4625
4626        let html = walk_with_slots(
4627            &["rows", "row", "name", "email", "tr", "td"],
4628            &[
4629                (0, 0, 0x04, 0x00, &[]),
4630                (1, 1, 0x05, 0x00, &[]),
4631                (2, 2, 0x01, 0x00, &[]),
4632                (3, 3, 0x01, 0x00, &[]),
4633            ],
4634            &opcodes,
4635            &slots,
4636        );
4637        assert!(
4638            html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
4639            "should contain Alice td, got: {html}"
4640        );
4641        assert!(
4642            html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
4643            "should contain alice email, got: {html}"
4644        );
4645        assert!(
4646            html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
4647            "should contain Bob td, got: {html}"
4648        );
4649        assert!(
4650            html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
4651            "should contain bob email, got: {html}"
4652        );
4653    }
4654
4655    // -- Test: PROP on non-Object returns Null (empty string for DYN_TEXT) ----
4656
4657    #[test]
4658    fn walk_prop_on_non_object() {
4659        // PROP(src=0, "name", target=1) then DYN_TEXT(1)
4660        // Slot 0 = Text("hello") — not an object
4661        let mut opcodes = Vec::new();
4662        opcodes.extend_from_slice(&encode_prop(0, 1, 1));
4663        opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4664
4665        let mut slots = SlotData::new(2);
4666        slots.set(0, SlotValue::Text("hello".to_string()));
4667
4668        let html = walk_with_slots(
4669            &["src", "name"],
4670            &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4671            &opcodes,
4672            &slots,
4673        );
4674        // PROP on non-Object produces Null → DYN_TEXT renders zero-width space (U+200B)
4675        assert!(
4676            html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4677            "non-object PROP should produce empty text, got: {html}"
4678        );
4679    }
4680
4681    // -- Test: PROP with missing property returns Null -----------------------
4682
4683    #[test]
4684    fn walk_prop_missing_property() {
4685        // PROP(src=0, "missing", target=1) then DYN_TEXT(1)
4686        // Slot 0 = Object with only "name" key
4687        let mut opcodes = Vec::new();
4688        opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4689        opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4690
4691        let mut slots = SlotData::new(2);
4692        slots.set(
4693            0,
4694            SlotValue::object(vec![(
4695                "name".to_string(),
4696                SlotValue::Text("Alice".to_string()),
4697            )]),
4698        );
4699
4700        let html = walk_with_slots(
4701            &["src", "target", "missing"],
4702            &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4703            &opcodes,
4704            &slots,
4705        );
4706        assert!(
4707            html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4708            "missing property should produce empty text, got: {html}"
4709        );
4710    }
4711
4712    // -- Recursion depth limit tests ----------------------------------------
4713
4714    #[test]
4715    fn walk_nested_show_if_within_limit() {
4716        // 5 levels of nested SHOW_IF — well within the 64-level limit.
4717        // Each level wraps the next; innermost renders TEXT "deep".
4718        // strings: 0..=4 = "s0".."s4", 5 = "deep"
4719        let inner = encode_text(5);
4720        let level4 = encode_show_if(4, &inner, &[]);
4721        let level3 = encode_show_if(3, &level4, &[]);
4722        let level2 = encode_show_if(2, &level3, &[]);
4723        let level1 = encode_show_if(1, &level2, &[]);
4724        let level0 = encode_show_if(0, &level1, &[]);
4725
4726        let mut slots = SlotData::new(5);
4727        for i in 0..5 {
4728            slots.set(i, SlotValue::Bool(true));
4729        }
4730
4731        let html = walk_with_slots(
4732            &["s0", "s1", "s2", "s3", "s4", "deep"],
4733            &[
4734                (0, 0, 0x02, 0x00, &[]),
4735                (1, 1, 0x02, 0x00, &[]),
4736                (2, 2, 0x02, 0x00, &[]),
4737                (3, 3, 0x02, 0x00, &[]),
4738                (4, 4, 0x02, 0x00, &[]),
4739            ],
4740            &level0,
4741            &slots,
4742        );
4743        assert!(
4744            html.contains("deep"),
4745            "5 levels of nesting should succeed, got: {html}"
4746        );
4747    }
4748
4749    /// Return body HTML and the separately owned, script-safe state JSON.
4750    fn script_tag_props_html(payload: &str) -> (String, String) {
4751        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
4752        // strings: 0="div", 1="Comp", 2="payload"
4753        let mut opcodes = Vec::new();
4754        opcodes.extend_from_slice(&encode_island_start(0));
4755        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4756        opcodes.extend_from_slice(&encode_close_tag(0));
4757        opcodes.extend_from_slice(&encode_island_end(0));
4758
4759        let mut slots = SlotData::new(2);
4760        slots.set(0, SlotValue::Text(payload.to_string()));
4761        slots.set(1, SlotValue::Text("x".repeat(1024)));
4762
4763        let output = walk_with_islands_output(
4764            &["div", "Comp", "payload", "padding"],
4765            &[(0, 2, 0x01, 0x00, &[]), (1, 3, 0x01, 0, &[])],
4766            &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
4767            &opcodes,
4768            &slots,
4769        );
4770
4771        (output.html, output.island_state.as_json().to_owned())
4772    }
4773
4774    #[test]
4775    fn owned_state_has_no_raw_script_openers() {
4776        // No bare '<' survives, so the JSON cannot terminate or reopen a
4777        // script element. Greater-than remains ordinary value-preserving text.
4778        let (_html, body) = script_tag_props_html("</script>alert(1)");
4779        assert!(
4780            !body.contains('<'),
4781            "state envelope must contain no raw script openers, got: {body}"
4782        );
4783        assert!(body.contains("\\u003c/script>"), "got: {body}");
4784
4785        // ...and the escaping is value-preserving: JSON.parse (here
4786        // serde_json::from_str, which decodes \u003c identically) round-trips
4787        // back to the original prop value.
4788        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4789        assert_eq!(parsed["i"][0]["p"]["payload"], "</script>alert(1)");
4790    }
4791
4792    #[test]
4793    fn owned_state_escapes_existing_comment_script_regression() {
4794        // The payload the old "</" replace did NOT handle. `<!--` followed by
4795        // `<script` drives a spec-compliant HTML tokenizer into the
4796        // script-data-double-escaped state, where our own trailing </script>
4797        // is consumed as text instead of ending the element — swallowing the
4798        // whole rest of the page and leaving an inert page.
4799        let payload = "<!--<script>PAYLOAD";
4800        let (html, body) = script_tag_props_html(payload);
4801
4802        assert!(
4803            !html.contains("<!--<script"),
4804            "must not emit the script-data-double-escape trigger, got: {html}"
4805        );
4806        assert!(!body.contains('<'), "got: {body}");
4807
4808        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4809        assert_eq!(parsed["i"][0]["p"]["payload"], payload);
4810    }
4811
4812    #[test]
4813    #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4814    fn walk_real_benchmark_ir() {
4815        // Find the benchmark IR file dynamically (hash changes on rebuild)
4816        let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4817        let ir_path = std::fs::read_dir(&dist_dir)
4818            .expect("admin/dist must exist")
4819            .filter_map(|e| e.ok())
4820            .find(|e| {
4821                let name = e.file_name();
4822                let s = name.to_string_lossy();
4823                s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4824            })
4825            .map(|e| e.path())
4826            .expect("platform-benchmark.*.ir must exist in admin/dist");
4827        let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4828        let module = IrModule::parse(&data).expect("IR must parse");
4829
4830        // Walk with default slots
4831        let slots = SlotData::new_from_defaults(&module.slots);
4832        let html = walk_to_html(&module, &slots).expect("walk must succeed");
4833
4834        // Verify island markers exist
4835        assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4836        assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4837        assert!(
4838            html.contains("data-forma-island=\"0\""),
4839            "missing island 0 attr"
4840        );
4841
4842        // The key assertion: island content must include FilterBar's children
4843        // (not just an empty shell div)
4844        assert!(
4845            html.contains("Search"),
4846            "FilterBar must contain 'Search' label text in SSR"
4847        );
4848        assert!(
4849            html.contains("filter-group"),
4850            "FilterBar must contain filter-group divs in SSR"
4851        );
4852        assert!(
4853            html.contains("Sort by"),
4854            "FilterBar must contain 'Sort by' label text in SSR"
4855        );
4856
4857        // PerfPanel content
4858        assert!(
4859            html.contains("Performance"),
4860            "PerfPanel must contain 'Performance' heading in SSR"
4861        );
4862
4863        // BenchmarkDataTable — SHOW_IF may pick either branch depending on slot defaults
4864        // Just check the structure exists
4865        assert!(
4866            html.contains("benchmark-data-table"),
4867            "BenchmarkDataTable root class must exist"
4868        );
4869
4870        // The FilterBar island must be a real, closed region, not just a
4871        // stray opening marker. (This replaced a pair of `eprintln!`s that
4872        // printed the region "for manual inspection" and asserted nothing.)
4873        let start = html
4874            .find("<!--f:i0-->")
4875            .expect("FilterBar island start marker");
4876        let end = html
4877            .find("<!--/f:i0-->")
4878            .expect("FilterBar island end marker");
4879        assert!(end > start, "island end marker must follow its start");
4880    }
4881
4882    // -- Output budget (F-02) -----------------------------------------------
4883
4884    #[test]
4885    fn walk_output_limit_trips_inside_one_opcode() {
4886        // The case a top-of-loop check cannot catch: ONE OpenTag opcode can
4887        // emit hundreds of megabytes, because attr_count goes to u16::MAX and
4888        // every value escapes up to 5x. The budget must be enforced by the
4889        // append itself, not once per iteration.
4890        let big = "&".repeat(4096); // escapes to 5x = 20 KiB each
4891        let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4892        let opcodes = encode_open_tag(0, &attrs);
4893
4894        let data = build_declared_fixture(&["div", "data-x", &big], &[], &opcodes, &[]);
4895        let module = IrModule::parse(&data).unwrap();
4896        let slots = SlotData::new(0);
4897
4898        let limits = WalkLimits {
4899            max_output_bytes: 64 * 1024,
4900            ..WalkLimits::default()
4901        };
4902        let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4903        assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4904
4905        // ...and a generous budget still renders it.
4906        let ok = WalkLimits {
4907            max_output_bytes: 64 * 1024 * 1024,
4908            ..WalkLimits::default()
4909        };
4910        assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4911    }
4912
4913    #[test]
4914    fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4915        // DynText emits a whole slot value in one iteration too.
4916        let opcodes = encode_dyn_text(0, 0);
4917        let data = build_declared_fixture(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4918        let module = IrModule::parse(&data).unwrap();
4919
4920        let mut slots = SlotData::new(1);
4921        slots.set(0, SlotValue::Text("x".repeat(100_000)));
4922
4923        let limits = WalkLimits {
4924            max_output_bytes: 4096,
4925            ..WalkLimits::default()
4926        };
4927        assert!(matches!(
4928            walk_to_html_with_limits(&module, &slots, limits),
4929            Err(IrError::OutputLimitExceeded { .. })
4930        ));
4931    }
4932
4933    // -- LIST iteration budget (F-08) ---------------------------------------
4934
4935    #[test]
4936    fn walk_list_budget_exceeded() {
4937        // Depth bounds nesting, not volume: two nested LISTs over the same
4938        // 300-element array is 90,000 body walks from a tiny module.
4939        let body = encode_text(2);
4940        let inner = encode_list(0, 1, &body);
4941        let opcodes = encode_list(0, 1, &inner);
4942
4943        let data = build_declared_fixture(
4944            &["items", "item", "x"],
4945            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4946            &opcodes,
4947            &[],
4948        );
4949        let module = IrModule::parse(&data).unwrap();
4950
4951        let mut slots = SlotData::new(2);
4952        slots.set(
4953            0,
4954            SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4955        );
4956
4957        let err = walk_to_html(&module, &slots).unwrap_err();
4958        assert_eq!(
4959            err,
4960            IrError::ListBudgetExceeded {
4961                max: DEFAULT_MAX_LIST_ITERATIONS
4962            }
4963        );
4964        // Deliberately a DIFFERENT error from ListDepthExceeded — an operator
4965        // reading "nested LIST depth exceeded maximum of 4" for this case
4966        // would be chasing the wrong thing.
4967        assert_ne!(
4968            err,
4969            IrError::ListDepthExceeded {
4970                max: MAX_LIST_DEPTH
4971            }
4972        );
4973    }
4974
4975    // -- Offset arithmetic and structural tiling (F-06 / F-07) --------------
4976
4977    /// Corrupt a u32 length field inside an opcode buffer.
4978    fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4979        buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4980    }
4981
4982    #[test]
4983    fn parse_rejects_show_if_then_len_past_the_stream() {
4984        // SHOW_IF: opcode(1) slot_id(2) then_len(4) else_len(4)
4985        let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4986        patch_u32(&mut opcodes, 3, 0xFFFF_FF00); // then_len
4987        let data =
4988            build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4989        assert!(matches!(
4990            IrModule::parse(&data),
4991            Err(IrError::MalformedControlFlow { .. })
4992        ));
4993    }
4994
4995    #[test]
4996    fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
4997        // then_len short by one, so the byte at then_end is a TEXT opcode
4998        // rather than the SHOW_ELSE marker.
4999        let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
5000        patch_u32(&mut opcodes, 3, 4); // then body is 5 bytes; claim 4
5001        let data =
5002            build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5003        assert!(matches!(
5004            IrModule::parse(&data),
5005            Err(IrError::MalformedControlFlow { .. })
5006        ));
5007    }
5008
5009    #[test]
5010    fn parse_rejects_list_body_len_past_the_stream() {
5011        // LIST: opcode(1) slot_id(2) item_slot_id(2) body_len(4)
5012        let mut opcodes = encode_list(0, 1, &encode_text(2));
5013        patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
5014        let data = build_declared_fixture(
5015            &["items", "item", "x"],
5016            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5017            &opcodes,
5018            &[],
5019        );
5020        assert!(matches!(
5021            IrModule::parse(&data),
5022            Err(IrError::MalformedControlFlow { .. })
5023        ));
5024    }
5025
5026    #[test]
5027    fn parse_accepts_zero_length_else_branch_known_residual() {
5028        // DOCUMENTED RESIDUAL: an under-long length whose orphaned bytes still
5029        // decode as a legal sibling sequence is structurally indistinguishable
5030        // from a legitimate module. No length check can reject it; only module
5031        // integrity (a CRC that the producer did not restamp) would. This test exists so the
5032        // case is not later mistaken for a fixed hole.
5033        let opcodes = encode_show_if(0, &encode_text(1), &[]);
5034        let data =
5035            build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5036        assert!(IrModule::parse(&data).is_ok());
5037    }
5038
5039    // -- TRY/FALLBACK scoping (F-10) ----------------------------------------
5040
5041    // -- Pending island / list-key scoping (F-11) ---------------------------
5042
5043    #[test]
5044    fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
5045        // ISLAND_START, SHOW_IF (both branches empty), then the root element.
5046        // Clearing pending state at the end of every walk_range would wipe the
5047        // island attributes here and the island would never hydrate — the
5048        // reason the clear lives on ISLAND_END instead.
5049        let mut opcodes = encode_island_start(0);
5050        opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
5051        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5052        opcodes.extend_from_slice(&encode_close_tag(1));
5053        opcodes.extend_from_slice(&encode_island_end(0));
5054
5055        let slots = SlotData::new(1);
5056        let html = walk_with_islands(
5057            &["Comp", "div", "flag"],
5058            &[(0, 2, 0x02, 0x00, &[])],
5059            &[(0, 0x01, 0x01, 0, 0, &[])],
5060            &opcodes,
5061            &slots,
5062        );
5063        assert!(
5064            html.contains("<div data-forma-island=\"0\""),
5065            "island root must keep its attributes, got: {html}"
5066        );
5067    }
5068
5069    #[test]
5070    fn walk_island_attrs_do_not_leak_past_the_island_end() {
5071        // ISLAND_START with no element inside, then an element AFTER the
5072        // island's closing marker. The island's identity attributes must not
5073        // land on it.
5074        let mut opcodes = encode_island_start(0);
5075        opcodes.extend_from_slice(&encode_island_end(0));
5076        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5077        opcodes.extend_from_slice(&encode_close_tag(1));
5078
5079        let slots = SlotData::new(0);
5080        let html = walk_with_islands(
5081            &["Comp", "div"],
5082            &[],
5083            &[(0, 0x01, 0x01, 0, 0, &[])],
5084            &opcodes,
5085            &slots,
5086        );
5087        assert!(
5088            !html.contains("data-forma-island"),
5089            "island attributes must not leak onto an element outside the island, got: {html}"
5090        );
5091    }
5092
5093    // -- Name validation (S13) ----------------------------------------------
5094
5095    #[test]
5096    fn walk_rejects_hostile_tag_name() {
5097        // Tag names are written unescaped, so an IR-authored name is direct
5098        // markup injection.
5099        let opcodes = encode_open_tag(0, &[]);
5100        let data = build_declared_fixture(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
5101        let module = IrModule::parse(&data).unwrap();
5102        let slots = SlotData::new(0);
5103        assert!(matches!(
5104            walk_to_html(&module, &slots),
5105            Err(IrError::InvalidName(_))
5106        ));
5107    }
5108
5109    #[test]
5110    fn walk_rejects_hostile_close_tag_name() {
5111        let mut opcodes = encode_open_tag(0, &[]);
5112        opcodes.extend_from_slice(&encode_close_tag(1));
5113        let data = build_declared_fixture(&["div", "div><script"], &[], &opcodes, &[]);
5114        let module = IrModule::parse(&data).unwrap();
5115        let slots = SlotData::new(0);
5116        assert!(matches!(
5117            walk_to_html(&module, &slots),
5118            Err(IrError::InvalidName(_))
5119        ));
5120    }
5121
5122    #[test]
5123    fn walk_drops_hostile_attribute_names() {
5124        // Attribute names are skippable, so one bad key costs the attribute
5125        // rather than the whole page.
5126        let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
5127        let data =
5128            build_declared_fixture(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
5129        let module = IrModule::parse(&data).unwrap();
5130        let slots = SlotData::new(0);
5131        let html = walk_to_html(&module, &slots).unwrap();
5132        assert_eq!(html, "<div ok-attr=\"v\">");
5133    }
5134
5135    #[test]
5136    fn walk_drops_hostile_boolean_attribute_name() {
5137        // Same, via the boolean (empty-value) branch.
5138        let opcodes = encode_open_tag(0, &[(1, 2)]);
5139        let data = build_declared_fixture(&["div", "y\"><script", ""], &[], &opcodes, &[]);
5140        let module = IrModule::parse(&data).unwrap();
5141        let slots = SlotData::new(0);
5142        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5143    }
5144
5145    #[test]
5146    fn walk_drops_hostile_dyn_attr_name() {
5147        let mut opcodes = encode_open_tag(0, &[]);
5148        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5149        let data = build_declared_fixture(
5150            &["div", "z\"><script", "val"],
5151            &[(0, 2, 0x01, 0x00, &[])],
5152            &opcodes,
5153            &[],
5154        );
5155        let module = IrModule::parse(&data).unwrap();
5156        let mut slots = SlotData::new(1);
5157        slots.set(0, SlotValue::Text("x".into()));
5158        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5159    }
5160
5161    // -- URL / event-handler attribute safety (S14) -------------------------
5162
5163    #[test]
5164    fn walk_drops_javascript_url_in_dyn_attr() {
5165        let mut opcodes = encode_open_tag(0, &[]);
5166        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5167        let data = build_declared_fixture(
5168            &["a", "href", "url"],
5169            &[(0, 2, 0x01, 0x00, &[])],
5170            &opcodes,
5171            &[],
5172        );
5173        let module = IrModule::parse(&data).unwrap();
5174
5175        // Also covers the normalization step: browsers strip control chars
5176        // before resolving the scheme, so `java\tscript:` executes.
5177        for payload in [
5178            "javascript:alert(1)",
5179            "JaVaScRiPt:alert(1)",
5180            "java\tscript:alert(1)",
5181            "  javascript:alert(1)",
5182            "vbscript:msgbox(1)",
5183            "data:text/html,<script>alert(1)</script>",
5184        ] {
5185            let mut slots = SlotData::new(1);
5186            slots.set(0, SlotValue::Text(payload.to_string()));
5187            let html = walk_to_html(&module, &slots).unwrap();
5188            // Dropped entirely, not emitted empty: href="" resolves to the
5189            // current page and would not match the client DOM either.
5190            assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
5191        }
5192    }
5193
5194    #[test]
5195    fn walk_keeps_safe_urls_and_data_images() {
5196        let mut opcodes = encode_open_tag(0, &[]);
5197        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5198        let data = build_declared_fixture(
5199            &["img", "src", "url"],
5200            &[(0, 2, 0x01, 0x00, &[])],
5201            &opcodes,
5202            &[],
5203        );
5204        let module = IrModule::parse(&data).unwrap();
5205
5206        for payload in [
5207            "/about",
5208            "https://example.com/x?a=1",
5209            "data:image/png;base64,AAAA",
5210            // svg+xml is allowed here because <img> is an image context — the
5211            // stock CSP permits img-src data:, so a blanket data: block would
5212            // break legitimate inline images.
5213            "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
5214        ] {
5215            let mut slots = SlotData::new(1);
5216            slots.set(0, SlotValue::Text(payload.to_string()));
5217            let html = walk_to_html(&module, &slots).unwrap();
5218            assert!(html.contains("src="), "payload {payload:?} was dropped");
5219        }
5220    }
5221
5222    #[test]
5223    fn walk_blocks_svg_data_url_outside_image_context() {
5224        let mut opcodes = encode_open_tag(0, &[]);
5225        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5226        let data = build_declared_fixture(
5227            &["a", "href", "url"],
5228            &[(0, 2, 0x01, 0x00, &[])],
5229            &opcodes,
5230            &[],
5231        );
5232        let module = IrModule::parse(&data).unwrap();
5233        let mut slots = SlotData::new(1);
5234        slots.set(
5235            0,
5236            SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
5237        );
5238        // Through a document sink the SVG is parsed as a document and its
5239        // onload fires, so it is blocked here even though <img> allows it.
5240        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
5241    }
5242
5243    #[test]
5244    fn walk_drops_event_handler_attributes() {
5245        // Static attribute path...
5246        let opcodes = encode_open_tag(0, &[(1, 2)]);
5247        let data = build_declared_fixture(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
5248        let module = IrModule::parse(&data).unwrap();
5249        assert_eq!(
5250            walk_to_html(&module, &SlotData::new(0)).unwrap(),
5251            "<div>",
5252            "static on* attribute must be dropped"
5253        );
5254
5255        // ...and the dynamic one.
5256        let mut opcodes = encode_open_tag(0, &[]);
5257        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5258        let data = build_declared_fixture(
5259            &["div", "ONCLICK", "h"],
5260            &[(0, 2, 0x01, 0x00, &[])],
5261            &opcodes,
5262            &[],
5263        );
5264        let module = IrModule::parse(&data).unwrap();
5265        let mut slots = SlotData::new(1);
5266        slots.set(0, SlotValue::Text("alert(1)".into()));
5267        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5268    }
5269
5270    #[test]
5271    fn walk_drops_javascript_url_in_static_attr() {
5272        // Static values come from the string table (template-author level),
5273        // but renderAttr applies identical rules to static props — leaving
5274        // them unchecked would preserve an SSR/CSR divergence.
5275        let opcodes = encode_open_tag(0, &[(1, 2)]);
5276        let data =
5277            build_declared_fixture(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
5278        let module = IrModule::parse(&data).unwrap();
5279        assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
5280    }
5281
5282    #[test]
5283    fn walk_drops_a_script_url_in_an_svg_animation_value() {
5284        // FORMA-277: `to`, `from`, `by` and each `;`-separated item of
5285        // `values` on `<set>` and on every `<animate…>` element are judged as
5286        // URLs, whatever `attributeName` says, static and dynamic, as core's
5287        // sinks judge them (the shared INV1 vectors run the same rule). The
5288        // values real animations use (numbers, transforms, coordinates,
5289        // colours) are written as they are.
5290        for tag in [
5291            "animate",
5292            "set",
5293            "animateTransform",
5294            "animateMotion",
5295            "animateColor",
5296            "ANIMATEMOTION",
5297        ] {
5298            for name in ["to", "from", "by", "values"] {
5299                for (value, written) in [
5300                    ("javascript:alert(1)", false),
5301                    ("#a; java\tscript:alert(1)", false),
5302                    ("0;data:text/html,<p>doc;2", false),
5303                    ("data:image/svg+xml,<svg onload=alert(1)>", false),
5304                    ("#a; #b", true),
5305                    ("0;10;20", true),
5306                    ("0 50 50;360 50 50", true),
5307                    ("0,0;10,10", true),
5308                    ("red;blue", true),
5309                    ("data:image/png;base64,aA==", true),
5310                ] {
5311                    let mut static_page = Page::new();
5312                    static_page
5313                        .open("svg", &[])
5314                        .open(tag, &[("attributeName", "href"), (name, value)])
5315                        .close(tag)
5316                        .close("svg");
5317                    let mut dynamic_page = Page::new();
5318                    let slot = dynamic_page.slot(SlotValue::Text(value.to_string()));
5319                    dynamic_page
5320                        .open("svg", &[])
5321                        .open(tag, &[("attributeName", "href")])
5322                        .dyn_attr(name, slot)
5323                        .close(tag)
5324                        .close("svg");
5325                    for (writer, html) in [
5326                        ("static", static_page.render()),
5327                        ("dynamic", dynamic_page.render()),
5328                    ] {
5329                        assert_eq!(
5330                            html.contains(&format!(" {name}=\"")),
5331                            written,
5332                            "{writer} <{tag} {name}={value:?}>: {html}"
5333                        );
5334                        assert!(html.contains(" attributeName=\"href\""), "{html}");
5335                    }
5336                }
5337            }
5338        }
5339        // Only those four attributes, and only on those tags: another
5340        // attribute of an animation element, the same names on another
5341        // element, and a tag that only starts with `animate` keep their text.
5342        for (tag, name) in [
5343            ("animate", "begin"),
5344            ("set", "fill"),
5345            ("animateTransform", "type"),
5346            ("animateMotion", "path"),
5347            ("g", "to"),
5348            ("animate-x", "to"),
5349        ] {
5350            let mut page = Page::new();
5351            page.open(tag, &[(name, "javascript:alert(1)")]).close(tag);
5352            assert_eq!(
5353                page.render(),
5354                format!("<{tag} {name}=\"javascript:alert(1)\"></{tag}>")
5355            );
5356        }
5357    }
5358
5359    // -- Number rendering parity (F-05) -------------------------------------
5360
5361    // -- Parsing context: text-only parents and form controls (FORMA-002) ---
5362    //
5363    // The tokenizer reads the content of `textarea` and `title` (RCDATA) and
5364    // of `style` and `script` (RAWTEXT, script data) as text, so a marker
5365    // comment there is literal text: it becomes the control's value and what a
5366    // form submits before JavaScript. A bound `value` on a `select` or a
5367    // `textarea` is a property HTML never reads as an attribute. Each test
5368    // asserts exact bytes for the affected element AND for a sibling in normal
5369    // content, so a rule cannot pass by dropping markers everywhere (P3).
5370
5371    /// A small opcode assembler with an interning string table, so these
5372    /// tests read as markup rather than as string indices.
5373    struct Page {
5374        strings: Vec<String>,
5375        ops: Vec<u8>,
5376        slots: Vec<(u8, SlotValue)>,
5377        islands: Vec<(u16, u32, u32)>,
5378    }
5379
5380    impl Page {
5381        fn new() -> Self {
5382            Page {
5383                strings: Vec::new(),
5384                ops: Vec::new(),
5385                slots: Vec::new(),
5386                islands: Vec::new(),
5387            }
5388        }
5389
5390        fn intern(&mut self, s: &str) -> u32 {
5391            let index = match self.strings.iter().position(|x| x == s) {
5392                Some(i) => i,
5393                None => {
5394                    self.strings.push(s.to_string());
5395                    self.strings.len() - 1
5396                }
5397            };
5398            u32::try_from(index).unwrap()
5399        }
5400
5401        /// Declare the next slot holding `value`; ids are assigned in order.
5402        fn slot(&mut self, value: SlotValue) -> u16 {
5403            let type_byte = match &value {
5404                SlotValue::Null | SlotValue::Text(_) => 0x01,
5405                SlotValue::Bool(_) => 0x02,
5406                SlotValue::Number(_) => 0x03,
5407                SlotValue::Array(_) => 0x04,
5408                SlotValue::Object(_) => 0x05,
5409            };
5410            self.slots.push((type_byte, value));
5411            u16::try_from(self.slots.len() - 1).unwrap()
5412        }
5413
5414        fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
5415            let tag = self.intern(tag);
5416            let attrs: Vec<(u32, u32)> = attrs
5417                .iter()
5418                .map(|(k, v)| (self.intern(k), self.intern(v)))
5419                .collect();
5420            self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
5421            self
5422        }
5423
5424        fn close(&mut self, tag: &str) -> &mut Self {
5425            let tag = self.intern(tag);
5426            self.ops.extend_from_slice(&encode_close_tag(tag));
5427            self
5428        }
5429
5430        fn text(&mut self, text: &str) -> &mut Self {
5431            let text = self.intern(text);
5432            self.ops.extend_from_slice(&encode_text(text));
5433            self
5434        }
5435
5436        /// DYN_TEXT whose marker id is its slot id, so ids stay distinct.
5437        fn dyn_text(&mut self, slot: u16) -> &mut Self {
5438            self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
5439            self
5440        }
5441
5442        fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
5443            let name = self.intern(name);
5444            self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
5445            self
5446        }
5447
5448        /// PROP: `target = source[name]`.
5449        fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
5450            let name = self.intern(name);
5451            self.ops
5452                .extend_from_slice(&encode_prop(source, name, target));
5453            self
5454        }
5455
5456        /// Assemble `f`'s opcodes on their own, sharing the string table.
5457        fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
5458            let outer = std::mem::take(&mut self.ops);
5459            f(self);
5460            std::mem::replace(&mut self.ops, outer)
5461        }
5462
5463        fn show_if(
5464            &mut self,
5465            slot: u16,
5466            then_ops: impl FnOnce(&mut Self),
5467            else_ops: impl FnOnce(&mut Self),
5468        ) -> &mut Self {
5469            let then_ops = self.body(then_ops);
5470            let else_ops = self.body(else_ops);
5471            self.ops
5472                .extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
5473            self
5474        }
5475
5476        fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5477            let body = self.body(body);
5478            self.ops.extend_from_slice(&encode_list(slot, item, &body));
5479            self
5480        }
5481
5482        /// An island at the top level (its table offset is taken here, so it
5483        /// must not be nested inside a `body`).
5484        fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5485            let name = self.intern(&format!("Island{id}"));
5486            let offset = u32::try_from(self.ops.len()).unwrap();
5487            self.islands.push((id, name, offset));
5488            self.ops.extend_from_slice(&encode_island_start(id));
5489            body(self);
5490            self.ops.extend_from_slice(&encode_island_end(id));
5491            self
5492        }
5493
5494        fn module_and_slots(&self) -> (IrModule, SlotData) {
5495            let mut strings = self.strings.clone();
5496            let mut decls = Vec::new();
5497            for (id, (type_byte, _)) in self.slots.iter().enumerate() {
5498                strings.push(format!("slot{id}"));
5499                let name = u32::try_from(strings.len() - 1).unwrap();
5500                decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
5501            }
5502            let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
5503                .islands
5504                .iter()
5505                .map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
5506                .collect();
5507            let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
5508            let data = build_declared_fixture(&strings, &decls, &self.ops, &islands);
5509            let module = IrModule::parse(&data).unwrap();
5510            let mut slots = SlotData::new(self.slots.len());
5511            for (id, (_, value)) in self.slots.iter().enumerate() {
5512                slots.set(u16::try_from(id).unwrap(), value.clone());
5513            }
5514            (module, slots)
5515        }
5516
5517        fn render(&self) -> String {
5518            let (module, slots) = self.module_and_slots();
5519            walk_to_html(&module, &slots).unwrap()
5520        }
5521
5522        fn render_island(&self, id: u16) -> String {
5523            let (module, slots) = self.module_and_slots();
5524            walk_island(&module, &slots, id).unwrap()
5525        }
5526    }
5527
5528    mod static_routes {
5529        use super::*;
5530
5531        fn render(page: &Page, limits: WalkLimits) -> Result<WalkOutput, WalkFailure> {
5532            let (module, slots) = page.module_and_slots();
5533            walk_to_html_counted_with_mode(&module, &slots, limits, RenderMode::Static)
5534        }
5535
5536        #[test]
5537        fn static_vectors_omit_markers_and_holders_but_keep_value_rules() {
5538            let cases: serde_json::Value =
5539                serde_json::from_str(include_str!("../tests/fixtures/parity/static-routes.json"))
5540                    .unwrap();
5541            for case in cases["text"].as_array().unwrap() {
5542                let mut page = Page::new();
5543                let value = page.slot(crate::json_to_slot_value(&case["value"]));
5544                page.open("p", &[]).dyn_text(value).close("p");
5545                let output = render(&page, WalkLimits::default()).unwrap();
5546                assert_eq!(
5547                    output.html,
5548                    format!("<p>{}</p>", case["html"].as_str().unwrap())
5549                );
5550                assert_eq!(output.counts.bytes, output.html.len());
5551                // The existing entry point still writes the adoption vocabulary.
5552                assert!(page.render().contains("<!--f:t0-->"));
5553            }
5554        }
5555
5556        #[test]
5557        fn static_dynamic_text_preserves_initial_line_feeds_across_empty_ranges() {
5558            let cases: serde_json::Value =
5559                serde_json::from_str(include_str!("../tests/fixtures/parity/static-routes.json"))
5560                    .unwrap();
5561            for tag in ["pre", "listing"] {
5562                for case in cases["pre"].as_array().unwrap() {
5563                    let mut page = Page::new();
5564                    let empty = page.slot(SlotValue::Null);
5565                    let yes = page.slot(SlotValue::Bool(true));
5566                    let value = page.slot(crate::json_to_slot_value(&case["value"]));
5567                    page.open(tag, &[])
5568                        .dyn_text(empty)
5569                        .show_if(
5570                            yes,
5571                            |p| {
5572                                p.dyn_text(value);
5573                            },
5574                            |_| {},
5575                        )
5576                        .close(tag);
5577                    assert_eq!(
5578                        render(&page, WalkLimits::default()).unwrap().html,
5579                        format!("<{tag}>{}</{tag}>", case["html"].as_str().unwrap())
5580                    );
5581                }
5582            }
5583        }
5584
5585        #[test]
5586        fn static_conditionals_lists_and_text_only_parents_keep_visible_values() {
5587            let mut page = Page::new();
5588            let yes = page.slot(SlotValue::Bool(true));
5589            let list = page.slot(SlotValue::array(vec![text("<a>"), text(""), text("b")]));
5590            let item = page.slot(SlotValue::Null);
5591            page.open("ul", &[])
5592                .show_if(
5593                    yes,
5594                    |p| {
5595                        p.list(list, item, |p| {
5596                            p.open("li", &[]).dyn_text(item).close("li");
5597                        });
5598                    },
5599                    |p| {
5600                        p.text("wrong");
5601                    },
5602                )
5603                .close("ul");
5604            assert_eq!(
5605                render(&page, WalkLimits::default()).unwrap().html,
5606                "<ul><li>&lt;a&gt;</li><li></li><li>b</li></ul>"
5607            );
5608            for tag in ["textarea", "title", "style", "script"] {
5609                let mut page = Page::new();
5610                let value = page.slot(text("\nx&y"));
5611                page.open(tag, &[]).dyn_text(value).close(tag);
5612                assert_eq!(
5613                    render(&page, WalkLimits::default()).unwrap().html,
5614                    page.render()
5615                );
5616            }
5617        }
5618
5619        #[test]
5620        fn static_limits_charge_actual_bytes_and_list_iterations() {
5621            let mut page = Page::new();
5622            let list = page.slot(SlotValue::array(vec![text("<&"), text("b")]));
5623            let item = page.slot(SlotValue::Null);
5624            page.list(list, item, |p| {
5625                p.open("p", &[]).dyn_text(item).close("p");
5626            });
5627            let html = "<p>&lt;&amp;</p><p>b</p>";
5628            let exact = WalkLimits {
5629                max_output_bytes: html.len(),
5630                max_list_iterations: 2,
5631            };
5632            assert_eq!(render(&page, exact).unwrap().html, html);
5633            assert!(matches!(
5634                render(
5635                    &page,
5636                    WalkLimits {
5637                        max_output_bytes: html.len() - 1,
5638                        ..exact
5639                    }
5640                )
5641                .unwrap_err()
5642                .error,
5643                IrError::OutputLimitExceeded { .. }
5644            ));
5645            assert!(render(
5646                &page,
5647                WalkLimits {
5648                    max_list_iterations: 1,
5649                    ..exact
5650                }
5651            )
5652            .is_err());
5653        }
5654
5655        #[test]
5656        fn static_mode_refuses_declared_islands_before_any_output() {
5657            let mut page = Page::new();
5658            page.island(0, |p| {
5659                p.open("div", &[]).close("div");
5660            });
5661            let failure = render(&page, WalkLimits::default()).unwrap_err();
5662            assert_eq!(failure.error, IrError::InvalidSection);
5663            assert_eq!(failure.counts, WalkCounts::default());
5664            assert!(page.render().contains("data-forma-island"));
5665        }
5666    }
5667
5668    fn text(s: &str) -> SlotValue {
5669        SlotValue::Text(s.to_string())
5670    }
5671
5672    #[test]
5673    fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
5674        // One table over every text-only parent (P2), plus an upper-case tag:
5675        // HTML tag names are case-insensitive. The value is still escaped:
5676        // RCDATA decodes the references back, and in RAWTEXT the escaping is
5677        // what stops a value from closing the element.
5678        for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
5679            let mut page = Page::new();
5680            let inside = page.slot(text("a<b&c"));
5681            let outside = page.slot(text("world"));
5682            page.open("div", &[])
5683                .open(parent, &[])
5684                .dyn_text(inside)
5685                .close(parent)
5686                .open("p", &[])
5687                .dyn_text(outside)
5688                .close("p")
5689                .close("div");
5690            assert_eq!(
5691                page.render(),
5692                format!(
5693                    "<div><{parent}>a&lt;b&amp;c</{parent}>\
5694                     <p><!--f:t1-->world<!--/f:t1--></p></div>"
5695                ),
5696                "inside <{parent}>"
5697            );
5698        }
5699    }
5700
5701    #[test]
5702    fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
5703        // Negative space (P5): the rule is four exact names, not a prefix or
5704        // substring match, and not every element with special parsing.
5705        for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
5706            let mut page = Page::new();
5707            let value = page.slot(text("hello"));
5708            page.open(parent, &[]).dyn_text(value).close(parent);
5709            assert_eq!(
5710                page.render(),
5711                format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
5712                "inside <{parent}>"
5713            );
5714        }
5715    }
5716
5717    #[test]
5718    fn an_empty_value_in_a_text_only_parent_writes_nothing() {
5719        // The zero-width space exists so a marker pair encloses a Text node.
5720        // With no markers it would only become part of the control's value.
5721        let mut page = Page::new();
5722        let inside = page.slot(text(""));
5723        let outside = page.slot(text(""));
5724        page.open("textarea", &[])
5725            .dyn_text(inside)
5726            .close("textarea")
5727            .open("p", &[])
5728            .dyn_text(outside)
5729            .close("p");
5730        assert_eq!(
5731            page.render(),
5732            "<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
5733        );
5734    }
5735
5736    #[test]
5737    fn a_textarea_doubles_a_leading_line_feed() {
5738        // The parser drops one LF straight after `<textarea>` (CR and CRLF are
5739        // LF by then). The marker used to shield it; without one, the first
5740        // line of the value would be lost before JavaScript runs.
5741        fn textarea(build: impl FnOnce(&mut Page)) -> String {
5742            let mut page = Page::new();
5743            page.open("textarea", &[]);
5744            build(&mut page);
5745            page.close("textarea");
5746            page.render()
5747        }
5748        let cases: [(&str, String, &str); 5] = [
5749            (
5750                "a dynamic value",
5751                textarea(|p| {
5752                    let v = p.slot(text("\nhello"));
5753                    p.dyn_text(v);
5754                }),
5755                "<textarea>\n\nhello</textarea>",
5756            ),
5757            (
5758                "a CRLF value",
5759                textarea(|p| {
5760                    let v = p.slot(text("\r\nhello"));
5761                    p.dyn_text(v);
5762                }),
5763                // Rule 1 (FORMA-182): the CRLF is written as LF, and that
5764                // initial LF is the one doubled.
5765                "<textarea>\n\nhello</textarea>",
5766            ),
5767            (
5768                "an empty value first: the first written byte decides",
5769                textarea(|p| {
5770                    let empty = p.slot(text(""));
5771                    let v = p.slot(text("\nx"));
5772                    p.dyn_text(empty).dyn_text(v);
5773                }),
5774                "<textarea>\n\nx</textarea>",
5775            ),
5776            (
5777                "an LF after other content is kept as is",
5778                textarea(|p| {
5779                    let v = p.slot(text("\nb"));
5780                    p.text("a").dyn_text(v);
5781                }),
5782                "<textarea>a\nb</textarea>",
5783            ),
5784            (
5785                "static text",
5786                textarea(|p| {
5787                    p.text("\nstatic");
5788                }),
5789                "<textarea>\n\nstatic</textarea>",
5790            ),
5791        ];
5792        for (case, html, expected) in cases {
5793            assert_eq!(html, expected, "{case}");
5794        }
5795
5796        // The rule is the textarea's alone: the parser drops no LF in a title.
5797        let mut page = Page::new();
5798        let v = page.slot(text("\nhello"));
5799        page.open("title", &[]).dyn_text(v).close("title");
5800        assert_eq!(page.render(), "<title>\nhello</title>");
5801    }
5802
5803    #[test]
5804    fn no_marker_kind_is_written_inside_a_text_only_parent() {
5805        // SHOW_IF still chooses its branch and LIST still repeats its body;
5806        // only their markers go. The same SHOW_IF in a `p` keeps them.
5807        let mut page = Page::new();
5808        let loading = page.slot(SlotValue::Bool(false));
5809        let name = page.slot(text("Ada"));
5810        let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
5811        let tag = page.slot(SlotValue::Null);
5812        page.open("title", &[])
5813            .show_if(
5814                loading,
5815                |p| {
5816                    p.text("Loading");
5817                },
5818                |p| {
5819                    p.dyn_text(name);
5820                },
5821            )
5822            .text(" | ")
5823            .list(tags, tag, |p| {
5824                p.dyn_text(tag).text(";");
5825            })
5826            .close("title")
5827            .open("p", &[])
5828            .show_if(
5829                loading,
5830                |p| {
5831                    p.text("Loading");
5832                },
5833                |p| {
5834                    p.dyn_text(name);
5835                },
5836            )
5837            .close("p");
5838        assert_eq!(
5839            page.render(),
5840            "<title>Ada | x;y;</title>\
5841             <p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
5842        );
5843
5844        // An island marker would be text too.
5845        let mut page = Page::new();
5846        page.open("textarea", &[])
5847            .island(0, |p| {
5848                p.text("x");
5849            })
5850            .close("textarea")
5851            .island(1, |p| {
5852                p.open("div", &[]).close("div");
5853            });
5854        assert_eq!(
5855            page.render(),
5856            "<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
5857             data-forma-component=\"Island1\" data-forma-status=\"pending\" \
5858             data-forma-hydrate=\"load\" data-forma-state=\"1\" data-forma-h=\"0101010101010101\"></div><!--/f:i1-->"
5859        );
5860    }
5861
5862    #[test]
5863    fn a_textarea_value_binding_is_written_as_its_text() {
5864        // `<textarea value>` is a property: HTML ignores the attribute, so the
5865        // server shipped an empty control. The client writes
5866        // `value == null ? '' : String(value)`, and so does the walker.
5867        let cases = [
5868            (text("hello"), "hello"),
5869            (
5870                text("</textarea><script>x</script>"),
5871                "&lt;/textarea&gt;&lt;script&gt;x&lt;/script&gt;",
5872            ),
5873            (text("\nhello"), "\n\nhello"),
5874            (SlotValue::Null, ""),
5875            (SlotValue::Number(0.0), "0"),
5876            (SlotValue::Bool(false), "false"),
5877        ];
5878        for (value, inner) in cases {
5879            let mut page = Page::new();
5880            let v = page.slot(value.clone());
5881            let note = page.slot(text("note"));
5882            page.open("textarea", &[("name", "t")])
5883                .dyn_attr("value", v)
5884                .close("textarea")
5885                .open("p", &[])
5886                .dyn_text(note)
5887                .close("p");
5888            assert_eq!(
5889                page.render(),
5890                format!(
5891                    "<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
5892                ),
5893                "value {value:?}"
5894            );
5895        }
5896
5897        // The static form, `<textarea value="hello">`, is the same property.
5898        let mut page = Page::new();
5899        page.open("textarea", &[("name", "t"), ("value", "hello")])
5900            .close("textarea");
5901        assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
5902    }
5903
5904    #[test]
5905    fn a_textarea_value_binding_wins_over_its_children() {
5906        // The live control shows the bound value whatever its children say,
5907        // so the server writes the value and not the children.
5908        let mut page = Page::new();
5909        let v = page.slot(text("bound"));
5910        let child = page.slot(text("child"));
5911        page.open("textarea", &[])
5912            .dyn_attr("value", v)
5913            .dyn_text(child)
5914            .text(" default")
5915            .close("textarea")
5916            .open("p", &[])
5917            .dyn_text(child)
5918            .close("p");
5919        assert_eq!(
5920            page.render(),
5921            "<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
5922        );
5923    }
5924
5925    /// `<select name="c" value={value}>` over options a, b and c, each label
5926    /// distinct from its value so a match on the wrong one shows.
5927    fn select_page(value: SlotValue) -> String {
5928        let mut page = Page::new();
5929        let v = page.slot(value);
5930        page.open("select", &[("name", "c")]).dyn_attr("value", v);
5931        for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5932            page.open("option", &[("value", value)])
5933                .text(label)
5934                .close("option");
5935        }
5936        page.close("select");
5937        page.render()
5938    }
5939
5940    fn option(value: &str, label: &str, selected: bool) -> String {
5941        let selected = if selected { " selected" } else { "" };
5942        format!("<option value=\"{value}\"{selected}>{label}</option>")
5943    }
5944
5945    #[test]
5946    fn a_select_value_binding_selects_the_matching_option() {
5947        // HTML ignores `value` on a select, so the server used to ship the
5948        // first option selected, and a form submitted it before JavaScript.
5949        for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
5950            let expected = format!(
5951                "<select name=\"c\">{}{}{}</select>",
5952                option("a", "Apple", chosen == "a"),
5953                option("b", "Banana", chosen == "b"),
5954                option("c", "Cherry", chosen == "c"),
5955            );
5956            assert_eq!(select_page(text(value)), expected, "value {value}");
5957        }
5958    }
5959
5960    #[test]
5961    fn a_select_value_binding_compares_as_the_client_string() {
5962        // `String(2)` is "2"; `null` is '' (the placeholder option).
5963        for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
5964            let mut page = Page::new();
5965            let v = page.slot(value.clone());
5966            page.open("select", &[]).dyn_attr("value", v);
5967            for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
5968                page.open("option", &[("value", value)])
5969                    .text(label)
5970                    .close("option");
5971            }
5972            page.close("select");
5973            // An empty static attribute is written bare (`emit_open_tag`).
5974            let placeholder = if chosen.is_empty() {
5975                "<option value selected>Choose</option>"
5976            } else {
5977                "<option value>Choose</option>"
5978            };
5979            let expected = format!(
5980                "<select>{placeholder}{}{}</select>",
5981                option("1", "One", chosen == "1"),
5982                option("2", "Two", chosen == "2"),
5983            );
5984            assert_eq!(page.render(), expected, "value {value:?}");
5985        }
5986    }
5987
5988    #[test]
5989    fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
5990        // An option without `value` is valued by its text, stripped and
5991        // collapsed: `<option> Dark \n green </option>` is "Dark green".
5992        let render = |value: &str| {
5993            let mut page = Page::new();
5994            let v = page.slot(text(value));
5995            let label = page.slot(text("Blue"));
5996            page.open("select", &[])
5997                .dyn_attr("value", v)
5998                .open("option", &[])
5999                .text("Red")
6000                .close("option")
6001                .open("option", &[])
6002                .text(" Dark \n  green ")
6003                .close("option")
6004                .open("option", &[])
6005                .dyn_text(label)
6006                .close("option")
6007                .close("select");
6008            page.render()
6009        };
6010        assert_eq!(
6011            render("Dark green"),
6012            "<select><option>Red</option><option selected> Dark \n  green </option>\
6013             <option><!--f:t1-->Blue<!--/f:t1--></option></select>"
6014        );
6015        assert_eq!(
6016            render("Blue"),
6017            "<select><option>Red</option><option> Dark \n  green </option>\
6018             <option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
6019        );
6020    }
6021
6022    #[test]
6023    fn a_select_value_binding_reaches_options_rendered_by_a_list() {
6024        // A combination (P2): list rows, PROP, a dynamic attribute and the
6025        // select. Labels differ from values, so an option that lost its
6026        // `value` and fell back to its text would match nothing. Three rows,
6027        // then 45 with the match past row 32 (TESTING.md P4's depth floor).
6028        let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
6029            .iter()
6030            .map(|(id, label)| (id.to_string(), label.to_string()))
6031            .collect();
6032        let long: Vec<(String, String)> = (0..45)
6033            .map(|i| (format!("r{i}"), format!("Row {i}")))
6034            .collect();
6035        let cases: [(&[(String, String)], &str, usize); 4] = [
6036            (&short, "a", 0),
6037            (&short, "c", 2),
6038            (&long, "r40", 40),
6039            (&long, "r44", 44),
6040        ];
6041        for (rows_in, value, chosen) in cases {
6042            let mut page = Page::new();
6043            let v = page.slot(text(value));
6044            let rows = rows_in
6045                .iter()
6046                .map(|(id, label)| {
6047                    SlotValue::object(vec![
6048                        ("id".to_string(), text(id)),
6049                        ("label".to_string(), text(label)),
6050                    ])
6051                })
6052                .collect();
6053            let items = page.slot(SlotValue::array(rows));
6054            let item = page.slot(SlotValue::Null);
6055            let id = page.slot(SlotValue::Null);
6056            let label = page.slot(SlotValue::Null);
6057            page.open("select", &[])
6058                .dyn_attr("value", v)
6059                .list(items, item, |p| {
6060                    p.prop(item, "id", id)
6061                        .prop(item, "label", label)
6062                        .open("option", &[])
6063                        .dyn_attr("value", id)
6064                        .dyn_text(label)
6065                        .close("option");
6066                })
6067                .close("select");
6068            let expected: String = rows_in
6069                .iter()
6070                .enumerate()
6071                .map(|(i, (id, label))| {
6072                    let selected = if i == chosen { " selected" } else { "" };
6073                    format!(
6074                        "<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
6075                    )
6076                })
6077                .collect();
6078            assert_eq!(
6079                page.render(),
6080                format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
6081                "value {value}"
6082            );
6083        }
6084    }
6085
6086    #[test]
6087    fn a_select_binding_ends_with_its_select() {
6088        // A `datalist` option after a bound select is a suggestion, not a
6089        // choice: the binding must not reach it. The select has no option
6090        // valued "b", so its single match is still unspent when the datalist
6091        // opens; only ending the binding at `</select>` keeps it off.
6092        let mut page = Page::new();
6093        let v = page.slot(text("b"));
6094        page.open("select", &[])
6095            .dyn_attr("value", v)
6096            .open("option", &[("value", "a")])
6097            .text("A")
6098            .close("option")
6099            .close("select")
6100            .open("datalist", &[])
6101            .open("option", &[("value", "b")])
6102            .text("B")
6103            .close("option")
6104            .close("datalist");
6105        assert_eq!(
6106            page.render(),
6107            format!(
6108                "<select>{}</select><datalist>{}</datalist>",
6109                option("a", "A", false),
6110                option("b", "B", false)
6111            )
6112        );
6113    }
6114
6115    #[test]
6116    fn an_options_first_value_attribute_is_its_value() {
6117        // `<option value="a" value="b">`: the parser keeps the first of two
6118        // same-named attributes, so the option's value is "a".
6119        for (bound, selected) in [("a", " selected"), ("b", "")] {
6120            let mut page = Page::new();
6121            let v = page.slot(text(bound));
6122            let second = page.slot(text("b"));
6123            page.open("select", &[])
6124                .dyn_attr("value", v)
6125                .open("option", &[("value", "a")])
6126                .dyn_attr("value", second)
6127                .text("Only")
6128                .close("option")
6129                .close("select");
6130            assert_eq!(
6131                page.render(),
6132                format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
6133                "bound to {bound}"
6134            );
6135        }
6136    }
6137
6138    #[test]
6139    fn multiple_counts_only_when_the_attribute_is_written() {
6140        // A `DYN_ATTR multiple` that is false writes no attribute, so the
6141        // select is single and an array value is `String(array)`, "a,c",
6142        // which matches none of these options. True writes it, and the
6143        // array then selects each member.
6144        for (multiple, attr, chosen) in [
6145            (SlotValue::Bool(false), "", false),
6146            (SlotValue::Bool(true), " multiple", true),
6147            (SlotValue::Text(String::new()), "", false),
6148            (SlotValue::Number(0.0), "", false),
6149            (SlotValue::Number(-0.0), "", false),
6150            (SlotValue::Text("false".into()), " multiple", true),
6151            (SlotValue::Number(1.0), " multiple", true),
6152        ] {
6153            let mut page = Page::new();
6154            let flag = page.slot(multiple.clone());
6155            let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6156            page.open("select", &[])
6157                .dyn_attr("multiple", flag)
6158                .dyn_attr("value", v);
6159            for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6160                page.open("option", &[("value", value)])
6161                    .text(label)
6162                    .close("option");
6163            }
6164            page.close("select");
6165            assert_eq!(
6166                page.render(),
6167                format!(
6168                    "<select{attr}>{}{}{}</select>",
6169                    option("a", "Apple", chosen),
6170                    option("b", "Banana", false),
6171                    option("c", "Cherry", chosen)
6172                ),
6173                "multiple {multiple:?}"
6174            );
6175        }
6176    }
6177
6178    #[test]
6179    fn a_null_member_of_a_multiple_value_compares_as_null() {
6180        // The client builds its set with `value.map(String)`, and
6181        // `String(null)` is "null", not '' as it is for a text hole.
6182        let mut page = Page::new();
6183        let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
6184        page.open("select", &[("multiple", "")])
6185            .dyn_attr("value", v);
6186        for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
6187            page.open("option", &[("value", value)])
6188                .text(label)
6189                .close("option");
6190        }
6191        page.close("select");
6192        // The middle option's static value cannot be empty (it would be
6193        // written bare), so it is `x`; the empty-string case is the
6194        // placeholder in `a_select_value_binding_compares_as_the_client_string`.
6195        assert_eq!(
6196            page.render(),
6197            format!(
6198                "<select multiple>{}{}{}</select>",
6199                option("null", "Null", true),
6200                option("x", "Empty", false),
6201                option("b", "Banana", true)
6202            )
6203        );
6204    }
6205
6206    #[test]
6207    fn an_option_ends_at_the_next_option_or_optgroup() {
6208        // `</option>` is optional: the parser ends an option at the next
6209        // `<option>` or `<optgroup>`, and an option valued by its text is
6210        // valued by the text up to there.
6211        let mut page = Page::new();
6212        let v = page.slot(text("A"));
6213        page.open("select", &[])
6214            .dyn_attr("value", v)
6215            .open("option", &[])
6216            .text("A")
6217            .open("option", &[])
6218            .text("B")
6219            .close("select");
6220        assert_eq!(
6221            page.render(),
6222            "<select><option selected>A<option>B</select>",
6223            "ended by <option>"
6224        );
6225
6226        let mut page = Page::new();
6227        let v = page.slot(text("A"));
6228        page.open("select", &[])
6229            .dyn_attr("value", v)
6230            .open("option", &[])
6231            .text("A")
6232            .open("optgroup", &[("label", "g")])
6233            .open("option", &[("value", "x")])
6234            .text("X")
6235            .close("option")
6236            .close("optgroup")
6237            .close("select");
6238        assert_eq!(
6239            page.render(),
6240            "<select><option selected>A<optgroup label=\"g\">\
6241             <option value=\"x\">X</option></optgroup></select>",
6242            "ended by <optgroup>"
6243        );
6244    }
6245
6246    #[test]
6247    fn a_single_select_selects_only_the_first_matching_option() {
6248        // `select.value = v` selects the first option with that value. Two
6249        // `selected` options would let the parser keep the last instead.
6250        let mut page = Page::new();
6251        let v = page.slot(text("b"));
6252        page.open("select", &[])
6253            .dyn_attr("value", v)
6254            .open("option", &[("value", "b")])
6255            .text("First")
6256            .close("option")
6257            .open("option", &[("value", "b")])
6258            .text("Second")
6259            .close("option")
6260            .close("select");
6261        assert_eq!(
6262            page.render(),
6263            format!(
6264                "<select>{}{}</select>",
6265                option("b", "First", true),
6266                option("b", "Second", false)
6267            )
6268        );
6269    }
6270
6271    #[test]
6272    fn a_multiple_select_selects_every_option_in_an_array_value() {
6273        // The client selects each option whose value is in the array, for a
6274        // `multiple` select only. A single select takes `String(array)`,
6275        // "a,c", which matches none of these options.
6276        let render = |attrs: &[(&str, &str)]| {
6277            let mut page = Page::new();
6278            let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6279            page.open("select", attrs).dyn_attr("value", v);
6280            for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6281                page.open("option", &[("value", value)])
6282                    .text(label)
6283                    .close("option");
6284            }
6285            page.close("select");
6286            page.render()
6287        };
6288        assert_eq!(
6289            render(&[("multiple", "")]),
6290            format!(
6291                "<select multiple>{}{}{}</select>",
6292                option("a", "Apple", true),
6293                option("b", "Banana", false),
6294                option("c", "Cherry", true)
6295            )
6296        );
6297        assert_eq!(
6298            render(&[]),
6299            format!(
6300                "<select>{}{}{}</select>",
6301                option("a", "Apple", false),
6302                option("b", "Banana", false),
6303                option("c", "Cherry", false)
6304            )
6305        );
6306    }
6307
6308    #[test]
6309    fn a_bound_select_overrides_authored_selected_on_its_options() {
6310        // The binding decides, as it does on the client. An unbound select
6311        // keeps what the author wrote, static and dynamic.
6312        let render = |bound: bool| {
6313            let mut page = Page::new();
6314            let v = page.slot(text("a"));
6315            let on = page.slot(SlotValue::Bool(true));
6316            page.open("select", &[]);
6317            if bound {
6318                page.dyn_attr("value", v);
6319            }
6320            page.open("option", &[("value", "a")])
6321                .text("A")
6322                .close("option")
6323                .open("option", &[("value", "b"), ("selected", "")])
6324                .text("B")
6325                .close("option")
6326                .open("option", &[("value", "c")])
6327                .dyn_attr("selected", on)
6328                .text("C")
6329                .close("option")
6330                .close("select");
6331            page.render()
6332        };
6333        assert_eq!(
6334            render(true),
6335            format!(
6336                "<select>{}{}{}</select>",
6337                option("a", "A", true),
6338                option("b", "B", false),
6339                option("c", "C", false)
6340            )
6341        );
6342        assert_eq!(
6343            render(false),
6344            format!(
6345                "<select>{}{}{}</select>",
6346                option("a", "A", false),
6347                option("b", "B", true),
6348                option("c", "C", true)
6349            )
6350        );
6351    }
6352
6353    #[test]
6354    fn a_static_select_value_attribute_selects_its_option() {
6355        let mut page = Page::new();
6356        page.open("select", &[("value", "b")])
6357            .open("option", &[("value", "a")])
6358            .text("A")
6359            .close("option")
6360            .open("option", &[("value", "b")])
6361            .text("B")
6362            .close("option")
6363            .close("select");
6364        assert_eq!(
6365            page.render(),
6366            format!(
6367                "<select>{}{}</select>",
6368                option("a", "A", false),
6369                option("b", "B", true)
6370            )
6371        );
6372    }
6373
6374    #[test]
6375    fn the_context_rules_hold_in_an_island_fragment() {
6376        // The internal fragment path must write what the
6377        // full page writes for the same island (P2).
6378        let mut page = Page::new();
6379        let body = page.slot(text("hello"));
6380        let choice = page.slot(text("b"));
6381        page.island(0, |p| {
6382            p.open("form", &[])
6383                .open("textarea", &[])
6384                .dyn_text(body)
6385                .close("textarea")
6386                .open("select", &[])
6387                .dyn_attr("value", choice)
6388                .open("option", &[("value", "a")])
6389                .text("A")
6390                .close("option")
6391                .open("option", &[("value", "b")])
6392                .text("B")
6393                .close("option")
6394                .close("select")
6395                .close("form");
6396        });
6397        let inner = format!(
6398            "<textarea>hello</textarea><select>{}{}</select>",
6399            option("a", "A", false),
6400            option("b", "B", true)
6401        );
6402        let full = page.render();
6403        let fragment = page.render_island(0);
6404        for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
6405            assert!(html.contains(&inner), "{path}: {html}");
6406        }
6407        assert!(
6408            full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
6409            "{full}"
6410        );
6411    }
6412
6413    // -- Counted walk (FORMA-120) -------------------------------------------
6414
6415    /// A "Branding" island: `<div>` + the tenant's name + one `<i>` per colour.
6416    ///
6417    /// Strings: 0 div, 1 Branding, 2 i, 3 name, 4 colors, 5 color.
6418    /// Slots: 0 name (text), 1 colors (array), 2 color (the row's item).
6419    fn branding_module() -> IrModule {
6420        let mut row = encode_open_tag(2, &[]);
6421        row.extend_from_slice(&encode_dyn_text(2, 1));
6422        row.extend_from_slice(&encode_close_tag(2));
6423        let mut ops = encode_island_start(0);
6424        ops.extend_from_slice(&encode_open_tag(0, &[]));
6425        ops.extend_from_slice(&encode_dyn_text(0, 0));
6426        ops.extend_from_slice(&encode_list(1, 2, &row));
6427        ops.extend_from_slice(&encode_close_tag(0));
6428        ops.extend_from_slice(&encode_island_end(0));
6429        let data = build_declared_fixture(
6430            &["div", "Branding", "i", "name", "colors", "color"],
6431            &[
6432                (0, 3, 0x01, 0x00, &[]),
6433                (1, 4, 0x04, 0x00, &[]),
6434                (2, 5, 0x01, 0x01, &[]),
6435            ],
6436            &ops,
6437            &[(0, 0x01, 0x01, 1, 0, &[])],
6438        );
6439        IrModule::parse(&data).unwrap()
6440    }
6441
6442    fn branding(name: &str, colors: usize) -> SlotData {
6443        let mut slots = SlotData::new(3);
6444        slots.set(0, SlotValue::Text(name.to_string()));
6445        slots.set(
6446            1,
6447            SlotValue::array(
6448                (0..colors)
6449                    .map(|i| SlotValue::Text(format!("c{i}")))
6450                    .collect(),
6451            ),
6452        );
6453        slots
6454    }
6455
6456    #[test]
6457    fn counted_walk_writes_what_walk_to_html_writes_and_counts_it() {
6458        let module = branding_module();
6459        let slots = branding("Acme", 3);
6460        let out = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
6461
6462        assert_eq!(out.html, walk_to_html(&module, &slots).unwrap());
6463        assert_eq!(out.counts.bytes, out.html.len());
6464        // div + the name's text + 3 x (i + its text) = 8 nodes; markers and
6465        // comments are not nodes.
6466        assert_eq!(out.counts.nodes, 8);
6467        assert_eq!(out.counts.list_iterations, 3);
6468    }
6469
6470    #[test]
6471    fn counted_walk_returns_the_counts_reached_when_a_budget_stops_it() {
6472        let module = branding_module();
6473
6474        // The list budget: 5 rows allowed, 9 asked for. The list is refused
6475        // before its first row (FORMA-122): the div and the name were written.
6476        let limits = WalkLimits {
6477            max_list_iterations: 5,
6478            ..WalkLimits::default()
6479        };
6480        let failure = walk_to_html_counted(&module, &branding("Acme", 9), limits).unwrap_err();
6481        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 5 });
6482        assert_eq!(failure.counts.list_iterations, 0);
6483        assert_eq!(failure.counts.nodes, 2);
6484        assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6485        assert!(failure.counts.bytes > 0);
6486
6487        // The output budget, set to stop the walk part-way down the list: it
6488        // stops inside the limit, never past it, and says how far it got.
6489        let limits = WalkLimits {
6490            max_output_bytes: 400,
6491            ..WalkLimits::default()
6492        };
6493        let failure = walk_to_html_counted(&module, &branding("Acme", 50), limits).unwrap_err();
6494        assert_eq!(failure.error, IrError::OutputLimitExceeded { limit: 400 });
6495        let counts = failure.counts;
6496        assert!(counts.bytes <= 400 && counts.bytes > 300, "{counts:?}");
6497        assert!(
6498            counts.list_iterations > 1 && counts.list_iterations < 50,
6499            "{counts:?}"
6500        );
6501        assert!(
6502            counts.nodes >= 2 + 2 * (counts.list_iterations - 1),
6503            "{counts:?}"
6504        );
6505        assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6506    }
6507
6508    #[test]
6509    fn counted_walk_names_no_component_for_a_failure_outside_every_island() {
6510        // Invalid HTML names remain a render error; bad indices now fail at load.
6511        let mut ops = encode_open_tag(0, &[]);
6512        ops.extend_from_slice(&encode_open_tag(1, &[]));
6513        let data = build_declared_fixture(&["p", "bad name"], &[], &ops, &[]);
6514        let module = IrModule::parse(&data).unwrap();
6515        let failure =
6516            walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6517        assert_eq!(failure.error, IrError::InvalidName("bad name".into()));
6518        assert!(failure.component_path.is_empty());
6519        assert_eq!(failure.counts.nodes, 1);
6520    }
6521
6522    #[test]
6523    fn counted_walk_names_every_island_around_a_failure_outermost_first() {
6524        // Outer "Shell" > inner "Card", the failure inside "Card", and a
6525        // closed sibling island before it that must not appear in the path.
6526        // Strings: 0 div, 1 Shell, 2 Card, 3 Done, 4 x.
6527        let mut ops = encode_island_start(0); // Shell, at byte 0
6528        ops.extend_from_slice(&encode_open_tag(0, &[]));
6529        let done_at = ops.len() as u32;
6530        ops.extend_from_slice(&encode_island_start(2)); // Done
6531        ops.extend_from_slice(&encode_open_tag(0, &[]));
6532        ops.extend_from_slice(&encode_close_tag(0));
6533        ops.extend_from_slice(&encode_island_end(2));
6534        let card_at = ops.len() as u32;
6535        ops.extend_from_slice(&encode_island_start(1)); // Card
6536        ops.extend_from_slice(&encode_open_tag(0, &[]));
6537        ops.extend_from_slice(&encode_open_tag(4, &[])); // invalid HTML name fails here
6538        ops.extend_from_slice(&encode_close_tag(0));
6539        ops.extend_from_slice(&encode_island_end(1));
6540        ops.extend_from_slice(&encode_close_tag(0));
6541        ops.extend_from_slice(&encode_island_end(0));
6542        let data = build_declared_fixture(
6543            &["div", "Shell", "Card", "Done", "bad name"],
6544            &[],
6545            &ops,
6546            &[
6547                (0, 0x01, 0x01, 1, 0, &[]),
6548                (1, 0x01, 0x01, 2, card_at, &[]),
6549                (2, 0x01, 0x01, 3, done_at, &[]),
6550            ],
6551        );
6552        let module = IrModule::parse(&data).unwrap();
6553        let failure =
6554            walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6555        assert_eq!(
6556            failure.component_path,
6557            vec!["Shell".to_string(), "Card".to_string()]
6558        );
6559    }
6560
6561    #[test]
6562    fn the_fault_trigger_is_armed_only_by_the_counted_walk() {
6563        // With the trigger compiled in (this crate's tests), the uncaught
6564        // 0.2.1 path renders the value as text; only the counted walk, which
6565        // catches the panic, ever takes it.
6566        let module = branding_module();
6567        let slots = branding(crate::fault_injection::FAULT_TRIGGER, 1);
6568        let html = walk_to_html(&module, &slots).expect("walk_to_html never arms it");
6569        assert!(html.contains("forma-test-fault"), "{html}");
6570        let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6571        assert!(matches!(failure.error, IrError::Panicked { .. }));
6572    }
6573
6574    #[test]
6575    fn counted_walk_catches_a_panic_and_names_its_island() {
6576        // SR44's stand-in: a component that panics on one tenant's data. The
6577        // trigger exists only in test builds and under the internal
6578        // `__fault-injection` feature.
6579        let module = branding_module();
6580        let slots = branding(crate::fault_injection::FAULT_TRIGGER, 2);
6581        let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6582        match &failure.error {
6583            IrError::Panicked { message } => {
6584                assert!(message.contains("stand-in"), "{message}")
6585            }
6586            other => panic!("expected Panicked, got {other:?}"),
6587        }
6588        assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6589        // ...and the next walk, on the same module, renders.
6590        let out =
6591            walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
6592        assert!(out.html.contains("Acme"));
6593    }
6594
6595    // -- A list's length checked before its first row (FORMA-122, DR-13) ------
6596
6597    /// `<div>` around one LIST per entry of `lists`, each over its own slot
6598    /// (slot 2k, item slot 2k+1) and writing `<i>` + the item per row; a list
6599    /// may nest an inner LIST over the item slot's array.
6600    fn lists_module(lists: usize, nested: bool) -> IrModule {
6601        let mut ops = encode_open_tag(0, &[]);
6602        let mut slots: Vec<(u16, u32, u8, u8, &[u8])> = Vec::new();
6603        for k in 0..lists as u16 {
6604            let (list_slot, item_slot) = (2 * k, 2 * k + 1);
6605            let mut row = encode_open_tag(1, &[]);
6606            if nested {
6607                // the inner list iterates the row's item, itself an array,
6608                // binding its rows to slot 2*lists (shared, rebound per row)
6609                let inner_item = 2 * lists as u16;
6610                let mut inner_row = encode_open_tag(1, &[]);
6611                inner_row.extend_from_slice(&encode_close_tag(1));
6612                row.extend_from_slice(&encode_list(item_slot, inner_item, &inner_row));
6613            }
6614            row.extend_from_slice(&encode_close_tag(1));
6615            ops.extend_from_slice(&encode_list(list_slot, item_slot, &row));
6616            slots.push((list_slot, 2, 0x04, 0x00, &[]));
6617            slots.push((item_slot, 3, if nested { 0x04 } else { 0x01 }, 0x00, &[]));
6618        }
6619        if nested {
6620            slots.push((2 * lists as u16, 3, 0x01, 0x00, &[]));
6621        }
6622        ops.extend_from_slice(&encode_close_tag(0));
6623        let data = build_declared_fixture(&["div", "i", "items", "item"], &slots, &ops, &[]);
6624        IrModule::parse(&data).unwrap()
6625    }
6626
6627    fn rows(n: usize) -> SlotValue {
6628        SlotValue::array((0..n).map(|_| SlotValue::Text("x".into())).collect())
6629    }
6630
6631    fn limits(max_list_iterations: usize) -> WalkLimits {
6632        WalkLimits {
6633            max_list_iterations,
6634            ..WalkLimits::default()
6635        }
6636    }
6637
6638    #[test]
6639    fn a_list_longer_than_the_remaining_budget_fails_before_its_first_row() {
6640        // SR38: one row too many, and not one row is walked.
6641        let module = lists_module(1, false);
6642        let mut slots = SlotData::new(2);
6643        slots.set(0, rows(51));
6644        let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6645        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6646        assert_eq!(failure.counts.list_iterations, 0);
6647        assert_eq!(failure.counts.nodes, 1, "only the wrapping div was written");
6648
6649        // The negative space: exactly the budget renders, every row.
6650        slots.set(0, rows(50));
6651        let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6652        assert_eq!(out.counts.list_iterations, 50);
6653        assert_eq!(out.html.matches("<i>").count(), 50);
6654    }
6655
6656    #[test]
6657    fn a_list_exactly_at_the_remaining_budget_still_renders() {
6658        // The budget is per render: a first list spends 20 of 50 rows, so a
6659        // second of exactly 30 renders and one of 31 fails before its first.
6660        let module = lists_module(2, false);
6661        let mut slots = SlotData::new(4);
6662        slots.set(0, rows(20));
6663        slots.set(2, rows(30));
6664        let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6665        assert_eq!(out.counts.list_iterations, 50);
6666        assert_eq!(out.html.matches("<i>").count(), 50);
6667
6668        slots.set(2, rows(31));
6669        let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6670        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6671        assert_eq!(
6672            failure.counts.list_iterations, 20,
6673            "the first list, and no more"
6674        );
6675        assert_eq!(failure.counts.nodes, 1 + 20);
6676    }
6677
6678    #[test]
6679    fn a_nested_list_is_checked_against_what_its_parents_left() {
6680        // 3 outer rows of 20 inner rows each, under 59: the outer list fits
6681        // (3 <= 59); rows and inner rows spend 1 + 20 + 1 + 20 + 1 = 43; the
6682        // third inner list (20 > 16 left) fails at its entry. At 0.2.1 the
6683        // walk ran 16 of its rows first.
6684        let module = lists_module(1, true);
6685        let mut slots = SlotData::new(3);
6686        slots.set(0, SlotValue::array((0..3).map(|_| rows(20)).collect()));
6687        let failure = walk_to_html_counted(&module, &slots, limits(59)).unwrap_err();
6688        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 59 });
6689        assert_eq!(failure.counts.list_iterations, 43);
6690
6691        // In all, 3 outer and 60 inner rows: a budget of 63 renders, 62 does not.
6692        let out = walk_to_html_counted(&module, &slots, limits(63)).unwrap();
6693        assert_eq!(out.counts.list_iterations, 63);
6694        assert!(walk_to_html_counted(&module, &slots, limits(62)).is_err());
6695    }
6696
6697    #[test]
6698    fn the_per_row_check_still_stops_a_list_whose_nested_rows_spend_the_budget() {
6699        // 3 outer rows of 11 inner rows under 24: every list fits on entry
6700        // (3 <= 24, 11 <= 23, 11 <= 11), and the inner rows spend the rest, so
6701        // the outer list's third row is the one refused, by the per-row check.
6702        let module = lists_module(1, true);
6703        let mut slots = SlotData::new(3);
6704        slots.set(0, SlotValue::array((0..3).map(|_| rows(11)).collect()));
6705        let failure = walk_to_html_counted(&module, &slots, limits(24)).unwrap_err();
6706        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 24 });
6707        assert_eq!(failure.counts.list_iterations, 24);
6708    }
6709
6710    // -- FORMA-182 rule 1: the shared normalization vectors ------------------
6711    //
6712    // One finite JSON file, read by this crate and by @getforma/core's suite
6713    // (packages/core/src/dom/__tests__/normalize-text.test.ts), so a vector
6714    // changed on one side fails the other. Every case walks the real opcode
6715    // stream through `walk_to_html`; nothing here calls a helper directly.
6716    // Each test collects every disagreeing vector before failing, so a first
6717    // run reports the whole gap, not its first line.
6718    mod normalization_vectors {
6719        use super::*;
6720        use serde_json::Value;
6721
6722        const VECTORS: &str = include_str!("../tests/fixtures/parity/normalization.json");
6723
6724        fn cases(section: &str) -> Vec<Value> {
6725            let all: Value = serde_json::from_str(VECTORS).unwrap();
6726            all[section].as_array().unwrap().clone()
6727        }
6728
6729        fn field<'a>(case: &'a Value, key: &str) -> &'a str {
6730            case[key].as_str().unwrap()
6731        }
6732
6733        fn children(case: &Value) -> Vec<String> {
6734            case["children"]
6735                .as_array()
6736                .unwrap()
6737                .iter()
6738                .map(|v| v.as_str().unwrap().to_string())
6739                .collect()
6740        }
6741
6742        fn escape_html(text: &str) -> String {
6743            text.replace('&', "&amp;")
6744                .replace('<', "&lt;")
6745                .replace('>', "&gt;")
6746        }
6747
6748        fn escape_attr(text: &str) -> String {
6749            escape_html(text).replace('"', "&quot;")
6750        }
6751
6752        /// A DYN_TEXT range as the walker writes it: an empty value is held
6753        /// by a zero-width space so the browser creates a Text node.
6754        fn marker(id: u16, text: &str) -> String {
6755            let inner = if text.is_empty() {
6756                "\u{200B}".to_string()
6757            } else {
6758                escape_html(text)
6759            };
6760            format!("<!--f:t{id}-->{inner}<!--/f:t{id}-->")
6761        }
6762
6763        /// The text a browser would show for a marker-separated output: the
6764        /// marker comments and the empty-range zero-width spaces removed. No
6765        /// `children` vector contains a zero-width space of its own.
6766        fn visible(rendered: &str) -> String {
6767            let mut out = String::new();
6768            let mut rest = rendered;
6769            while let Some(start) = rest.find("<!--") {
6770                out.push_str(&rest[..start]);
6771                let end = rest[start..].find("-->").unwrap() + start + 3;
6772                rest = &rest[end..];
6773            }
6774            out.push_str(rest);
6775            out.replace('\u{200B}', "")
6776        }
6777
6778        fn check(failures: &mut Vec<String>, label: &str, actual: &str, expected: &str) {
6779            if actual != expected {
6780                failures.push(format!(
6781                    "{label}: expected {expected:?}, walker wrote {actual:?}"
6782                ));
6783            }
6784        }
6785
6786        fn finish(failures: Vec<String>) {
6787            assert!(
6788                failures.is_empty(),
6789                "{} disagreeing vector(s):\n{}",
6790                failures.len(),
6791                failures.join("\n")
6792            );
6793        }
6794
6795        #[test]
6796        fn text_and_dyn_text_write_each_value_normalized() {
6797            let mut failures = Vec::new();
6798            for case in cases("values") {
6799                let (id, input, expected) = (
6800                    field(&case, "id"),
6801                    field(&case, "input"),
6802                    field(&case, "expected"),
6803                );
6804                let mut page = Page::new();
6805                let slot = page.slot(text(input));
6806                page.open("p", &[])
6807                    .text(input)
6808                    .close("p")
6809                    .open("p", &[])
6810                    .dyn_text(slot)
6811                    .close("p");
6812                let want = format!(
6813                    "<p>{}</p><p>{}</p>",
6814                    escape_html(expected),
6815                    marker(slot, expected)
6816                );
6817                check(
6818                    &mut failures,
6819                    &format!("values/{id} text+dyn_text"),
6820                    &page.render(),
6821                    &want,
6822                );
6823            }
6824            finish(failures);
6825        }
6826
6827        #[test]
6828        fn static_and_dynamic_attributes_write_each_value_normalized() {
6829            let mut failures = Vec::new();
6830            for case in cases("values") {
6831                let (id, input, expected) = (
6832                    field(&case, "id"),
6833                    field(&case, "input"),
6834                    field(&case, "expected"),
6835                );
6836                let mut page = Page::new();
6837                let slot = page.slot(text(input));
6838                page.open("p", &[("title", input), ("id", "ok")])
6839                    .close("p")
6840                    .open("p", &[("id", "ok")])
6841                    .dyn_attr("title", slot)
6842                    .close("p");
6843                let e = escape_attr(expected);
6844                // A static empty value is written as the bare name (the
6845                // walker's existing byte-stable shorthand; same DOM value).
6846                let static_attr = if expected.is_empty() {
6847                    " title".to_string()
6848                } else {
6849                    format!(" title=\"{e}\"")
6850                };
6851                let want = format!("<p{static_attr} id=\"ok\"></p><p id=\"ok\" title=\"{e}\"></p>");
6852                check(
6853                    &mut failures,
6854                    &format!("values/{id} attr+dyn_attr"),
6855                    &page.render(),
6856                    &want,
6857                );
6858            }
6859            finish(failures);
6860        }
6861
6862        #[test]
6863        fn adjacent_values_normalize_each_before_composition() {
6864            // `['\r', '\n']` is two line feeds and `['\r\n']` is one: the rule
6865            // is per authored value, so a normalizer that buffered the output
6866            // stream would fold the first case and fail here. Three shapes of
6867            // the same children: adjacent TEXT, adjacent DYN_TEXT ranges, and
6868            // a static first value before dynamic ones.
6869            let mut failures = Vec::new();
6870            for case in cases("children") {
6871                let (id, expected) = (field(&case, "id"), field(&case, "expected"));
6872                let values = children(&case);
6873
6874                let mut page = Page::new();
6875                page.open("p", &[]);
6876                for value in &values {
6877                    page.text(value);
6878                }
6879                page.close("p");
6880                let want = format!("<p>{}</p>", escape_html(expected));
6881                check(
6882                    &mut failures,
6883                    &format!("children/{id} adjacent TEXT"),
6884                    &page.render(),
6885                    &want,
6886                );
6887
6888                let mut page = Page::new();
6889                let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
6890                page.open("p", &[]);
6891                for slot in &slots {
6892                    page.dyn_text(*slot);
6893                }
6894                page.close("p");
6895                check(
6896                    &mut failures,
6897                    &format!("children/{id} adjacent DYN_TEXT (visible text)"),
6898                    &visible(&page.render()),
6899                    &want,
6900                );
6901
6902                let mut page = Page::new();
6903                let slots: Vec<u16> = values[1..].iter().map(|v| page.slot(text(v))).collect();
6904                page.open("p", &[]).text(&values[0]);
6905                for slot in &slots {
6906                    page.dyn_text(*slot);
6907                }
6908                page.close("p");
6909                check(
6910                    &mut failures,
6911                    &format!("children/{id} TEXT then DYN_TEXT (visible text)"),
6912                    &visible(&page.render()),
6913                    &want,
6914                );
6915            }
6916            finish(failures);
6917        }
6918
6919        #[test]
6920        fn textarea_children_protect_only_an_initial_normalized_line_feed() {
6921            // The parser drops one LF straight after `<textarea>`, so the
6922            // writer doubles an initial LF. After normalization that is the
6923            // only initial break there is; an empty chunk does not consume
6924            // the boundary, and a separate `\r` then `\n` is two values.
6925            let mut failures = Vec::new();
6926            for case in cases("textarea") {
6927                let (id, html) = (field(&case, "id"), field(&case, "html"));
6928                let values = children(&case);
6929
6930                let mut page = Page::new();
6931                page.open("textarea", &[]);
6932                for value in &values {
6933                    page.text(value);
6934                }
6935                page.close("textarea");
6936                check(
6937                    &mut failures,
6938                    &format!("textarea/{id} static"),
6939                    &page.render(),
6940                    html,
6941                );
6942
6943                let mut page = Page::new();
6944                let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
6945                page.open("textarea", &[]);
6946                for slot in &slots {
6947                    page.dyn_text(*slot);
6948                }
6949                page.close("textarea");
6950                check(
6951                    &mut failures,
6952                    &format!("textarea/{id} dynamic"),
6953                    &page.render(),
6954                    html,
6955                );
6956            }
6957            finish(failures);
6958        }
6959
6960        #[test]
6961        fn url_attributes_are_checked_as_the_normalized_string_that_is_written() {
6962            // The URL policy classifies the raw value: a NUL anywhere in a URL
6963            // attribute refuses the write, a CR or CRLF inside a scheme is
6964            // refused as before, and an admitted value is written normalized.
6965            // The benign sibling attribute stays in both branches (P3).
6966            let mut failures = Vec::new();
6967            for case in cases("unsafeUrlNearMiss") {
6968                let (id, input) = (field(&case, "id"), field(&case, "input"));
6969                let attribute = case["attribute"].as_str().unwrap_or("href");
6970                let tag = case["tag"].as_str().unwrap_or("a");
6971                let refused = case["refused"].as_bool().unwrap();
6972                let mut page = Page::new();
6973                let slot = page.slot(text(input));
6974                page.open(tag, &[(attribute, input), ("id", "ok")])
6975                    .close(tag)
6976                    .open(tag, &[("id", "ok")])
6977                    .dyn_attr(attribute, slot)
6978                    .close(tag);
6979                let want = if refused {
6980                    format!("<{tag} id=\"ok\"></{tag}><{tag} id=\"ok\"></{tag}>")
6981                } else {
6982                    let written = escape_attr(field(&case, "written"));
6983                    format!(
6984                        "<{tag} {attribute}=\"{written}\" id=\"ok\"></{tag}>\
6985                         <{tag} id=\"ok\" {attribute}=\"{written}\"></{tag}>"
6986                    )
6987                };
6988                check(
6989                    &mut failures,
6990                    &format!("unsafeUrlNearMiss/{id}"),
6991                    &page.render(),
6992                    &want,
6993                );
6994            }
6995            finish(failures);
6996        }
6997
6998        #[test]
6999        fn pre_children_protect_only_an_initial_normalized_line_feed() {
7000            // `pre` and `listing` share the textarea rule, by output position:
7001            // the first content token starting with a normalized LF gets one
7002            // more. A DYN_TEXT writes its marker comment first, which the
7003            // parser treats as the first token, so the dynamic shape is never
7004            // doubled (`htmlDynamic`).
7005            let mut failures = Vec::new();
7006            for case in cases("pre") {
7007                let (id, html, dynamic) = (
7008                    field(&case, "id"),
7009                    field(&case, "html"),
7010                    field(&case, "htmlDynamic"),
7011                );
7012                let values = children(&case);
7013                for tag in ["pre", "listing", "PRE"] {
7014                    let mut page = Page::new();
7015                    page.open(tag, &[]);
7016                    for value in &values {
7017                        page.text(value);
7018                    }
7019                    page.close(tag);
7020                    let want = html
7021                        .replace("<pre>", &format!("<{tag}>"))
7022                        .replace("</pre>", &format!("</{tag}>"));
7023                    check(
7024                        &mut failures,
7025                        &format!("pre/{id} static <{tag}>"),
7026                        &page.render(),
7027                        &want,
7028                    );
7029                }
7030                let mut page = Page::new();
7031                let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
7032                page.open("pre", &[]);
7033                for slot in &slots {
7034                    page.dyn_text(*slot);
7035                }
7036                page.close("pre");
7037                check(
7038                    &mut failures,
7039                    &format!("pre/{id} dynamic"),
7040                    &page.render(),
7041                    dynamic,
7042                );
7043            }
7044            finish(failures);
7045        }
7046
7047        #[test]
7048        fn the_pre_boundary_is_consumed_by_any_token_but_not_by_a_value_only_opcode() {
7049            // A child element, a conditional's marker and the closing tag of
7050            // an inner element are tokens: the LF after them is not the first
7051            // content token, so it is written once. PROP writes nothing, so
7052            // the boundary survives it. A sibling `pre` starts its own.
7053            let mut page = Page::new();
7054            page.open("pre", &[])
7055                .open("b", &[])
7056                .close("b")
7057                .text("\nx")
7058                .close("pre");
7059            assert_eq!(page.render(), "<pre><b></b>\nx</pre>");
7060
7061            let mut page = Page::new();
7062            let shown = page.slot(SlotValue::Bool(true));
7063            page.open("pre", &[])
7064                .show_if(
7065                    shown,
7066                    |p| {
7067                        p.text("\nx");
7068                    },
7069                    |_| {},
7070                )
7071                .close("pre");
7072            let rendered = page.render();
7073            assert!(rendered.starts_with("<pre><!--f:s"), "{rendered:?}");
7074            assert!(rendered.contains("-->\nx<!--/f:s"), "{rendered:?}");
7075            assert!(!rendered.contains("\n\n"), "{rendered:?}");
7076
7077            let mut page = Page::new();
7078            let object = page.slot(SlotValue::object(vec![]));
7079            let target = page.slot(text(""));
7080            page.open("pre", &[])
7081                .prop(object, "name", target)
7082                .text("\nx")
7083                .close("pre")
7084                .open("pre", &[])
7085                .text("\ny")
7086                .close("pre")
7087                .open("p", &[])
7088                .text("\nz")
7089                .close("p");
7090            assert_eq!(page.render(), "<pre>\n\nx</pre><pre>\n\ny</pre><p>\nz</p>");
7091        }
7092
7093        #[test]
7094        fn the_output_budget_counts_the_normalized_and_escaped_bytes_actually_written() {
7095            // A top-level TEXT with no closing tag, so the budget ends inside
7096            // the text itself: exactly-at renders, one byte under refuses. The
7097            // ten-byte prefix forces the per-character slow path (worst-case
7098            // expansion no longer fits). Before this check a three-byte U+FFFD
7099            // or four-byte astral character was pushed when `out.len()` was
7100            // merely below the limit, overshooting it by up to three bytes.
7101            for (input, written) in [
7102                ("xxxxxxxxxx\0", "xxxxxxxxxx\u{FFFD}"),
7103                ("xxxxxxxxxx😀", "xxxxxxxxxx😀"),
7104                ("xxxxxxxxxx&", "xxxxxxxxxx&amp;"),
7105                ("xxxxxxxxxx\r\n", "xxxxxxxxxx\n"),
7106            ] {
7107                let mut page = Page::new();
7108                page.text(input);
7109                let (module, slots) = page.module_and_slots();
7110                let limits = |max_output_bytes| WalkLimits {
7111                    max_output_bytes,
7112                    ..WalkLimits::default()
7113                };
7114                let exact = written.len();
7115                assert_eq!(
7116                    walk_to_html_with_limits(&module, &slots, limits(exact)).as_deref(),
7117                    Ok(written),
7118                    "{input:?} at its exact budget"
7119                );
7120                assert_eq!(
7121                    walk_to_html_with_limits(&module, &slots, limits(exact - 1)),
7122                    Err(IrError::OutputLimitExceeded { limit: exact - 1 }),
7123                    "{input:?} one byte under its budget"
7124                );
7125            }
7126        }
7127    }
7128
7129    // FORMA-140: both languages read this same finite INV1 specification.
7130    mod inv1_vectors {
7131        use super::*;
7132        use serde_json::Value;
7133
7134        #[test]
7135        fn names_and_urls_agree_for_static_and_dynamic_attributes() {
7136            let vectors: Value =
7137                serde_json::from_str(include_str!("../tests/fixtures/parity/inv1.json")).unwrap();
7138            let mut failures = Vec::new();
7139            for (section, tag, value) in [("names", "div", "value"), ("urls", "", "")] {
7140                for case in vectors[section].as_array().unwrap() {
7141                    let name = case["name"].as_str().unwrap();
7142                    let tag = case["tag"].as_str().unwrap_or(tag);
7143                    let value = case["value"].as_str().unwrap_or(value);
7144                    let written = case["written"].as_bool().unwrap();
7145                    if section == "names" {
7146                        assert_eq!(
7147                            is_valid_attr_name(name),
7148                            case["valid"].as_bool().unwrap(),
7149                            "{}",
7150                            case["id"]
7151                        );
7152                    }
7153                    let mut escaped = String::new();
7154                    push_escaped_attr(&mut escaped, value, usize::MAX).unwrap();
7155                    let attribute = if written {
7156                        format!(" {name}=\"{escaped}\"")
7157                    } else {
7158                        String::new()
7159                    };
7160                    let want = format!("<{tag} id=\"ok\"{attribute}></{tag}>");
7161                    let mut static_page = Page::new();
7162                    static_page
7163                        .open(tag, &[("id", "ok"), (name, value)])
7164                        .close(tag);
7165                    let mut dynamic_page = Page::new();
7166                    let slot = dynamic_page.slot(SlotValue::Text(value.to_string()));
7167                    dynamic_page
7168                        .open(tag, &[("id", "ok")])
7169                        .dyn_attr(name, slot)
7170                        .close(tag);
7171                    for (writer, actual) in [
7172                        ("static", static_page.render()),
7173                        ("dynamic", dynamic_page.render()),
7174                    ] {
7175                        // Static OPEN_TAG uses the bare spelling for an empty
7176                        // value; DYN_ATTR uses ="". Both parse to the same DOM.
7177                        let expected = if writer == "static" && written && value.is_empty() {
7178                            format!("<{tag} id=\"ok\" {name}></{tag}>")
7179                        } else {
7180                            want.clone()
7181                        };
7182                        if actual != expected {
7183                            failures.push(format!(
7184                                "{} {writer}: {actual:?} != {expected:?}",
7185                                case["id"]
7186                            ));
7187                        }
7188                    }
7189                }
7190            }
7191            let names: Vec<_> = vectors["urlAttributes"]
7192                .as_array()
7193                .unwrap()
7194                .iter()
7195                .map(|v| v.as_str().unwrap())
7196                .collect();
7197            assert_eq!(names, URL_ATTRS);
7198            assert!(failures.is_empty(), "{}", failures.join("\n"));
7199        }
7200
7201        #[test]
7202        fn ordinary_payloads_are_escaped_in_both_output_contexts() {
7203            let vectors: Value =
7204                serde_json::from_str(include_str!("../tests/fixtures/parity/inv1.json")).unwrap();
7205            for payload in vectors["payloads"].as_array().unwrap() {
7206                let text = payload["text"].as_str().unwrap();
7207                let attr = payload["attribute"].as_str().unwrap();
7208                let payload = payload["value"].as_str().unwrap();
7209                let mut page = Page::new();
7210                page.open("p", &[("title", payload)])
7211                    .text(payload)
7212                    .close("p");
7213                let html = page.render();
7214                assert!(!html.contains("<script"));
7215                assert!(!html.contains("<img"));
7216                assert!(!html.contains("</textarea>"));
7217                assert_eq!(html, format!("<p title=\"{attr}\">{text}</p>"));
7218            }
7219        }
7220    }
7221
7222    // -- FORMA-016 F5: a conditional branch that is a scalar -----------------
7223    //
7224    // The shared vectors (`show-text-branch.json`) are read here and by
7225    // @getforma/core's suite, so the bytes the walker writes for a branch
7226    // that is a DYN_TEXT and the text core's createShow writes for a branch
7227    // that returns a string or a number cannot drift apart.
7228    mod show_text_branch_vectors {
7229        use super::*;
7230        use serde_json::Value;
7231
7232        const VECTORS: &str = include_str!("../tests/fixtures/parity/show-text-branch.json");
7233
7234        #[test]
7235        fn a_dyn_text_branch_is_written_between_the_conditional_markers() {
7236            let all: Value = serde_json::from_str(VECTORS).unwrap();
7237            let mut failures = Vec::new();
7238            let mut walked = 0;
7239            for case in all["values"].as_array().unwrap() {
7240                if case["clientOnly"].as_bool() == Some(true) {
7241                    continue;
7242                }
7243                walked += 1;
7244                let id = case["id"].as_str().unwrap();
7245                let mut page = Page::new();
7246                let shown = page.slot(SlotValue::Bool(true));
7247                let value = page.slot(crate::slot::json_to_slot_value(&case["value"]));
7248                page.show_if(
7249                    shown,
7250                    |p| {
7251                        p.dyn_text(value);
7252                    },
7253                    |_| {},
7254                );
7255                let want = format!("<!--f:s0-->{}<!--/f:s0-->", case["html"].as_str().unwrap());
7256                let got = page.render();
7257                if got != want {
7258                    failures.push(format!("{id}: expected {want:?}, walker wrote {got:?}"));
7259                }
7260            }
7261            assert!(walked >= 9, "the vectors were not read");
7262            assert!(failures.is_empty(), "{}", failures.join("\n"));
7263        }
7264    }
7265
7266    // -- FORMA-182 rules 2 to 7: the shared value-rule vectors ---------------
7267    //
7268    // One JSON file per rule under tests/fixtures/parity/, each read by this
7269    // crate and by @getforma/core's per-rule suites (packages/core/src/dom/
7270    // __tests__/value-rule-<n>-*.test.ts); the compiled shapes of rules 4, 6
7271    // and 7 are read by @getforma/compiler's (packages/compiler/tests/
7272    // value-rule-<n>-*.test.ts). Every case walks the real opcode stream.
7273    mod value_rule_vectors {
7274        use super::*;
7275        use serde_json::Value;
7276
7277        const NUMBERS: &str = include_str!("../tests/fixtures/parity/numbers.json");
7278        const INTEGERS: &str = include_str!("../tests/fixtures/parity/integers.json");
7279        const NULL: &str = include_str!("../tests/fixtures/parity/null.json");
7280        const BOOLEAN_TEXT: &str = include_str!("../tests/fixtures/parity/boolean-text.json");
7281        const CONDITIONS: &str = include_str!("../tests/fixtures/parity/conditions.json");
7282        const ATTRIBUTES: &str = include_str!("../tests/fixtures/parity/attributes.json");
7283
7284        fn cases(file: &str, section: &str) -> Vec<Value> {
7285            let all: Value = serde_json::from_str(file).unwrap();
7286            all[section].as_array().unwrap().clone()
7287        }
7288
7289        fn field<'a>(case: &'a Value, key: &str) -> &'a str {
7290            case[key].as_str().unwrap()
7291        }
7292
7293        /// A DYN_TEXT range as the walker writes it (an empty value is held
7294        /// by a zero-width space).
7295        fn marker(id: u16, text: &str) -> String {
7296            let inner = if text.is_empty() {
7297                "\u{200B}".to_string()
7298            } else {
7299                text.to_string()
7300            };
7301            format!("<!--f:t{id}-->{inner}<!--/f:t{id}-->")
7302        }
7303
7304        /// The text a browser shows: marker comments and the empty-range
7305        /// zero-width spaces removed.
7306        fn visible(rendered: &str) -> String {
7307            let mut out = String::new();
7308            let mut rest = rendered;
7309            while let Some(start) = rest.find("<!--") {
7310                out.push_str(&rest[..start]);
7311                let end = rest[start..].find("-->").unwrap() + start + 3;
7312                rest = &rest[end..];
7313            }
7314            out.push_str(rest);
7315            out.replace('\u{200B}', "")
7316        }
7317
7318        fn check(failures: &mut Vec<String>, label: &str, actual: &str, expected: &str) {
7319            if actual != expected {
7320                failures.push(format!(
7321                    "{label}: expected {expected:?}, walker wrote {actual:?}"
7322                ));
7323            }
7324        }
7325
7326        fn finish(failures: Vec<String>) {
7327            assert!(
7328                failures.is_empty(),
7329                "{} disagreeing vector(s):\n{}",
7330                failures.len(),
7331                failures.join("\n")
7332            );
7333        }
7334
7335        /// What a vector's JSON value reads as on the page: null is empty,
7336        /// a string itself, a number or boolean its JSON spelling.
7337        fn vector_text(value: &Value) -> String {
7338            match value {
7339                Value::Null => String::new(),
7340                Value::String(s) => s.clone(),
7341                other => other.to_string(),
7342            }
7343        }
7344
7345        /// A slot value from a vector's JSON value: a string, a number, a
7346        /// boolean or null (the shapes the vectors use).
7347        fn slot_value(value: &Value) -> SlotValue {
7348            match value {
7349                Value::Null => SlotValue::Null,
7350                Value::Bool(b) => SlotValue::Bool(*b),
7351                Value::Number(n) => SlotValue::Number(n.as_f64().unwrap()),
7352                Value::String(s) => SlotValue::Text(s.clone()),
7353                other => panic!("unsupported vector value {other}"),
7354            }
7355        }
7356
7357        #[test]
7358        fn rule_2_numbers_are_written_in_ecmascript_formatting_as_text_and_attribute() {
7359            let mut failures = Vec::new();
7360            for case in cases(NUMBERS, "values") {
7361                let (id, lexeme, expected) = (
7362                    field(&case, "id"),
7363                    field(&case, "lexeme"),
7364                    field(&case, "expected"),
7365                );
7366                let number: f64 = lexeme.parse().unwrap();
7367                let mut page = Page::new();
7368                let slot = page.slot(SlotValue::Number(number));
7369                page.open("p", &[]).dyn_text(slot).close("p");
7370                page.open("span", &[("id", "ok")])
7371                    .dyn_attr("title", slot)
7372                    .close("span");
7373                let want = format!(
7374                    "<p>{}</p><span id=\"ok\" title=\"{expected}\"></span>",
7375                    marker(slot, expected)
7376                );
7377                check(
7378                    &mut failures,
7379                    &format!("numbers/{id}"),
7380                    &page.render(),
7381                    &want,
7382                );
7383            }
7384            finish(failures);
7385        }
7386
7387        #[test]
7388        fn rule_3_an_admitted_exact_integer_is_written_as_its_decimal_digits() {
7389            // The admission itself is tests/page_props.rs (encode_props on
7390            // the integer origin). Here: an admitted value's text is exact,
7391            // and a float origin formats by rule 2 whatever its magnitude.
7392            let mut failures = Vec::new();
7393            for case in cases(INTEGERS, "exact") {
7394                if !case["admitted"].as_bool().unwrap() {
7395                    continue;
7396                }
7397                let (id, lexeme) = (field(&case, "id"), field(&case, "lexeme"));
7398                let number: f64 = lexeme.parse().unwrap();
7399                let mut page = Page::new();
7400                let slot = page.slot(SlotValue::Number(number));
7401                page.open("p", &[]).dyn_text(slot).close("p");
7402                let want = format!("<p>{}</p>", marker(slot, lexeme));
7403                check(
7404                    &mut failures,
7405                    &format!("integers/exact/{id}"),
7406                    &page.render(),
7407                    &want,
7408                );
7409            }
7410            for case in cases(INTEGERS, "floatOrigin") {
7411                let (id, lexeme, expected) = (
7412                    field(&case, "id"),
7413                    field(&case, "lexeme"),
7414                    field(&case, "expected"),
7415                );
7416                let number: f64 = lexeme.parse().unwrap();
7417                let mut page = Page::new();
7418                let slot = page.slot(SlotValue::Number(number));
7419                page.open("p", &[]).dyn_text(slot).close("p");
7420                let want = format!("<p>{}</p>", marker(slot, expected));
7421                check(
7422                    &mut failures,
7423                    &format!("integers/floatOrigin/{id}"),
7424                    &page.render(),
7425                    &want,
7426                );
7427            }
7428            finish(failures);
7429        }
7430
7431        #[test]
7432        fn rule_5_null_writes_nothing_as_text_attribute_and_textarea_child() {
7433            let mut failures = Vec::new();
7434            for case in cases(NULL, "children") {
7435                let (id, expected) = (field(&case, "id"), field(&case, "expected"));
7436                let values = case["children"].as_array().unwrap();
7437
7438                // Dynamic children: each value in its own range; a Null range
7439                // is held by the zero-width space and shows nothing.
7440                let mut page = Page::new();
7441                let slots: Vec<u16> = values.iter().map(|v| page.slot(slot_value(v))).collect();
7442                page.open("p", &[]);
7443                for slot in &slots {
7444                    page.dyn_text(*slot);
7445                }
7446                page.close("p");
7447                let rendered = page.render();
7448                check(
7449                    &mut failures,
7450                    &format!("null/children/{id} dynamic (visible text)"),
7451                    &visible(&rendered),
7452                    &format!("<p>{expected}</p>"),
7453                );
7454                // The exact ranges, which the three-engine replay
7455                // (packages/core/e2e/value-rules.spec.ts) serves: a Null range
7456                // holds only the zero-width space.
7457                let ranges: String = slots
7458                    .iter()
7459                    .zip(values)
7460                    .map(|(slot, value)| marker(*slot, &vector_text(value)))
7461                    .collect();
7462                check(
7463                    &mut failures,
7464                    &format!("null/children/{id} dynamic"),
7465                    &rendered,
7466                    &format!("<p>{ranges}</p>"),
7467                );
7468                if rendered.contains("null") {
7469                    failures.push(format!(
7470                        "null/children/{id}: the word null was written: {rendered:?}"
7471                    ));
7472                }
7473
7474                // Inside a textarea the same children compose to the value.
7475                let mut page = Page::new();
7476                let slots: Vec<u16> = values.iter().map(|v| page.slot(slot_value(v))).collect();
7477                page.open("textarea", &[]);
7478                for slot in &slots {
7479                    page.dyn_text(*slot);
7480                }
7481                page.close("textarea");
7482                check(
7483                    &mut failures,
7484                    &format!("null/children/{id} textarea"),
7485                    &page.render(),
7486                    &format!("<textarea>{expected}</textarea>"),
7487                );
7488            }
7489            for case in cases(NULL, "attribute") {
7490                let (id, attribute) = (field(&case, "id"), field(&case, "attribute"));
7491                let mut page = Page::new();
7492                let slot = page.slot(SlotValue::Null);
7493                page.open("span", &[("id", "ok")])
7494                    .dyn_attr(attribute, slot)
7495                    .close("span");
7496                check(
7497                    &mut failures,
7498                    &format!("null/attribute/{id}"),
7499                    &page.render(),
7500                    "<span id=\"ok\"></span>",
7501                );
7502            }
7503            finish(failures);
7504        }
7505
7506        #[test]
7507        fn rule_6_the_string_near_miss_writes_the_boolean_word_and_the_idiom_its_range() {
7508            // The build error itself is the compiler's (BUILD-614). What the
7509            // walker writes for the admitted `String(flag)` is DYN_TEXT on the
7510            // boolean slot. An attribute bound to a boolean follows rule 6's
7511            // attribute table, read by rule_6_attribute_values_follow_one_table.
7512            let mut failures = Vec::new();
7513            for case in cases(BOOLEAN_TEXT, "nearMissText") {
7514                let (id, expected) = (field(&case, "id"), field(&case, "expected"));
7515                let value = case["value"].as_bool().unwrap();
7516                let mut page = Page::new();
7517                let slot = page.slot(SlotValue::Bool(value));
7518                page.open("p", &[]).dyn_text(slot).close("p");
7519                check(
7520                    &mut failures,
7521                    &format!("boolean-text/nearMissText/{id}"),
7522                    &page.render(),
7523                    &format!("<p>{}</p>", marker(slot, expected)),
7524                );
7525            }
7526            // The mixed idiom `() => ready() && 'Go'`: the compiler gives its
7527            // Text slot no default at false and the string at true.
7528            for case in cases(BOOLEAN_TEXT, "idiom") {
7529                let (id, expected) = (field(&case, "id"), field(&case, "expected"));
7530                let mut page = Page::new();
7531                let slot = page.slot(if expected.is_empty() {
7532                    SlotValue::Null
7533                } else {
7534                    SlotValue::Text(expected.to_string())
7535                });
7536                page.open("p", &[]).dyn_text(slot).close("p");
7537                check(
7538                    &mut failures,
7539                    &format!("boolean-text/idiom/{id}"),
7540                    &page.render(),
7541                    &format!("<p>{}</p>", marker(slot, expected)),
7542                );
7543            }
7544            finish(failures);
7545        }
7546
7547        #[test]
7548        fn rule_6_attribute_values_follow_one_table() {
7549            // Findings F2 and F3: presence only for the HTML boolean set; any
7550            // other attribute is the value's string, `true` the word and `""`
7551            // a value; false and Null omit it.
7552            let mut failures = Vec::new();
7553            for case in cases(ATTRIBUTES, "values") {
7554                let (id, attribute, html) = (
7555                    field(&case, "id"),
7556                    field(&case, "attribute"),
7557                    field(&case, "html"),
7558                );
7559                let mut page = Page::new();
7560                let slot = page.slot(slot_value(&case["value"]));
7561                page.open("input", &[("id", "ok")])
7562                    .dyn_attr(attribute, slot)
7563                    .close("input");
7564                check(
7565                    &mut failures,
7566                    &format!("attributes/values/{id}"),
7567                    &page.render(),
7568                    &format!("<input id=\"ok\"{html}></input>"),
7569                );
7570            }
7571            // The walker's boolean set is the vector's: each member is bare for
7572            // true, and a name beside each (its `data-` twin) is the word.
7573            for name in cases(ATTRIBUTES, "booleanAttributes") {
7574                let name = name.as_str().unwrap();
7575                for (attribute, want) in [
7576                    (name.to_string(), format!(" {name}")),
7577                    (format!("data-{name}"), format!(" data-{name}=\"true\"")),
7578                ] {
7579                    let mut page = Page::new();
7580                    let slot = page.slot(SlotValue::Bool(true));
7581                    page.open("input", &[("id", "ok")])
7582                        .dyn_attr(&attribute, slot)
7583                        .close("input");
7584                    check(
7585                        &mut failures,
7586                        &format!("attributes/booleanAttributes/{attribute}"),
7587                        &page.render(),
7588                        &format!("<input id=\"ok\"{want}></input>"),
7589                    );
7590                }
7591            }
7592            finish(failures);
7593        }
7594
7595        #[test]
7596        fn rule_7_a_boolean_condition_selects_the_branch() {
7597            // The type refusal itself is the compiler's (BUILD-615); a
7598            // compiled SHOW_IF reads a Bool slot, and its range is what core's
7599            // suite and the three-engine replay adopt.
7600            let mut failures = Vec::new();
7601            for case in cases(CONDITIONS, "walker") {
7602                let (id, branch) = (field(&case, "id"), field(&case, "branch"));
7603                let value = case["value"].as_bool().unwrap();
7604                let mut page = Page::new();
7605                let slot = page.slot(SlotValue::Bool(value));
7606                page.open("div", &[])
7607                    .show_if(
7608                        slot,
7609                        |p| {
7610                            p.open("b", &[]).text("then").close("b");
7611                        },
7612                        |p| {
7613                            p.open("i", &[]).text("else").close("i");
7614                        },
7615                    )
7616                    .close("div");
7617                let inner = if branch == "then" {
7618                    "<b>then</b>"
7619                } else {
7620                    "<i>else</i>"
7621                };
7622                check(
7623                    &mut failures,
7624                    &format!("conditions/walker/{id}"),
7625                    &page.render(),
7626                    &format!("<div><!--f:s{slot}-->{inner}<!--/f:s{slot}--></div>"),
7627                );
7628            }
7629            finish(failures);
7630        }
7631    }
7632
7633    /// FORMA-181 checkpoint B: keyed rows. Hand-built modules with a real FMIR section
7634    /// `0x0006`, so the walker's own refusals (RENDER-503) are reached without the
7635    /// compiler's source proof in front of them, and the unkeyed low-level form is pinned
7636    /// beside each keyed case.
7637    mod keyed_rows {
7638        use super::*;
7639        use crate::format::{ListKeyReason, ListKeyViolation};
7640        use crate::parser::test_helpers::with_key_paths;
7641
7642        const STRINGS: &[&str] = &[
7643            "rows",           // 0
7644            "row",            // 1
7645            "li",             // 2
7646            "id",             // 3
7647            "span",           // 4
7648            "label",          // 5
7649            "input",          // 6
7650            "type",           // 7
7651            "checkbox",       // 8
7652            "data-forma-key", // 9
7653            "authored",       // 10
7654            "tags",           // 11
7655            "b",              // 12
7656            "Child",          // 13
7657            "ul",             // 14
7658            "DATA-FORMA-KEY", // 15
7659            "text",           // 16
7660            "user",           // 17
7661            "tag",            // 18
7662        ];
7663        const LI: u32 = 2;
7664        const ID: u32 = 3;
7665        const SPAN: u32 = 4;
7666        const INPUT: u32 = 6;
7667        const TYPE: u32 = 7;
7668        const CHECKBOX: u32 = 8;
7669        const KEY_ATTR: u32 = 9;
7670        const AUTHORED: u32 = 10;
7671        const TAGS: u32 = 11;
7672        const B: u32 = 12;
7673        const CHILD: u32 = 13;
7674        const UL: u32 = 14;
7675        const KEY_ATTR_UPPER: u32 = 15;
7676        const TEXT: u32 = 16;
7677        const USER: u32 = 17;
7678        /// rows (0), row (1), tags (2, read from the row), tag (3), label (4). The row
7679        /// slots are client-sourced so the declared-fixture builder leaves them out of the
7680        /// page schema it writes for the island cases (it encodes scalars only).
7681        const SLOTS: &[(u16, u32, u8, u8, &[u8])] = &[
7682            (0, 0, 0x04, 0x00, &[]),
7683            (1, 1, 0x05, 0x01, &[]),
7684            (2, 11, 0x04, 0x01, &[]),
7685            (3, 18, 0x05, 0x01, &[]),
7686            (4, 5, 0x01, 0x00, &[]),
7687        ];
7688        /// A LIST's header: opcode, array slot, item slot, body length.
7689        const LIST_HEADER: usize = 9;
7690
7691        fn keyed(opcodes: &[u8], paths: &[(u32, &[u32])]) -> IrModule {
7692            let base = build_declared_fixture(STRINGS, SLOTS, opcodes, &[]);
7693            IrModule::parse(&with_key_paths(&base, paths)).unwrap()
7694        }
7695
7696        #[test]
7697        fn static_mode_omits_row_keys_but_keeps_key_and_shape_refusals() {
7698            let body = [
7699                encode_open_tag(LI, &[]),
7700                encode_dyn_text(4, 4),
7701                encode_close_tag(LI),
7702            ]
7703            .concat();
7704            let module = keyed(&encode_list(0, 1, &body), &[(0, &[ID])]);
7705            let data = rows(vec![id(num(1.0)), id(text("two"))]);
7706            let output = walk_to_html_counted_with_mode(
7707                &module,
7708                &data,
7709                WalkLimits::default(),
7710                RenderMode::Static,
7711            )
7712            .unwrap();
7713            assert_eq!(output.html, "<li>x</li><li>x</li>");
7714            for data in [
7715                rows(vec![id(num(1.0)), id(text("1"))]),
7716                rows(vec![id(SlotValue::Null)]),
7717            ] {
7718                let expected = walk_to_html_counted(&module, &data, WalkLimits::default())
7719                    .unwrap_err()
7720                    .error;
7721                let actual = walk_to_html_counted_with_mode(
7722                    &module,
7723                    &data,
7724                    WalkLimits::default(),
7725                    RenderMode::Static,
7726                )
7727                .unwrap_err()
7728                .error;
7729                assert_eq!(actual, expected);
7730            }
7731            let invalid = keyed(&encode_list(0, 1, &encode_dyn_text(4, 4)), &[(0, &[ID])]);
7732            let expected = walk_to_html_counted(&invalid, &data, WalkLimits::default())
7733                .unwrap_err()
7734                .error;
7735            let actual = walk_to_html_counted_with_mode(
7736                &invalid,
7737                &data,
7738                WalkLimits::default(),
7739                RenderMode::Static,
7740            )
7741            .unwrap_err()
7742            .error;
7743            assert_eq!(actual, expected);
7744        }
7745
7746        /// With an island table the module's typed flow is checked, and the fixture
7747        /// builder declares `rows` as `string[]`, so the row slot is a string slot. The
7748        /// walker binds whatever rows the data holds; the object rows below carry the key.
7749        fn keyed_with_islands(
7750            opcodes: &[u8],
7751            paths: &[(u32, &[u32])],
7752            islands: &[(u16, u8, u8, u32, u32, &[u16])],
7753        ) -> IrModule {
7754            const ISLAND_SLOTS: &[(u16, u32, u8, u8, &[u8])] =
7755                &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x01, &[])];
7756            let base = build_declared_fixture(STRINGS, ISLAND_SLOTS, opcodes, islands);
7757            IrModule::parse(&with_key_paths(&base, paths)).unwrap()
7758        }
7759
7760        fn unkeyed(opcodes: &[u8]) -> IrModule {
7761            IrModule::parse(&build_declared_fixture(STRINGS, SLOTS, opcodes, &[])).unwrap()
7762        }
7763
7764        fn rows(values: Vec<SlotValue>) -> SlotData {
7765            let mut slots = SlotData::new(5);
7766            slots.set(0, SlotValue::array(values));
7767            slots.set(4, SlotValue::Text("x".into()));
7768            slots
7769        }
7770
7771        fn object(pairs: Vec<(&str, SlotValue)>) -> SlotValue {
7772            SlotValue::object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
7773        }
7774
7775        fn id(value: SlotValue) -> SlotValue {
7776            object(vec![("id", value), ("name", text("secret"))])
7777        }
7778
7779        fn text(value: &str) -> SlotValue {
7780            SlotValue::Text(value.into())
7781        }
7782
7783        fn num(value: f64) -> SlotValue {
7784            SlotValue::Number(value)
7785        }
7786
7787        fn refused(module: &IrModule, slots: &SlotData) -> ListKeyViolation {
7788            match walk_to_html(module, slots) {
7789                Err(IrError::InvalidListKey(violation)) => {
7790                    let message = IrError::InvalidListKey(violation.clone()).to_string();
7791                    assert!(message.starts_with("RENDER-503: LIST at bytecode offset "));
7792                    // A diagnostic names positions and the path, never row data.
7793                    assert!(!message.contains("secret"), "{message}");
7794                    *violation
7795                }
7796                other => panic!("expected RENDER-503, got {other:?}"),
7797            }
7798        }
7799
7800        fn violation(
7801            list_offset: u32,
7802            row: usize,
7803            path: &str,
7804            reason: ListKeyReason,
7805        ) -> ListKeyViolation {
7806            ListKeyViolation {
7807                list_offset,
7808                row,
7809                path: path.into(),
7810                reason,
7811            }
7812        }
7813
7814        /// `<li><span></span></li>`: the key belongs on the `li`.
7815        fn li_body() -> Vec<u8> {
7816            [
7817                encode_open_tag(LI, &[]),
7818                encode_open_tag(SPAN, &[]),
7819                encode_close_tag(SPAN),
7820                encode_close_tag(LI),
7821            ]
7822            .concat()
7823        }
7824
7825        #[test]
7826        fn a_keyed_list_writes_each_rows_canonical_key_once_on_its_root() {
7827            let ops = encode_list(0, 1, &li_body());
7828            let data = rows(vec![
7829                id(num(1.0)),
7830                id(text("a&\"<b>'")),
7831                id(num(-0.0)),
7832                id(text("x\r\ny\0")),
7833                id(num(-9_007_199_254_740_991.0)),
7834                id(text("")),
7835            ]);
7836            let html = walk_to_html(&keyed(&ops, &[(0, &[ID])]), &data).unwrap();
7837            assert_eq!(
7838                html,
7839                concat!(
7840                    "<!--f:l0-->",
7841                    "<li data-forma-key=\"1\"><span></span></li>",
7842                    "<li data-forma-key=\"a&amp;&quot;&lt;b&gt;'\"><span></span></li>",
7843                    "<li data-forma-key=\"0\"><span></span></li>",
7844                    "<li data-forma-key=\"x\ny\u{FFFD}\"><span></span></li>",
7845                    "<li data-forma-key=\"-9007199254740991\"><span></span></li>",
7846                    "<li data-forma-key=\"\"><span></span></li>",
7847                    "<!--/f:l0-->"
7848                )
7849            );
7850        }
7851
7852        #[test]
7853        fn an_unkeyed_list_renders_byte_identically_with_no_key_and_no_key_rules() {
7854            let ops = encode_list(0, 1, &li_body());
7855            let data = rows(vec![id(num(1.0)), id(text("a"))]);
7856            let keyed_out =
7857                walk_to_html_counted(&keyed(&ops, &[(0, &[ID])]), &data, WalkLimits::default())
7858                    .unwrap();
7859            let plain = walk_to_html_counted(&unkeyed(&ops), &data, WalkLimits::default()).unwrap();
7860            assert_eq!(
7861                plain.html,
7862                "<!--f:l0--><li><span></span></li><li><span></span></li><!--/f:l0-->"
7863            );
7864            assert_eq!(
7865                keyed_out
7866                    .html
7867                    .replace(" data-forma-key=\"1\"", "")
7868                    .replace(" data-forma-key=\"a\"", ""),
7869                plain.html
7870            );
7871            assert_eq!(
7872                keyed_out.counts.list_iterations,
7873                plain.counts.list_iterations
7874            );
7875            assert_eq!(keyed_out.counts.nodes, plain.counts.nodes);
7876            // Without section 6 none of the key rules apply: scalar, missing and repeated
7877            // values, several roots, text beside a root and an authored attribute all render
7878            // as the low-level reader always did.
7879            let anything = rows(vec![text("a"), text("a"), SlotValue::Null, object(vec![])]);
7880            assert_eq!(
7881                walk_to_html(&unkeyed(&ops), &anything).unwrap(),
7882                format!(
7883                    "<!--f:l0-->{}<!--/f:l0-->",
7884                    "<li><span></span></li>".repeat(4)
7885                )
7886            );
7887            let loose = [
7888                encode_open_tag(LI, &[(KEY_ATTR, AUTHORED)]),
7889                encode_close_tag(LI),
7890                encode_text(TEXT),
7891                encode_void_tag(INPUT, &[]),
7892            ]
7893            .concat();
7894            assert_eq!(
7895                walk_to_html(&unkeyed(&encode_list(0, 1, &loose)), &rows(vec![text("a")])).unwrap(),
7896                "<!--f:l0--><li data-forma-key=\"authored\"></li>text<input><!--/f:l0-->"
7897            );
7898        }
7899
7900        #[test]
7901        fn a_void_root_carries_the_key_and_admits_nothing_beside_it() {
7902            let void = encode_void_tag(INPUT, &[(TYPE, CHECKBOX)]);
7903            let html = walk_to_html(
7904                &keyed(&encode_list(0, 1, &void), &[(0, &[ID])]),
7905                &rows(vec![id(num(7.0)), id(num(8.0))]),
7906            )
7907            .unwrap();
7908            assert_eq!(
7909                html,
7910                "<!--f:l0--><input type=\"checkbox\" data-forma-key=\"7\"><input type=\"checkbox\" data-forma-key=\"8\"><!--/f:l0-->"
7911            );
7912            let beside = [void.clone(), encode_text(TEXT)].concat();
7913            assert_eq!(
7914                refused(
7915                    &keyed(&encode_list(0, 1, &beside), &[(0, &[ID])]),
7916                    &rows(vec![id(num(7.0))])
7917                ),
7918                violation(
7919                    0,
7920                    0,
7921                    "id",
7922                    ListKeyReason::RowShape {
7923                        roots: 1,
7924                        stray_content: true
7925                    }
7926                )
7927            );
7928        }
7929
7930        /// A registered child as the row: `<!--f:iN-->` wholly encloses the host, which is
7931        /// the row's root and carries both the island attributes and the key.
7932        #[test]
7933        fn a_registered_child_envelope_enclosing_the_root_puts_the_key_on_the_host() {
7934            let body = [
7935                encode_island_start(0),
7936                encode_open_tag(B, &[]),
7937                encode_close_tag(B),
7938                encode_island_end(0),
7939            ]
7940            .concat();
7941            let ops = encode_list(0, 1, &body);
7942            let island = (0, 0x01, 0x01, CHILD, LIST_HEADER as u32, &[][..]);
7943            let module = keyed_with_islands(&ops, &[(0, &[ID])], &[island]);
7944            let html = walk_to_html(&module, &rows(vec![id(num(1.0)), id(num(2.0))])).unwrap();
7945            let host = |occurrence: usize, key: &str| {
7946                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-->")
7947            };
7948            assert_eq!(
7949                html,
7950                format!("<!--f:l0-->{}{}<!--/f:l0-->", host(0, "1"), host(1, "2"))
7951            );
7952        }
7953
7954        #[test]
7955        fn an_island_envelope_that_does_not_wholly_enclose_the_root_refuses() {
7956            // (body, ISLAND_START's offset inside the body, roots counted)
7957            let empty_island_then_root = [
7958                encode_island_start(0),
7959                encode_island_end(0),
7960                encode_open_tag(LI, &[]),
7961                encode_close_tag(LI),
7962            ]
7963            .concat();
7964            let root_then_child = [
7965                encode_open_tag(LI, &[]),
7966                encode_close_tag(LI),
7967                encode_island_start(0),
7968                encode_open_tag(B, &[]),
7969                encode_close_tag(B),
7970                encode_island_end(0),
7971            ]
7972            .concat();
7973            let cases: [(&[u8], usize, usize); 2] = [
7974                (&empty_island_then_root, 0, 1),
7975                (
7976                    &root_then_child,
7977                    encode_open_tag(LI, &[]).len() + encode_close_tag(LI).len(),
7978                    2,
7979                ),
7980            ];
7981            for (body, at, roots) in cases {
7982                let island = (0, 0x01, 0x01, CHILD, (LIST_HEADER + at) as u32, &[][..]);
7983                let module = keyed_with_islands(&encode_list(0, 1, body), &[(0, &[ID])], &[island]);
7984                assert_eq!(
7985                    refused(&module, &rows(vec![id(num(1.0))])),
7986                    violation(
7987                        0,
7988                        0,
7989                        "id",
7990                        ListKeyReason::RowShape {
7991                            roots,
7992                            stray_content: true
7993                        }
7994                    )
7995                );
7996            }
7997        }
7998
7999        /// `<li>` PROP(row.tags) LIST(tags){ `<b></b>` } `</li>`, both lists keyed by `id`.
8000        fn nested() -> (Vec<u8>, u32) {
8001            let inner = encode_list(
8002                2,
8003                3,
8004                &[encode_open_tag(B, &[]), encode_close_tag(B)].concat(),
8005            );
8006            let outer_body = [
8007                encode_open_tag(LI, &[]),
8008                encode_prop(1, TAGS, 2),
8009                inner,
8010                encode_close_tag(LI),
8011            ]
8012            .concat();
8013            let inner_offset =
8014                LIST_HEADER + encode_open_tag(LI, &[]).len() + encode_prop(1, TAGS, 2).len();
8015            (encode_list(0, 1, &outer_body), inner_offset as u32)
8016        }
8017
8018        fn group(key: f64, tags: Vec<SlotValue>) -> SlotValue {
8019            object(vec![("id", num(key)), ("tags", SlotValue::array(tags))])
8020        }
8021
8022        #[test]
8023        fn each_execution_of_a_list_is_its_own_duplicate_scope() {
8024            let (ops, inner) = nested();
8025            let module = keyed(&ops, &[(0, &[ID]), (inner, &[ID])]);
8026            // The inner LIST runs once per outer row: the same keys under two outer rows,
8027            // and an inner key equal to its outer row's key, are not duplicates.
8028            let data = rows(vec![
8029                group(1.0, vec![id(text("a")), id(num(1.0))]),
8030                group(2.0, vec![id(text("a"))]),
8031            ]);
8032            assert_eq!(
8033                walk_to_html(&module, &data).unwrap(),
8034                concat!(
8035                    "<!--f:l0-->",
8036                    "<li data-forma-key=\"1\"><!--f:l2--><b data-forma-key=\"a\"></b><b data-forma-key=\"1\"></b><!--/f:l2--></li>",
8037                    "<li data-forma-key=\"2\"><!--f:l2--><b data-forma-key=\"a\"></b><!--/f:l2--></li>",
8038                    "<!--/f:l0-->"
8039                )
8040            );
8041            // Within one execution they are: the inner LIST's offset, its row, the path.
8042            let duplicate = rows(vec![
8043                group(1.0, vec![id(text("a"))]),
8044                group(2.0, vec![id(text("b")), id(text("a")), id(text("b"))]),
8045            ]);
8046            assert_eq!(
8047                refused(&module, &duplicate),
8048                violation(inner, 2, "id", ListKeyReason::Duplicate { first_row: 0 })
8049            );
8050            // Two sibling LISTs over the same rows are two executions too.
8051            let twice = [encode_list(0, 1, &li_body()), encode_list(0, 1, &li_body())].concat();
8052            let second = encode_list(0, 1, &li_body()).len() as u32;
8053            let html = walk_to_html(
8054                &keyed(&twice, &[(0, &[ID]), (second, &[ID])]),
8055                &rows(vec![id(num(1.0)), id(num(2.0))]),
8056            )
8057            .unwrap();
8058            assert_eq!(html.matches("data-forma-key=\"1\"").count(), 2, "{html}");
8059            assert_eq!(html.matches("data-forma-key=\"2\"").count(), 2, "{html}");
8060        }
8061
8062        #[test]
8063        fn keys_collide_on_their_canonical_text_not_on_their_type_or_spelling() {
8064            let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8065            let collide = [
8066                (num(1.0), text("1")),
8067                (num(0.0), num(-0.0)),
8068                (text("0"), num(-0.0)),
8069                (text("a\r\nb"), text("a\nb")),
8070                (text("a\rb"), text("a\nb")),
8071                (text("\0"), text("\u{FFFD}")),
8072            ];
8073            for (first, second) in collide {
8074                let label = format!("{first:?} {second:?}");
8075                assert_eq!(
8076                    refused(&module, &rows(vec![id(first), id(second)])),
8077                    violation(0, 1, "id", ListKeyReason::Duplicate { first_row: 0 }),
8078                    "{label}"
8079                );
8080            }
8081            let distinct = [
8082                (num(1.0), text("01")),
8083                (text("A"), text("a")),
8084                (text("a"), text("a ")),
8085                (text("\u{e9}"), text("e\u{301}")),
8086                (text("-0"), num(0.0)),
8087            ];
8088            for (first, second) in distinct {
8089                let label = format!("{first:?} {second:?}");
8090                assert!(
8091                    walk_to_html(&module, &rows(vec![id(first), id(second)])).is_ok(),
8092                    "{label}"
8093                );
8094            }
8095        }
8096
8097        #[test]
8098        fn a_row_without_an_identity_at_the_path_refuses_with_its_reason() {
8099            let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8100            let not_identity = |kind| ListKeyReason::NotAnIdentity { kind };
8101            let twice = SlotValue::object(vec![("id".into(), num(1.0)), ("id".into(), num(2.0))]);
8102            let cases = [
8103                (
8104                    object(vec![("name", text("secret"))]),
8105                    ListKeyReason::MissingField {
8106                        segment: "id".into(),
8107                    },
8108                ),
8109                (id(SlotValue::Null), not_identity("null")),
8110                (id(SlotValue::Bool(true)), not_identity("a boolean")),
8111                (
8112                    id(object(vec![("secret", num(1.0))])),
8113                    not_identity("an object"),
8114                ),
8115                (
8116                    id(SlotValue::array(vec![text("secret")])),
8117                    not_identity("an array"),
8118                ),
8119                (id(num(1.5)), ListKeyReason::NotASafeInteger),
8120                (id(num(f64::NAN)), ListKeyReason::NotASafeInteger),
8121                (id(num(f64::INFINITY)), ListKeyReason::NotASafeInteger),
8122                (id(num(f64::NEG_INFINITY)), ListKeyReason::NotASafeInteger),
8123                (
8124                    id(num(9_007_199_254_740_992.0)),
8125                    ListKeyReason::NotASafeInteger,
8126                ),
8127                (
8128                    id(num(-9_007_199_254_740_992.0)),
8129                    ListKeyReason::NotASafeInteger,
8130                ),
8131                (
8132                    text("secret"),
8133                    ListKeyReason::NotAnObject {
8134                        segment: "id".into(),
8135                    },
8136                ),
8137                (
8138                    twice,
8139                    ListKeyReason::DuplicateField {
8140                        segment: "id".into(),
8141                    },
8142                ),
8143            ];
8144            for (bad, reason) in cases {
8145                // The refused row is the second: the first rendered nothing the page keeps.
8146                assert_eq!(
8147                    refused(&module, &rows(vec![id(num(1.0)), bad])),
8148                    violation(0, 1, "id", reason)
8149                );
8150            }
8151            // A nested path reads objects only, and names itself dotted.
8152            let nested = keyed(&encode_list(0, 1, &li_body()), &[(0, &[USER, ID])]);
8153            let user = |value| object(vec![("user", value)]);
8154            assert_eq!(
8155                walk_to_html(&nested, &rows(vec![user(id(text("u1")))])).unwrap(),
8156                "<!--f:l0--><li data-forma-key=\"u1\"><span></span></li><!--/f:l0-->"
8157            );
8158            for value in [
8159                text("u1"),
8160                SlotValue::Null,
8161                SlotValue::array(vec![id(num(1.0))]),
8162            ] {
8163                assert_eq!(
8164                    refused(&nested, &rows(vec![user(value)])),
8165                    violation(
8166                        0,
8167                        0,
8168                        "user.id",
8169                        ListKeyReason::NotAnObject {
8170                            segment: "id".into()
8171                        }
8172                    )
8173                );
8174            }
8175        }
8176
8177        #[test]
8178        fn a_keyed_row_renders_exactly_one_balanced_root_and_nothing_beside_it() {
8179            let shape = |roots, stray_content| ListKeyReason::RowShape {
8180                roots,
8181                stray_content,
8182            };
8183            let li = [encode_open_tag(LI, &[]), encode_close_tag(LI)].concat();
8184            let cases: Vec<(&str, Vec<u8>, ListKeyReason)> = vec![
8185                ("empty row", Vec::new(), shape(0, false)),
8186                (
8187                    "two roots",
8188                    [li.clone(), li.clone()].concat(),
8189                    shape(2, false),
8190                ),
8191                (
8192                    "text beside",
8193                    [li.clone(), encode_text(TEXT)].concat(),
8194                    shape(1, true),
8195                ),
8196                ("text only", encode_text(TEXT), shape(0, true)),
8197                ("dynamic text only", encode_dyn_text(4, 0), shape(0, true)),
8198                (
8199                    "conditional root",
8200                    encode_show_if(4, &li, &li),
8201                    shape(1, true),
8202                ),
8203                (
8204                    // Its one row's `li` is at the outer row's level too, so it counts as
8205                    // the outer row's root; the range around it is the refusal.
8206                    "list at the row's level",
8207                    encode_list(2, 3, &li),
8208                    shape(1, true),
8209                ),
8210                (
8211                    "closes the list's parent",
8212                    encode_close_tag(UL),
8213                    ListKeyReason::UnbalancedRow,
8214                ),
8215                (
8216                    "closes the parent after its root",
8217                    [li.clone(), encode_close_tag(UL), encode_open_tag(UL, &[])].concat(),
8218                    ListKeyReason::UnbalancedRow,
8219                ),
8220                (
8221                    "leaves its root open",
8222                    encode_open_tag(LI, &[]),
8223                    ListKeyReason::UnbalancedRow,
8224                ),
8225            ];
8226            for (name, body, reason) in cases {
8227                let ops = [
8228                    encode_open_tag(UL, &[]),
8229                    encode_list(0, 1, &body),
8230                    encode_close_tag(UL),
8231                ]
8232                .concat();
8233                let offset = encode_open_tag(UL, &[]).len() as u32;
8234                let module = keyed(&ops, &[(offset, &[ID])]);
8235                let mut data = rows(vec![id(num(1.0))]);
8236                data.set(2, SlotValue::array(vec![text("t")]));
8237                assert_eq!(
8238                    refused(&module, &data),
8239                    violation(offset, 0, "id", reason),
8240                    "{name}"
8241                );
8242            }
8243        }
8244
8245        #[test]
8246        fn an_authored_data_forma_key_on_the_root_refuses_and_on_a_descendant_renders() {
8247            let authored = |body: Vec<u8>| {
8248                refused(
8249                    &keyed(&encode_list(0, 1, &body), &[(0, &[ID])]),
8250                    &rows(vec![id(num(1.0))]),
8251                )
8252            };
8253            let expected = violation(0, 0, "id", ListKeyReason::AuthoredKey);
8254            for attr in [KEY_ATTR, KEY_ATTR_UPPER] {
8255                assert_eq!(
8256                    authored(
8257                        [
8258                            encode_open_tag(LI, &[(attr, AUTHORED)]),
8259                            encode_close_tag(LI)
8260                        ]
8261                        .concat()
8262                    ),
8263                    expected
8264                );
8265                assert_eq!(
8266                    authored(encode_void_tag(INPUT, &[(attr, AUTHORED)])),
8267                    expected
8268                );
8269                assert_eq!(
8270                    authored(
8271                        [
8272                            encode_open_tag(LI, &[]),
8273                            encode_dyn_attr(attr, 4),
8274                            encode_close_tag(LI)
8275                        ]
8276                        .concat()
8277                    ),
8278                    expected
8279                );
8280            }
8281            let descendant = [
8282                encode_open_tag(LI, &[]),
8283                encode_open_tag(SPAN, &[(KEY_ATTR, AUTHORED)]),
8284                encode_close_tag(SPAN),
8285                encode_close_tag(LI),
8286            ]
8287            .concat();
8288            assert_eq!(
8289                walk_to_html(
8290                    &keyed(&encode_list(0, 1, &descendant), &[(0, &[ID])]),
8291                    &rows(vec![id(num(1.0))])
8292                )
8293                .unwrap(),
8294                "<!--f:l0--><li data-forma-key=\"1\"><span data-forma-key=\"authored\"></span></li><!--/f:l0-->"
8295            );
8296        }
8297
8298        #[test]
8299        fn a_refused_row_leaves_the_slot_journal_keyed_rows_and_list_depth_as_found() {
8300            let (ops, inner) = nested();
8301            let module = keyed(&ops, &[(0, &[ID]), (inner, &[ID])]);
8302            let bad_inner_key = rows(vec![
8303                group(1.0, vec![id(text("a"))]),
8304                group(2.0, vec![id(text("a")), id(text("a"))]),
8305            ]);
8306            // The inner row's key is fine, but the output budget runs out inside it (50 bytes
8307            // end within its `<b data-forma-key="a">`): a non-key error unwinds the same way.
8308            let over_budget_inside = rows(vec![group(1.0, vec![id(text("a"))])]);
8309            let cases = [
8310                (
8311                    bad_inner_key,
8312                    WalkLimits::default(),
8313                    IrError::InvalidListKey(Box::new(violation(
8314                        inner,
8315                        1,
8316                        "id",
8317                        ListKeyReason::Duplicate { first_row: 0 },
8318                    ))),
8319                ),
8320                (
8321                    over_budget_inside,
8322                    WalkLimits {
8323                        max_output_bytes: 50,
8324                        ..WalkLimits::default()
8325                    },
8326                    IrError::OutputLimitExceeded { limit: 50 },
8327                ),
8328            ];
8329            for (data, limits, expected) in cases {
8330                let mut slots = data.clone();
8331                let before = format!("{slots:?}");
8332                let mut state = WalkState::new(limits);
8333                let mut out = String::new();
8334                let error = walk_range(
8335                    &module,
8336                    &mut slots,
8337                    &module.opcodes,
8338                    0,
8339                    module.opcodes.len(),
8340                    &mut out,
8341                    &mut state,
8342                    0,
8343                )
8344                .unwrap_err();
8345                assert_eq!(error, expected);
8346                assert_eq!(state.list_depth, 0, "{error:?}");
8347                assert!(state.keyed_rows.is_empty(), "{error:?}");
8348                assert!(state.slot_journal.is_empty(), "{error:?}");
8349                assert_eq!(format!("{slots:?}"), before, "{error:?}");
8350            }
8351        }
8352
8353        #[test]
8354        fn a_key_is_charged_to_the_output_budget_before_it_is_built() {
8355            let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8356            let long = "k".repeat(1000);
8357            let opened = "<!--f:l0-->".len();
8358            let limits = WalkLimits {
8359                max_output_bytes: opened + 999,
8360                ..WalkLimits::default()
8361            };
8362            let failure =
8363                walk_to_html_counted(&module, &rows(vec![id(text(&long))]), limits).unwrap_err();
8364            assert_eq!(
8365                failure.error,
8366                IrError::OutputLimitExceeded {
8367                    limit: opened + 999
8368                }
8369            );
8370            // Refused before the row wrote anything: only the list's opening marker.
8371            assert_eq!(failure.counts.bytes, opened);
8372            // A NUL is counted as the three bytes U+FFFD takes.
8373            let nul = "\0".repeat(400);
8374            let limits = WalkLimits {
8375                max_output_bytes: opened + 1000,
8376                ..WalkLimits::default()
8377            };
8378            let failure =
8379                walk_to_html_counted(&module, &rows(vec![id(text(&nul))]), limits).unwrap_err();
8380            assert_eq!(failure.counts.bytes, opened);
8381            // Exactly at the budget the page renders.
8382            let html = walk_to_html(&module, &rows(vec![id(text("key"))])).unwrap();
8383            let exact = WalkLimits {
8384                max_output_bytes: html.len(),
8385                ..WalkLimits::default()
8386            };
8387            assert_eq!(
8388                walk_to_html_with_limits(&module, &rows(vec![id(text("key"))]), exact).unwrap(),
8389                html
8390            );
8391        }
8392
8393        #[test]
8394        fn the_iteration_budget_refuses_a_keyed_list_before_any_key_is_read() {
8395            let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8396            let limits = WalkLimits {
8397                max_list_iterations: 1,
8398                ..WalkLimits::default()
8399            };
8400            let failure = walk_to_html_counted(
8401                &module,
8402                &rows(vec![id(SlotValue::Null), id(num(1.0))]),
8403                limits,
8404            )
8405            .unwrap_err();
8406            assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 1 });
8407            assert_eq!(failure.counts.list_iterations, 0);
8408        }
8409
8410        /// FORMA-181 checkpoint C: the `served` markup of every keys.json vector is exactly
8411        /// what this walker writes for that key, because the browser spec parses that markup
8412        /// in Chromium, Firefox and WebKit and checks it decodes to the canonical text
8413        /// (packages/core/e2e/keyed-adoption.spec.ts). A change to the attribute escaping
8414        /// fails here before it can drift from what the browsers were shown.
8415        #[test]
8416        fn every_shared_key_vector_is_written_as_its_served_markup() {
8417            let vectors: serde_json::Value =
8418                serde_json::from_str(include_str!("../tests/fixtures/parity/keys.json")).unwrap();
8419            let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8420            let mut checked = 0;
8421            for case in vectors["values"].as_array().unwrap() {
8422                let Some(served) = case.get("served").and_then(serde_json::Value::as_str) else {
8423                    continue;
8424                };
8425                let row = object(vec![(
8426                    "id",
8427                    crate::slot::json_to_slot_value(&case["value"]),
8428                )]);
8429                assert_eq!(
8430                    walk_to_html(&module, &rows(vec![row])).unwrap(),
8431                    format!(
8432                        "<!--f:l0--><li data-forma-key=\"{served}\"><span></span></li><!--/f:l0-->"
8433                    ),
8434                    "{}",
8435                    case["id"]
8436                );
8437                checked += 1;
8438            }
8439            assert!(
8440                checked >= 20,
8441                "the vector file lost its served markup: {checked}"
8442            );
8443        }
8444
8445        #[test]
8446        fn a_diagnostic_path_quotes_a_segment_a_dot_would_make_ambiguous() {
8447            assert_eq!(dotted_path(&["user", "id"]), "user.id");
8448            assert_eq!(dotted_path(&["user.id"]), "[\"user.id\"]");
8449            assert_eq!(dotted_path(&["a", "b.c", "d"]), "a[\"b.c\"].d");
8450            assert_eq!(dotted_path(&["", "x[0]"]), "[\"\"][\"x[0]\"]");
8451        }
8452    }
8453}