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