1pub 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
19struct IslandPending {
25 id: u16,
26 component_name: String,
27 occurrence: usize,
28 hash: String,
29 trigger: IslandTrigger,
30}
31
32pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
41
42pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct WalkLimits {
54 pub max_output_bytes: usize,
56 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
69struct KeyedRow {
83 list_offset: u32,
85 ordinal: usize,
87 path: Rc<str>,
89 key: Option<Rc<str>>,
91 entry_depth: usize,
93 roots: usize,
95 stray_content: bool,
98 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
109struct WalkState {
114 list_depth: u8,
116 pending_island: Option<IslandPending>,
118 element_depth: usize,
122 keyed_rows: Vec<KeyedRow>,
128 open_tag_is_row_root: bool,
131 island_state: StateBuilder,
133 pending_tag_close: bool,
136 pending_tag_name: String,
139 pre_boundary: Option<usize>,
152 max_output_bytes: usize,
156 list_iterations: usize,
159 max_list_iterations: usize,
161 slot_journal: Vec<(u16, SlotValue)>,
175 text_only: Option<TextOnlyParent>,
179 pending_control: PendingControl,
182 select: Option<SelectBinding>,
184 option_by_text: Option<OptionByText>,
187 nodes: usize,
189 islands: Vec<u16>,
192 #[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 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 fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
239 while self.slot_journal.len() > mark {
240 if let Some((slot_id, previous)) = self.slot_journal.pop() {
242 slots.set(slot_id, previous);
243 }
244 }
245 }
246
247 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 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 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 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 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 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#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
317fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
318 #[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
332pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
342 walk_to_html_with_limits(module, slots, WalkLimits::default())
343}
344
345pub 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
366fn initial_capacity(opcodes_len: usize) -> usize {
374 opcodes_len.saturating_mul(4).min(1 << 20)
375}
376
377#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
379#[non_exhaustive]
380pub struct WalkCounts {
381 pub nodes: usize,
384 pub bytes: usize,
386 pub list_iterations: usize,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq)]
393#[non_exhaustive]
394pub struct WalkOutput {
395 pub html: String,
397 pub island_state: IslandState,
400 pub counts: WalkCounts,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
408#[non_exhaustive]
409pub struct WalkFailure {
410 pub error: IrError,
412 pub component_path: Vec<String>,
417 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
437pub 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 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
517fn 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#[allow(dead_code)] pub(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#[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 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 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 let opcode = Opcode::from_byte(ops[byte_offset])?;
585 if opcode != Opcode::IslandStart {
586 return Err(IrError::InvalidOpcode(ops[byte_offset]));
587 }
588
589 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 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 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
631const MAX_LIST_DEPTH: u8 = crate::format::MAX_LIST_DEPTH as u8;
638
639const MAX_RECURSION_DEPTH: usize = crate::format::MAX_CONTROL_DEPTH;
642
643enum WalkMode {
645 Range { end: usize },
649 UntilIslandEnd { target_island_id: u16 },
652}
653
654#[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#[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#[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 let loop_bound = match mode {
756 WalkMode::Range { end } => end,
757 WalkMode::UntilIslandEnd { .. } => usize::MAX, };
759
760 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 if out.len() > limit {
772 return Err(IrError::OutputLimitExceeded { limit });
773 }
774
775 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, }
787 }
788
789 let opcode = Opcode::from_byte(ops[pos])?;
790 pos += 1; if state.pending_tag_close && opcode != Opcode::DynAttr {
795 close_start_tag(out, state, limit)?;
796 }
797
798 match opcode {
799 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 if !is_valid_name(tag) {
816 return Err(IrError::InvalidName(tag.to_string()));
817 }
818 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 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 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 option.text.extend(normalized_chars(text));
859 }
860 }
861 state.nodes += 1;
862 }
863
864 Opcode::DynText => {
865 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 #[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 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 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 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 if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
936 continue;
937 }
938 let text;
941 let value = match slots.get(slot_id) {
942 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 Some(AttrValue::Valued(text.as_str()))
960 }
961 };
962 if let Some(value) = value {
963 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 }
973
974 Opcode::IslandStart => {
975 let island_id = read_u16(ops, pos)?;
977 pos += 2;
978
979 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 state.note_row_level_island_start(island_id);
995
996 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 if let Some(entry) = module.island_entry(island_id) {
1009 let component_name = strings.get(entry.name_str_idx)?.to_string();
1010
1011 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 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 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 if let WalkMode::UntilIslandEnd { target_island_id } = mode {
1058 if island_id == target_island_id {
1059 if state.pending_tag_close {
1061 close_start_tag(out, state, limit)?;
1062 }
1063 return Ok(());
1064 }
1065 }
1066
1067 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 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 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 let then_end = bounded_offset(pos, then_len, region_limit)?;
1094 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; let else_end = bounded_offset(else_start, else_len, region_limit)?;
1102
1103 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 pos = else_end;
1144 }
1145
1146 Opcode::ShowElse => {
1147 }
1151
1152 Opcode::List => {
1163 let list_offset = (pos - 1) as u32;
1166 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 state.note_row_level_content();
1176
1177 let body_start = pos;
1178 let body_end = bounded_offset(pos, body_len, region_limit)?;
1179
1180 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 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 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 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 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 let previous = slots.replace(target_slot_id, value);
1285 state.journal_write(target_slot_id, previous);
1286 }
1287 }
1288 }
1289
1290 match mode {
1292 WalkMode::Range { .. } => {
1293 if state.pending_tag_close {
1303 close_start_tag(out, state, limit)?;
1304 }
1305 Ok(())
1306 }
1307 WalkMode::UntilIslandEnd { target_island_id } => {
1308 Err(IrError::IslandNotFound(target_island_id))
1311 }
1312 }
1313}
1314
1315struct ListKey<'m> {
1321 list_offset: u32,
1323 segments: Vec<&'m str>,
1325 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
1349fn 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
1367struct 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
1376fn 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 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 let mut seen_keys: HashMap<Rc<str>, usize> = HashMap::new();
1420 for (ordinal, item) in rows.items.iter().enumerate() {
1421 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 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 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 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 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 state.unwind_slot_journal(slots, mark);
1487 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 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
1513fn 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
1523fn 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
1532fn 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
1547fn 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
1569fn 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
1578fn is_event_handler_attr(name: &str) -> bool {
1580 name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1581}
1582
1583const URL_ATTRS: [&str; 8] = [
1591 "href",
1592 "src",
1593 "action",
1594 "formaction",
1595 "xlink:href",
1596 "poster",
1597 "background",
1598 "data",
1599];
1600
1601const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1605 "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1606 "td", "th",
1607];
1608
1609fn is_url_attr(name: &str) -> bool {
1611 URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1612}
1613
1614fn is_dangerous_url(value: &str, tag: &str) -> bool {
1623 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1668enum AttrValue<'a> {
1669 Bare,
1671 Valued(&'a str),
1673}
1674
1675const 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
1708fn 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 #[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
1752fn 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 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 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 if consume_control_attr(state, key, Prop::Static(val)) {
1797 continue;
1798 }
1799 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 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 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 state.pending_tag_close = true;
1845 state.pending_tag_name.clear();
1846 state.pending_tag_name.push_str(tag);
1847 Ok(())
1848}
1849
1850#[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#[derive(Debug)]
1910struct TextOnlyParent {
1911 tag: TextOnlyTag,
1912 started: bool,
1915 value_bound: bool,
1918}
1919
1920fn 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#[derive(Debug, Default)]
1960enum PendingControl {
1961 #[default]
1962 None,
1963 Textarea { value: Option<String> },
1965 Select {
1967 value: Option<SlotValue>,
1968 multiple: bool,
1969 },
1970 OptionTag { value: Option<String> },
1973}
1974
1975#[derive(Debug)]
1990struct SelectBinding {
1991 wanted: SelectWanted,
1992 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 let wanted = match value {
2008 SlotValue::Array(items) if multiple => {
2009 SelectWanted::Any(items.iter().map(js_string).collect())
2010 }
2011 other => SelectWanted::One(normalize_text(&other.to_text()).into_owned()),
2013 };
2014 SelectBinding {
2015 wanted,
2016 matched: false,
2017 }
2018 }
2019
2020 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
2033fn 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#[derive(Debug)]
2045struct OptionByText {
2046 insert_at: usize,
2049 text: String,
2051}
2052
2053#[derive(Clone, Copy)]
2055enum Prop<'a> {
2056 Static(&'a str),
2057 Slot(&'a SlotValue),
2058}
2059
2060impl Prop<'_> {
2061 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 fn is_written(self) -> bool {
2073 !matches!(
2074 self,
2075 Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
2076 )
2077 }
2078
2079 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
2097fn 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 "selected" => true,
2129 "value" => {
2130 if value.is_none() {
2132 *value = prop.written();
2133 }
2134 false
2135 }
2136 _ => false,
2137 },
2138 }
2139}
2140
2141fn 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 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
2176fn 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 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
2203fn 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
2246fn 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
2269fn 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
2282pub(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
2297pub(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
2313pub(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; 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
2342const MAX_ESCAPE_EXPANSION: usize = 6;
2349
2350fn 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
2364fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
2370 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 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
2390fn 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
2404fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2413 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("&"),
2422 '<' => out.push_str("<"),
2423 '>' => out.push_str(">"),
2424 _ => out.push(ch),
2425 }
2426 }
2427 return Ok(());
2428 }
2429 for ch in normalized_chars(text) {
2430 match ch {
2431 '&' => push_checked(out, "&", limit)?,
2432 '<' => push_checked(out, "<", limit)?,
2433 '>' => push_checked(out, ">", limit)?,
2434 _ => push_char_checked(out, ch, limit)?,
2435 }
2436 }
2437 Ok(())
2438}
2439
2440fn 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("&"),
2453 '"' => out.push_str("""),
2454 '<' => out.push_str("<"),
2455 '>' => out.push_str(">"),
2456 _ => out.push(ch),
2457 }
2458 }
2459 return Ok(());
2460 }
2461 for ch in normalized_chars(text) {
2462 match ch {
2463 '&' => push_checked(out, "&", limit)?,
2464 '"' => push_checked(out, """, limit)?,
2465 '<' => push_checked(out, "<", limit)?,
2466 '>' => push_checked(out, ">", limit)?,
2467 _ => push_char_checked(out, ch, limit)?,
2468 }
2469 }
2470 Ok(())
2471}
2472
2473#[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 fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
2490 let mut buf = Vec::new();
2491 buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
2493 buf.extend_from_slice(&marker_id.to_le_bytes());
2494 buf
2495 }
2496
2497 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 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 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 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 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 fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
2558 let mut bytes = vec![0x06]; 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]
2567 fn walk_static_div() {
2568 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]
2582 fn walk_void_tag() {
2583 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]
2594 fn walk_nested_elements() {
2595 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]
2611 fn walk_text_escaping() {
2612 let opcodes = encode_text(0);
2614
2615 let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
2616 assert_eq!(html, "<script>alert('xss')</script>");
2617 }
2618
2619 #[test]
2622 fn walk_attr_escaping() {
2623 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"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<";
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"\">";
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]
2681 fn walk_empty_element() {
2682 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]
2694 fn walk_dyn_text() {
2695 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 let opcodes = encode_dyn_text(0, 0);
2712 let slots = SlotData::new(1); 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 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]
2732 fn walk_dyn_text_escaping() {
2733 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--><b>bold</b><!--/f:t1-->");
2741 }
2742
2743 #[test]
2746 fn walk_island_markers() {
2747 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]
2771 fn walk_boolean_attr() {
2772 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 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 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 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]
2845 fn walk_multiple_attrs() {
2846 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]
2865 fn walk_show_if_true_branch() {
2866 let then_ops = encode_text(1); let else_ops = encode_text(2); 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, &[])], &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]
2891 fn walk_show_if_false_branch() {
2892 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]
2916 fn walk_show_if_truthy_text() {
2917 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, &[])], &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]
2944 fn walk_show_if_falsy_null() {
2945 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); 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]
2968 fn walk_show_if_no_else() {
2969 let then_ops = encode_text(1);
2971 let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
2973
2974 let slots = SlotData::new(1); 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]
2991 fn walk_show_if_nested() {
2992 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; 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)); slots.set(1, SlotValue::Bool(false)); 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 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 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]
3055 fn walk_show_if_with_elements() {
3056 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]
3084 fn walk_list_renders_items() {
3085 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, &[])], &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]
3121 fn walk_list_empty_array() {
3122 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]
3145 fn walk_list_with_elements() {
3146 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]
3180 fn walk_list_null_slot() {
3181 let body = encode_dyn_text(1, 0);
3184 let opcodes = encode_list(0, 1, &body);
3185
3186 let slots = SlotData::new(2); 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]
3203 fn walk_list_nested() {
3204 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 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 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 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]
3261 fn walk_list_depth_exceeded() {
3262 let innermost_body = encode_dyn_text(5, 0);
3266 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 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 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]
3331 fn roundtrip_page_structure() {
3332 let mut opcodes = Vec::new();
3340 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_open_tag(3, &[])); opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); opcodes.extend_from_slice(&encode_open_tag(7, &[])); opcodes.extend_from_slice(&encode_text(8)); opcodes.extend_from_slice(&encode_close_tag(7)); opcodes.extend_from_slice(&encode_close_tag(3)); opcodes.extend_from_slice(&encode_open_tag(9, &[])); opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); opcodes.extend_from_slice(&encode_close_tag(10)); opcodes.extend_from_slice(&encode_close_tag(9)); opcodes.extend_from_slice(&encode_close_tag(0)); let ir = build_declared_fixture(
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 #[test]
3374 fn roundtrip_dynamic_greeting() {
3375 let mut opcodes = Vec::new();
3381 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_dyn_text(0, 0)); opcodes.extend_from_slice(&encode_close_tag(0)); let ir = build_declared_fixture(
3386 &["div", "class", "greeting", "content"],
3387 &[(0, 3, 0x01, 0x00, &[])], &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 #[test]
3404 fn roundtrip_conditional_content() {
3405 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, &[])], &opcodes,
3427 &[],
3428 );
3429
3430 {
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 {
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 #[test]
3458 fn roundtrip_list_rendering() {
3459 let mut body = Vec::new();
3465 body.extend_from_slice(&encode_open_tag(2, &[])); body.extend_from_slice(&encode_dyn_text(1, 0)); body.extend_from_slice(&encode_close_tag(2)); let opcodes = encode_list(0, 1, &body);
3470
3471 let ir = build_declared_fixture(
3472 &["items", "item", "li"],
3473 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &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 #[test]
3503 fn roundtrip_nested_structure() {
3504 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 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 let mut opcodes = Vec::new();
3541 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&show_if_ops); opcodes.extend_from_slice(&encode_open_tag(8, &[])); opcodes.extend_from_slice(&list_ops); opcodes.extend_from_slice(&encode_close_tag(8)); opcodes.extend_from_slice(&encode_close_tag(0)); let ir = build_declared_fixture(
3549 &[
3550 "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
3551 ],
3552 &[
3553 (0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
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 #[test]
3585 fn walk_island_attrs_on_open_tag() {
3586 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 &[(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]
3614 fn walk_island_attrs_with_existing_attrs() {
3615 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]
3640 fn walk_island_attrs_on_void_tag() {
3641 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 #[test]
3665 fn declared_props_are_separate_from_body_html() {
3666 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, &[]), (1, 3, 0x03, 0x00, &[]), ],
3686 &[(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]
3712 fn walk_island_no_entry_in_table() {
3713 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 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 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 #[test]
3790 fn empty_declared_inputs_do_not_emit_inline_props() {
3791 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, &[])], &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 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 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 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]
3912 fn multiple_islands_share_one_owned_envelope() {
3913 let mut opcodes = Vec::new();
3916 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 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, &[]), (1, 5, 0x02, 0x00, &[]),
3938 (2, 6, 0x01, 0, &[]), ],
3940 &[
3941 (0, 0x01, 0x02, 2, 0, &[0, 2]), (1, 0x01, 0x02, 3, island1_offset, &[1, 2]), ],
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]
3959 fn slot_value_to_json_all_types() {
3960 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]
3997 fn dyn_attr_emits_attribute_with_value() {
3998 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")], &opcodes,
4013 &slots,
4014 );
4015 assert_eq!(html, r#"<input type="password"></input>"#);
4016 }
4017
4018 #[test]
4021 fn dyn_attr_skips_when_slot_null() {
4022 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); 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]
4044 fn dyn_attr_multiple_on_same_element() {
4045 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]
4069 fn dyn_attr_on_void_tag() {
4070 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 #[test]
4092 fn walk_island_basic() {
4093 let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
4108 let open_p = encode_open_tag(2, &[]); 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; 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, &[])], );
4131 let module = IrModule::parse(&data).unwrap();
4132 let slots = SlotData::new(0);
4133
4134 let html = walk_island(&module, &slots, 0).unwrap();
4135 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 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 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])], );
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 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])], );
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 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]), ],
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 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) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
4278 }
4279 }
4280
4281 #[test]
4282 fn walk_island_with_dyn_text() {
4283 let island_start = encode_island_start(0);
4288 let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
4291 let island_end = encode_island_end(0);
4292
4293 let byte_offset = 0u32; 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, &[])], &opcodes,
4306 &[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
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 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 let opcodes = encode_text(0); let data = build_declared_fixture(
4334 &["BadIsland", "hello"],
4335 &[],
4336 &opcodes,
4337 &[(0, 0x01, 0x01, 0, 9999, &[])], );
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 fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
4353 let mut buf = vec![0x12]; 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]
4363 fn walk_prop_in_list_body() {
4364 let mut body = Vec::new();
4371 body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_dyn_text(2, 0)); let opcodes = encode_list(0, 1, &body);
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, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
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]
4407 fn walk_prop_multiple_properties() {
4408 let mut body = Vec::new();
4415 body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_prop(1, 3, 3)); body.extend_from_slice(&encode_open_tag(4, &[])); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(2, 0)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(3, 1)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_close_tag(4)); let opcodes = encode_list(0, 1, &body);
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]
4480 fn walk_prop_on_non_object() {
4481 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 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]
4506 fn walk_prop_missing_property() {
4507 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 #[test]
4537 fn walk_nested_show_if_within_limit() {
4538 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 fn script_tag_props_html(payload: &str) -> (String, String) {
4573 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 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 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 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 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 let slots = SlotData::new_from_defaults(&module.slots);
4654 let html = walk_to_html(&module, &slots).expect("walk must succeed");
4655
4656 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 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 assert!(
4681 html.contains("Performance"),
4682 "PerfPanel must contain 'Performance' heading in SSR"
4683 );
4684
4685 assert!(
4688 html.contains("benchmark-data-table"),
4689 "BenchmarkDataTable root class must exist"
4690 );
4691
4692 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 #[test]
4707 fn walk_output_limit_trips_inside_one_opcode() {
4708 let big = "&".repeat(4096); 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 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 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 #[test]
4758 fn walk_list_budget_exceeded() {
4759 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 assert_ne!(
4790 err,
4791 IrError::ListDepthExceeded {
4792 max: MAX_LIST_DEPTH
4793 }
4794 );
4795 }
4796
4797 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 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4808 patch_u32(&mut opcodes, 3, 0xFFFF_FF00); 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 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4822 patch_u32(&mut opcodes, 3, 4); 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 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 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 #[test]
4866 fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
4867 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 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 #[test]
4918 fn walk_rejects_hostile_tag_name() {
4919 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 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 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 #[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 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 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 "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 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
5063 }
5064
5065 #[test]
5066 fn walk_drops_event_handler_attributes() {
5067 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 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 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 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 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 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 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 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 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 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<b&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 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 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 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 "<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 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 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 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 let cases = [
5473 (text("hello"), "hello"),
5474 (
5475 text("</textarea><script>x</script>"),
5476 "</textarea><script>x</script>",
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut ops = encode_island_start(0); 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)); 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)); ops.extend_from_slice(&encode_open_tag(0, &[]));
6142 ops.extend_from_slice(&encode_open_tag(4, &[])); 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 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 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 let out =
6196 walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
6197 assert!(out.html.contains("Acme"));
6198 }
6199
6200 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 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 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 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 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 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 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 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 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('&', "&")
6349 .replace('<', "<")
6350 .replace('>', ">")
6351 }
6352
6353 fn escape_attr(text: &str) -> String {
6354 escape_html(text).replace('"', """)
6355 }
6356
6357 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 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 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 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 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 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 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 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 for (input, written) in [
6707 ("xxxxxxxxxx\0", "xxxxxxxxxx\u{FFFD}"),
6708 ("xxxxxxxxxx😀", "xxxxxxxxxx😀"),
6709 ("xxxxxxxxxx&", "xxxxxxxxxx&"),
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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", "row", "li", "id", "span", "label", "input", "type", "checkbox", "data-forma-key", "authored", "tags", "b", "Child", "ul", "DATA-FORMA-KEY", "text", "user", "tag", ];
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 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 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 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 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 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&"<b>'\"><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 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 #[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 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 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 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 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 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 assert_eq!(
7702 refused(&module, &rows(vec![id(num(1.0)), bad])),
7703 violation(0, 1, "id", reason)
7704 );
7705 }
7706 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 "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 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 assert_eq!(failure.counts.bytes, opened);
7927 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 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 #[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}