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
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum RenderMode {
76 Adoptable,
78 Static,
81}
82
83struct KeyedRow {
97 list_offset: u32,
99 ordinal: usize,
101 path: Rc<str>,
103 key: Option<Rc<str>>,
105 entry_depth: usize,
107 roots: usize,
109 stray_content: bool,
112 envelope: Option<u16>,
115}
116
117impl KeyedRow {
118 fn refuse(&self, reason: ListKeyReason) -> IrError {
119 list_key_error(self.list_offset, self.ordinal, &self.path, reason)
120 }
121}
122
123struct WalkState {
128 mode: RenderMode,
130 list_depth: u8,
132 pending_island: Option<IslandPending>,
134 element_depth: usize,
138 keyed_rows: Vec<KeyedRow>,
144 open_tag_is_row_root: bool,
147 island_state: StateBuilder,
149 pending_tag_close: bool,
152 pending_tag_name: String,
155 pre_boundary: Option<usize>,
168 max_output_bytes: usize,
172 list_iterations: usize,
175 max_list_iterations: usize,
177 slot_journal: Vec<(u16, SlotValue)>,
191 text_only: Option<TextOnlyParent>,
195 pending_control: PendingControl,
198 select: Option<SelectBinding>,
200 option_by_text: Option<OptionByText>,
203 nodes: usize,
205 islands: Vec<u16>,
208 #[cfg(any(test, feature = "__fault-injection"))]
212 fault_armed: bool,
213}
214
215impl WalkState {
216 fn new(limits: WalkLimits) -> Self {
217 WalkState {
218 mode: RenderMode::Adoptable,
219 list_depth: 0,
220 pending_island: None,
221 element_depth: 0,
222 keyed_rows: Vec::new(),
223 open_tag_is_row_root: false,
224 island_state: StateBuilder::new(),
225 pending_tag_close: false,
226 pending_tag_name: String::new(),
227 pre_boundary: None,
228 max_output_bytes: limits.max_output_bytes,
229 list_iterations: 0,
230 max_list_iterations: limits.max_list_iterations,
231 slot_journal: Vec::new(),
232 text_only: None,
233 pending_control: PendingControl::None,
234 select: None,
235 option_by_text: None,
236 nodes: 0,
237 islands: Vec::new(),
238 #[cfg(any(test, feature = "__fault-injection"))]
239 fault_armed: false,
240 }
241 }
242
243 fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
249 if self.list_depth > 0 {
250 self.slot_journal.push((slot_id, previous));
251 }
252 }
253
254 fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
256 while self.slot_journal.len() > mark {
257 if let Some((slot_id, previous)) = self.slot_journal.pop() {
259 slots.set(slot_id, previous);
260 }
261 }
262 }
263
264 fn keyed_row_at_entry_depth(&mut self) -> Option<&mut KeyedRow> {
267 let depth = self.element_depth;
268 self.keyed_rows
269 .last_mut()
270 .filter(|row| row.entry_depth == depth)
271 }
272
273 fn open_row_root(&mut self) -> Option<Rc<str>> {
276 let row = self.keyed_row_at_entry_depth()?;
277 row.roots += 1;
278 row.key.take()
279 }
280
281 fn note_row_level_content(&mut self) {
284 if let Some(row) = self.keyed_row_at_entry_depth() {
285 row.stray_content = true;
286 }
287 }
288
289 fn note_row_level_island_start(&mut self, island_id: u16) {
293 if let Some(row) = self.keyed_row_at_entry_depth() {
294 if row.roots == 0 && row.envelope.is_none() && !row.stray_content {
295 row.envelope = Some(island_id);
296 } else {
297 row.stray_content = true;
298 }
299 }
300 }
301
302 fn note_row_level_island_end(&mut self, island_id: u16) {
305 if let Some(row) = self.keyed_row_at_entry_depth() {
306 if row.envelope == Some(island_id) && row.roots == 1 {
307 row.envelope = None;
308 } else {
309 row.stray_content = true;
310 }
311 }
312 }
313
314 fn closes_outside_keyed_row(&self) -> bool {
317 self.keyed_rows
318 .last()
319 .is_some_and(|row| row.entry_depth == self.element_depth)
320 }
321}
322
323#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
334fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
335 #[cfg(feature = "tracing")]
339 if let Some(entry) = module.slot_entry(slot_id) {
340 if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
341 tracing::warn!(
342 slot_id = slot_id,
343 "Server-sourced slot has no value at render time — handler may have a bug"
344 );
345 }
346 }
347}
348
349pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
359 walk_to_html_with_limits(module, slots, WalkLimits::default())
360}
361
362pub fn walk_to_html_with_limits(
369 module: &IrModule,
370 slots: &SlotData,
371 limits: WalkLimits,
372) -> Result<String, IrError> {
373 let ops = &module.opcodes;
374 let len = ops.len();
375 let mut out = String::with_capacity(initial_capacity(len));
376 let mut state = WalkState::new(limits);
377 let mut slots_mut = slots.clone();
378 slots_mut.apply_module_constants(module);
379 walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
380 Ok(out)
381}
382
383fn initial_capacity(opcodes_len: usize) -> usize {
391 opcodes_len.saturating_mul(4).min(1 << 20)
392}
393
394#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
396#[non_exhaustive]
397pub struct WalkCounts {
398 pub nodes: usize,
401 pub bytes: usize,
403 pub list_iterations: usize,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
410#[non_exhaustive]
411pub struct WalkOutput {
412 pub html: String,
414 pub island_state: IslandState,
417 pub counts: WalkCounts,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
425#[non_exhaustive]
426pub struct WalkFailure {
427 pub error: IrError,
429 pub component_path: Vec<String>,
434 pub counts: WalkCounts,
436}
437
438impl fmt::Display for WalkFailure {
439 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440 write!(f, "{}", self.error)?;
441 if !self.component_path.is_empty() {
442 write!(f, " (in {})", self.component_path.join(" > "))?;
443 }
444 Ok(())
445 }
446}
447
448impl std::error::Error for WalkFailure {
449 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
450 Some(&self.error)
451 }
452}
453
454pub fn walk_to_html_counted(
469 module: &IrModule,
470 slots: &SlotData,
471 limits: WalkLimits,
472) -> Result<WalkOutput, WalkFailure> {
473 walk_to_html_counted_with_mode(module, slots, limits, RenderMode::Adoptable)
474}
475
476pub fn walk_to_html_counted_with_mode(
483 module: &IrModule,
484 slots: &SlotData,
485 limits: WalkLimits,
486 mode: RenderMode,
487) -> Result<WalkOutput, WalkFailure> {
488 if mode == RenderMode::Static && !module.islands.is_empty() {
489 return Err(WalkFailure {
490 error: IrError::InvalidSection,
491 component_path: Vec::new(),
492 counts: WalkCounts::default(),
493 });
494 }
495 let ops = &module.opcodes;
496 let len = ops.len();
497 let mut out = String::with_capacity(initial_capacity(len));
498 let mut state = WalkState::new(limits);
499 state.mode = mode;
500 #[cfg(any(test, feature = "__fault-injection"))]
501 {
502 state.fault_armed = true;
503 }
504 let mut slots_mut = slots.clone();
505 slots_mut.apply_module_constants(module);
506
507 let walked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
511 walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)
512 }));
513 let counts = WalkCounts {
514 nodes: state.nodes,
515 bytes: out.len(),
516 list_iterations: state.list_iterations,
517 };
518 let error = match walked {
519 Ok(Ok(())) => match state.island_state.finish() {
520 Ok(island_state) => {
521 return Ok(WalkOutput {
522 html: out,
523 island_state,
524 counts,
525 })
526 }
527 Err(error) => {
528 return Err(WalkFailure {
529 error,
530 component_path: Vec::new(),
531 counts,
532 })
533 }
534 },
535 Ok(Err(error)) => error,
536 Err(payload) => IrError::Panicked {
537 message: panic_message(payload.as_ref()),
538 },
539 };
540 let component_path = state
541 .islands
542 .iter()
543 .map(|&id| {
544 module
545 .island_entry(id)
546 .and_then(|entry| module.strings.get(entry.name_str_idx).ok())
547 .map_or_else(|| format!("island {id}"), str::to_string)
548 })
549 .collect();
550 Err(WalkFailure {
551 error,
552 component_path,
553 counts,
554 })
555}
556
557fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
560 if let Some(message) = payload.downcast_ref::<&str>() {
561 (*message).to_string()
562 } else if let Some(message) = payload.downcast_ref::<String>() {
563 message.clone()
564 } else {
565 "a panic with no message".to_string()
566 }
567}
568
569#[allow(dead_code)] pub(crate) fn walk_island(
591 module: &IrModule,
592 slots: &SlotData,
593 island_id: u16,
594) -> Result<String, IrError> {
595 walk_island_with_limits(module, slots, island_id, WalkLimits::default())
596}
597
598#[allow(dead_code)]
600pub(crate) fn walk_island_with_limits(
601 module: &IrModule,
602 slots: &SlotData,
603 island_id: u16,
604 limits: WalkLimits,
605) -> Result<String, IrError> {
606 let entry = module
608 .island_entry(island_id)
609 .ok_or(IrError::IslandNotFound(island_id))?;
610
611 let byte_offset = entry.byte_offset as usize;
612 let ops = &module.opcodes;
613
614 if byte_offset >= ops.len() {
617 return Err(IrError::BufferTooShort {
618 expected: byte_offset.saturating_add(1),
619 actual: ops.len(),
620 });
621 }
622
623 let opcode = Opcode::from_byte(ops[byte_offset])?;
625 if opcode != Opcode::IslandStart {
626 return Err(IrError::InvalidOpcode(ops[byte_offset]));
627 }
628
629 let content_start = byte_offset.saturating_add(3);
631 if content_start > ops.len() {
632 return Err(IrError::BufferTooShort {
633 expected: content_start,
634 actual: ops.len(),
635 });
636 }
637
638 let mut out = String::with_capacity(256);
642 let mut state = WalkState::new(limits);
643 let mut slots_mut = slots.clone();
644 slots_mut.apply_module_constants(module);
645
646 let component_name = module.strings.get(entry.name_str_idx)?.to_string();
647 let (occurrence, hash) = state.island_state.snapshot(module, entry, &slots_mut)?;
648 state.pending_island = Some(IslandPending {
649 id: island_id,
650 component_name,
651 occurrence,
652 hash,
653 trigger: entry.trigger,
654 });
655
656 walk_range_until_island_end(
658 module,
659 &mut slots_mut,
660 ops,
661 content_start,
662 island_id,
663 &mut out,
664 &mut state,
665 0,
666 )?;
667
668 Ok(out)
669}
670
671const MAX_LIST_DEPTH: u8 = crate::format::MAX_LIST_DEPTH as u8;
678
679const MAX_RECURSION_DEPTH: usize = crate::format::MAX_CONTROL_DEPTH;
682
683enum WalkMode {
685 Range { end: usize },
689 UntilIslandEnd { target_island_id: u16 },
692}
693
694#[allow(clippy::too_many_arguments)]
701fn walk_range(
702 module: &IrModule,
703 slots: &mut SlotData,
704 ops: &[u8],
705 start: usize,
706 end: usize,
707 out: &mut String,
708 state: &mut WalkState,
709 depth: usize,
710) -> Result<(), IrError> {
711 walk_range_impl(
712 module,
713 slots,
714 ops,
715 start,
716 WalkMode::Range { end },
717 out,
718 state,
719 depth,
720 )
721}
722
723#[allow(clippy::too_many_arguments)]
730fn walk_range_until_island_end(
731 module: &IrModule,
732 slots: &mut SlotData,
733 ops: &[u8],
734 start: usize,
735 target_island_id: u16,
736 out: &mut String,
737 state: &mut WalkState,
738 depth: usize,
739) -> Result<(), IrError> {
740 walk_range_impl(
741 module,
742 slots,
743 ops,
744 start,
745 WalkMode::UntilIslandEnd { target_island_id },
746 out,
747 state,
748 depth,
749 )
750}
751
752#[allow(clippy::too_many_arguments)]
767fn walk_range_impl(
768 module: &IrModule,
769 slots: &mut SlotData,
770 ops: &[u8],
771 start: usize,
772 mode: WalkMode,
773 out: &mut String,
774 state: &mut WalkState,
775 depth: usize,
776) -> Result<(), IrError> {
777 if depth > MAX_RECURSION_DEPTH {
778 return Err(IrError::RecursionLimitExceeded);
779 }
780
781 let strings = &module.strings;
782 let mut pos = start;
783 let limit = state.max_output_bytes;
784 let loop_bound = match mode {
796 WalkMode::Range { end } => end,
797 WalkMode::UntilIslandEnd { .. } => usize::MAX, };
799
800 let region_limit = match mode {
804 WalkMode::Range { end } => end.min(ops.len()),
805 WalkMode::UntilIslandEnd { .. } => ops.len(),
806 };
807
808 while pos < loop_bound {
809 if out.len() > limit {
812 return Err(IrError::OutputLimitExceeded { limit });
813 }
814
815 if pos >= ops.len() {
818 match mode {
819 WalkMode::Range { .. } => {
820 return Err(IrError::BufferTooShort {
821 expected: pos + 1,
822 actual: ops.len(),
823 });
824 }
825 WalkMode::UntilIslandEnd { .. } => break, }
827 }
828
829 let opcode = Opcode::from_byte(ops[pos])?;
830 pos += 1; if state.pending_tag_close && opcode != Opcode::DynAttr {
835 close_start_tag(out, state, limit)?;
836 }
837
838 match opcode {
839 Opcode::OpenTag | Opcode::VoidTag => {
841 let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
842 let tag = strings.get(tag_str_idx)?;
843 let has_content = opcode == Opcode::OpenTag;
844 emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
845 state.nodes += 1;
846 pos = new_pos;
847 }
848
849 Opcode::CloseTag => {
850 let str_idx = read_u32(ops, pos)?;
851 pos += 4;
852 let tag = strings.get(str_idx)?;
853 if !is_valid_name(tag) {
856 return Err(IrError::InvalidName(tag.to_string()));
857 }
858 if state.closes_outside_keyed_row() {
862 return Err(innermost_row_error(
863 state,
864 ListKeyReason::UnbalancedRow,
865 IrError::InvalidSection,
866 ));
867 }
868 leave_element(out, tag, state, limit)?;
869 state.element_depth = state.element_depth.saturating_sub(1);
872 push_checked(out, "</", limit)?;
873 push_checked(out, tag, limit)?;
874 push_checked(out, ">", limit)?;
875 }
876
877 Opcode::Text => {
878 let str_idx = read_u32(ops, pos)?;
879 pos += 4;
880 let text = strings.get(str_idx)?;
881 state.note_row_level_content();
882 if let Some(parent) = state.text_only.as_mut() {
883 if !parent.value_bound {
884 write_text_only(out, parent, text, limit)?;
885 }
886 } else {
887 if state.pre_boundary == Some(out.len())
888 && normalized_chars(text).next() == Some('\n')
889 {
890 push_checked(out, "\n", limit)?;
894 }
895 push_escaped_html(out, text, limit)?;
896 if let Some(option) = state.option_by_text.as_mut() {
897 option.text.extend(normalized_chars(text));
899 }
900 }
901 state.nodes += 1;
902 }
903
904 Opcode::DynText => {
905 let slot_id = read_u16(ops, pos)?;
907 pos += 2;
908 let marker_id = read_u16(ops, pos)?;
909 pos += 2;
910
911 check_slot_source(module, slot_id, slots.get(slot_id));
912 state.note_row_level_content();
913 let mut number = ryu_js::Buffer::new();
914 let value = slots.get(slot_id).text_ref(&mut number);
915
916 #[cfg(any(test, feature = "__fault-injection"))]
920 #[allow(clippy::panic)]
921 if state.fault_armed && value == crate::fault_injection::FAULT_TRIGGER {
922 panic!("stand-in component panicked (FAULT_TRIGGER)");
923 }
924
925 if let Some(parent) = state.text_only.as_mut() {
926 if !parent.value_bound {
929 write_text_only(out, parent, &value, limit)?;
930 }
931 } else if state.mode == RenderMode::Static {
932 if state.pre_boundary == Some(out.len())
935 && normalized_chars(&value).next() == Some('\n')
936 {
937 push_checked(out, "\n", limit)?;
938 }
939 push_escaped_html(out, &value, limit)?;
940 if let Some(option) = state.option_by_text.as_mut() {
941 option.text.extend(normalized_chars(&value));
942 }
943 } else {
944 push_checked(out, "<!--f:t", limit)?;
945 push_u16(out, marker_id, limit)?;
946 push_checked(out, "-->", limit)?;
947 let written = if value.is_empty() {
953 "\u{200B}"
954 } else {
955 value.as_ref()
956 };
957 push_escaped_html(out, written, limit)?;
958 push_checked(out, "<!--/f:t", limit)?;
959 push_u16(out, marker_id, limit)?;
960 push_checked(out, "-->", limit)?;
961 if let Some(option) = state.option_by_text.as_mut() {
962 option.text.extend(normalized_chars(written));
963 }
964 }
965 state.nodes += 1;
966 }
967
968 Opcode::DynAttr => {
969 let attr_str_idx = read_u32(ops, pos)?;
971 let slot_id = read_u16(ops, pos + 4)?;
972 pos += 6;
973 check_slot_source(module, slot_id, slots.get(slot_id));
974
975 if state.pending_tag_close {
976 let attr_name = strings.get(attr_str_idx)?;
977 if state.open_tag_is_row_root
978 && attr_name.eq_ignore_ascii_case("data-forma-key")
979 {
980 return Err(innermost_row_error(
981 state,
982 ListKeyReason::AuthoredKey,
983 IrError::InvalidName(attr_name.to_string()),
984 ));
985 }
986 if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
989 continue;
990 }
991 let text;
994 let mut number = ryu_js::Buffer::new();
995 let value = match slots.get(slot_id) {
996 SlotValue::Bool(false) | SlotValue::Null => None,
1003 other if is_html_boolean_attribute(attr_name) => {
1004 other.as_bool().then_some(AttrValue::Bare)
1005 }
1006 SlotValue::Bool(true) => Some(AttrValue::Valued("true")),
1007 other => {
1008 text = other.text_ref(&mut number);
1009 Some(AttrValue::Valued(text.as_ref()))
1014 }
1015 };
1016 if let Some(value) = value {
1017 let tag = std::mem::take(&mut state.pending_tag_name);
1021 emit_attr(out, &tag, attr_name, value, limit)?;
1022 state.pending_tag_name = tag;
1023 }
1024 }
1025 }
1027
1028 Opcode::IslandStart => {
1029 if state.mode == RenderMode::Static {
1031 return Err(IrError::InvalidSection);
1032 }
1033 let island_id = read_u16(ops, pos)?;
1035 pos += 2;
1036
1037 if let Some(outer) = &state.pending_island {
1044 return Err(IrError::NestedIslandRoot {
1045 outer: outer.id,
1046 inner: island_id,
1047 });
1048 }
1049 state.islands.push(island_id);
1050 state.note_row_level_island_start(island_id);
1053
1054 if state.text_only.is_none() {
1056 push_checked(out, "<!--f:i", limit)?;
1057 push_u16(out, island_id, limit)?;
1058 push_checked(out, "-->", limit)?;
1059 }
1060
1061 if let Some(entry) = module.island_entry(island_id) {
1067 let component_name = strings.get(entry.name_str_idx)?.to_string();
1068
1069 let (occurrence, hash) = state.island_state.snapshot(module, entry, slots)?;
1072
1073 state.pending_island = Some(IslandPending {
1074 id: island_id,
1075 component_name,
1076 occurrence,
1077 hash,
1078 trigger: entry.trigger,
1079 });
1080 } else {
1081 #[cfg(feature = "tracing")]
1082 tracing::warn!(
1083 island_id = island_id,
1084 "ISLAND_START names an island that is not in the island table — \
1085 markers emitted but no data-forma-* attributes, so it will not hydrate \
1086 (module was not validated)"
1087 );
1088 }
1089 }
1090
1091 Opcode::IslandEnd => {
1092 let island_id = read_u16(ops, pos)?;
1094 pos += 2;
1095 if state.islands.last() == Some(&island_id) {
1096 state.islands.pop();
1097 }
1098 state.note_row_level_island_end(island_id);
1099
1100 if state.pending_island.take().is_some() {
1106 #[cfg(feature = "tracing")]
1107 tracing::warn!(
1108 island_id = island_id,
1109 "ISLAND_START was not followed by an element — island attributes dropped, \
1110 this island will not hydrate (compiler should emit a shell element)"
1111 );
1112 }
1113
1114 if let WalkMode::UntilIslandEnd { target_island_id } = mode {
1116 if island_id == target_island_id {
1117 if state.pending_tag_close {
1119 close_start_tag(out, state, limit)?;
1120 }
1121 return Ok(());
1122 }
1123 }
1124
1125 if state.text_only.is_none() {
1129 push_checked(out, "<!--/f:i", limit)?;
1130 push_u16(out, island_id, limit)?;
1131 push_checked(out, "-->", limit)?;
1132 }
1133 }
1134
1135 Opcode::ShowIf => {
1136 let slot_id = read_u16(ops, pos)?;
1138 let then_len = read_u32(ops, pos + 2)? as usize;
1139 let else_len = read_u32(ops, pos + 6)? as usize;
1140 pos += 10;
1141 state.note_row_level_content();
1144
1145 check_slot_source(module, slot_id, slots.get(slot_id));
1146 let condition = slots.get(slot_id).as_bool();
1147 let then_start = pos;
1148 let then_end = bounded_offset(pos, then_len, region_limit)?;
1152 if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
1156 return Err(IrError::InvalidSection);
1157 }
1158 let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
1160
1161 let markers = state.mode == RenderMode::Adoptable && state.text_only.is_none();
1164 if markers {
1165 push_checked(out, "<!--f:s", limit)?;
1166 push_u16(out, slot_id, limit)?;
1167 push_checked(out, "-->", limit)?;
1168 }
1169
1170 if condition {
1171 walk_range(
1172 module,
1173 slots,
1174 ops,
1175 then_start,
1176 then_end,
1177 out,
1178 state,
1179 depth + 1,
1180 )?;
1181 } else {
1182 walk_range(
1183 module,
1184 slots,
1185 ops,
1186 else_start,
1187 else_end,
1188 out,
1189 state,
1190 depth + 1,
1191 )?;
1192 }
1193
1194 if markers {
1195 push_checked(out, "<!--/f:s", limit)?;
1196 push_u16(out, slot_id, limit)?;
1197 push_checked(out, "-->", limit)?;
1198 }
1199
1200 pos = else_end;
1202 }
1203
1204 Opcode::ShowElse => {
1205 }
1209
1210 Opcode::List => {
1221 let list_offset = (pos - 1) as u32;
1224 let slot_id = read_u16(ops, pos)?;
1226 let item_slot_id = read_u16(ops, pos + 2)?;
1227 let body_len = read_u32(ops, pos + 4)? as usize;
1228 pos += 8;
1229
1230 check_slot_source(module, slot_id, slots.get(slot_id));
1231 state.note_row_level_content();
1234
1235 let body_start = pos;
1236 let body_end = bounded_offset(pos, body_len, region_limit)?;
1237
1238 let key_path = match module
1245 .key_paths
1246 .binary_search_by_key(&list_offset, |path| path.list_offset)
1247 {
1248 Ok(index) => Some(ListKey::resolve(
1249 list_offset,
1250 &module.key_paths[index].segments,
1251 strings,
1252 )?),
1253 Err(_) => None,
1254 };
1255
1256 let list_marker_id = slot_id;
1259 let markers = state.mode == RenderMode::Adoptable && state.text_only.is_none();
1260 if markers {
1261 push_checked(out, "<!--f:l", limit)?;
1262 push_u16(out, list_marker_id, limit)?;
1263 push_checked(out, "-->", limit)?;
1264 }
1265
1266 let items = match slots.get(slot_id) {
1272 SlotValue::Array(a) => Some(a.clone()),
1273 _ => None,
1274 };
1275
1276 if let Some(items) = items.filter(|a| !a.is_empty()) {
1277 state.list_depth += 1;
1278 let walked = if state.list_depth > MAX_LIST_DEPTH {
1279 Err(IrError::ListDepthExceeded {
1280 max: MAX_LIST_DEPTH,
1281 })
1282 } else {
1283 walk_list_rows(
1284 module,
1285 slots,
1286 ops,
1287 ListRows {
1288 items: &items,
1289 item_slot_id,
1290 body_start,
1291 body_end,
1292 key: key_path.as_ref(),
1293 },
1294 out,
1295 state,
1296 depth,
1297 )
1298 };
1299 state.list_depth -= 1;
1303 walked?;
1304 }
1305
1306 if markers {
1307 push_checked(out, "<!--/f:l", limit)?;
1308 push_u16(out, list_marker_id, limit)?;
1309 push_checked(out, "-->", limit)?;
1310 }
1311
1312 pos = body_end;
1313 }
1314
1315 Opcode::Coalesce => {
1316 let source = read_u16(ops, pos)?;
1317 let fallback = read_u16(ops, pos + 2)?;
1318 let destination = read_u16(ops, pos + 4)?;
1319 pos += 6;
1320 let chosen = if matches!(slots.get(source), SlotValue::Null) {
1321 slots.get(fallback)
1322 } else {
1323 slots.get(source)
1324 };
1325 let previous = slots.replace(destination, chosen.clone());
1326 state.journal_write(destination, previous);
1327 }
1328
1329 Opcode::Prop => {
1330 let src_slot_id = read_u16(ops, pos)?;
1332 let prop_str_idx = read_u32(ops, pos + 2)?;
1333 let target_slot_id = read_u16(ops, pos + 6)?;
1334 pos += 8;
1335
1336 let prop_name = strings.get(prop_str_idx)?;
1337 let value = slots.get(src_slot_id).get_property(prop_name);
1338 let previous = slots.replace(target_slot_id, value);
1343 state.journal_write(target_slot_id, previous);
1344 }
1345 }
1346 }
1347
1348 match mode {
1350 WalkMode::Range { .. } => {
1351 if state.pending_tag_close {
1361 close_start_tag(out, state, limit)?;
1362 }
1363 Ok(())
1364 }
1365 WalkMode::UntilIslandEnd { target_island_id } => {
1366 Err(IrError::IslandNotFound(target_island_id))
1369 }
1370 }
1371}
1372
1373struct ListKey<'m> {
1379 list_offset: u32,
1381 segments: Vec<&'m str>,
1383 path: Rc<str>,
1386}
1387
1388impl<'m> ListKey<'m> {
1389 fn resolve(
1390 list_offset: u32,
1391 segments: &[u32],
1392 strings: &'m crate::parser::StringTable,
1393 ) -> Result<Self, IrError> {
1394 let segments = segments
1395 .iter()
1396 .map(|segment| strings.get(*segment))
1397 .collect::<Result<Vec<&str>, IrError>>()?;
1398 let path = Rc::from(dotted_path(&segments));
1399 Ok(ListKey {
1400 list_offset,
1401 segments,
1402 path,
1403 })
1404 }
1405}
1406
1407fn dotted_path(segments: &[&str]) -> String {
1411 let mut path = String::new();
1412 for segment in segments {
1413 if segment.is_empty() || segment.contains(['.', '[', ']']) {
1414 path.push_str(&format!("[{segment:?}]"));
1415 } else {
1416 if !path.is_empty() {
1417 path.push('.');
1418 }
1419 path.push_str(segment);
1420 }
1421 }
1422 path
1423}
1424
1425struct ListRows<'a, 'm> {
1427 items: &'a [SlotValue],
1428 item_slot_id: u16,
1429 body_start: usize,
1430 body_end: usize,
1431 key: Option<&'a ListKey<'m>>,
1432}
1433
1434fn walk_list_rows(
1442 module: &IrModule,
1443 slots: &mut SlotData,
1444 ops: &[u8],
1445 rows: ListRows<'_, '_>,
1446 out: &mut String,
1447 state: &mut WalkState,
1448 depth: usize,
1449) -> Result<(), IrError> {
1450 let limit = state.max_output_bytes;
1451 let remaining = state
1462 .max_list_iterations
1463 .saturating_sub(state.list_iterations);
1464 if rows.items.len() > remaining {
1465 return Err(IrError::ListBudgetExceeded {
1466 max: state.max_list_iterations,
1467 });
1468 }
1469 let mut seen_keys: HashMap<Rc<str>, usize> = HashMap::new();
1478 for (ordinal, item) in rows.items.iter().enumerate() {
1479 if state.list_iterations >= state.max_list_iterations {
1486 return Err(IrError::ListBudgetExceeded {
1487 max: state.max_list_iterations,
1488 });
1489 }
1490 state.list_iterations += 1;
1491
1492 if let Some(key) = rows.key {
1496 let refuse = |reason| list_key_error(key.list_offset, ordinal, &key.path, reason);
1497 let value = crate::keys::key_value_at(item, &key.segments).map_err(refuse)?;
1498 if crate::keys::normalized_len_exceeds(value, limit.saturating_sub(out.len())) {
1502 return Err(IrError::OutputLimitExceeded { limit });
1503 }
1504 let text: Rc<str> = crate::keys::canonical_key_text(value)
1505 .map_err(refuse)?
1506 .into();
1507 if let Some(&first_row) = seen_keys.get(&text) {
1508 return Err(refuse(ListKeyReason::Duplicate { first_row }));
1510 }
1511 seen_keys.insert(Rc::clone(&text), ordinal);
1512 state.keyed_rows.push(KeyedRow {
1513 list_offset: key.list_offset,
1514 ordinal,
1515 path: Rc::clone(&key.path),
1516 key: Some(text),
1517 entry_depth: state.element_depth,
1518 roots: 0,
1519 stray_content: false,
1520 envelope: None,
1521 });
1522 }
1523
1524 let mark = state.slot_journal.len();
1528 let previous = slots.replace(rows.item_slot_id, item.clone());
1529 state.slot_journal.push((rows.item_slot_id, previous));
1530
1531 let walked = walk_range(
1532 module,
1533 slots,
1534 ops,
1535 rows.body_start,
1536 rows.body_end,
1537 out,
1538 state,
1539 depth + 1,
1540 );
1541 state.unwind_slot_journal(slots, mark);
1545 let keyed_row = if rows.key.is_some() {
1548 state.keyed_rows.pop()
1549 } else {
1550 None
1551 };
1552 walked?;
1553
1554 if let Some(row) = keyed_row {
1555 if state.element_depth != row.entry_depth {
1556 return Err(row.refuse(ListKeyReason::UnbalancedRow));
1559 }
1560 if row.roots != 1 || row.stray_content || row.envelope.is_some() {
1561 return Err(row.refuse(ListKeyReason::RowShape {
1562 roots: row.roots,
1563 stray_content: row.stray_content || row.envelope.is_some(),
1564 }));
1565 }
1566 }
1567 }
1568 Ok(())
1569}
1570
1571fn list_key_error(list_offset: u32, row: usize, path: &str, reason: ListKeyReason) -> IrError {
1573 IrError::InvalidListKey(Box::new(ListKeyViolation::new(
1574 list_offset,
1575 row,
1576 path,
1577 reason,
1578 )))
1579}
1580
1581fn innermost_row_error(state: &WalkState, reason: ListKeyReason, fallback: IrError) -> IrError {
1584 match state.keyed_rows.last() {
1585 Some(row) => row.refuse(reason),
1586 None => fallback,
1587 }
1588}
1589
1590fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1600 base.checked_add(len)
1601 .filter(|e| *e <= limit)
1602 .ok_or(IrError::InvalidSection)
1603}
1604
1605fn is_valid_name(name: &str) -> bool {
1619 let mut chars = name.chars();
1620 match chars.next() {
1621 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1622 _ => return false,
1623 }
1624 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1625}
1626
1627fn is_valid_attr_name(name: &str) -> bool {
1630 is_valid_name(name)
1631 && name
1632 .strip_prefix("xlink:")
1633 .map_or(true, |local| is_valid_name(local) && !local.contains(':'))
1634}
1635
1636fn is_event_handler_attr(name: &str) -> bool {
1638 name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1639}
1640
1641const URL_ATTRS: [&str; 8] = [
1649 "href",
1650 "src",
1651 "action",
1652 "formaction",
1653 "xlink:href",
1654 "poster",
1655 "background",
1656 "data",
1657];
1658
1659const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1663 "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1664 "td", "th",
1665];
1666
1667fn is_url_attr(name: &str) -> bool {
1669 URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1670}
1671
1672fn is_animation_tag(tag: &str) -> bool {
1679 let bytes = tag.as_bytes();
1680 tag.eq_ignore_ascii_case("set")
1681 || (bytes.len() >= 7
1682 && bytes[..7].eq_ignore_ascii_case(b"animate")
1683 && bytes[7..]
1684 .iter()
1685 .all(|b| b.is_ascii_alphanumeric() || *b == b'_'))
1686}
1687
1688const ANIMATION_VALUE_ATTRS: [&str; 4] = ["to", "from", "by", "values"];
1691
1692fn is_dangerous_url_attr(tag: &str, key: &str, value: &str) -> bool {
1705 if is_url_attr(key) {
1706 return is_dangerous_url(value, tag);
1707 }
1708 is_animation_tag(tag)
1709 && ANIMATION_VALUE_ATTRS
1710 .iter()
1711 .any(|a| key.eq_ignore_ascii_case(a))
1712 && value.split(';').any(|item| is_dangerous_url(item, ""))
1713}
1714
1715fn is_dangerous_url(value: &str, tag: &str) -> bool {
1724 if value.contains('\0') {
1733 return true;
1734 }
1735 let normalized: String = value
1736 .chars()
1737 .filter(|c| {
1738 let n = *c as u32;
1739 !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1740 })
1741 .collect();
1742 let lower = normalized.to_ascii_lowercase();
1743
1744 if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1745 return true;
1746 }
1747 if lower.starts_with("data:text/html") {
1748 return true;
1749 }
1750 if lower.starts_with("data:image/svg+xml") {
1755 return !IMAGE_CONTEXT_TAGS
1756 .iter()
1757 .any(|t| tag.eq_ignore_ascii_case(t));
1758 }
1759 false
1760}
1761
1762#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1769enum AttrValue<'a> {
1770 Bare,
1772 Valued(&'a str),
1774}
1775
1776const HTML_BOOLEAN_ATTRIBUTES: [&str; 22] = [
1781 "async",
1782 "autofocus",
1783 "autoplay",
1784 "checked",
1785 "controls",
1786 "default",
1787 "defer",
1788 "disabled",
1789 "formnovalidate",
1790 "hidden",
1791 "ismap",
1792 "loop",
1793 "multiple",
1794 "muted",
1795 "nomodule",
1796 "novalidate",
1797 "open",
1798 "playsinline",
1799 "readonly",
1800 "required",
1801 "reversed",
1802 "selected",
1803];
1804
1805fn is_html_boolean_attribute(name: &str) -> bool {
1806 HTML_BOOLEAN_ATTRIBUTES.contains(&name)
1807}
1808
1809fn emit_attr(
1816 out: &mut String,
1817 tag: &str,
1818 key: &str,
1819 val: AttrValue<'_>,
1820 limit: usize,
1821) -> Result<(), IrError> {
1822 if !is_valid_attr_name(key) {
1823 #[cfg(feature = "tracing")]
1827 tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1828 return Ok(());
1829 }
1830 if is_event_handler_attr(key) {
1831 #[cfg(feature = "tracing")]
1832 tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1833 return Ok(());
1834 }
1835 if let AttrValue::Valued(v) = val {
1836 if is_dangerous_url_attr(tag, key, v) {
1837 #[cfg(feature = "tracing")]
1838 tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1839 return Ok(());
1840 }
1841 }
1842
1843 push_checked(out, " ", limit)?;
1844 push_checked(out, key, limit)?;
1845 if let AttrValue::Valued(v) = val {
1846 push_checked(out, "=\"", limit)?;
1847 push_escaped_attr(out, v, limit)?;
1848 push_checked(out, "\"", limit)?;
1849 }
1850 Ok(())
1851}
1852
1853fn emit_open_tag(
1859 out: &mut String,
1860 tag: &str,
1861 attrs: &[(&str, &str)],
1862 has_content: bool,
1863 state: &mut WalkState,
1864 limit: usize,
1865) -> Result<(), IrError> {
1866 if !is_valid_name(tag) {
1867 return Err(IrError::InvalidName(tag.to_string()));
1868 }
1869 let row_key = state.open_row_root();
1872 state.open_tag_is_row_root = row_key.is_some();
1873 if row_key.is_some()
1874 && attrs
1875 .iter()
1876 .any(|(key, _)| key.eq_ignore_ascii_case("data-forma-key"))
1877 {
1878 return Err(innermost_row_error(
1882 state,
1883 ListKeyReason::AuthoredKey,
1884 IrError::InvalidName("data-forma-key".to_string()),
1885 ));
1886 }
1887 if has_content {
1888 enter_element(out, tag, state, limit)?;
1889 state.element_depth += 1;
1890 }
1891 push_checked(out, "<", limit)?;
1892 push_checked(out, tag, limit)?;
1893
1894 for (key, val) in attrs {
1895 if consume_control_attr(state, key, Prop::Static(val)) {
1898 continue;
1899 }
1900 let value = if val.is_empty() {
1904 AttrValue::Bare
1905 } else {
1906 AttrValue::Valued(val)
1907 };
1908 emit_attr(out, tag, key, value, limit)?;
1909 }
1910
1911 if let Some(island) = state.pending_island.take() {
1913 push_checked(out, " data-forma-island=\"", limit)?;
1914 push_u16(out, island.id, limit)?;
1915 push_checked(out, "\" data-forma-component=\"", limit)?;
1916 push_escaped_attr(out, &island.component_name, limit)?;
1917 push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1918 let trigger_str = match island.trigger {
1919 IslandTrigger::Load => "load",
1920 IslandTrigger::Visible => "visible",
1921 IslandTrigger::Interaction => "interaction",
1922 IslandTrigger::Idle => "idle",
1923 };
1924 push_checked(out, " data-forma-hydrate=\"", limit)?;
1925 push_checked(out, trigger_str, limit)?;
1926 push_checked(out, "\"", limit)?;
1927 push_checked(out, " data-forma-state=\"", limit)?;
1928 push_checked(out, &island.occurrence.to_string(), limit)?;
1929 push_checked(out, "\" data-forma-h=\"", limit)?;
1930 push_checked(out, &island.hash, limit)?;
1931 push_checked(out, "\"", limit)?;
1932 }
1933
1934 if let Some(key) = row_key.filter(|_| state.mode == RenderMode::Adoptable) {
1939 push_checked(out, " data-forma-key=\"", limit)?;
1940 push_escaped_attr(out, &key, limit)?;
1941 push_checked(out, "\"", limit)?;
1942 }
1943
1944 state.pending_tag_close = true;
1946 state.pending_tag_name.clear();
1947 state.pending_tag_name.push_str(tag);
1948 Ok(())
1949}
1950
1951#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1985enum TextOnlyTag {
1986 Textarea,
1987 Title,
1988 Style,
1989 Script,
1990}
1991
1992impl TextOnlyTag {
1993 fn from_tag(tag: &str) -> Option<Self> {
1994 [Self::Textarea, Self::Title, Self::Style, Self::Script]
1995 .into_iter()
1996 .find(|t| tag.eq_ignore_ascii_case(t.name()))
1997 }
1998
1999 fn name(self) -> &'static str {
2000 match self {
2001 TextOnlyTag::Textarea => "textarea",
2002 TextOnlyTag::Title => "title",
2003 TextOnlyTag::Style => "style",
2004 TextOnlyTag::Script => "script",
2005 }
2006 }
2007}
2008
2009#[derive(Debug)]
2011struct TextOnlyParent {
2012 tag: TextOnlyTag,
2013 started: bool,
2016 value_bound: bool,
2019}
2020
2021fn write_text_only(
2034 out: &mut String,
2035 parent: &mut TextOnlyParent,
2036 text: &str,
2037 limit: usize,
2038) -> Result<(), IrError> {
2039 if text.is_empty() {
2040 return Ok(());
2041 }
2042 if parent.tag == TextOnlyTag::Textarea
2043 && !parent.started
2044 && normalized_chars(text).next() == Some('\n')
2045 {
2046 push_checked(out, "\n", limit)?;
2047 }
2048 parent.started = true;
2049 push_escaped_html(out, text, limit)
2050}
2051
2052#[derive(Debug, Default)]
2061enum PendingControl {
2062 #[default]
2063 None,
2064 Textarea { value: Option<String> },
2066 Select {
2068 value: Option<SlotValue>,
2069 multiple: bool,
2070 },
2071 OptionTag { value: Option<String> },
2074}
2075
2076#[derive(Debug)]
2091struct SelectBinding {
2092 wanted: SelectWanted,
2093 matched: bool,
2095}
2096
2097#[derive(Debug)]
2098enum SelectWanted {
2099 One(String),
2100 Any(Vec<String>),
2101}
2102
2103impl SelectBinding {
2104 fn new(value: &SlotValue, multiple: bool) -> Self {
2105 let wanted = match value {
2109 SlotValue::Array(items) if multiple => {
2110 SelectWanted::Any(items.iter().map(js_string).collect())
2111 }
2112 other => SelectWanted::One(normalize_text(&other.to_text()).into_owned()),
2114 };
2115 SelectBinding {
2116 wanted,
2117 matched: false,
2118 }
2119 }
2120
2121 fn selects(&mut self, option_value: &str) -> bool {
2123 match &self.wanted {
2124 SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
2125 SelectWanted::One(value) => {
2126 let hit = !self.matched && value == option_value;
2127 self.matched |= hit;
2128 hit
2129 }
2130 }
2131 }
2132}
2133
2134fn js_string(value: &SlotValue) -> String {
2137 match value {
2138 SlotValue::Null => "null".to_string(),
2139 other => normalize_text(&other.to_text()).into_owned(),
2140 }
2141}
2142
2143#[derive(Debug)]
2146struct OptionByText {
2147 insert_at: usize,
2150 text: String,
2152}
2153
2154#[derive(Clone, Copy)]
2156enum Prop<'a> {
2157 Static(&'a str),
2158 Slot(&'a SlotValue),
2159}
2160
2161impl Prop<'_> {
2162 fn control_text(self) -> String {
2165 match self {
2166 Prop::Static(s) => normalize_text(s).into_owned(),
2167 Prop::Slot(v) => normalize_text(&v.to_text()).into_owned(),
2168 }
2169 }
2170
2171 fn is_written(self) -> bool {
2174 !matches!(
2175 self,
2176 Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
2177 )
2178 }
2179
2180 fn written(self) -> Option<String> {
2183 match self {
2184 _ if !self.is_written() => None,
2185 Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
2186 other => Some(other.control_text()),
2187 }
2188 }
2189
2190 fn to_slot(self) -> SlotValue {
2191 match self {
2192 Prop::Static(s) => SlotValue::Text(s.to_string()),
2193 Prop::Slot(v) => v.clone(),
2194 }
2195 }
2196}
2197
2198fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
2203 match &mut state.pending_control {
2204 PendingControl::None => false,
2205 PendingControl::Textarea { value } => {
2206 if key != "value" {
2207 return false;
2208 }
2209 *value = Some(prop.control_text());
2210 true
2211 }
2212 PendingControl::Select { value, multiple } => match key {
2213 "value" => {
2214 *value = Some(prop.to_slot());
2215 true
2216 }
2217 "multiple" => {
2218 *multiple |= match prop {
2219 Prop::Static(_) => true,
2220 Prop::Slot(value) => value.as_bool(),
2221 };
2222 false
2223 }
2224 _ => false,
2225 },
2226 PendingControl::OptionTag { value } => match key {
2227 "selected" => true,
2230 "value" => {
2231 if value.is_none() {
2233 *value = prop.written();
2234 }
2235 false
2236 }
2237 _ => false,
2238 },
2239 }
2240}
2241
2242fn enter_element(
2245 out: &mut String,
2246 tag: &str,
2247 state: &mut WalkState,
2248 limit: usize,
2249) -> Result<(), IrError> {
2250 let is = |name: &str| tag.eq_ignore_ascii_case(name);
2251 if is("option") || is("optgroup") {
2253 finish_option_by_text(out, state, limit)?;
2254 }
2255 state.pending_control = if is("textarea") {
2256 PendingControl::Textarea { value: None }
2257 } else if is("select") {
2258 PendingControl::Select {
2259 value: None,
2260 multiple: false,
2261 }
2262 } else if is("option") && state.select.is_some() {
2263 PendingControl::OptionTag { value: None }
2264 } else {
2265 PendingControl::None
2266 };
2267 if state.text_only.is_none() {
2268 state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
2269 tag,
2270 started: false,
2271 value_bound: false,
2272 });
2273 }
2274 Ok(())
2275}
2276
2277fn leave_element(
2279 out: &mut String,
2280 tag: &str,
2281 state: &mut WalkState,
2282 limit: usize,
2283) -> Result<(), IrError> {
2284 let is = |name: &str| tag.eq_ignore_ascii_case(name);
2285 if is("option") || is("optgroup") || is("select") {
2286 finish_option_by_text(out, state, limit)?;
2287 }
2288 if is("select") {
2289 state.select = None;
2290 }
2291 state.pre_boundary = None;
2294 if state
2295 .text_only
2296 .as_ref()
2297 .is_some_and(|parent| is(parent.tag.name()))
2298 {
2299 state.text_only = None;
2300 }
2301 Ok(())
2302}
2303
2304fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
2310 state.pending_tag_close = false;
2311 state.open_tag_is_row_root = false;
2312 match std::mem::take(&mut state.pending_control) {
2313 PendingControl::None => {}
2314 PendingControl::Textarea { value } => {
2315 push_checked(out, ">", limit)?;
2316 if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
2317 if parent.tag == TextOnlyTag::Textarea {
2318 parent.value_bound = true;
2319 write_text_only(out, parent, &value, limit)?;
2320 }
2321 }
2322 return Ok(());
2323 }
2324 PendingControl::Select { value, multiple } => {
2325 state.select = value.map(|value| SelectBinding::new(&value, multiple));
2326 }
2327 PendingControl::OptionTag { value: Some(value) } => {
2328 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2329 push_checked(out, " selected", limit)?;
2330 }
2331 }
2332 PendingControl::OptionTag { value: None } => {
2333 state.option_by_text = Some(OptionByText {
2334 insert_at: out.len(),
2335 text: String::new(),
2336 });
2337 }
2338 }
2339 push_checked(out, ">", limit)?;
2340 let tag = &state.pending_tag_name;
2341 if tag.eq_ignore_ascii_case("pre") || tag.eq_ignore_ascii_case("listing") {
2342 state.pre_boundary = Some(out.len());
2343 }
2344 Ok(())
2345}
2346
2347fn finish_option_by_text(
2352 out: &mut String,
2353 state: &mut WalkState,
2354 limit: usize,
2355) -> Result<(), IrError> {
2356 let Some(option) = state.option_by_text.take() else {
2357 return Ok(());
2358 };
2359 let value = strip_and_collapse_ascii_whitespace(&option.text);
2360 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2361 const SELECTED: &str = " selected";
2362 if out.len().saturating_add(SELECTED.len()) > limit {
2363 return Err(IrError::OutputLimitExceeded { limit });
2364 }
2365 out.insert_str(option.insert_at, SELECTED);
2366 }
2367 Ok(())
2368}
2369
2370fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
2373 let mut out = String::with_capacity(text.len());
2374 for word in text.split_ascii_whitespace() {
2375 if !out.is_empty() {
2376 out.push(' ');
2377 }
2378 out.push_str(word);
2379 }
2380 out
2381}
2382
2383pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
2389 if pos + 2 > data.len() {
2390 return Err(IrError::BufferTooShort {
2391 expected: pos + 2,
2392 actual: data.len(),
2393 });
2394 }
2395 Ok(u16::from_le_bytes([data[pos], data[pos + 1]]))
2396}
2397
2398pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
2400 if pos + 4 > data.len() {
2401 return Err(IrError::BufferTooShort {
2402 expected: pos + 4,
2403 actual: data.len(),
2404 });
2405 }
2406 Ok(u32::from_le_bytes([
2407 data[pos],
2408 data[pos + 1],
2409 data[pos + 2],
2410 data[pos + 3],
2411 ]))
2412}
2413
2414pub(crate) fn read_tag_with_attrs<'a>(
2419 ops: &[u8],
2420 pos: usize,
2421 strings: &'a crate::parser::StringTable,
2422) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
2423 let tag_str_idx = read_u32(ops, pos)?;
2424 let attr_count = read_u16(ops, pos + 4)? as usize;
2425 let mut cursor = pos + 6; let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
2431 for _ in 0..attr_count {
2432 let key_idx = read_u32(ops, cursor)?;
2433 let val_idx = read_u32(ops, cursor + 4)?;
2434 let key = strings.get(key_idx)?;
2435 let val = strings.get(val_idx)?;
2436 attrs.push((key, val));
2437 cursor += 8;
2438 }
2439
2440 Ok((tag_str_idx, attrs, cursor))
2441}
2442
2443const MAX_ESCAPE_EXPANSION: usize = 6;
2450
2451fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
2458 if out.len().saturating_add(s.len()) > limit {
2459 return Err(IrError::OutputLimitExceeded { limit });
2460 }
2461 out.push_str(s);
2462 Ok(())
2463}
2464
2465fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
2471 let mut buf = [0u8; 5];
2473 let mut remaining = value;
2474 let mut start = buf.len();
2475 loop {
2476 start -= 1;
2477 buf[start] = b'0' + (remaining % 10) as u8;
2478 remaining /= 10;
2479 if remaining == 0 {
2480 break;
2481 }
2482 }
2483 match core::str::from_utf8(&buf[start..]) {
2486 Ok(digits) => push_checked(out, digits, limit),
2487 Err(e) => Err(IrError::InvalidUtf8(e.to_string())),
2488 }
2489}
2490
2491fn push_char_checked(out: &mut String, ch: char, limit: usize) -> Result<(), IrError> {
2498 if out.len().saturating_add(ch.len_utf8()) > limit {
2499 return Err(IrError::OutputLimitExceeded { limit });
2500 }
2501 out.push(ch);
2502 Ok(())
2503}
2504
2505fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2514 if out
2516 .len()
2517 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2518 <= limit
2519 {
2520 push_escaped_runs::<false>(out, text);
2521 return Ok(());
2522 }
2523 for ch in normalized_chars(text) {
2524 match ch {
2525 '&' => push_checked(out, "&", limit)?,
2526 '<' => push_checked(out, "<", limit)?,
2527 '>' => push_checked(out, ">", limit)?,
2528 _ => push_char_checked(out, ch, limit)?,
2529 }
2530 }
2531 Ok(())
2532}
2533
2534fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2539 if out
2540 .len()
2541 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2542 <= limit
2543 {
2544 push_escaped_runs::<true>(out, text);
2545 return Ok(());
2546 }
2547 for ch in normalized_chars(text) {
2548 match ch {
2549 '&' => push_checked(out, "&", limit)?,
2550 '"' => push_checked(out, """, limit)?,
2551 '<' => push_checked(out, "<", limit)?,
2552 '>' => push_checked(out, ">", limit)?,
2553 _ => push_char_checked(out, ch, limit)?,
2554 }
2555 }
2556 Ok(())
2557}
2558
2559fn push_escaped_runs<const ATTRIBUTE: bool>(out: &mut String, text: &str) {
2564 let bytes = text.as_bytes();
2565 let mut start = 0;
2566 let mut i = 0;
2567 while i < bytes.len() {
2568 let replacement = match bytes[i] {
2569 b'&' => "&",
2570 b'<' => "<",
2571 b'>' => ">",
2572 b'"' if ATTRIBUTE => """,
2573 b'\r' => "\n",
2574 0 => "\u{FFFD}",
2575 _ => {
2576 i += 1;
2577 continue;
2578 }
2579 };
2580 out.push_str(&text[start..i]);
2581 out.push_str(replacement);
2582 if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
2583 i += 1;
2584 }
2585 i += 1;
2586 start = i;
2587 }
2588 out.push_str(&text[start..]);
2589}
2590
2591#[cfg(test)]
2596mod tests {
2597 use super::*;
2598
2599 #[test]
2600 fn bulk_escaping_matches_character_reference_at_every_output_boundary() {
2601 fn reference(
2602 out: &mut String,
2603 text: &str,
2604 limit: usize,
2605 attr: bool,
2606 ) -> Result<(), IrError> {
2607 for ch in normalized_chars(text) {
2608 match ch {
2609 '&' => push_checked(out, "&", limit)?,
2610 '<' => push_checked(out, "<", limit)?,
2611 '>' => push_checked(out, ">", limit)?,
2612 '"' if attr => push_checked(out, """, limit)?,
2613 _ => push_char_checked(out, ch, limit)?,
2614 }
2615 }
2616 Ok(())
2617 }
2618 let mut cases = vec![
2619 String::new(),
2620 "plain UTF-8 é 🦀 text".into(),
2621 "\r\n\r\0<&>\"\r\r\n🦀\0".into(),
2622 "\r".into(),
2623 "\n".into(),
2624 ];
2625 let alphabet = ['a', 'é', '🦀', '\0', '\r', '\n', '&', '<', '>', '"'];
2628 let mut seed = 258_u64;
2629 for length in 0..128 {
2630 let mut text = String::new();
2631 for _ in 0..length {
2632 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
2633 text.push(alphabet[(seed >> 32) as usize % alphabet.len()]);
2634 }
2635 cases.push(text);
2636 }
2637 for text in cases {
2638 for attr in [false, true] {
2639 for limit in 0..=text.len() * MAX_ESCAPE_EXPANSION + 4 {
2640 let mut expected = String::from("é");
2641 let mut actual = expected.clone();
2642 let expected_result = reference(&mut expected, &text, limit, attr);
2643 let actual_result = if attr {
2644 push_escaped_attr(&mut actual, &text, limit)
2645 } else {
2646 push_escaped_html(&mut actual, &text, limit)
2647 };
2648 assert_eq!(
2649 actual_result.is_ok(),
2650 expected_result.is_ok(),
2651 "{text:?}, {limit}, {attr}"
2652 );
2653 assert_eq!(actual, expected, "partial bytes: {text:?}, {limit}, {attr}");
2654 }
2655 }
2656 }
2657 }
2658 use crate::parser::test_helpers::{
2659 build_declared_fixture, encode_close_tag, encode_list, encode_open_tag, encode_show_if,
2660 encode_text, encode_void_tag,
2661 };
2662 use crate::slot::{SlotData, SlotValue};
2663
2664 fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
2668 let mut buf = Vec::new();
2669 buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
2671 buf.extend_from_slice(&marker_id.to_le_bytes());
2672 buf
2673 }
2674
2675 fn encode_island_start(island_id: u16) -> Vec<u8> {
2677 let mut buf = vec![0x0B];
2678 buf.extend_from_slice(&island_id.to_le_bytes());
2679 buf
2680 }
2681
2682 fn encode_island_end(island_id: u16) -> Vec<u8> {
2684 let mut buf = vec![0x0C];
2685 buf.extend_from_slice(&island_id.to_le_bytes());
2686 buf
2687 }
2688
2689 fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
2691 let data = build_declared_fixture(strings, &[], opcodes, &[]);
2692 let module = IrModule::parse(&data).unwrap();
2693 let slots = SlotData::new(0);
2694 walk_to_html(&module, &slots).unwrap()
2695 }
2696
2697 fn walk_with_slots(
2699 strings: &[&str],
2700 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2701 opcodes: &[u8],
2702 slots: &SlotData,
2703 ) -> String {
2704 let data = build_declared_fixture(strings, slot_decls, opcodes, &[]);
2705 let module = IrModule::parse(&data).unwrap();
2706 walk_to_html(&module, slots).unwrap()
2707 }
2708
2709 fn walk_with_islands(
2711 strings: &[&str],
2712 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2713 island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2714 opcodes: &[u8],
2715 slots: &SlotData,
2716 ) -> String {
2717 let data = build_declared_fixture(strings, slot_decls, opcodes, island_decls);
2718 let module = IrModule::parse(&data).unwrap();
2719 walk_to_html(&module, slots).unwrap()
2720 }
2721
2722 fn walk_with_islands_output(
2723 strings: &[&str],
2724 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2725 island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2726 opcodes: &[u8],
2727 slots: &SlotData,
2728 ) -> WalkOutput {
2729 let data = build_declared_fixture(strings, slot_decls, opcodes, island_decls);
2730 let module = IrModule::parse(&data).unwrap();
2731 walk_to_html_counted(&module, slots, WalkLimits::default()).unwrap()
2732 }
2733
2734 fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
2736 let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
2738 bytes.extend_from_slice(&slot_id.to_le_bytes());
2739 bytes
2740 }
2741
2742 #[test]
2745 fn walk_static_div() {
2746 let mut opcodes = Vec::new();
2749 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2750 opcodes.extend_from_slice(&encode_text(3));
2751 opcodes.extend_from_slice(&encode_close_tag(0));
2752
2753 let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
2754 assert_eq!(html, r#"<div class="container">Hello</div>"#);
2755 }
2756
2757 #[test]
2760 fn walk_void_tag() {
2761 let opcodes = encode_void_tag(0, &[(1, 2)]);
2764
2765 let html = walk_static(&["input", "type", "email"], &opcodes);
2766 assert_eq!(html, r#"<input type="email">"#);
2767 }
2768
2769 #[test]
2772 fn walk_nested_elements() {
2773 let mut opcodes = Vec::new();
2776 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2777 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
2778 opcodes.extend_from_slice(&encode_text(2));
2779 opcodes.extend_from_slice(&encode_close_tag(1));
2780 opcodes.extend_from_slice(&encode_close_tag(0));
2781
2782 let html = walk_static(&["div", "span", "Hi"], &opcodes);
2783 assert_eq!(html, "<div><span>Hi</span></div>");
2784 }
2785
2786 #[test]
2789 fn walk_text_escaping() {
2790 let opcodes = encode_text(0);
2792
2793 let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
2794 assert_eq!(html, "<script>alert('xss')</script>");
2795 }
2796
2797 #[test]
2800 fn walk_attr_escaping() {
2801 let opcodes = encode_open_tag(0, &[(1, 2)]);
2804
2805 let html = walk_static(&["div", "title", "a\"b"], &opcodes);
2806 assert_eq!(html, r#"<div title="a"b">"#);
2807 }
2808
2809 #[test]
2810 fn security_probe_bounded_text_escaping() {
2811 let data = build_declared_fixture(&["xxxxxxxxxx<"], &[], &encode_text(0), &[]);
2812 let module = IrModule::parse(&data).unwrap();
2813 let expected = "xxxxxxxxxx<";
2814 let limits = WalkLimits {
2815 max_output_bytes: expected.len(),
2816 ..WalkLimits::default()
2817 };
2818 assert_eq!(
2819 walk_to_html_with_limits(&module, &SlotData::new(0), limits).as_deref(),
2820 Ok(expected)
2821 );
2822 let short = WalkLimits {
2823 max_output_bytes: expected.len() - 1,
2824 ..limits
2825 };
2826 assert!(matches!(
2827 walk_to_html_with_limits(&module, &SlotData::new(0), short),
2828 Err(IrError::OutputLimitExceeded { .. })
2829 ));
2830 }
2831
2832 #[test]
2833 fn security_probe_bounded_attribute_escaping() {
2834 let opcodes = encode_open_tag(0, &[(1, 2)]);
2835 let data = build_declared_fixture(&["div", "title", "xxxxxxxxxx\""], &[], &opcodes, &[]);
2836 let module = IrModule::parse(&data).unwrap();
2837 let expected = "<div title=\"xxxxxxxxxx"\">";
2838 let limits = WalkLimits {
2839 max_output_bytes: expected.len(),
2840 ..WalkLimits::default()
2841 };
2842 assert_eq!(
2843 walk_to_html_with_limits(&module, &SlotData::new(0), limits).as_deref(),
2844 Ok(expected)
2845 );
2846 let short = WalkLimits {
2847 max_output_bytes: expected.len() - 1,
2848 ..limits
2849 };
2850 assert!(matches!(
2851 walk_to_html_with_limits(&module, &SlotData::new(0), short),
2852 Err(IrError::OutputLimitExceeded { .. })
2853 ));
2854 }
2855
2856 #[test]
2859 fn walk_empty_element() {
2860 let mut opcodes = Vec::new();
2862 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2863 opcodes.extend_from_slice(&encode_close_tag(0));
2864
2865 let html = walk_static(&["div"], &opcodes);
2866 assert_eq!(html, "<div></div>");
2867 }
2868
2869 #[test]
2872 fn walk_dyn_text() {
2873 let opcodes = encode_dyn_text(0, 0);
2877
2878 let mut slots = SlotData::new(1);
2879 slots.set(0, SlotValue::Text("World".to_string()));
2880
2881 let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2882 assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
2883 }
2884
2885 #[test]
2886 fn walk_dyn_text_null_emits_zwsp() {
2887 let opcodes = encode_dyn_text(0, 0);
2890 let slots = SlotData::new(1); let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2893 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2894 }
2895
2896 #[test]
2897 fn walk_dyn_text_empty_string_emits_zwsp() {
2898 let opcodes = encode_dyn_text(0, 0);
2900 let mut slots = SlotData::new(1);
2901 slots.set(0, SlotValue::Text(String::new()));
2902
2903 let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2904 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2905 }
2906
2907 #[test]
2910 fn walk_dyn_text_escaping() {
2911 let opcodes = encode_dyn_text(0, 1);
2913
2914 let mut slots = SlotData::new(1);
2915 slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
2916
2917 let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2918 assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
2919 }
2920
2921 #[test]
2924 fn walk_island_markers() {
2925 let mut opcodes = Vec::new();
2929 opcodes.extend_from_slice(&encode_island_start(0));
2930 opcodes.extend_from_slice(&encode_text(0));
2931 opcodes.extend_from_slice(&encode_island_end(0));
2932
2933 let slots = SlotData::new(0);
2934 let html = walk_with_islands(
2935 &["Hello"],
2936 &[],
2937 &[(0, 0x01, 0x01, 0, 0, &[])],
2938 &opcodes,
2939 &slots,
2940 );
2941 assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
2942 }
2943
2944 #[test]
2949 fn walk_boolean_attr() {
2950 let opcodes = encode_void_tag(0, &[(1, 2)]);
2953
2954 let html = walk_static(&["input", "disabled", ""], &opcodes);
2955 assert_eq!(html, "<input disabled>");
2956 }
2957
2958 fn walk_dyn_attr_with(value: SlotValue) -> String {
2962 let mut opcodes = encode_void_tag(0, &[]);
2963 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
2964 let mut slots = SlotData::new(1);
2965 slots.set(0, value);
2966 walk_with_slots(
2967 &["input", "disabled"],
2968 &[(0, 1, 0x02, 0x00, &[])],
2969 &opcodes,
2970 &slots,
2971 )
2972 }
2973
2974 #[test]
2975 fn dyn_attr_bool_matches_the_client_renderer() {
2976 assert_eq!(
2984 walk_dyn_attr_with(SlotValue::Bool(true)),
2985 "<input disabled>",
2986 "true must render the bare attribute"
2987 );
2988 assert_eq!(
2989 walk_dyn_attr_with(SlotValue::Bool(false)),
2990 "<input>",
2991 "false must omit the attribute, not write disabled=\"false\""
2992 );
2993 assert_eq!(
2994 walk_dyn_attr_with(SlotValue::Null),
2995 "<input>",
2996 "null must omit the attribute"
2997 );
2998 }
2999
3000 #[test]
3001 fn dyn_attr_empty_string_and_zero_follow_boolean_presence() {
3002 assert_eq!(
3006 walk_dyn_attr_with(SlotValue::Text(String::new())),
3007 "<input>"
3008 );
3009 assert_eq!(
3010 walk_dyn_attr_with(SlotValue::Text("yes".into())),
3011 "<input disabled>"
3012 );
3013 assert_eq!(walk_dyn_attr_with(SlotValue::Number(0.0)), "<input>");
3014 assert_eq!(
3015 walk_dyn_attr_with(SlotValue::Number(1.0)),
3016 "<input disabled>"
3017 );
3018 }
3019
3020 #[test]
3023 fn walk_multiple_attrs() {
3024 let mut opcodes = Vec::new();
3027 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
3028 opcodes.extend_from_slice(&encode_close_tag(0));
3029
3030 let html = walk_static(
3031 &["div", "id", "main", "class", "container", "data-x", "42"],
3032 &opcodes,
3033 );
3034 assert_eq!(
3035 html,
3036 r#"<div id="main" class="container" data-x="42"></div>"#
3037 );
3038 }
3039
3040 #[test]
3043 fn walk_show_if_true_branch() {
3044 let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
3050
3051 let mut slots = SlotData::new(1);
3052 slots.set(0, SlotValue::Bool(true));
3053
3054 let html = walk_with_slots(
3055 &["visible", "Yes", "No"],
3056 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
3058 &slots,
3059 );
3060 assert!(html.contains("Yes"), "should contain then-branch text");
3061 assert!(!html.contains("No"), "should NOT contain else-branch text");
3062 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
3063 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
3064 }
3065
3066 #[test]
3069 fn walk_show_if_false_branch() {
3070 let then_ops = encode_text(1);
3073 let else_ops = encode_text(2);
3074 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3075
3076 let mut slots = SlotData::new(1);
3077 slots.set(0, SlotValue::Bool(false));
3078
3079 let html = walk_with_slots(
3080 &["visible", "Yes", "No"],
3081 &[(0, 0, 0x02, 0x00, &[])],
3082 &opcodes,
3083 &slots,
3084 );
3085 assert!(html.contains("No"), "should contain else-branch text");
3086 assert!(!html.contains("Yes"), "should NOT contain then-branch text");
3087 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
3088 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
3089 }
3090
3091 #[test]
3094 fn walk_show_if_truthy_text() {
3095 let then_ops = encode_text(1);
3097 let else_ops = encode_text(2);
3098 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3099
3100 let mut slots = SlotData::new(1);
3101 slots.set(0, SlotValue::Text("hello".to_string()));
3102
3103 let html = walk_with_slots(
3104 &["greeting", "Shown", "Hidden"],
3105 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
3107 &slots,
3108 );
3109 assert!(
3110 html.contains("Shown"),
3111 "truthy text should render then branch"
3112 );
3113 assert!(
3114 !html.contains("Hidden"),
3115 "truthy text should NOT render else branch"
3116 );
3117 }
3118
3119 #[test]
3122 fn walk_show_if_falsy_null() {
3123 let then_ops = encode_text(1);
3125 let else_ops = encode_text(2);
3126 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3127
3128 let slots = SlotData::new(1); let html = walk_with_slots(
3131 &["maybe", "Present", "Missing"],
3132 &[(0, 0, 0x01, 0x00, &[])],
3133 &opcodes,
3134 &slots,
3135 );
3136 assert!(html.contains("Missing"), "null should render else branch");
3137 assert!(
3138 !html.contains("Present"),
3139 "null should NOT render then branch"
3140 );
3141 }
3142
3143 #[test]
3146 fn walk_show_if_no_else() {
3147 let then_ops = encode_text(1);
3149 let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
3151
3152 let slots = SlotData::new(1); let html = walk_with_slots(
3155 &["flag", "Content"],
3156 &[(0, 0, 0x02, 0x00, &[])],
3157 &opcodes,
3158 &slots,
3159 );
3160 assert_eq!(
3161 html, "<!--f:s0--><!--/f:s0-->",
3162 "empty else should produce only markers"
3163 );
3164 }
3165
3166 #[test]
3169 fn walk_show_if_nested() {
3170 let inner_then = encode_text(4);
3174 let inner_else = encode_text(5);
3175 let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
3176
3177 let outer_then = inner_show_if; let outer_else = encode_text(3);
3179 let opcodes = encode_show_if(0, &outer_then, &outer_else);
3180
3181 let mut slots = SlotData::new(2);
3182 slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false)); let html = walk_with_slots(
3186 &[
3187 "outer",
3188 "inner",
3189 "outer-yes",
3190 "outer-no",
3191 "inner-yes",
3192 "inner-no",
3193 ],
3194 &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
3195 &opcodes,
3196 &slots,
3197 );
3198 assert!(
3200 html.contains("inner-no"),
3201 "nested: inner false should render inner else"
3202 );
3203 assert!(
3204 !html.contains("inner-yes"),
3205 "nested: inner true branch should not render"
3206 );
3207 assert!(
3208 !html.contains("outer-no"),
3209 "nested: outer else should not render"
3210 );
3211 assert!(
3213 html.contains("<!--f:s0-->"),
3214 "should have outer opening marker"
3215 );
3216 assert!(
3217 html.contains("<!--/f:s0-->"),
3218 "should have outer closing marker"
3219 );
3220 assert!(
3221 html.contains("<!--f:s1-->"),
3222 "should have inner opening marker"
3223 );
3224 assert!(
3225 html.contains("<!--/f:s1-->"),
3226 "should have inner closing marker"
3227 );
3228 }
3229
3230 #[test]
3233 fn walk_show_if_with_elements() {
3234 let mut then_ops = Vec::new();
3237 then_ops.extend_from_slice(&encode_open_tag(1, &[]));
3238 then_ops.extend_from_slice(&encode_text(2));
3239 then_ops.extend_from_slice(&encode_close_tag(1));
3240
3241 let else_ops = encode_text(3);
3242 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3243
3244 let mut slots = SlotData::new(1);
3245 slots.set(0, SlotValue::Bool(true));
3246
3247 let html = walk_with_slots(
3248 &["flag", "span", "Hello", "Bye"],
3249 &[(0, 0, 0x02, 0x00, &[])],
3250 &opcodes,
3251 &slots,
3252 );
3253 assert_eq!(
3254 html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
3255 "then branch should produce full HTML element"
3256 );
3257 }
3258
3259 #[test]
3262 fn walk_list_renders_items() {
3263 let body = encode_dyn_text(1, 0);
3267 let opcodes = encode_list(0, 1, &body);
3268
3269 let mut slots = SlotData::new(2);
3270 slots.set(
3271 0,
3272 SlotValue::array(vec![
3273 SlotValue::Text("Alice".to_string()),
3274 SlotValue::Text("Bob".to_string()),
3275 ]),
3276 );
3277
3278 let html = walk_with_slots(
3279 &["items", "item"],
3280 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
3282 &slots,
3283 );
3284 assert!(html.contains("Alice"), "should contain first item");
3285 assert!(html.contains("Bob"), "should contain second item");
3286 assert!(
3287 html.starts_with("<!--f:l0-->"),
3288 "should start with list open marker"
3289 );
3290 assert!(
3291 html.ends_with("<!--/f:l0-->"),
3292 "should end with list close marker"
3293 );
3294 }
3295
3296 #[test]
3299 fn walk_list_empty_array() {
3300 let body = encode_dyn_text(1, 0);
3303 let opcodes = encode_list(0, 1, &body);
3304
3305 let mut slots = SlotData::new(2);
3306 slots.set(0, SlotValue::array(vec![]));
3307
3308 let html = walk_with_slots(
3309 &["items", "item"],
3310 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3311 &opcodes,
3312 &slots,
3313 );
3314 assert_eq!(
3315 html, "<!--f:l0--><!--/f:l0-->",
3316 "empty array should produce only markers"
3317 );
3318 }
3319
3320 #[test]
3323 fn walk_list_with_elements() {
3324 let mut body = Vec::new();
3328 body.extend_from_slice(&encode_open_tag(2, &[]));
3329 body.extend_from_slice(&encode_dyn_text(1, 0));
3330 body.extend_from_slice(&encode_close_tag(2));
3331
3332 let opcodes = encode_list(0, 1, &body);
3333
3334 let mut slots = SlotData::new(2);
3335 slots.set(
3336 0,
3337 SlotValue::array(vec![
3338 SlotValue::Text("A".to_string()),
3339 SlotValue::Text("B".to_string()),
3340 ]),
3341 );
3342
3343 let html = walk_with_slots(
3344 &["items", "item", "li"],
3345 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3346 &opcodes,
3347 &slots,
3348 );
3349 assert!(
3350 html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
3351 "should render li elements with content, got: {html}"
3352 );
3353 }
3354
3355 #[test]
3358 fn walk_list_null_slot() {
3359 let body = encode_dyn_text(1, 0);
3362 let opcodes = encode_list(0, 1, &body);
3363
3364 let slots = SlotData::new(2); let html = walk_with_slots(
3367 &["items", "item"],
3368 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3369 &opcodes,
3370 &slots,
3371 );
3372 assert_eq!(
3373 html, "<!--f:l0--><!--/f:l0-->",
3374 "null slot should produce only markers"
3375 );
3376 }
3377
3378 #[test]
3381 fn walk_list_nested() {
3382 let inner_body = encode_dyn_text(2, 0);
3387 let inner_list = encode_list(1, 2, &inner_body);
3388 let opcodes = encode_list(0, 1, &inner_list);
3389
3390 let mut slots = SlotData::new(3);
3391 slots.set(
3392 0,
3393 SlotValue::array(vec![
3394 SlotValue::array(vec![
3395 SlotValue::Text("a".to_string()),
3396 SlotValue::Text("b".to_string()),
3397 ]),
3398 SlotValue::array(vec![SlotValue::Text("c".to_string())]),
3399 ]),
3400 );
3401
3402 let html = walk_with_slots(
3403 &["outer", "inner", "item"],
3404 &[
3405 (0, 0, 0x04, 0x00, &[]),
3406 (1, 1, 0x04, 0x00, &[]),
3407 (2, 2, 0x01, 0x00, &[]),
3408 ],
3409 &opcodes,
3410 &slots,
3411 );
3412 assert!(html.contains("a"), "should contain nested item 'a'");
3414 assert!(html.contains("b"), "should contain nested item 'b'");
3415 assert!(html.contains("c"), "should contain nested item 'c'");
3416 assert!(
3418 html.starts_with("<!--f:l0-->"),
3419 "should have outer list open marker"
3420 );
3421 assert!(
3422 html.ends_with("<!--/f:l0-->"),
3423 "should have outer list close marker"
3424 );
3425 assert!(
3427 html.contains("<!--f:l1-->"),
3428 "should have inner list open marker"
3429 );
3430 assert!(
3431 html.contains("<!--/f:l1-->"),
3432 "should have inner list close marker"
3433 );
3434 }
3435
3436 #[test]
3439 fn walk_list_depth_exceeded() {
3440 let innermost_body = encode_dyn_text(5, 0);
3444 let level5 = encode_list(4, 5, &innermost_body);
3446 let level4 = encode_list(3, 4, &level5);
3447 let level3 = encode_list(2, 3, &level4);
3448 let level2 = encode_list(1, 2, &level3);
3449 let level1 = encode_list(0, 1, &level2);
3450
3451 let mut slots = SlotData::new(6);
3452 slots.set(
3454 0,
3455 SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
3456 SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
3457 "deep".to_string(),
3458 )])]),
3459 ])])]),
3460 );
3461 let data = build_declared_fixture(
3464 &["s0", "s1", "s2", "s3", "s4", "s5"],
3465 &[
3466 (0, 0, 0x04, 0x00, &[]),
3467 (1, 1, 0x04, 0x00, &[]),
3468 (2, 2, 0x04, 0x00, &[]),
3469 (3, 3, 0x04, 0x00, &[]),
3470 (4, 4, 0x04, 0x00, &[]),
3471 (5, 5, 0x01, 0x00, &[]),
3472 ],
3473 &level1,
3474 &[],
3475 );
3476 let module = IrModule::parse(&data).unwrap();
3477 let result = walk_to_html(&module, &slots);
3478 assert!(result.is_err(), "should fail with depth exceeded");
3479 match result.unwrap_err() {
3480 IrError::ListDepthExceeded { max: 4 } => {}
3481 other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
3482 }
3483 }
3484
3485 #[test]
3509 fn roundtrip_page_structure() {
3510 let mut opcodes = Vec::new();
3518 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(
3532 &[
3533 "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
3534 "div", "id", "app",
3535 ],
3536 &[],
3537 &opcodes,
3538 &[],
3539 );
3540 let module = IrModule::parse(&ir).unwrap();
3541 let slots = SlotData::new(0);
3542 let html = walk_to_html(&module, &slots).unwrap();
3543 assert_eq!(
3544 html,
3545 r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
3546 );
3547 }
3548
3549 #[test]
3552 fn roundtrip_dynamic_greeting() {
3553 let mut opcodes = Vec::new();
3559 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(
3564 &["div", "class", "greeting", "content"],
3565 &[(0, 3, 0x01, 0x00, &[])], &opcodes,
3567 &[],
3568 );
3569 let module = IrModule::parse(&ir).unwrap();
3570 let mut slots = SlotData::new(1);
3571 slots.set(0, SlotValue::Text("Hello, World".into()));
3572 let html = walk_to_html(&module, &slots).unwrap();
3573 assert_eq!(
3574 html,
3575 r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
3576 );
3577 }
3578
3579 #[test]
3582 fn roundtrip_conditional_content() {
3583 let mut then_ops = Vec::new();
3590 then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3591 then_ops.extend_from_slice(&encode_text(4));
3592 then_ops.extend_from_slice(&encode_close_tag(1));
3593
3594 let mut else_ops = Vec::new();
3595 else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3596 else_ops.extend_from_slice(&encode_text(5));
3597 else_ops.extend_from_slice(&encode_close_tag(1));
3598
3599 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3600
3601 let ir = build_declared_fixture(
3602 &["auth", "span", "class", "badge", "Logged In", "Guest"],
3603 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
3605 &[],
3606 );
3607
3608 {
3610 let module = IrModule::parse(&ir).unwrap();
3611 let mut slots = SlotData::new(1);
3612 slots.set(0, SlotValue::Bool(true));
3613 let html = walk_to_html(&module, &slots).unwrap();
3614 assert_eq!(
3615 html,
3616 r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
3617 );
3618 }
3619
3620 {
3622 let module = IrModule::parse(&ir).unwrap();
3623 let mut slots = SlotData::new(1);
3624 slots.set(0, SlotValue::Bool(false));
3625 let html = walk_to_html(&module, &slots).unwrap();
3626 assert_eq!(
3627 html,
3628 r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
3629 );
3630 }
3631 }
3632
3633 #[test]
3636 fn roundtrip_list_rendering() {
3637 let mut body = Vec::new();
3643 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);
3648
3649 let ir = build_declared_fixture(
3650 &["items", "item", "li"],
3651 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
3653 &[],
3654 );
3655 let module = IrModule::parse(&ir).unwrap();
3656 let mut slots = SlotData::new(2);
3657 slots.set(
3658 0,
3659 SlotValue::array(vec![
3660 SlotValue::Text("Alice".into()),
3661 SlotValue::Text("Bob".into()),
3662 SlotValue::Text("Charlie".into()),
3663 ]),
3664 );
3665 let html = walk_to_html(&module, &slots).unwrap();
3666 assert_eq!(
3667 html,
3668 "<!--f:l0-->\
3669 <li><!--f:t0-->Alice<!--/f:t0--></li>\
3670 <li><!--f:t0-->Bob<!--/f:t0--></li>\
3671 <li><!--f:t0-->Charlie<!--/f:t0--></li>\
3672 <!--/f:l0-->"
3673 );
3674 }
3675
3676 #[test]
3681 fn roundtrip_nested_structure() {
3682 let mut then_ops = Vec::new();
3703 then_ops.extend_from_slice(&encode_open_tag(4, &[]));
3704 then_ops.extend_from_slice(&encode_text(5));
3705 then_ops.extend_from_slice(&encode_close_tag(4));
3706
3707 let show_if_ops = encode_show_if(0, &then_ops, &[]);
3708
3709 let mut list_body = Vec::new();
3711 list_body.extend_from_slice(&encode_open_tag(9, &[]));
3712 list_body.extend_from_slice(&encode_dyn_text(2, 0));
3713 list_body.extend_from_slice(&encode_close_tag(9));
3714
3715 let list_ops = encode_list(1, 2, &list_body);
3716
3717 let mut opcodes = Vec::new();
3719 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(
3727 &[
3728 "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
3729 ],
3730 &[
3731 (0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
3735 &opcodes,
3736 &[],
3737 );
3738 let module = IrModule::parse(&ir).unwrap();
3739 let mut slots = SlotData::new(3);
3740 slots.set(0, SlotValue::Bool(true));
3741 slots.set(
3742 1,
3743 SlotValue::array(vec![
3744 SlotValue::Text("Home".into()),
3745 SlotValue::Text("About".into()),
3746 ]),
3747 );
3748
3749 let html = walk_to_html(&module, &slots).unwrap();
3750 assert_eq!(
3751 html,
3752 r#"<nav class="main-nav"><!--f:s0--><span>Welcome</span><!--/f:s0--><ul><!--f:l1--><li><!--f:t0-->Home<!--/f:t0--></li><li><!--f:t0-->About<!--/f:t0--></li><!--/f:l1--></ul></nav>"#
3753 );
3754 }
3755
3756 #[test]
3763 fn walk_island_attrs_on_open_tag() {
3764 let mut opcodes = Vec::new();
3768 opcodes.extend_from_slice(&encode_island_start(0));
3769 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3770 opcodes.extend_from_slice(&encode_text(1));
3771 opcodes.extend_from_slice(&encode_close_tag(0));
3772 opcodes.extend_from_slice(&encode_island_end(0));
3773
3774 let slots = SlotData::new(0);
3775 let html = walk_with_islands(
3776 &["div", "Hello", "AuthForm"],
3777 &[],
3778 &[(0, 0x01, 0x01, 2, 0, &[])],
3780 &opcodes,
3781 &slots,
3782 );
3783 assert_eq!(
3784 html,
3785 r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load" data-forma-state="0" data-forma-h="0000000000000000">Hello</div><!--/f:i0-->"#
3786 );
3787 }
3788
3789 #[test]
3792 fn walk_island_attrs_with_existing_attrs() {
3793 let mut opcodes = Vec::new();
3796 opcodes.extend_from_slice(&encode_island_start(0));
3797 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3798 opcodes.extend_from_slice(&encode_close_tag(0));
3799 opcodes.extend_from_slice(&encode_island_end(0));
3800
3801 let slots = SlotData::new(0);
3802 let html = walk_with_islands(
3803 &["div", "class", "form", "AuthForm"],
3804 &[],
3805 &[(0, 0x01, 0x01, 3, 0, &[])],
3806 &opcodes,
3807 &slots,
3808 );
3809 assert_eq!(
3810 html,
3811 r#"<!--f:i0--><div class="form" data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load" data-forma-state="0" data-forma-h="0000000000000000"></div><!--/f:i0-->"#
3812 );
3813 }
3814
3815 #[test]
3818 fn walk_island_attrs_on_void_tag() {
3819 let mut opcodes = Vec::new();
3822 opcodes.extend_from_slice(&encode_island_start(0));
3823 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3824 opcodes.extend_from_slice(&encode_island_end(0));
3825
3826 let slots = SlotData::new(0);
3827 let html = walk_with_islands(
3828 &["input", "Widget"],
3829 &[],
3830 &[(0, 0x01, 0x01, 1, 0, &[])],
3831 &opcodes,
3832 &slots,
3833 );
3834 assert_eq!(
3835 html,
3836 r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load" data-forma-state="0" data-forma-h="0000000000000000"><!--/f:i0-->"#
3837 );
3838 }
3839
3840 #[test]
3843 fn declared_props_are_separate_from_body_html() {
3844 let mut opcodes = Vec::new();
3849 opcodes.extend_from_slice(&encode_island_start(0));
3850 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3851 opcodes.extend_from_slice(&encode_close_tag(0));
3852 opcodes.extend_from_slice(&encode_island_end(0));
3853
3854 let mut slots = SlotData::new(2);
3855 slots.set(0, SlotValue::Text("Hello".to_string()));
3856 slots.set(1, SlotValue::Number(42.0));
3857
3858 let output = walk_with_islands_output(
3859 &["div", "MyComponent", "title", "count"],
3860 &[
3861 (0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
3864 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3866 &opcodes,
3867 &slots,
3868 );
3869 assert!(!output.html.contains("data-forma-props"));
3870 assert!(!output.html.contains("<script"));
3871 assert!(output.html.contains("data-forma-component=\"MyComponent\""));
3872 let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
3873 assert_eq!(json["i"][0]["p"]["title"], "Hello");
3874 assert_eq!(json["i"][0]["p"]["count"], 42);
3875 }
3876
3877 #[test]
3890 fn walk_island_no_entry_in_table() {
3891 let mut opcodes = Vec::new();
3896 opcodes.extend_from_slice(&encode_island_start(5));
3897 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3898 opcodes.extend_from_slice(&encode_text(1));
3899 opcodes.extend_from_slice(&encode_close_tag(0));
3900 opcodes.extend_from_slice(&encode_island_end(5));
3901
3902 let data = build_declared_fixture(&["div", "Hello"], &[], &opcodes, &[]);
3903 assert_eq!(
3904 IrModule::parse(&data).unwrap_err(),
3905 IrError::UnknownIslandId(5)
3906 );
3907 }
3908
3909 #[test]
3910 fn walk_nested_island_start_before_a_root_element_is_an_error() {
3911 let mut opcodes = Vec::new();
3915 opcodes.extend_from_slice(&encode_island_start(0));
3916 opcodes.extend_from_slice(&encode_island_start(1));
3917 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3918 opcodes.extend_from_slice(&encode_close_tag(0));
3919 opcodes.extend_from_slice(&encode_island_end(1));
3920 opcodes.extend_from_slice(&encode_island_end(0));
3921
3922 let slots = SlotData::new(0);
3923 let data = build_declared_fixture(
3924 &["div", "Outer", "Inner"],
3925 &[],
3926 &opcodes,
3927 &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3928 );
3929 let module = IrModule::parse(&data).unwrap();
3930 assert_eq!(
3931 walk_to_html(&module, &slots).unwrap_err(),
3932 IrError::NestedIslandRoot { outer: 0, inner: 1 }
3933 );
3934 }
3935
3936 #[test]
3937 fn walk_island_nested_inside_another_islands_root_is_fine() {
3938 let mut opcodes = Vec::new();
3940 opcodes.extend_from_slice(&encode_island_start(0));
3941 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3942 opcodes.extend_from_slice(&encode_island_start(1));
3943 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3944 opcodes.extend_from_slice(&encode_close_tag(0));
3945 opcodes.extend_from_slice(&encode_island_end(1));
3946 opcodes.extend_from_slice(&encode_close_tag(0));
3947 opcodes.extend_from_slice(&encode_island_end(0));
3948
3949 let slots = SlotData::new(0);
3950 let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3951 let html = walk_with_islands(
3952 &["div", "Outer", "Inner"],
3953 &[],
3954 &[
3955 (0, 0x01, 0x01, 1, 0, &[]),
3956 (1, 0x01, 0x01, 2, inner_offset, &[]),
3957 ],
3958 &opcodes,
3959 &slots,
3960 );
3961 assert!(html.contains(r#"data-forma-component="Outer""#));
3962 assert!(html.contains(r#"data-forma-component="Inner""#));
3963 }
3964
3965 #[test]
3968 fn empty_declared_inputs_do_not_emit_inline_props() {
3969 let mut opcodes = Vec::new();
3972 opcodes.extend_from_slice(&encode_island_start(0));
3973 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3974 opcodes.extend_from_slice(&encode_close_tag(0));
3975 opcodes.extend_from_slice(&encode_island_end(0));
3976
3977 let slots = SlotData::new(0);
3978 let html = walk_with_islands(
3979 &["div", "Simple"],
3980 &[],
3981 &[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
3983 &slots,
3984 );
3985 assert!(
3986 !html.contains("data-forma-props"),
3987 "empty slot_ids should not emit props, got: {html}"
3988 );
3989 assert!(
3990 html.contains(r#"data-forma-island="0""#),
3991 "should contain island id"
3992 );
3993 }
3994
3995 fn walk_island_with_prop_payload(payload: &str) -> WalkOutput {
3999 let mut opcodes = Vec::new();
4000 opcodes.extend_from_slice(&encode_island_start(0));
4001 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4002 opcodes.extend_from_slice(&encode_close_tag(0));
4003 opcodes.extend_from_slice(&encode_island_end(0));
4004
4005 let mut slots = SlotData::new(1);
4006 slots.set(0, SlotValue::Text(payload.to_string()));
4007 walk_with_islands_output(
4008 &["div", "Big", "blob"],
4009 &[(0, 2, 0x01, 0x00, &[])],
4010 &[(0, 0x01, 0x01, 1, 0, &[0])],
4011 &opcodes,
4012 &slots,
4013 )
4014 }
4015
4016 #[test]
4017 fn duplicate_slot_names_do_not_invent_declared_island_props() {
4018 let mut opcodes = Vec::new();
4021 opcodes.extend_from_slice(&encode_island_start(0));
4022 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4023 opcodes.extend_from_slice(&encode_close_tag(0));
4024 opcodes.extend_from_slice(&encode_island_end(0));
4025
4026 let mut slots = SlotData::new(2);
4027 slots.set(0, SlotValue::Text("first".into()));
4028 slots.set(1, SlotValue::Text("second".into()));
4029
4030 let output = walk_with_islands_output(
4031 &["div", "Comp", "dup"],
4032 &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
4033 &[(0, 0x01, 0x01, 1, 0, &[])],
4034 &opcodes,
4035 &slots,
4036 );
4037 let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
4038 assert_eq!(json["i"][0]["p"], serde_json::json!({}));
4039 assert!(!output.html.contains("data-forma-props"));
4040 }
4041
4042 #[test]
4043 fn small_props_use_the_owned_state_channel() {
4044 let output = walk_island_with_prop_payload("small");
4045 assert!(output.island_state.as_json().contains("\"blob\":\"small\""));
4046 assert!(!output.html.contains("data-forma-props"));
4047 assert!(!output.html.contains("__forma_islands"));
4048 }
4049
4050 #[test]
4051 fn larger_props_use_the_same_owned_state_channel() {
4052 let payload = "x".repeat(1025);
4053 let output = walk_island_with_prop_payload(&payload);
4054 assert!(!output.html.contains("data-forma-props"));
4055 assert!(!output.html.contains("__forma_islands"));
4056 assert!(output.island_state.as_json().contains(&payload));
4057 }
4058
4059 #[test]
4060 fn diagnostic_fragment_does_not_restore_an_inline_state_channel() {
4061 let mut opcodes = Vec::new();
4064 opcodes.extend_from_slice(&encode_island_start(0));
4065 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4066 opcodes.extend_from_slice(&encode_close_tag(0));
4067 opcodes.extend_from_slice(&encode_island_end(0));
4068
4069 let payload = "x".repeat(1024 + 1);
4070 let mut slots = SlotData::new(1);
4071 slots.set(0, SlotValue::Text(payload));
4072
4073 let data = build_declared_fixture(
4074 &["div", "Big", "blob"],
4075 &[(0, 2, 0x01, 0x00, &[])],
4076 &opcodes,
4077 &[(0, 0x01, 0x01, 1, 0, &[0])],
4078 );
4079 let module = IrModule::parse(&data).unwrap();
4080 let fragment = walk_island(&module, &slots, 0).unwrap();
4081 assert!(!fragment.contains("data-forma-props"));
4082 assert!(!fragment.contains("__forma_islands"));
4083 }
4084
4085 #[test]
4090 fn multiple_islands_share_one_owned_envelope() {
4091 let mut opcodes = Vec::new();
4094 opcodes.extend_from_slice(&encode_island_start(0));
4096 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4097 opcodes.extend_from_slice(&encode_close_tag(0));
4098 opcodes.extend_from_slice(&encode_island_end(0));
4099 let island1_offset = opcodes.len() as u32;
4101 opcodes.extend_from_slice(&encode_island_start(1));
4102 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4103 opcodes.extend_from_slice(&encode_close_tag(1));
4104 opcodes.extend_from_slice(&encode_island_end(1));
4105
4106 let mut slots = SlotData::new(3);
4107 slots.set(0, SlotValue::Text("hello".to_string()));
4108 slots.set(1, SlotValue::Bool(true));
4109 slots.set(2, SlotValue::Text("x".repeat(1024)));
4110
4111 let output = walk_with_islands_output(
4112 &["div", "span", "CompA", "CompB", "msg", "flag", "padding"],
4113 &[
4114 (0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]),
4116 (2, 6, 0x01, 0, &[]), ],
4118 &[
4119 (0, 0x01, 0x02, 2, 0, &[0, 2]), (1, 0x01, 0x02, 3, island1_offset, &[1, 2]), ],
4122 &opcodes,
4123 &slots,
4124 );
4125 let json: serde_json::Value = serde_json::from_str(output.island_state.as_json()).unwrap();
4126 assert_eq!(json["i"][0]["p"]["msg"], "hello");
4127 assert_eq!(json["i"][1]["p"]["flag"], true);
4128 assert_eq!(output.island_state.len(), 2);
4129 assert!(!output.html.contains("<script"));
4130 }
4131
4132 #[test]
4137 fn slot_value_to_json_all_types() {
4138 assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
4140 assert_eq!(
4141 SlotValue::Text("hello".to_string()).to_json(),
4142 serde_json::Value::String("hello".to_string())
4143 );
4144 assert_eq!(
4145 SlotValue::Bool(true).to_json(),
4146 serde_json::Value::Bool(true)
4147 );
4148 assert_eq!(
4149 SlotValue::Bool(false).to_json(),
4150 serde_json::Value::Bool(false)
4151 );
4152 assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
4153 assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
4154 assert_eq!(
4155 SlotValue::array(vec![
4156 SlotValue::Text("a".to_string()),
4157 SlotValue::Number(1.0)
4158 ])
4159 .to_json(),
4160 serde_json::json!(["a", 1])
4161 );
4162 assert_eq!(
4163 SlotValue::object(vec![(
4164 "key".to_string(),
4165 SlotValue::Text("val".to_string())
4166 ),])
4167 .to_json(),
4168 serde_json::json!({"key": "val"})
4169 );
4170 }
4171
4172 #[test]
4175 fn dyn_attr_emits_attribute_with_value() {
4176 let mut opcodes = Vec::new();
4180 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4181 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4182 opcodes.extend_from_slice(&encode_close_tag(0));
4183
4184 let mut slots = SlotData::new(1);
4185 slots.set(0, SlotValue::Text("password".to_string()));
4186
4187 let html = walk_with_slots(
4188 &["input", "type", "input_type"],
4189 &[(0, 2, 0x01, 0x01, b"password")], &opcodes,
4191 &slots,
4192 );
4193 assert_eq!(html, r#"<input type="password"></input>"#);
4194 }
4195
4196 #[test]
4199 fn dyn_attr_skips_when_slot_null() {
4200 let mut opcodes = Vec::new();
4204 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4205 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4206 opcodes.extend_from_slice(&encode_close_tag(0));
4207
4208 let slots = SlotData::new(1); let html = walk_with_slots(
4211 &["div", "class", "cls"],
4212 &[(0, 2, 0x01, 0x00, &[])],
4213 &opcodes,
4214 &slots,
4215 );
4216 assert_eq!(html, "<div></div>");
4217 }
4218
4219 #[test]
4222 fn dyn_attr_multiple_on_same_element() {
4223 let mut opcodes = Vec::new();
4226 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4227 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4228 opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
4229 opcodes.extend_from_slice(&encode_close_tag(0));
4230
4231 let mut slots = SlotData::new(2);
4232 slots.set(0, SlotValue::Text("email".to_string()));
4233 slots.set(1, SlotValue::Text("form-input".to_string()));
4234
4235 let html = walk_with_slots(
4236 &["input", "type", "class", "slot_type", "slot_class"],
4237 &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
4238 &opcodes,
4239 &slots,
4240 );
4241 assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
4242 }
4243
4244 #[test]
4247 fn dyn_attr_on_void_tag() {
4248 let mut opcodes = Vec::new();
4252 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
4253 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4254
4255 let mut slots = SlotData::new(1);
4256 slots.set(0, SlotValue::Text("checkbox".to_string()));
4257
4258 let html = walk_with_slots(
4259 &["input", "type", "slot_type"],
4260 &[(0, 2, 0x01, 0x01, b"text")],
4261 &opcodes,
4262 &slots,
4263 );
4264 assert_eq!(html, r#"<input type="checkbox">"#);
4265 }
4266
4267 #[test]
4270 fn walk_island_basic() {
4271 let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
4286 let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
4288 let close_p = encode_close_tag(2);
4289 let island_end = encode_island_end(0);
4290 let close_div = encode_close_tag(1);
4291
4292 let byte_offset = pre_island.len() as u32; let mut opcodes = Vec::new();
4295 opcodes.extend_from_slice(&pre_island);
4296 opcodes.extend_from_slice(&island_start);
4297 opcodes.extend_from_slice(&open_p);
4298 opcodes.extend_from_slice(&text_hello);
4299 opcodes.extend_from_slice(&close_p);
4300 opcodes.extend_from_slice(&island_end);
4301 opcodes.extend_from_slice(&close_div);
4302
4303 let data = build_declared_fixture(
4304 &["MyIsland", "div", "p", "hello"],
4305 &[],
4306 &opcodes,
4307 &[(0, 0x01, 0x01, 0, byte_offset, &[])], );
4309 let module = IrModule::parse(&data).unwrap();
4310 let slots = SlotData::new(0);
4311
4312 let html = walk_island(&module, &slots, 0).unwrap();
4313 assert_eq!(
4317 html,
4318 "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
4319 data-forma-status=\"pending\" data-forma-hydrate=\"load\" data-forma-state=\"0\" data-forma-h=\"0000000000000000\">hello</p>"
4320 );
4321 assert!(!html.contains("<!--f:i0-->"));
4324 assert!(!html.contains("<!--/f:i0-->"));
4325 }
4326
4327 #[test]
4328 fn walk_island_attrs_match_walk_to_html() {
4329 let island_start = encode_island_start(0);
4332 let open_div = encode_open_tag(1, &[]);
4333 let dyn_text = encode_dyn_text(0, 0);
4334 let close_div = encode_close_tag(1);
4335 let island_end = encode_island_end(0);
4336
4337 let mut opcodes = Vec::new();
4338 opcodes.extend_from_slice(&island_start);
4339 opcodes.extend_from_slice(&open_div);
4340 opcodes.extend_from_slice(&dyn_text);
4341 opcodes.extend_from_slice(&close_div);
4342 opcodes.extend_from_slice(&island_end);
4343
4344 let data = build_declared_fixture(
4345 &["IslandComp", "div", "greeting"],
4346 &[(0, 2, 0x01, 0x00, &[])],
4347 &opcodes,
4348 &[(0, 0x02, 0x01, 0, 0, &[0])], );
4350 let module = IrModule::parse(&data).unwrap();
4351 let mut slots = SlotData::new(1);
4352 slots.set(0, SlotValue::Text("World".to_string()));
4353
4354 let page = walk_to_html(&module, &slots).unwrap();
4355 let fragment = walk_island(&module, &slots, 0).unwrap();
4356
4357 for attr in [
4358 "data-forma-island=\"0\"",
4359 "data-forma-component=\"IslandComp\"",
4360 "data-forma-status=\"pending\"",
4361 "data-forma-hydrate=\"visible\"",
4362 "data-forma-state=\"0\"",
4363 "data-forma-h=\"0000000000000000\"",
4364 ] {
4365 assert!(page.contains(attr), "page missing {attr}: {page}");
4366 assert!(
4367 fragment.contains(attr),
4368 "fragment missing {attr}: {fragment}"
4369 );
4370 }
4371 }
4372
4373 #[test]
4374 fn obsolete_mode_cannot_restore_fragment_inline_props() {
4375 let mut opcodes = Vec::new();
4378 opcodes.extend_from_slice(&encode_island_start(0));
4379 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4380 opcodes.extend_from_slice(&encode_close_tag(1));
4381 opcodes.extend_from_slice(&encode_island_end(0));
4382
4383 let data = build_declared_fixture(
4384 &["IslandComp", "div", "greeting"],
4385 &[(0, 2, 0x01, 0x00, &[])],
4386 &opcodes,
4387 &[(0, 0x01, 0x02, 0, 0, &[0])], );
4389 let module = IrModule::parse(&data).unwrap();
4390 let mut slots = SlotData::new(1);
4391 slots.set(0, SlotValue::Text("World".to_string()));
4392
4393 let fragment = walk_island(&module, &slots, 0).unwrap();
4394 assert!(
4395 !fragment.contains("data-forma-props="),
4396 "diagnostic fragment must not inline props: {fragment}"
4397 );
4398 assert!(
4399 !fragment.contains("__forma_islands"),
4400 "fragment must not emit a second islands block: {fragment}"
4401 );
4402 }
4403
4404 #[test]
4405 fn nested_diagnostic_fragment_has_occurrence_markers() {
4406 let mut opcodes = encode_island_start(0);
4409 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4410 let inner_offset = opcodes.len() as u32;
4411 opcodes.extend_from_slice(&encode_island_start(1));
4412 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4413 opcodes.extend_from_slice(&encode_close_tag(1));
4414 opcodes.extend_from_slice(&encode_island_end(1));
4415 opcodes.extend_from_slice(&encode_close_tag(1));
4416 opcodes.extend_from_slice(&encode_island_end(0));
4417
4418 let data = build_declared_fixture(
4419 &["Outer", "div", "greeting", "Inner"],
4420 &[(0, 2, 0x01, 0x00, &[])],
4421 &opcodes,
4422 &[
4423 (0, 0x01, 0x01, 0, 0, &[]),
4424 (1, 0x01, 0x02, 3, inner_offset, &[0]), ],
4426 );
4427 let module = IrModule::parse(&data).unwrap();
4428 let mut slots = SlotData::new(1);
4429 slots.set(0, SlotValue::Text("World".into()));
4430
4431 let fragment = walk_island(&module, &slots, 0).unwrap();
4432 assert!(
4433 fragment.contains("data-forma-component=\"Inner\""),
4434 "nested island must render: {fragment}"
4435 );
4436 assert!(
4437 fragment.contains("data-forma-state=\"1\""),
4438 "nested island must retain its own occurrence: {fragment}"
4439 );
4440 assert!(!fragment.contains("__forma_islands"), "{fragment}");
4441 }
4442
4443 #[test]
4444 fn walk_island_not_found() {
4445 let opcodes = encode_text(0);
4447 let data = build_declared_fixture(&["Hello"], &[], &opcodes, &[]);
4448 let module = IrModule::parse(&data).unwrap();
4449 let slots = SlotData::new(0);
4450
4451 let result = walk_island(&module, &slots, 5);
4452 assert!(result.is_err());
4453 match result.unwrap_err() {
4454 IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
4456 }
4457 }
4458
4459 #[test]
4460 fn walk_island_with_dyn_text() {
4461 let island_start = encode_island_start(0);
4466 let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
4469 let island_end = encode_island_end(0);
4470
4471 let byte_offset = 0u32; let mut opcodes = Vec::new();
4474 opcodes.extend_from_slice(&island_start);
4475 opcodes.extend_from_slice(&open_div);
4476 opcodes.extend_from_slice(&dyn_text);
4477 opcodes.extend_from_slice(&close_div);
4478 opcodes.extend_from_slice(&island_end);
4479
4480 let data = build_declared_fixture(
4481 &["IslandComp", "div", "greeting"],
4482 &[(0, 2, 0x01, 0x00, &[])], &opcodes,
4484 &[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
4486 let module = IrModule::parse(&data).unwrap();
4487
4488 let mut slots = SlotData::new(1);
4489 slots.set(0, SlotValue::Text("World".to_string()));
4490
4491 let html = walk_island(&module, &slots, 0).unwrap();
4492 assert_eq!(
4494 html,
4495 "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
4496 data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
4497 data-forma-state=\"0\" data-forma-h=\"0000000000000000\">\
4498 <!--f:t0-->World<!--/f:t0--></div>"
4499 );
4500 }
4501
4502 #[test]
4503 fn walk_island_byte_offset_out_of_bounds() {
4504 let opcodes = encode_text(0); let data = build_declared_fixture(
4512 &["BadIsland", "hello"],
4513 &[],
4514 &opcodes,
4515 &[(0, 0x01, 0x01, 0, 9999, &[])], );
4517 let err = IrModule::parse(&data).unwrap_err();
4518 assert_eq!(
4519 err,
4520 IrError::IslandOffsetMismatch {
4521 island_id: 0,
4522 byte_offset: 9999
4523 }
4524 );
4525 }
4526
4527 fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
4531 let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
4533 buf.extend_from_slice(&prop_str_idx.to_le_bytes());
4534 buf.extend_from_slice(&target_slot_id.to_le_bytes());
4535 buf
4536 }
4537
4538 #[test]
4541 fn walk_prop_in_list_body() {
4542 let mut body = Vec::new();
4549 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);
4552
4553 let mut slots = SlotData::new(3);
4554 slots.set(
4555 0,
4556 SlotValue::array(vec![
4557 SlotValue::object(vec![(
4558 "name".to_string(),
4559 SlotValue::Text("Alice".to_string()),
4560 )]),
4561 SlotValue::object(vec![(
4562 "name".to_string(),
4563 SlotValue::Text("Bob".to_string()),
4564 )]),
4565 ]),
4566 );
4567
4568 let html = walk_with_slots(
4569 &["items", "item", "name"],
4570 &[
4571 (0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
4575 &opcodes,
4576 &slots,
4577 );
4578 assert!(html.contains("Alice"), "should contain Alice, got: {html}");
4579 assert!(html.contains("Bob"), "should contain Bob, got: {html}");
4580 }
4581
4582 #[test]
4585 fn walk_prop_multiple_properties() {
4586 let mut body = Vec::new();
4593 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);
4604
4605 let mut slots = SlotData::new(4);
4606 slots.set(
4607 0,
4608 SlotValue::array(vec![
4609 SlotValue::object(vec![
4610 ("name".to_string(), SlotValue::Text("Alice".to_string())),
4611 (
4612 "email".to_string(),
4613 SlotValue::Text("alice@test.com".to_string()),
4614 ),
4615 ]),
4616 SlotValue::object(vec![
4617 ("name".to_string(), SlotValue::Text("Bob".to_string())),
4618 (
4619 "email".to_string(),
4620 SlotValue::Text("bob@test.com".to_string()),
4621 ),
4622 ]),
4623 ]),
4624 );
4625
4626 let html = walk_with_slots(
4627 &["rows", "row", "name", "email", "tr", "td"],
4628 &[
4629 (0, 0, 0x04, 0x00, &[]),
4630 (1, 1, 0x05, 0x00, &[]),
4631 (2, 2, 0x01, 0x00, &[]),
4632 (3, 3, 0x01, 0x00, &[]),
4633 ],
4634 &opcodes,
4635 &slots,
4636 );
4637 assert!(
4638 html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
4639 "should contain Alice td, got: {html}"
4640 );
4641 assert!(
4642 html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
4643 "should contain alice email, got: {html}"
4644 );
4645 assert!(
4646 html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
4647 "should contain Bob td, got: {html}"
4648 );
4649 assert!(
4650 html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
4651 "should contain bob email, got: {html}"
4652 );
4653 }
4654
4655 #[test]
4658 fn walk_prop_on_non_object() {
4659 let mut opcodes = Vec::new();
4662 opcodes.extend_from_slice(&encode_prop(0, 1, 1));
4663 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4664
4665 let mut slots = SlotData::new(2);
4666 slots.set(0, SlotValue::Text("hello".to_string()));
4667
4668 let html = walk_with_slots(
4669 &["src", "name"],
4670 &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4671 &opcodes,
4672 &slots,
4673 );
4674 assert!(
4676 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4677 "non-object PROP should produce empty text, got: {html}"
4678 );
4679 }
4680
4681 #[test]
4684 fn walk_prop_missing_property() {
4685 let mut opcodes = Vec::new();
4688 opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4689 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4690
4691 let mut slots = SlotData::new(2);
4692 slots.set(
4693 0,
4694 SlotValue::object(vec![(
4695 "name".to_string(),
4696 SlotValue::Text("Alice".to_string()),
4697 )]),
4698 );
4699
4700 let html = walk_with_slots(
4701 &["src", "target", "missing"],
4702 &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4703 &opcodes,
4704 &slots,
4705 );
4706 assert!(
4707 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4708 "missing property should produce empty text, got: {html}"
4709 );
4710 }
4711
4712 #[test]
4715 fn walk_nested_show_if_within_limit() {
4716 let inner = encode_text(5);
4720 let level4 = encode_show_if(4, &inner, &[]);
4721 let level3 = encode_show_if(3, &level4, &[]);
4722 let level2 = encode_show_if(2, &level3, &[]);
4723 let level1 = encode_show_if(1, &level2, &[]);
4724 let level0 = encode_show_if(0, &level1, &[]);
4725
4726 let mut slots = SlotData::new(5);
4727 for i in 0..5 {
4728 slots.set(i, SlotValue::Bool(true));
4729 }
4730
4731 let html = walk_with_slots(
4732 &["s0", "s1", "s2", "s3", "s4", "deep"],
4733 &[
4734 (0, 0, 0x02, 0x00, &[]),
4735 (1, 1, 0x02, 0x00, &[]),
4736 (2, 2, 0x02, 0x00, &[]),
4737 (3, 3, 0x02, 0x00, &[]),
4738 (4, 4, 0x02, 0x00, &[]),
4739 ],
4740 &level0,
4741 &slots,
4742 );
4743 assert!(
4744 html.contains("deep"),
4745 "5 levels of nesting should succeed, got: {html}"
4746 );
4747 }
4748
4749 fn script_tag_props_html(payload: &str) -> (String, String) {
4751 let mut opcodes = Vec::new();
4754 opcodes.extend_from_slice(&encode_island_start(0));
4755 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4756 opcodes.extend_from_slice(&encode_close_tag(0));
4757 opcodes.extend_from_slice(&encode_island_end(0));
4758
4759 let mut slots = SlotData::new(2);
4760 slots.set(0, SlotValue::Text(payload.to_string()));
4761 slots.set(1, SlotValue::Text("x".repeat(1024)));
4762
4763 let output = walk_with_islands_output(
4764 &["div", "Comp", "payload", "padding"],
4765 &[(0, 2, 0x01, 0x00, &[]), (1, 3, 0x01, 0, &[])],
4766 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
4767 &opcodes,
4768 &slots,
4769 );
4770
4771 (output.html, output.island_state.as_json().to_owned())
4772 }
4773
4774 #[test]
4775 fn owned_state_has_no_raw_script_openers() {
4776 let (_html, body) = script_tag_props_html("</script>alert(1)");
4779 assert!(
4780 !body.contains('<'),
4781 "state envelope must contain no raw script openers, got: {body}"
4782 );
4783 assert!(body.contains("\\u003c/script>"), "got: {body}");
4784
4785 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4789 assert_eq!(parsed["i"][0]["p"]["payload"], "</script>alert(1)");
4790 }
4791
4792 #[test]
4793 fn owned_state_escapes_existing_comment_script_regression() {
4794 let payload = "<!--<script>PAYLOAD";
4800 let (html, body) = script_tag_props_html(payload);
4801
4802 assert!(
4803 !html.contains("<!--<script"),
4804 "must not emit the script-data-double-escape trigger, got: {html}"
4805 );
4806 assert!(!body.contains('<'), "got: {body}");
4807
4808 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4809 assert_eq!(parsed["i"][0]["p"]["payload"], payload);
4810 }
4811
4812 #[test]
4813 #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4814 fn walk_real_benchmark_ir() {
4815 let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4817 let ir_path = std::fs::read_dir(&dist_dir)
4818 .expect("admin/dist must exist")
4819 .filter_map(|e| e.ok())
4820 .find(|e| {
4821 let name = e.file_name();
4822 let s = name.to_string_lossy();
4823 s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4824 })
4825 .map(|e| e.path())
4826 .expect("platform-benchmark.*.ir must exist in admin/dist");
4827 let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4828 let module = IrModule::parse(&data).expect("IR must parse");
4829
4830 let slots = SlotData::new_from_defaults(&module.slots);
4832 let html = walk_to_html(&module, &slots).expect("walk must succeed");
4833
4834 assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4836 assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4837 assert!(
4838 html.contains("data-forma-island=\"0\""),
4839 "missing island 0 attr"
4840 );
4841
4842 assert!(
4845 html.contains("Search"),
4846 "FilterBar must contain 'Search' label text in SSR"
4847 );
4848 assert!(
4849 html.contains("filter-group"),
4850 "FilterBar must contain filter-group divs in SSR"
4851 );
4852 assert!(
4853 html.contains("Sort by"),
4854 "FilterBar must contain 'Sort by' label text in SSR"
4855 );
4856
4857 assert!(
4859 html.contains("Performance"),
4860 "PerfPanel must contain 'Performance' heading in SSR"
4861 );
4862
4863 assert!(
4866 html.contains("benchmark-data-table"),
4867 "BenchmarkDataTable root class must exist"
4868 );
4869
4870 let start = html
4874 .find("<!--f:i0-->")
4875 .expect("FilterBar island start marker");
4876 let end = html
4877 .find("<!--/f:i0-->")
4878 .expect("FilterBar island end marker");
4879 assert!(end > start, "island end marker must follow its start");
4880 }
4881
4882 #[test]
4885 fn walk_output_limit_trips_inside_one_opcode() {
4886 let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4892 let opcodes = encode_open_tag(0, &attrs);
4893
4894 let data = build_declared_fixture(&["div", "data-x", &big], &[], &opcodes, &[]);
4895 let module = IrModule::parse(&data).unwrap();
4896 let slots = SlotData::new(0);
4897
4898 let limits = WalkLimits {
4899 max_output_bytes: 64 * 1024,
4900 ..WalkLimits::default()
4901 };
4902 let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4903 assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4904
4905 let ok = WalkLimits {
4907 max_output_bytes: 64 * 1024 * 1024,
4908 ..WalkLimits::default()
4909 };
4910 assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4911 }
4912
4913 #[test]
4914 fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4915 let opcodes = encode_dyn_text(0, 0);
4917 let data = build_declared_fixture(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4918 let module = IrModule::parse(&data).unwrap();
4919
4920 let mut slots = SlotData::new(1);
4921 slots.set(0, SlotValue::Text("x".repeat(100_000)));
4922
4923 let limits = WalkLimits {
4924 max_output_bytes: 4096,
4925 ..WalkLimits::default()
4926 };
4927 assert!(matches!(
4928 walk_to_html_with_limits(&module, &slots, limits),
4929 Err(IrError::OutputLimitExceeded { .. })
4930 ));
4931 }
4932
4933 #[test]
4936 fn walk_list_budget_exceeded() {
4937 let body = encode_text(2);
4940 let inner = encode_list(0, 1, &body);
4941 let opcodes = encode_list(0, 1, &inner);
4942
4943 let data = build_declared_fixture(
4944 &["items", "item", "x"],
4945 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4946 &opcodes,
4947 &[],
4948 );
4949 let module = IrModule::parse(&data).unwrap();
4950
4951 let mut slots = SlotData::new(2);
4952 slots.set(
4953 0,
4954 SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4955 );
4956
4957 let err = walk_to_html(&module, &slots).unwrap_err();
4958 assert_eq!(
4959 err,
4960 IrError::ListBudgetExceeded {
4961 max: DEFAULT_MAX_LIST_ITERATIONS
4962 }
4963 );
4964 assert_ne!(
4968 err,
4969 IrError::ListDepthExceeded {
4970 max: MAX_LIST_DEPTH
4971 }
4972 );
4973 }
4974
4975 fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4979 buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4980 }
4981
4982 #[test]
4983 fn parse_rejects_show_if_then_len_past_the_stream() {
4984 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4986 patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data =
4988 build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4989 assert!(matches!(
4990 IrModule::parse(&data),
4991 Err(IrError::MalformedControlFlow { .. })
4992 ));
4993 }
4994
4995 #[test]
4996 fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
4997 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
5000 patch_u32(&mut opcodes, 3, 4); let data =
5002 build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5003 assert!(matches!(
5004 IrModule::parse(&data),
5005 Err(IrError::MalformedControlFlow { .. })
5006 ));
5007 }
5008
5009 #[test]
5010 fn parse_rejects_list_body_len_past_the_stream() {
5011 let mut opcodes = encode_list(0, 1, &encode_text(2));
5013 patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
5014 let data = build_declared_fixture(
5015 &["items", "item", "x"],
5016 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5017 &opcodes,
5018 &[],
5019 );
5020 assert!(matches!(
5021 IrModule::parse(&data),
5022 Err(IrError::MalformedControlFlow { .. })
5023 ));
5024 }
5025
5026 #[test]
5027 fn parse_accepts_zero_length_else_branch_known_residual() {
5028 let opcodes = encode_show_if(0, &encode_text(1), &[]);
5034 let data =
5035 build_declared_fixture(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5036 assert!(IrModule::parse(&data).is_ok());
5037 }
5038
5039 #[test]
5044 fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
5045 let mut opcodes = encode_island_start(0);
5050 opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
5051 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5052 opcodes.extend_from_slice(&encode_close_tag(1));
5053 opcodes.extend_from_slice(&encode_island_end(0));
5054
5055 let slots = SlotData::new(1);
5056 let html = walk_with_islands(
5057 &["Comp", "div", "flag"],
5058 &[(0, 2, 0x02, 0x00, &[])],
5059 &[(0, 0x01, 0x01, 0, 0, &[])],
5060 &opcodes,
5061 &slots,
5062 );
5063 assert!(
5064 html.contains("<div data-forma-island=\"0\""),
5065 "island root must keep its attributes, got: {html}"
5066 );
5067 }
5068
5069 #[test]
5070 fn walk_island_attrs_do_not_leak_past_the_island_end() {
5071 let mut opcodes = encode_island_start(0);
5075 opcodes.extend_from_slice(&encode_island_end(0));
5076 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5077 opcodes.extend_from_slice(&encode_close_tag(1));
5078
5079 let slots = SlotData::new(0);
5080 let html = walk_with_islands(
5081 &["Comp", "div"],
5082 &[],
5083 &[(0, 0x01, 0x01, 0, 0, &[])],
5084 &opcodes,
5085 &slots,
5086 );
5087 assert!(
5088 !html.contains("data-forma-island"),
5089 "island attributes must not leak onto an element outside the island, got: {html}"
5090 );
5091 }
5092
5093 #[test]
5096 fn walk_rejects_hostile_tag_name() {
5097 let opcodes = encode_open_tag(0, &[]);
5100 let data = build_declared_fixture(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
5101 let module = IrModule::parse(&data).unwrap();
5102 let slots = SlotData::new(0);
5103 assert!(matches!(
5104 walk_to_html(&module, &slots),
5105 Err(IrError::InvalidName(_))
5106 ));
5107 }
5108
5109 #[test]
5110 fn walk_rejects_hostile_close_tag_name() {
5111 let mut opcodes = encode_open_tag(0, &[]);
5112 opcodes.extend_from_slice(&encode_close_tag(1));
5113 let data = build_declared_fixture(&["div", "div><script"], &[], &opcodes, &[]);
5114 let module = IrModule::parse(&data).unwrap();
5115 let slots = SlotData::new(0);
5116 assert!(matches!(
5117 walk_to_html(&module, &slots),
5118 Err(IrError::InvalidName(_))
5119 ));
5120 }
5121
5122 #[test]
5123 fn walk_drops_hostile_attribute_names() {
5124 let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
5127 let data =
5128 build_declared_fixture(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
5129 let module = IrModule::parse(&data).unwrap();
5130 let slots = SlotData::new(0);
5131 let html = walk_to_html(&module, &slots).unwrap();
5132 assert_eq!(html, "<div ok-attr=\"v\">");
5133 }
5134
5135 #[test]
5136 fn walk_drops_hostile_boolean_attribute_name() {
5137 let opcodes = encode_open_tag(0, &[(1, 2)]);
5139 let data = build_declared_fixture(&["div", "y\"><script", ""], &[], &opcodes, &[]);
5140 let module = IrModule::parse(&data).unwrap();
5141 let slots = SlotData::new(0);
5142 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5143 }
5144
5145 #[test]
5146 fn walk_drops_hostile_dyn_attr_name() {
5147 let mut opcodes = encode_open_tag(0, &[]);
5148 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5149 let data = build_declared_fixture(
5150 &["div", "z\"><script", "val"],
5151 &[(0, 2, 0x01, 0x00, &[])],
5152 &opcodes,
5153 &[],
5154 );
5155 let module = IrModule::parse(&data).unwrap();
5156 let mut slots = SlotData::new(1);
5157 slots.set(0, SlotValue::Text("x".into()));
5158 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5159 }
5160
5161 #[test]
5164 fn walk_drops_javascript_url_in_dyn_attr() {
5165 let mut opcodes = encode_open_tag(0, &[]);
5166 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5167 let data = build_declared_fixture(
5168 &["a", "href", "url"],
5169 &[(0, 2, 0x01, 0x00, &[])],
5170 &opcodes,
5171 &[],
5172 );
5173 let module = IrModule::parse(&data).unwrap();
5174
5175 for payload in [
5178 "javascript:alert(1)",
5179 "JaVaScRiPt:alert(1)",
5180 "java\tscript:alert(1)",
5181 " javascript:alert(1)",
5182 "vbscript:msgbox(1)",
5183 "data:text/html,<script>alert(1)</script>",
5184 ] {
5185 let mut slots = SlotData::new(1);
5186 slots.set(0, SlotValue::Text(payload.to_string()));
5187 let html = walk_to_html(&module, &slots).unwrap();
5188 assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
5191 }
5192 }
5193
5194 #[test]
5195 fn walk_keeps_safe_urls_and_data_images() {
5196 let mut opcodes = encode_open_tag(0, &[]);
5197 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5198 let data = build_declared_fixture(
5199 &["img", "src", "url"],
5200 &[(0, 2, 0x01, 0x00, &[])],
5201 &opcodes,
5202 &[],
5203 );
5204 let module = IrModule::parse(&data).unwrap();
5205
5206 for payload in [
5207 "/about",
5208 "https://example.com/x?a=1",
5209 "data:image/png;base64,AAAA",
5210 "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
5214 ] {
5215 let mut slots = SlotData::new(1);
5216 slots.set(0, SlotValue::Text(payload.to_string()));
5217 let html = walk_to_html(&module, &slots).unwrap();
5218 assert!(html.contains("src="), "payload {payload:?} was dropped");
5219 }
5220 }
5221
5222 #[test]
5223 fn walk_blocks_svg_data_url_outside_image_context() {
5224 let mut opcodes = encode_open_tag(0, &[]);
5225 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5226 let data = build_declared_fixture(
5227 &["a", "href", "url"],
5228 &[(0, 2, 0x01, 0x00, &[])],
5229 &opcodes,
5230 &[],
5231 );
5232 let module = IrModule::parse(&data).unwrap();
5233 let mut slots = SlotData::new(1);
5234 slots.set(
5235 0,
5236 SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
5237 );
5238 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
5241 }
5242
5243 #[test]
5244 fn walk_drops_event_handler_attributes() {
5245 let opcodes = encode_open_tag(0, &[(1, 2)]);
5247 let data = build_declared_fixture(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
5248 let module = IrModule::parse(&data).unwrap();
5249 assert_eq!(
5250 walk_to_html(&module, &SlotData::new(0)).unwrap(),
5251 "<div>",
5252 "static on* attribute must be dropped"
5253 );
5254
5255 let mut opcodes = encode_open_tag(0, &[]);
5257 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5258 let data = build_declared_fixture(
5259 &["div", "ONCLICK", "h"],
5260 &[(0, 2, 0x01, 0x00, &[])],
5261 &opcodes,
5262 &[],
5263 );
5264 let module = IrModule::parse(&data).unwrap();
5265 let mut slots = SlotData::new(1);
5266 slots.set(0, SlotValue::Text("alert(1)".into()));
5267 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5268 }
5269
5270 #[test]
5271 fn walk_drops_javascript_url_in_static_attr() {
5272 let opcodes = encode_open_tag(0, &[(1, 2)]);
5276 let data =
5277 build_declared_fixture(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
5278 let module = IrModule::parse(&data).unwrap();
5279 assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
5280 }
5281
5282 #[test]
5283 fn walk_drops_a_script_url_in_an_svg_animation_value() {
5284 for tag in [
5291 "animate",
5292 "set",
5293 "animateTransform",
5294 "animateMotion",
5295 "animateColor",
5296 "ANIMATEMOTION",
5297 ] {
5298 for name in ["to", "from", "by", "values"] {
5299 for (value, written) in [
5300 ("javascript:alert(1)", false),
5301 ("#a; java\tscript:alert(1)", false),
5302 ("0;data:text/html,<p>doc;2", false),
5303 ("data:image/svg+xml,<svg onload=alert(1)>", false),
5304 ("#a; #b", true),
5305 ("0;10;20", true),
5306 ("0 50 50;360 50 50", true),
5307 ("0,0;10,10", true),
5308 ("red;blue", true),
5309 ("data:image/png;base64,aA==", true),
5310 ] {
5311 let mut static_page = Page::new();
5312 static_page
5313 .open("svg", &[])
5314 .open(tag, &[("attributeName", "href"), (name, value)])
5315 .close(tag)
5316 .close("svg");
5317 let mut dynamic_page = Page::new();
5318 let slot = dynamic_page.slot(SlotValue::Text(value.to_string()));
5319 dynamic_page
5320 .open("svg", &[])
5321 .open(tag, &[("attributeName", "href")])
5322 .dyn_attr(name, slot)
5323 .close(tag)
5324 .close("svg");
5325 for (writer, html) in [
5326 ("static", static_page.render()),
5327 ("dynamic", dynamic_page.render()),
5328 ] {
5329 assert_eq!(
5330 html.contains(&format!(" {name}=\"")),
5331 written,
5332 "{writer} <{tag} {name}={value:?}>: {html}"
5333 );
5334 assert!(html.contains(" attributeName=\"href\""), "{html}");
5335 }
5336 }
5337 }
5338 }
5339 for (tag, name) in [
5343 ("animate", "begin"),
5344 ("set", "fill"),
5345 ("animateTransform", "type"),
5346 ("animateMotion", "path"),
5347 ("g", "to"),
5348 ("animate-x", "to"),
5349 ] {
5350 let mut page = Page::new();
5351 page.open(tag, &[(name, "javascript:alert(1)")]).close(tag);
5352 assert_eq!(
5353 page.render(),
5354 format!("<{tag} {name}=\"javascript:alert(1)\"></{tag}>")
5355 );
5356 }
5357 }
5358
5359 struct Page {
5374 strings: Vec<String>,
5375 ops: Vec<u8>,
5376 slots: Vec<(u8, SlotValue)>,
5377 islands: Vec<(u16, u32, u32)>,
5378 }
5379
5380 impl Page {
5381 fn new() -> Self {
5382 Page {
5383 strings: Vec::new(),
5384 ops: Vec::new(),
5385 slots: Vec::new(),
5386 islands: Vec::new(),
5387 }
5388 }
5389
5390 fn intern(&mut self, s: &str) -> u32 {
5391 let index = match self.strings.iter().position(|x| x == s) {
5392 Some(i) => i,
5393 None => {
5394 self.strings.push(s.to_string());
5395 self.strings.len() - 1
5396 }
5397 };
5398 u32::try_from(index).unwrap()
5399 }
5400
5401 fn slot(&mut self, value: SlotValue) -> u16 {
5403 let type_byte = match &value {
5404 SlotValue::Null | SlotValue::Text(_) => 0x01,
5405 SlotValue::Bool(_) => 0x02,
5406 SlotValue::Number(_) => 0x03,
5407 SlotValue::Array(_) => 0x04,
5408 SlotValue::Object(_) => 0x05,
5409 };
5410 self.slots.push((type_byte, value));
5411 u16::try_from(self.slots.len() - 1).unwrap()
5412 }
5413
5414 fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
5415 let tag = self.intern(tag);
5416 let attrs: Vec<(u32, u32)> = attrs
5417 .iter()
5418 .map(|(k, v)| (self.intern(k), self.intern(v)))
5419 .collect();
5420 self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
5421 self
5422 }
5423
5424 fn close(&mut self, tag: &str) -> &mut Self {
5425 let tag = self.intern(tag);
5426 self.ops.extend_from_slice(&encode_close_tag(tag));
5427 self
5428 }
5429
5430 fn text(&mut self, text: &str) -> &mut Self {
5431 let text = self.intern(text);
5432 self.ops.extend_from_slice(&encode_text(text));
5433 self
5434 }
5435
5436 fn dyn_text(&mut self, slot: u16) -> &mut Self {
5438 self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
5439 self
5440 }
5441
5442 fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
5443 let name = self.intern(name);
5444 self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
5445 self
5446 }
5447
5448 fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
5450 let name = self.intern(name);
5451 self.ops
5452 .extend_from_slice(&encode_prop(source, name, target));
5453 self
5454 }
5455
5456 fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
5458 let outer = std::mem::take(&mut self.ops);
5459 f(self);
5460 std::mem::replace(&mut self.ops, outer)
5461 }
5462
5463 fn show_if(
5464 &mut self,
5465 slot: u16,
5466 then_ops: impl FnOnce(&mut Self),
5467 else_ops: impl FnOnce(&mut Self),
5468 ) -> &mut Self {
5469 let then_ops = self.body(then_ops);
5470 let else_ops = self.body(else_ops);
5471 self.ops
5472 .extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
5473 self
5474 }
5475
5476 fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5477 let body = self.body(body);
5478 self.ops.extend_from_slice(&encode_list(slot, item, &body));
5479 self
5480 }
5481
5482 fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5485 let name = self.intern(&format!("Island{id}"));
5486 let offset = u32::try_from(self.ops.len()).unwrap();
5487 self.islands.push((id, name, offset));
5488 self.ops.extend_from_slice(&encode_island_start(id));
5489 body(self);
5490 self.ops.extend_from_slice(&encode_island_end(id));
5491 self
5492 }
5493
5494 fn module_and_slots(&self) -> (IrModule, SlotData) {
5495 let mut strings = self.strings.clone();
5496 let mut decls = Vec::new();
5497 for (id, (type_byte, _)) in self.slots.iter().enumerate() {
5498 strings.push(format!("slot{id}"));
5499 let name = u32::try_from(strings.len() - 1).unwrap();
5500 decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
5501 }
5502 let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
5503 .islands
5504 .iter()
5505 .map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
5506 .collect();
5507 let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
5508 let data = build_declared_fixture(&strings, &decls, &self.ops, &islands);
5509 let module = IrModule::parse(&data).unwrap();
5510 let mut slots = SlotData::new(self.slots.len());
5511 for (id, (_, value)) in self.slots.iter().enumerate() {
5512 slots.set(u16::try_from(id).unwrap(), value.clone());
5513 }
5514 (module, slots)
5515 }
5516
5517 fn render(&self) -> String {
5518 let (module, slots) = self.module_and_slots();
5519 walk_to_html(&module, &slots).unwrap()
5520 }
5521
5522 fn render_island(&self, id: u16) -> String {
5523 let (module, slots) = self.module_and_slots();
5524 walk_island(&module, &slots, id).unwrap()
5525 }
5526 }
5527
5528 mod static_routes {
5529 use super::*;
5530
5531 fn render(page: &Page, limits: WalkLimits) -> Result<WalkOutput, WalkFailure> {
5532 let (module, slots) = page.module_and_slots();
5533 walk_to_html_counted_with_mode(&module, &slots, limits, RenderMode::Static)
5534 }
5535
5536 #[test]
5537 fn static_vectors_omit_markers_and_holders_but_keep_value_rules() {
5538 let cases: serde_json::Value =
5539 serde_json::from_str(include_str!("../tests/fixtures/parity/static-routes.json"))
5540 .unwrap();
5541 for case in cases["text"].as_array().unwrap() {
5542 let mut page = Page::new();
5543 let value = page.slot(crate::json_to_slot_value(&case["value"]));
5544 page.open("p", &[]).dyn_text(value).close("p");
5545 let output = render(&page, WalkLimits::default()).unwrap();
5546 assert_eq!(
5547 output.html,
5548 format!("<p>{}</p>", case["html"].as_str().unwrap())
5549 );
5550 assert_eq!(output.counts.bytes, output.html.len());
5551 assert!(page.render().contains("<!--f:t0-->"));
5553 }
5554 }
5555
5556 #[test]
5557 fn static_dynamic_text_preserves_initial_line_feeds_across_empty_ranges() {
5558 let cases: serde_json::Value =
5559 serde_json::from_str(include_str!("../tests/fixtures/parity/static-routes.json"))
5560 .unwrap();
5561 for tag in ["pre", "listing"] {
5562 for case in cases["pre"].as_array().unwrap() {
5563 let mut page = Page::new();
5564 let empty = page.slot(SlotValue::Null);
5565 let yes = page.slot(SlotValue::Bool(true));
5566 let value = page.slot(crate::json_to_slot_value(&case["value"]));
5567 page.open(tag, &[])
5568 .dyn_text(empty)
5569 .show_if(
5570 yes,
5571 |p| {
5572 p.dyn_text(value);
5573 },
5574 |_| {},
5575 )
5576 .close(tag);
5577 assert_eq!(
5578 render(&page, WalkLimits::default()).unwrap().html,
5579 format!("<{tag}>{}</{tag}>", case["html"].as_str().unwrap())
5580 );
5581 }
5582 }
5583 }
5584
5585 #[test]
5586 fn static_conditionals_lists_and_text_only_parents_keep_visible_values() {
5587 let mut page = Page::new();
5588 let yes = page.slot(SlotValue::Bool(true));
5589 let list = page.slot(SlotValue::array(vec![text("<a>"), text(""), text("b")]));
5590 let item = page.slot(SlotValue::Null);
5591 page.open("ul", &[])
5592 .show_if(
5593 yes,
5594 |p| {
5595 p.list(list, item, |p| {
5596 p.open("li", &[]).dyn_text(item).close("li");
5597 });
5598 },
5599 |p| {
5600 p.text("wrong");
5601 },
5602 )
5603 .close("ul");
5604 assert_eq!(
5605 render(&page, WalkLimits::default()).unwrap().html,
5606 "<ul><li><a></li><li></li><li>b</li></ul>"
5607 );
5608 for tag in ["textarea", "title", "style", "script"] {
5609 let mut page = Page::new();
5610 let value = page.slot(text("\nx&y"));
5611 page.open(tag, &[]).dyn_text(value).close(tag);
5612 assert_eq!(
5613 render(&page, WalkLimits::default()).unwrap().html,
5614 page.render()
5615 );
5616 }
5617 }
5618
5619 #[test]
5620 fn static_limits_charge_actual_bytes_and_list_iterations() {
5621 let mut page = Page::new();
5622 let list = page.slot(SlotValue::array(vec![text("<&"), text("b")]));
5623 let item = page.slot(SlotValue::Null);
5624 page.list(list, item, |p| {
5625 p.open("p", &[]).dyn_text(item).close("p");
5626 });
5627 let html = "<p><&</p><p>b</p>";
5628 let exact = WalkLimits {
5629 max_output_bytes: html.len(),
5630 max_list_iterations: 2,
5631 };
5632 assert_eq!(render(&page, exact).unwrap().html, html);
5633 assert!(matches!(
5634 render(
5635 &page,
5636 WalkLimits {
5637 max_output_bytes: html.len() - 1,
5638 ..exact
5639 }
5640 )
5641 .unwrap_err()
5642 .error,
5643 IrError::OutputLimitExceeded { .. }
5644 ));
5645 assert!(render(
5646 &page,
5647 WalkLimits {
5648 max_list_iterations: 1,
5649 ..exact
5650 }
5651 )
5652 .is_err());
5653 }
5654
5655 #[test]
5656 fn static_mode_refuses_declared_islands_before_any_output() {
5657 let mut page = Page::new();
5658 page.island(0, |p| {
5659 p.open("div", &[]).close("div");
5660 });
5661 let failure = render(&page, WalkLimits::default()).unwrap_err();
5662 assert_eq!(failure.error, IrError::InvalidSection);
5663 assert_eq!(failure.counts, WalkCounts::default());
5664 assert!(page.render().contains("data-forma-island"));
5665 }
5666 }
5667
5668 fn text(s: &str) -> SlotValue {
5669 SlotValue::Text(s.to_string())
5670 }
5671
5672 #[test]
5673 fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
5674 for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
5679 let mut page = Page::new();
5680 let inside = page.slot(text("a<b&c"));
5681 let outside = page.slot(text("world"));
5682 page.open("div", &[])
5683 .open(parent, &[])
5684 .dyn_text(inside)
5685 .close(parent)
5686 .open("p", &[])
5687 .dyn_text(outside)
5688 .close("p")
5689 .close("div");
5690 assert_eq!(
5691 page.render(),
5692 format!(
5693 "<div><{parent}>a<b&c</{parent}>\
5694 <p><!--f:t1-->world<!--/f:t1--></p></div>"
5695 ),
5696 "inside <{parent}>"
5697 );
5698 }
5699 }
5700
5701 #[test]
5702 fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
5703 for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
5706 let mut page = Page::new();
5707 let value = page.slot(text("hello"));
5708 page.open(parent, &[]).dyn_text(value).close(parent);
5709 assert_eq!(
5710 page.render(),
5711 format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
5712 "inside <{parent}>"
5713 );
5714 }
5715 }
5716
5717 #[test]
5718 fn an_empty_value_in_a_text_only_parent_writes_nothing() {
5719 let mut page = Page::new();
5722 let inside = page.slot(text(""));
5723 let outside = page.slot(text(""));
5724 page.open("textarea", &[])
5725 .dyn_text(inside)
5726 .close("textarea")
5727 .open("p", &[])
5728 .dyn_text(outside)
5729 .close("p");
5730 assert_eq!(
5731 page.render(),
5732 "<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
5733 );
5734 }
5735
5736 #[test]
5737 fn a_textarea_doubles_a_leading_line_feed() {
5738 fn textarea(build: impl FnOnce(&mut Page)) -> String {
5742 let mut page = Page::new();
5743 page.open("textarea", &[]);
5744 build(&mut page);
5745 page.close("textarea");
5746 page.render()
5747 }
5748 let cases: [(&str, String, &str); 5] = [
5749 (
5750 "a dynamic value",
5751 textarea(|p| {
5752 let v = p.slot(text("\nhello"));
5753 p.dyn_text(v);
5754 }),
5755 "<textarea>\n\nhello</textarea>",
5756 ),
5757 (
5758 "a CRLF value",
5759 textarea(|p| {
5760 let v = p.slot(text("\r\nhello"));
5761 p.dyn_text(v);
5762 }),
5763 "<textarea>\n\nhello</textarea>",
5766 ),
5767 (
5768 "an empty value first: the first written byte decides",
5769 textarea(|p| {
5770 let empty = p.slot(text(""));
5771 let v = p.slot(text("\nx"));
5772 p.dyn_text(empty).dyn_text(v);
5773 }),
5774 "<textarea>\n\nx</textarea>",
5775 ),
5776 (
5777 "an LF after other content is kept as is",
5778 textarea(|p| {
5779 let v = p.slot(text("\nb"));
5780 p.text("a").dyn_text(v);
5781 }),
5782 "<textarea>a\nb</textarea>",
5783 ),
5784 (
5785 "static text",
5786 textarea(|p| {
5787 p.text("\nstatic");
5788 }),
5789 "<textarea>\n\nstatic</textarea>",
5790 ),
5791 ];
5792 for (case, html, expected) in cases {
5793 assert_eq!(html, expected, "{case}");
5794 }
5795
5796 let mut page = Page::new();
5798 let v = page.slot(text("\nhello"));
5799 page.open("title", &[]).dyn_text(v).close("title");
5800 assert_eq!(page.render(), "<title>\nhello</title>");
5801 }
5802
5803 #[test]
5804 fn no_marker_kind_is_written_inside_a_text_only_parent() {
5805 let mut page = Page::new();
5808 let loading = page.slot(SlotValue::Bool(false));
5809 let name = page.slot(text("Ada"));
5810 let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
5811 let tag = page.slot(SlotValue::Null);
5812 page.open("title", &[])
5813 .show_if(
5814 loading,
5815 |p| {
5816 p.text("Loading");
5817 },
5818 |p| {
5819 p.dyn_text(name);
5820 },
5821 )
5822 .text(" | ")
5823 .list(tags, tag, |p| {
5824 p.dyn_text(tag).text(";");
5825 })
5826 .close("title")
5827 .open("p", &[])
5828 .show_if(
5829 loading,
5830 |p| {
5831 p.text("Loading");
5832 },
5833 |p| {
5834 p.dyn_text(name);
5835 },
5836 )
5837 .close("p");
5838 assert_eq!(
5839 page.render(),
5840 "<title>Ada | x;y;</title>\
5841 <p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
5842 );
5843
5844 let mut page = Page::new();
5846 page.open("textarea", &[])
5847 .island(0, |p| {
5848 p.text("x");
5849 })
5850 .close("textarea")
5851 .island(1, |p| {
5852 p.open("div", &[]).close("div");
5853 });
5854 assert_eq!(
5855 page.render(),
5856 "<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
5857 data-forma-component=\"Island1\" data-forma-status=\"pending\" \
5858 data-forma-hydrate=\"load\" data-forma-state=\"1\" data-forma-h=\"0101010101010101\"></div><!--/f:i1-->"
5859 );
5860 }
5861
5862 #[test]
5863 fn a_textarea_value_binding_is_written_as_its_text() {
5864 let cases = [
5868 (text("hello"), "hello"),
5869 (
5870 text("</textarea><script>x</script>"),
5871 "</textarea><script>x</script>",
5872 ),
5873 (text("\nhello"), "\n\nhello"),
5874 (SlotValue::Null, ""),
5875 (SlotValue::Number(0.0), "0"),
5876 (SlotValue::Bool(false), "false"),
5877 ];
5878 for (value, inner) in cases {
5879 let mut page = Page::new();
5880 let v = page.slot(value.clone());
5881 let note = page.slot(text("note"));
5882 page.open("textarea", &[("name", "t")])
5883 .dyn_attr("value", v)
5884 .close("textarea")
5885 .open("p", &[])
5886 .dyn_text(note)
5887 .close("p");
5888 assert_eq!(
5889 page.render(),
5890 format!(
5891 "<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
5892 ),
5893 "value {value:?}"
5894 );
5895 }
5896
5897 let mut page = Page::new();
5899 page.open("textarea", &[("name", "t"), ("value", "hello")])
5900 .close("textarea");
5901 assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
5902 }
5903
5904 #[test]
5905 fn a_textarea_value_binding_wins_over_its_children() {
5906 let mut page = Page::new();
5909 let v = page.slot(text("bound"));
5910 let child = page.slot(text("child"));
5911 page.open("textarea", &[])
5912 .dyn_attr("value", v)
5913 .dyn_text(child)
5914 .text(" default")
5915 .close("textarea")
5916 .open("p", &[])
5917 .dyn_text(child)
5918 .close("p");
5919 assert_eq!(
5920 page.render(),
5921 "<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
5922 );
5923 }
5924
5925 fn select_page(value: SlotValue) -> String {
5928 let mut page = Page::new();
5929 let v = page.slot(value);
5930 page.open("select", &[("name", "c")]).dyn_attr("value", v);
5931 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5932 page.open("option", &[("value", value)])
5933 .text(label)
5934 .close("option");
5935 }
5936 page.close("select");
5937 page.render()
5938 }
5939
5940 fn option(value: &str, label: &str, selected: bool) -> String {
5941 let selected = if selected { " selected" } else { "" };
5942 format!("<option value=\"{value}\"{selected}>{label}</option>")
5943 }
5944
5945 #[test]
5946 fn a_select_value_binding_selects_the_matching_option() {
5947 for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
5950 let expected = format!(
5951 "<select name=\"c\">{}{}{}</select>",
5952 option("a", "Apple", chosen == "a"),
5953 option("b", "Banana", chosen == "b"),
5954 option("c", "Cherry", chosen == "c"),
5955 );
5956 assert_eq!(select_page(text(value)), expected, "value {value}");
5957 }
5958 }
5959
5960 #[test]
5961 fn a_select_value_binding_compares_as_the_client_string() {
5962 for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
5964 let mut page = Page::new();
5965 let v = page.slot(value.clone());
5966 page.open("select", &[]).dyn_attr("value", v);
5967 for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
5968 page.open("option", &[("value", value)])
5969 .text(label)
5970 .close("option");
5971 }
5972 page.close("select");
5973 let placeholder = if chosen.is_empty() {
5975 "<option value selected>Choose</option>"
5976 } else {
5977 "<option value>Choose</option>"
5978 };
5979 let expected = format!(
5980 "<select>{placeholder}{}{}</select>",
5981 option("1", "One", chosen == "1"),
5982 option("2", "Two", chosen == "2"),
5983 );
5984 assert_eq!(page.render(), expected, "value {value:?}");
5985 }
5986 }
5987
5988 #[test]
5989 fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
5990 let render = |value: &str| {
5993 let mut page = Page::new();
5994 let v = page.slot(text(value));
5995 let label = page.slot(text("Blue"));
5996 page.open("select", &[])
5997 .dyn_attr("value", v)
5998 .open("option", &[])
5999 .text("Red")
6000 .close("option")
6001 .open("option", &[])
6002 .text(" Dark \n green ")
6003 .close("option")
6004 .open("option", &[])
6005 .dyn_text(label)
6006 .close("option")
6007 .close("select");
6008 page.render()
6009 };
6010 assert_eq!(
6011 render("Dark green"),
6012 "<select><option>Red</option><option selected> Dark \n green </option>\
6013 <option><!--f:t1-->Blue<!--/f:t1--></option></select>"
6014 );
6015 assert_eq!(
6016 render("Blue"),
6017 "<select><option>Red</option><option> Dark \n green </option>\
6018 <option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
6019 );
6020 }
6021
6022 #[test]
6023 fn a_select_value_binding_reaches_options_rendered_by_a_list() {
6024 let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
6029 .iter()
6030 .map(|(id, label)| (id.to_string(), label.to_string()))
6031 .collect();
6032 let long: Vec<(String, String)> = (0..45)
6033 .map(|i| (format!("r{i}"), format!("Row {i}")))
6034 .collect();
6035 let cases: [(&[(String, String)], &str, usize); 4] = [
6036 (&short, "a", 0),
6037 (&short, "c", 2),
6038 (&long, "r40", 40),
6039 (&long, "r44", 44),
6040 ];
6041 for (rows_in, value, chosen) in cases {
6042 let mut page = Page::new();
6043 let v = page.slot(text(value));
6044 let rows = rows_in
6045 .iter()
6046 .map(|(id, label)| {
6047 SlotValue::object(vec![
6048 ("id".to_string(), text(id)),
6049 ("label".to_string(), text(label)),
6050 ])
6051 })
6052 .collect();
6053 let items = page.slot(SlotValue::array(rows));
6054 let item = page.slot(SlotValue::Null);
6055 let id = page.slot(SlotValue::Null);
6056 let label = page.slot(SlotValue::Null);
6057 page.open("select", &[])
6058 .dyn_attr("value", v)
6059 .list(items, item, |p| {
6060 p.prop(item, "id", id)
6061 .prop(item, "label", label)
6062 .open("option", &[])
6063 .dyn_attr("value", id)
6064 .dyn_text(label)
6065 .close("option");
6066 })
6067 .close("select");
6068 let expected: String = rows_in
6069 .iter()
6070 .enumerate()
6071 .map(|(i, (id, label))| {
6072 let selected = if i == chosen { " selected" } else { "" };
6073 format!(
6074 "<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
6075 )
6076 })
6077 .collect();
6078 assert_eq!(
6079 page.render(),
6080 format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
6081 "value {value}"
6082 );
6083 }
6084 }
6085
6086 #[test]
6087 fn a_select_binding_ends_with_its_select() {
6088 let mut page = Page::new();
6093 let v = page.slot(text("b"));
6094 page.open("select", &[])
6095 .dyn_attr("value", v)
6096 .open("option", &[("value", "a")])
6097 .text("A")
6098 .close("option")
6099 .close("select")
6100 .open("datalist", &[])
6101 .open("option", &[("value", "b")])
6102 .text("B")
6103 .close("option")
6104 .close("datalist");
6105 assert_eq!(
6106 page.render(),
6107 format!(
6108 "<select>{}</select><datalist>{}</datalist>",
6109 option("a", "A", false),
6110 option("b", "B", false)
6111 )
6112 );
6113 }
6114
6115 #[test]
6116 fn an_options_first_value_attribute_is_its_value() {
6117 for (bound, selected) in [("a", " selected"), ("b", "")] {
6120 let mut page = Page::new();
6121 let v = page.slot(text(bound));
6122 let second = page.slot(text("b"));
6123 page.open("select", &[])
6124 .dyn_attr("value", v)
6125 .open("option", &[("value", "a")])
6126 .dyn_attr("value", second)
6127 .text("Only")
6128 .close("option")
6129 .close("select");
6130 assert_eq!(
6131 page.render(),
6132 format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
6133 "bound to {bound}"
6134 );
6135 }
6136 }
6137
6138 #[test]
6139 fn multiple_counts_only_when_the_attribute_is_written() {
6140 for (multiple, attr, chosen) in [
6145 (SlotValue::Bool(false), "", false),
6146 (SlotValue::Bool(true), " multiple", true),
6147 (SlotValue::Text(String::new()), "", false),
6148 (SlotValue::Number(0.0), "", false),
6149 (SlotValue::Number(-0.0), "", false),
6150 (SlotValue::Text("false".into()), " multiple", true),
6151 (SlotValue::Number(1.0), " multiple", true),
6152 ] {
6153 let mut page = Page::new();
6154 let flag = page.slot(multiple.clone());
6155 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6156 page.open("select", &[])
6157 .dyn_attr("multiple", flag)
6158 .dyn_attr("value", v);
6159 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6160 page.open("option", &[("value", value)])
6161 .text(label)
6162 .close("option");
6163 }
6164 page.close("select");
6165 assert_eq!(
6166 page.render(),
6167 format!(
6168 "<select{attr}>{}{}{}</select>",
6169 option("a", "Apple", chosen),
6170 option("b", "Banana", false),
6171 option("c", "Cherry", chosen)
6172 ),
6173 "multiple {multiple:?}"
6174 );
6175 }
6176 }
6177
6178 #[test]
6179 fn a_null_member_of_a_multiple_value_compares_as_null() {
6180 let mut page = Page::new();
6183 let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
6184 page.open("select", &[("multiple", "")])
6185 .dyn_attr("value", v);
6186 for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
6187 page.open("option", &[("value", value)])
6188 .text(label)
6189 .close("option");
6190 }
6191 page.close("select");
6192 assert_eq!(
6196 page.render(),
6197 format!(
6198 "<select multiple>{}{}{}</select>",
6199 option("null", "Null", true),
6200 option("x", "Empty", false),
6201 option("b", "Banana", true)
6202 )
6203 );
6204 }
6205
6206 #[test]
6207 fn an_option_ends_at_the_next_option_or_optgroup() {
6208 let mut page = Page::new();
6212 let v = page.slot(text("A"));
6213 page.open("select", &[])
6214 .dyn_attr("value", v)
6215 .open("option", &[])
6216 .text("A")
6217 .open("option", &[])
6218 .text("B")
6219 .close("select");
6220 assert_eq!(
6221 page.render(),
6222 "<select><option selected>A<option>B</select>",
6223 "ended by <option>"
6224 );
6225
6226 let mut page = Page::new();
6227 let v = page.slot(text("A"));
6228 page.open("select", &[])
6229 .dyn_attr("value", v)
6230 .open("option", &[])
6231 .text("A")
6232 .open("optgroup", &[("label", "g")])
6233 .open("option", &[("value", "x")])
6234 .text("X")
6235 .close("option")
6236 .close("optgroup")
6237 .close("select");
6238 assert_eq!(
6239 page.render(),
6240 "<select><option selected>A<optgroup label=\"g\">\
6241 <option value=\"x\">X</option></optgroup></select>",
6242 "ended by <optgroup>"
6243 );
6244 }
6245
6246 #[test]
6247 fn a_single_select_selects_only_the_first_matching_option() {
6248 let mut page = Page::new();
6251 let v = page.slot(text("b"));
6252 page.open("select", &[])
6253 .dyn_attr("value", v)
6254 .open("option", &[("value", "b")])
6255 .text("First")
6256 .close("option")
6257 .open("option", &[("value", "b")])
6258 .text("Second")
6259 .close("option")
6260 .close("select");
6261 assert_eq!(
6262 page.render(),
6263 format!(
6264 "<select>{}{}</select>",
6265 option("b", "First", true),
6266 option("b", "Second", false)
6267 )
6268 );
6269 }
6270
6271 #[test]
6272 fn a_multiple_select_selects_every_option_in_an_array_value() {
6273 let render = |attrs: &[(&str, &str)]| {
6277 let mut page = Page::new();
6278 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6279 page.open("select", attrs).dyn_attr("value", v);
6280 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6281 page.open("option", &[("value", value)])
6282 .text(label)
6283 .close("option");
6284 }
6285 page.close("select");
6286 page.render()
6287 };
6288 assert_eq!(
6289 render(&[("multiple", "")]),
6290 format!(
6291 "<select multiple>{}{}{}</select>",
6292 option("a", "Apple", true),
6293 option("b", "Banana", false),
6294 option("c", "Cherry", true)
6295 )
6296 );
6297 assert_eq!(
6298 render(&[]),
6299 format!(
6300 "<select>{}{}{}</select>",
6301 option("a", "Apple", false),
6302 option("b", "Banana", false),
6303 option("c", "Cherry", false)
6304 )
6305 );
6306 }
6307
6308 #[test]
6309 fn a_bound_select_overrides_authored_selected_on_its_options() {
6310 let render = |bound: bool| {
6313 let mut page = Page::new();
6314 let v = page.slot(text("a"));
6315 let on = page.slot(SlotValue::Bool(true));
6316 page.open("select", &[]);
6317 if bound {
6318 page.dyn_attr("value", v);
6319 }
6320 page.open("option", &[("value", "a")])
6321 .text("A")
6322 .close("option")
6323 .open("option", &[("value", "b"), ("selected", "")])
6324 .text("B")
6325 .close("option")
6326 .open("option", &[("value", "c")])
6327 .dyn_attr("selected", on)
6328 .text("C")
6329 .close("option")
6330 .close("select");
6331 page.render()
6332 };
6333 assert_eq!(
6334 render(true),
6335 format!(
6336 "<select>{}{}{}</select>",
6337 option("a", "A", true),
6338 option("b", "B", false),
6339 option("c", "C", false)
6340 )
6341 );
6342 assert_eq!(
6343 render(false),
6344 format!(
6345 "<select>{}{}{}</select>",
6346 option("a", "A", false),
6347 option("b", "B", true),
6348 option("c", "C", true)
6349 )
6350 );
6351 }
6352
6353 #[test]
6354 fn a_static_select_value_attribute_selects_its_option() {
6355 let mut page = Page::new();
6356 page.open("select", &[("value", "b")])
6357 .open("option", &[("value", "a")])
6358 .text("A")
6359 .close("option")
6360 .open("option", &[("value", "b")])
6361 .text("B")
6362 .close("option")
6363 .close("select");
6364 assert_eq!(
6365 page.render(),
6366 format!(
6367 "<select>{}{}</select>",
6368 option("a", "A", false),
6369 option("b", "B", true)
6370 )
6371 );
6372 }
6373
6374 #[test]
6375 fn the_context_rules_hold_in_an_island_fragment() {
6376 let mut page = Page::new();
6379 let body = page.slot(text("hello"));
6380 let choice = page.slot(text("b"));
6381 page.island(0, |p| {
6382 p.open("form", &[])
6383 .open("textarea", &[])
6384 .dyn_text(body)
6385 .close("textarea")
6386 .open("select", &[])
6387 .dyn_attr("value", choice)
6388 .open("option", &[("value", "a")])
6389 .text("A")
6390 .close("option")
6391 .open("option", &[("value", "b")])
6392 .text("B")
6393 .close("option")
6394 .close("select")
6395 .close("form");
6396 });
6397 let inner = format!(
6398 "<textarea>hello</textarea><select>{}{}</select>",
6399 option("a", "A", false),
6400 option("b", "B", true)
6401 );
6402 let full = page.render();
6403 let fragment = page.render_island(0);
6404 for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
6405 assert!(html.contains(&inner), "{path}: {html}");
6406 }
6407 assert!(
6408 full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
6409 "{full}"
6410 );
6411 }
6412
6413 fn branding_module() -> IrModule {
6420 let mut row = encode_open_tag(2, &[]);
6421 row.extend_from_slice(&encode_dyn_text(2, 1));
6422 row.extend_from_slice(&encode_close_tag(2));
6423 let mut ops = encode_island_start(0);
6424 ops.extend_from_slice(&encode_open_tag(0, &[]));
6425 ops.extend_from_slice(&encode_dyn_text(0, 0));
6426 ops.extend_from_slice(&encode_list(1, 2, &row));
6427 ops.extend_from_slice(&encode_close_tag(0));
6428 ops.extend_from_slice(&encode_island_end(0));
6429 let data = build_declared_fixture(
6430 &["div", "Branding", "i", "name", "colors", "color"],
6431 &[
6432 (0, 3, 0x01, 0x00, &[]),
6433 (1, 4, 0x04, 0x00, &[]),
6434 (2, 5, 0x01, 0x01, &[]),
6435 ],
6436 &ops,
6437 &[(0, 0x01, 0x01, 1, 0, &[])],
6438 );
6439 IrModule::parse(&data).unwrap()
6440 }
6441
6442 fn branding(name: &str, colors: usize) -> SlotData {
6443 let mut slots = SlotData::new(3);
6444 slots.set(0, SlotValue::Text(name.to_string()));
6445 slots.set(
6446 1,
6447 SlotValue::array(
6448 (0..colors)
6449 .map(|i| SlotValue::Text(format!("c{i}")))
6450 .collect(),
6451 ),
6452 );
6453 slots
6454 }
6455
6456 #[test]
6457 fn counted_walk_writes_what_walk_to_html_writes_and_counts_it() {
6458 let module = branding_module();
6459 let slots = branding("Acme", 3);
6460 let out = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
6461
6462 assert_eq!(out.html, walk_to_html(&module, &slots).unwrap());
6463 assert_eq!(out.counts.bytes, out.html.len());
6464 assert_eq!(out.counts.nodes, 8);
6467 assert_eq!(out.counts.list_iterations, 3);
6468 }
6469
6470 #[test]
6471 fn counted_walk_returns_the_counts_reached_when_a_budget_stops_it() {
6472 let module = branding_module();
6473
6474 let limits = WalkLimits {
6477 max_list_iterations: 5,
6478 ..WalkLimits::default()
6479 };
6480 let failure = walk_to_html_counted(&module, &branding("Acme", 9), limits).unwrap_err();
6481 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 5 });
6482 assert_eq!(failure.counts.list_iterations, 0);
6483 assert_eq!(failure.counts.nodes, 2);
6484 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6485 assert!(failure.counts.bytes > 0);
6486
6487 let limits = WalkLimits {
6490 max_output_bytes: 400,
6491 ..WalkLimits::default()
6492 };
6493 let failure = walk_to_html_counted(&module, &branding("Acme", 50), limits).unwrap_err();
6494 assert_eq!(failure.error, IrError::OutputLimitExceeded { limit: 400 });
6495 let counts = failure.counts;
6496 assert!(counts.bytes <= 400 && counts.bytes > 300, "{counts:?}");
6497 assert!(
6498 counts.list_iterations > 1 && counts.list_iterations < 50,
6499 "{counts:?}"
6500 );
6501 assert!(
6502 counts.nodes >= 2 + 2 * (counts.list_iterations - 1),
6503 "{counts:?}"
6504 );
6505 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6506 }
6507
6508 #[test]
6509 fn counted_walk_names_no_component_for_a_failure_outside_every_island() {
6510 let mut ops = encode_open_tag(0, &[]);
6512 ops.extend_from_slice(&encode_open_tag(1, &[]));
6513 let data = build_declared_fixture(&["p", "bad name"], &[], &ops, &[]);
6514 let module = IrModule::parse(&data).unwrap();
6515 let failure =
6516 walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6517 assert_eq!(failure.error, IrError::InvalidName("bad name".into()));
6518 assert!(failure.component_path.is_empty());
6519 assert_eq!(failure.counts.nodes, 1);
6520 }
6521
6522 #[test]
6523 fn counted_walk_names_every_island_around_a_failure_outermost_first() {
6524 let mut ops = encode_island_start(0); ops.extend_from_slice(&encode_open_tag(0, &[]));
6529 let done_at = ops.len() as u32;
6530 ops.extend_from_slice(&encode_island_start(2)); ops.extend_from_slice(&encode_open_tag(0, &[]));
6532 ops.extend_from_slice(&encode_close_tag(0));
6533 ops.extend_from_slice(&encode_island_end(2));
6534 let card_at = ops.len() as u32;
6535 ops.extend_from_slice(&encode_island_start(1)); ops.extend_from_slice(&encode_open_tag(0, &[]));
6537 ops.extend_from_slice(&encode_open_tag(4, &[])); ops.extend_from_slice(&encode_close_tag(0));
6539 ops.extend_from_slice(&encode_island_end(1));
6540 ops.extend_from_slice(&encode_close_tag(0));
6541 ops.extend_from_slice(&encode_island_end(0));
6542 let data = build_declared_fixture(
6543 &["div", "Shell", "Card", "Done", "bad name"],
6544 &[],
6545 &ops,
6546 &[
6547 (0, 0x01, 0x01, 1, 0, &[]),
6548 (1, 0x01, 0x01, 2, card_at, &[]),
6549 (2, 0x01, 0x01, 3, done_at, &[]),
6550 ],
6551 );
6552 let module = IrModule::parse(&data).unwrap();
6553 let failure =
6554 walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6555 assert_eq!(
6556 failure.component_path,
6557 vec!["Shell".to_string(), "Card".to_string()]
6558 );
6559 }
6560
6561 #[test]
6562 fn the_fault_trigger_is_armed_only_by_the_counted_walk() {
6563 let module = branding_module();
6567 let slots = branding(crate::fault_injection::FAULT_TRIGGER, 1);
6568 let html = walk_to_html(&module, &slots).expect("walk_to_html never arms it");
6569 assert!(html.contains("forma-test-fault"), "{html}");
6570 let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6571 assert!(matches!(failure.error, IrError::Panicked { .. }));
6572 }
6573
6574 #[test]
6575 fn counted_walk_catches_a_panic_and_names_its_island() {
6576 let module = branding_module();
6580 let slots = branding(crate::fault_injection::FAULT_TRIGGER, 2);
6581 let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6582 match &failure.error {
6583 IrError::Panicked { message } => {
6584 assert!(message.contains("stand-in"), "{message}")
6585 }
6586 other => panic!("expected Panicked, got {other:?}"),
6587 }
6588 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6589 let out =
6591 walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
6592 assert!(out.html.contains("Acme"));
6593 }
6594
6595 fn lists_module(lists: usize, nested: bool) -> IrModule {
6601 let mut ops = encode_open_tag(0, &[]);
6602 let mut slots: Vec<(u16, u32, u8, u8, &[u8])> = Vec::new();
6603 for k in 0..lists as u16 {
6604 let (list_slot, item_slot) = (2 * k, 2 * k + 1);
6605 let mut row = encode_open_tag(1, &[]);
6606 if nested {
6607 let inner_item = 2 * lists as u16;
6610 let mut inner_row = encode_open_tag(1, &[]);
6611 inner_row.extend_from_slice(&encode_close_tag(1));
6612 row.extend_from_slice(&encode_list(item_slot, inner_item, &inner_row));
6613 }
6614 row.extend_from_slice(&encode_close_tag(1));
6615 ops.extend_from_slice(&encode_list(list_slot, item_slot, &row));
6616 slots.push((list_slot, 2, 0x04, 0x00, &[]));
6617 slots.push((item_slot, 3, if nested { 0x04 } else { 0x01 }, 0x00, &[]));
6618 }
6619 if nested {
6620 slots.push((2 * lists as u16, 3, 0x01, 0x00, &[]));
6621 }
6622 ops.extend_from_slice(&encode_close_tag(0));
6623 let data = build_declared_fixture(&["div", "i", "items", "item"], &slots, &ops, &[]);
6624 IrModule::parse(&data).unwrap()
6625 }
6626
6627 fn rows(n: usize) -> SlotValue {
6628 SlotValue::array((0..n).map(|_| SlotValue::Text("x".into())).collect())
6629 }
6630
6631 fn limits(max_list_iterations: usize) -> WalkLimits {
6632 WalkLimits {
6633 max_list_iterations,
6634 ..WalkLimits::default()
6635 }
6636 }
6637
6638 #[test]
6639 fn a_list_longer_than_the_remaining_budget_fails_before_its_first_row() {
6640 let module = lists_module(1, false);
6642 let mut slots = SlotData::new(2);
6643 slots.set(0, rows(51));
6644 let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6645 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6646 assert_eq!(failure.counts.list_iterations, 0);
6647 assert_eq!(failure.counts.nodes, 1, "only the wrapping div was written");
6648
6649 slots.set(0, rows(50));
6651 let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6652 assert_eq!(out.counts.list_iterations, 50);
6653 assert_eq!(out.html.matches("<i>").count(), 50);
6654 }
6655
6656 #[test]
6657 fn a_list_exactly_at_the_remaining_budget_still_renders() {
6658 let module = lists_module(2, false);
6661 let mut slots = SlotData::new(4);
6662 slots.set(0, rows(20));
6663 slots.set(2, rows(30));
6664 let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6665 assert_eq!(out.counts.list_iterations, 50);
6666 assert_eq!(out.html.matches("<i>").count(), 50);
6667
6668 slots.set(2, rows(31));
6669 let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6670 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6671 assert_eq!(
6672 failure.counts.list_iterations, 20,
6673 "the first list, and no more"
6674 );
6675 assert_eq!(failure.counts.nodes, 1 + 20);
6676 }
6677
6678 #[test]
6679 fn a_nested_list_is_checked_against_what_its_parents_left() {
6680 let module = lists_module(1, true);
6685 let mut slots = SlotData::new(3);
6686 slots.set(0, SlotValue::array((0..3).map(|_| rows(20)).collect()));
6687 let failure = walk_to_html_counted(&module, &slots, limits(59)).unwrap_err();
6688 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 59 });
6689 assert_eq!(failure.counts.list_iterations, 43);
6690
6691 let out = walk_to_html_counted(&module, &slots, limits(63)).unwrap();
6693 assert_eq!(out.counts.list_iterations, 63);
6694 assert!(walk_to_html_counted(&module, &slots, limits(62)).is_err());
6695 }
6696
6697 #[test]
6698 fn the_per_row_check_still_stops_a_list_whose_nested_rows_spend_the_budget() {
6699 let module = lists_module(1, true);
6703 let mut slots = SlotData::new(3);
6704 slots.set(0, SlotValue::array((0..3).map(|_| rows(11)).collect()));
6705 let failure = walk_to_html_counted(&module, &slots, limits(24)).unwrap_err();
6706 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 24 });
6707 assert_eq!(failure.counts.list_iterations, 24);
6708 }
6709
6710 mod normalization_vectors {
6719 use super::*;
6720 use serde_json::Value;
6721
6722 const VECTORS: &str = include_str!("../tests/fixtures/parity/normalization.json");
6723
6724 fn cases(section: &str) -> Vec<Value> {
6725 let all: Value = serde_json::from_str(VECTORS).unwrap();
6726 all[section].as_array().unwrap().clone()
6727 }
6728
6729 fn field<'a>(case: &'a Value, key: &str) -> &'a str {
6730 case[key].as_str().unwrap()
6731 }
6732
6733 fn children(case: &Value) -> Vec<String> {
6734 case["children"]
6735 .as_array()
6736 .unwrap()
6737 .iter()
6738 .map(|v| v.as_str().unwrap().to_string())
6739 .collect()
6740 }
6741
6742 fn escape_html(text: &str) -> String {
6743 text.replace('&', "&")
6744 .replace('<', "<")
6745 .replace('>', ">")
6746 }
6747
6748 fn escape_attr(text: &str) -> String {
6749 escape_html(text).replace('"', """)
6750 }
6751
6752 fn marker(id: u16, text: &str) -> String {
6755 let inner = if text.is_empty() {
6756 "\u{200B}".to_string()
6757 } else {
6758 escape_html(text)
6759 };
6760 format!("<!--f:t{id}-->{inner}<!--/f:t{id}-->")
6761 }
6762
6763 fn visible(rendered: &str) -> String {
6767 let mut out = String::new();
6768 let mut rest = rendered;
6769 while let Some(start) = rest.find("<!--") {
6770 out.push_str(&rest[..start]);
6771 let end = rest[start..].find("-->").unwrap() + start + 3;
6772 rest = &rest[end..];
6773 }
6774 out.push_str(rest);
6775 out.replace('\u{200B}', "")
6776 }
6777
6778 fn check(failures: &mut Vec<String>, label: &str, actual: &str, expected: &str) {
6779 if actual != expected {
6780 failures.push(format!(
6781 "{label}: expected {expected:?}, walker wrote {actual:?}"
6782 ));
6783 }
6784 }
6785
6786 fn finish(failures: Vec<String>) {
6787 assert!(
6788 failures.is_empty(),
6789 "{} disagreeing vector(s):\n{}",
6790 failures.len(),
6791 failures.join("\n")
6792 );
6793 }
6794
6795 #[test]
6796 fn text_and_dyn_text_write_each_value_normalized() {
6797 let mut failures = Vec::new();
6798 for case in cases("values") {
6799 let (id, input, expected) = (
6800 field(&case, "id"),
6801 field(&case, "input"),
6802 field(&case, "expected"),
6803 );
6804 let mut page = Page::new();
6805 let slot = page.slot(text(input));
6806 page.open("p", &[])
6807 .text(input)
6808 .close("p")
6809 .open("p", &[])
6810 .dyn_text(slot)
6811 .close("p");
6812 let want = format!(
6813 "<p>{}</p><p>{}</p>",
6814 escape_html(expected),
6815 marker(slot, expected)
6816 );
6817 check(
6818 &mut failures,
6819 &format!("values/{id} text+dyn_text"),
6820 &page.render(),
6821 &want,
6822 );
6823 }
6824 finish(failures);
6825 }
6826
6827 #[test]
6828 fn static_and_dynamic_attributes_write_each_value_normalized() {
6829 let mut failures = Vec::new();
6830 for case in cases("values") {
6831 let (id, input, expected) = (
6832 field(&case, "id"),
6833 field(&case, "input"),
6834 field(&case, "expected"),
6835 );
6836 let mut page = Page::new();
6837 let slot = page.slot(text(input));
6838 page.open("p", &[("title", input), ("id", "ok")])
6839 .close("p")
6840 .open("p", &[("id", "ok")])
6841 .dyn_attr("title", slot)
6842 .close("p");
6843 let e = escape_attr(expected);
6844 let static_attr = if expected.is_empty() {
6847 " title".to_string()
6848 } else {
6849 format!(" title=\"{e}\"")
6850 };
6851 let want = format!("<p{static_attr} id=\"ok\"></p><p id=\"ok\" title=\"{e}\"></p>");
6852 check(
6853 &mut failures,
6854 &format!("values/{id} attr+dyn_attr"),
6855 &page.render(),
6856 &want,
6857 );
6858 }
6859 finish(failures);
6860 }
6861
6862 #[test]
6863 fn adjacent_values_normalize_each_before_composition() {
6864 let mut failures = Vec::new();
6870 for case in cases("children") {
6871 let (id, expected) = (field(&case, "id"), field(&case, "expected"));
6872 let values = children(&case);
6873
6874 let mut page = Page::new();
6875 page.open("p", &[]);
6876 for value in &values {
6877 page.text(value);
6878 }
6879 page.close("p");
6880 let want = format!("<p>{}</p>", escape_html(expected));
6881 check(
6882 &mut failures,
6883 &format!("children/{id} adjacent TEXT"),
6884 &page.render(),
6885 &want,
6886 );
6887
6888 let mut page = Page::new();
6889 let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
6890 page.open("p", &[]);
6891 for slot in &slots {
6892 page.dyn_text(*slot);
6893 }
6894 page.close("p");
6895 check(
6896 &mut failures,
6897 &format!("children/{id} adjacent DYN_TEXT (visible text)"),
6898 &visible(&page.render()),
6899 &want,
6900 );
6901
6902 let mut page = Page::new();
6903 let slots: Vec<u16> = values[1..].iter().map(|v| page.slot(text(v))).collect();
6904 page.open("p", &[]).text(&values[0]);
6905 for slot in &slots {
6906 page.dyn_text(*slot);
6907 }
6908 page.close("p");
6909 check(
6910 &mut failures,
6911 &format!("children/{id} TEXT then DYN_TEXT (visible text)"),
6912 &visible(&page.render()),
6913 &want,
6914 );
6915 }
6916 finish(failures);
6917 }
6918
6919 #[test]
6920 fn textarea_children_protect_only_an_initial_normalized_line_feed() {
6921 let mut failures = Vec::new();
6926 for case in cases("textarea") {
6927 let (id, html) = (field(&case, "id"), field(&case, "html"));
6928 let values = children(&case);
6929
6930 let mut page = Page::new();
6931 page.open("textarea", &[]);
6932 for value in &values {
6933 page.text(value);
6934 }
6935 page.close("textarea");
6936 check(
6937 &mut failures,
6938 &format!("textarea/{id} static"),
6939 &page.render(),
6940 html,
6941 );
6942
6943 let mut page = Page::new();
6944 let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
6945 page.open("textarea", &[]);
6946 for slot in &slots {
6947 page.dyn_text(*slot);
6948 }
6949 page.close("textarea");
6950 check(
6951 &mut failures,
6952 &format!("textarea/{id} dynamic"),
6953 &page.render(),
6954 html,
6955 );
6956 }
6957 finish(failures);
6958 }
6959
6960 #[test]
6961 fn url_attributes_are_checked_as_the_normalized_string_that_is_written() {
6962 let mut failures = Vec::new();
6967 for case in cases("unsafeUrlNearMiss") {
6968 let (id, input) = (field(&case, "id"), field(&case, "input"));
6969 let attribute = case["attribute"].as_str().unwrap_or("href");
6970 let tag = case["tag"].as_str().unwrap_or("a");
6971 let refused = case["refused"].as_bool().unwrap();
6972 let mut page = Page::new();
6973 let slot = page.slot(text(input));
6974 page.open(tag, &[(attribute, input), ("id", "ok")])
6975 .close(tag)
6976 .open(tag, &[("id", "ok")])
6977 .dyn_attr(attribute, slot)
6978 .close(tag);
6979 let want = if refused {
6980 format!("<{tag} id=\"ok\"></{tag}><{tag} id=\"ok\"></{tag}>")
6981 } else {
6982 let written = escape_attr(field(&case, "written"));
6983 format!(
6984 "<{tag} {attribute}=\"{written}\" id=\"ok\"></{tag}>\
6985 <{tag} id=\"ok\" {attribute}=\"{written}\"></{tag}>"
6986 )
6987 };
6988 check(
6989 &mut failures,
6990 &format!("unsafeUrlNearMiss/{id}"),
6991 &page.render(),
6992 &want,
6993 );
6994 }
6995 finish(failures);
6996 }
6997
6998 #[test]
6999 fn pre_children_protect_only_an_initial_normalized_line_feed() {
7000 let mut failures = Vec::new();
7006 for case in cases("pre") {
7007 let (id, html, dynamic) = (
7008 field(&case, "id"),
7009 field(&case, "html"),
7010 field(&case, "htmlDynamic"),
7011 );
7012 let values = children(&case);
7013 for tag in ["pre", "listing", "PRE"] {
7014 let mut page = Page::new();
7015 page.open(tag, &[]);
7016 for value in &values {
7017 page.text(value);
7018 }
7019 page.close(tag);
7020 let want = html
7021 .replace("<pre>", &format!("<{tag}>"))
7022 .replace("</pre>", &format!("</{tag}>"));
7023 check(
7024 &mut failures,
7025 &format!("pre/{id} static <{tag}>"),
7026 &page.render(),
7027 &want,
7028 );
7029 }
7030 let mut page = Page::new();
7031 let slots: Vec<u16> = values.iter().map(|v| page.slot(text(v))).collect();
7032 page.open("pre", &[]);
7033 for slot in &slots {
7034 page.dyn_text(*slot);
7035 }
7036 page.close("pre");
7037 check(
7038 &mut failures,
7039 &format!("pre/{id} dynamic"),
7040 &page.render(),
7041 dynamic,
7042 );
7043 }
7044 finish(failures);
7045 }
7046
7047 #[test]
7048 fn the_pre_boundary_is_consumed_by_any_token_but_not_by_a_value_only_opcode() {
7049 let mut page = Page::new();
7054 page.open("pre", &[])
7055 .open("b", &[])
7056 .close("b")
7057 .text("\nx")
7058 .close("pre");
7059 assert_eq!(page.render(), "<pre><b></b>\nx</pre>");
7060
7061 let mut page = Page::new();
7062 let shown = page.slot(SlotValue::Bool(true));
7063 page.open("pre", &[])
7064 .show_if(
7065 shown,
7066 |p| {
7067 p.text("\nx");
7068 },
7069 |_| {},
7070 )
7071 .close("pre");
7072 let rendered = page.render();
7073 assert!(rendered.starts_with("<pre><!--f:s"), "{rendered:?}");
7074 assert!(rendered.contains("-->\nx<!--/f:s"), "{rendered:?}");
7075 assert!(!rendered.contains("\n\n"), "{rendered:?}");
7076
7077 let mut page = Page::new();
7078 let object = page.slot(SlotValue::object(vec![]));
7079 let target = page.slot(text(""));
7080 page.open("pre", &[])
7081 .prop(object, "name", target)
7082 .text("\nx")
7083 .close("pre")
7084 .open("pre", &[])
7085 .text("\ny")
7086 .close("pre")
7087 .open("p", &[])
7088 .text("\nz")
7089 .close("p");
7090 assert_eq!(page.render(), "<pre>\n\nx</pre><pre>\n\ny</pre><p>\nz</p>");
7091 }
7092
7093 #[test]
7094 fn the_output_budget_counts_the_normalized_and_escaped_bytes_actually_written() {
7095 for (input, written) in [
7102 ("xxxxxxxxxx\0", "xxxxxxxxxx\u{FFFD}"),
7103 ("xxxxxxxxxx😀", "xxxxxxxxxx😀"),
7104 ("xxxxxxxxxx&", "xxxxxxxxxx&"),
7105 ("xxxxxxxxxx\r\n", "xxxxxxxxxx\n"),
7106 ] {
7107 let mut page = Page::new();
7108 page.text(input);
7109 let (module, slots) = page.module_and_slots();
7110 let limits = |max_output_bytes| WalkLimits {
7111 max_output_bytes,
7112 ..WalkLimits::default()
7113 };
7114 let exact = written.len();
7115 assert_eq!(
7116 walk_to_html_with_limits(&module, &slots, limits(exact)).as_deref(),
7117 Ok(written),
7118 "{input:?} at its exact budget"
7119 );
7120 assert_eq!(
7121 walk_to_html_with_limits(&module, &slots, limits(exact - 1)),
7122 Err(IrError::OutputLimitExceeded { limit: exact - 1 }),
7123 "{input:?} one byte under its budget"
7124 );
7125 }
7126 }
7127 }
7128
7129 mod inv1_vectors {
7131 use super::*;
7132 use serde_json::Value;
7133
7134 #[test]
7135 fn names_and_urls_agree_for_static_and_dynamic_attributes() {
7136 let vectors: Value =
7137 serde_json::from_str(include_str!("../tests/fixtures/parity/inv1.json")).unwrap();
7138 let mut failures = Vec::new();
7139 for (section, tag, value) in [("names", "div", "value"), ("urls", "", "")] {
7140 for case in vectors[section].as_array().unwrap() {
7141 let name = case["name"].as_str().unwrap();
7142 let tag = case["tag"].as_str().unwrap_or(tag);
7143 let value = case["value"].as_str().unwrap_or(value);
7144 let written = case["written"].as_bool().unwrap();
7145 if section == "names" {
7146 assert_eq!(
7147 is_valid_attr_name(name),
7148 case["valid"].as_bool().unwrap(),
7149 "{}",
7150 case["id"]
7151 );
7152 }
7153 let mut escaped = String::new();
7154 push_escaped_attr(&mut escaped, value, usize::MAX).unwrap();
7155 let attribute = if written {
7156 format!(" {name}=\"{escaped}\"")
7157 } else {
7158 String::new()
7159 };
7160 let want = format!("<{tag} id=\"ok\"{attribute}></{tag}>");
7161 let mut static_page = Page::new();
7162 static_page
7163 .open(tag, &[("id", "ok"), (name, value)])
7164 .close(tag);
7165 let mut dynamic_page = Page::new();
7166 let slot = dynamic_page.slot(SlotValue::Text(value.to_string()));
7167 dynamic_page
7168 .open(tag, &[("id", "ok")])
7169 .dyn_attr(name, slot)
7170 .close(tag);
7171 for (writer, actual) in [
7172 ("static", static_page.render()),
7173 ("dynamic", dynamic_page.render()),
7174 ] {
7175 let expected = if writer == "static" && written && value.is_empty() {
7178 format!("<{tag} id=\"ok\" {name}></{tag}>")
7179 } else {
7180 want.clone()
7181 };
7182 if actual != expected {
7183 failures.push(format!(
7184 "{} {writer}: {actual:?} != {expected:?}",
7185 case["id"]
7186 ));
7187 }
7188 }
7189 }
7190 }
7191 let names: Vec<_> = vectors["urlAttributes"]
7192 .as_array()
7193 .unwrap()
7194 .iter()
7195 .map(|v| v.as_str().unwrap())
7196 .collect();
7197 assert_eq!(names, URL_ATTRS);
7198 assert!(failures.is_empty(), "{}", failures.join("\n"));
7199 }
7200
7201 #[test]
7202 fn ordinary_payloads_are_escaped_in_both_output_contexts() {
7203 let vectors: Value =
7204 serde_json::from_str(include_str!("../tests/fixtures/parity/inv1.json")).unwrap();
7205 for payload in vectors["payloads"].as_array().unwrap() {
7206 let text = payload["text"].as_str().unwrap();
7207 let attr = payload["attribute"].as_str().unwrap();
7208 let payload = payload["value"].as_str().unwrap();
7209 let mut page = Page::new();
7210 page.open("p", &[("title", payload)])
7211 .text(payload)
7212 .close("p");
7213 let html = page.render();
7214 assert!(!html.contains("<script"));
7215 assert!(!html.contains("<img"));
7216 assert!(!html.contains("</textarea>"));
7217 assert_eq!(html, format!("<p title=\"{attr}\">{text}</p>"));
7218 }
7219 }
7220 }
7221
7222 mod show_text_branch_vectors {
7229 use super::*;
7230 use serde_json::Value;
7231
7232 const VECTORS: &str = include_str!("../tests/fixtures/parity/show-text-branch.json");
7233
7234 #[test]
7235 fn a_dyn_text_branch_is_written_between_the_conditional_markers() {
7236 let all: Value = serde_json::from_str(VECTORS).unwrap();
7237 let mut failures = Vec::new();
7238 let mut walked = 0;
7239 for case in all["values"].as_array().unwrap() {
7240 if case["clientOnly"].as_bool() == Some(true) {
7241 continue;
7242 }
7243 walked += 1;
7244 let id = case["id"].as_str().unwrap();
7245 let mut page = Page::new();
7246 let shown = page.slot(SlotValue::Bool(true));
7247 let value = page.slot(crate::slot::json_to_slot_value(&case["value"]));
7248 page.show_if(
7249 shown,
7250 |p| {
7251 p.dyn_text(value);
7252 },
7253 |_| {},
7254 );
7255 let want = format!("<!--f:s0-->{}<!--/f:s0-->", case["html"].as_str().unwrap());
7256 let got = page.render();
7257 if got != want {
7258 failures.push(format!("{id}: expected {want:?}, walker wrote {got:?}"));
7259 }
7260 }
7261 assert!(walked >= 9, "the vectors were not read");
7262 assert!(failures.is_empty(), "{}", failures.join("\n"));
7263 }
7264 }
7265
7266 mod value_rule_vectors {
7274 use super::*;
7275 use serde_json::Value;
7276
7277 const NUMBERS: &str = include_str!("../tests/fixtures/parity/numbers.json");
7278 const INTEGERS: &str = include_str!("../tests/fixtures/parity/integers.json");
7279 const NULL: &str = include_str!("../tests/fixtures/parity/null.json");
7280 const BOOLEAN_TEXT: &str = include_str!("../tests/fixtures/parity/boolean-text.json");
7281 const CONDITIONS: &str = include_str!("../tests/fixtures/parity/conditions.json");
7282 const ATTRIBUTES: &str = include_str!("../tests/fixtures/parity/attributes.json");
7283
7284 fn cases(file: &str, section: &str) -> Vec<Value> {
7285 let all: Value = serde_json::from_str(file).unwrap();
7286 all[section].as_array().unwrap().clone()
7287 }
7288
7289 fn field<'a>(case: &'a Value, key: &str) -> &'a str {
7290 case[key].as_str().unwrap()
7291 }
7292
7293 fn marker(id: u16, text: &str) -> String {
7296 let inner = if text.is_empty() {
7297 "\u{200B}".to_string()
7298 } else {
7299 text.to_string()
7300 };
7301 format!("<!--f:t{id}-->{inner}<!--/f:t{id}-->")
7302 }
7303
7304 fn visible(rendered: &str) -> String {
7307 let mut out = String::new();
7308 let mut rest = rendered;
7309 while let Some(start) = rest.find("<!--") {
7310 out.push_str(&rest[..start]);
7311 let end = rest[start..].find("-->").unwrap() + start + 3;
7312 rest = &rest[end..];
7313 }
7314 out.push_str(rest);
7315 out.replace('\u{200B}', "")
7316 }
7317
7318 fn check(failures: &mut Vec<String>, label: &str, actual: &str, expected: &str) {
7319 if actual != expected {
7320 failures.push(format!(
7321 "{label}: expected {expected:?}, walker wrote {actual:?}"
7322 ));
7323 }
7324 }
7325
7326 fn finish(failures: Vec<String>) {
7327 assert!(
7328 failures.is_empty(),
7329 "{} disagreeing vector(s):\n{}",
7330 failures.len(),
7331 failures.join("\n")
7332 );
7333 }
7334
7335 fn vector_text(value: &Value) -> String {
7338 match value {
7339 Value::Null => String::new(),
7340 Value::String(s) => s.clone(),
7341 other => other.to_string(),
7342 }
7343 }
7344
7345 fn slot_value(value: &Value) -> SlotValue {
7348 match value {
7349 Value::Null => SlotValue::Null,
7350 Value::Bool(b) => SlotValue::Bool(*b),
7351 Value::Number(n) => SlotValue::Number(n.as_f64().unwrap()),
7352 Value::String(s) => SlotValue::Text(s.clone()),
7353 other => panic!("unsupported vector value {other}"),
7354 }
7355 }
7356
7357 #[test]
7358 fn rule_2_numbers_are_written_in_ecmascript_formatting_as_text_and_attribute() {
7359 let mut failures = Vec::new();
7360 for case in cases(NUMBERS, "values") {
7361 let (id, lexeme, expected) = (
7362 field(&case, "id"),
7363 field(&case, "lexeme"),
7364 field(&case, "expected"),
7365 );
7366 let number: f64 = lexeme.parse().unwrap();
7367 let mut page = Page::new();
7368 let slot = page.slot(SlotValue::Number(number));
7369 page.open("p", &[]).dyn_text(slot).close("p");
7370 page.open("span", &[("id", "ok")])
7371 .dyn_attr("title", slot)
7372 .close("span");
7373 let want = format!(
7374 "<p>{}</p><span id=\"ok\" title=\"{expected}\"></span>",
7375 marker(slot, expected)
7376 );
7377 check(
7378 &mut failures,
7379 &format!("numbers/{id}"),
7380 &page.render(),
7381 &want,
7382 );
7383 }
7384 finish(failures);
7385 }
7386
7387 #[test]
7388 fn rule_3_an_admitted_exact_integer_is_written_as_its_decimal_digits() {
7389 let mut failures = Vec::new();
7393 for case in cases(INTEGERS, "exact") {
7394 if !case["admitted"].as_bool().unwrap() {
7395 continue;
7396 }
7397 let (id, lexeme) = (field(&case, "id"), field(&case, "lexeme"));
7398 let number: f64 = lexeme.parse().unwrap();
7399 let mut page = Page::new();
7400 let slot = page.slot(SlotValue::Number(number));
7401 page.open("p", &[]).dyn_text(slot).close("p");
7402 let want = format!("<p>{}</p>", marker(slot, lexeme));
7403 check(
7404 &mut failures,
7405 &format!("integers/exact/{id}"),
7406 &page.render(),
7407 &want,
7408 );
7409 }
7410 for case in cases(INTEGERS, "floatOrigin") {
7411 let (id, lexeme, expected) = (
7412 field(&case, "id"),
7413 field(&case, "lexeme"),
7414 field(&case, "expected"),
7415 );
7416 let number: f64 = lexeme.parse().unwrap();
7417 let mut page = Page::new();
7418 let slot = page.slot(SlotValue::Number(number));
7419 page.open("p", &[]).dyn_text(slot).close("p");
7420 let want = format!("<p>{}</p>", marker(slot, expected));
7421 check(
7422 &mut failures,
7423 &format!("integers/floatOrigin/{id}"),
7424 &page.render(),
7425 &want,
7426 );
7427 }
7428 finish(failures);
7429 }
7430
7431 #[test]
7432 fn rule_5_null_writes_nothing_as_text_attribute_and_textarea_child() {
7433 let mut failures = Vec::new();
7434 for case in cases(NULL, "children") {
7435 let (id, expected) = (field(&case, "id"), field(&case, "expected"));
7436 let values = case["children"].as_array().unwrap();
7437
7438 let mut page = Page::new();
7441 let slots: Vec<u16> = values.iter().map(|v| page.slot(slot_value(v))).collect();
7442 page.open("p", &[]);
7443 for slot in &slots {
7444 page.dyn_text(*slot);
7445 }
7446 page.close("p");
7447 let rendered = page.render();
7448 check(
7449 &mut failures,
7450 &format!("null/children/{id} dynamic (visible text)"),
7451 &visible(&rendered),
7452 &format!("<p>{expected}</p>"),
7453 );
7454 let ranges: String = slots
7458 .iter()
7459 .zip(values)
7460 .map(|(slot, value)| marker(*slot, &vector_text(value)))
7461 .collect();
7462 check(
7463 &mut failures,
7464 &format!("null/children/{id} dynamic"),
7465 &rendered,
7466 &format!("<p>{ranges}</p>"),
7467 );
7468 if rendered.contains("null") {
7469 failures.push(format!(
7470 "null/children/{id}: the word null was written: {rendered:?}"
7471 ));
7472 }
7473
7474 let mut page = Page::new();
7476 let slots: Vec<u16> = values.iter().map(|v| page.slot(slot_value(v))).collect();
7477 page.open("textarea", &[]);
7478 for slot in &slots {
7479 page.dyn_text(*slot);
7480 }
7481 page.close("textarea");
7482 check(
7483 &mut failures,
7484 &format!("null/children/{id} textarea"),
7485 &page.render(),
7486 &format!("<textarea>{expected}</textarea>"),
7487 );
7488 }
7489 for case in cases(NULL, "attribute") {
7490 let (id, attribute) = (field(&case, "id"), field(&case, "attribute"));
7491 let mut page = Page::new();
7492 let slot = page.slot(SlotValue::Null);
7493 page.open("span", &[("id", "ok")])
7494 .dyn_attr(attribute, slot)
7495 .close("span");
7496 check(
7497 &mut failures,
7498 &format!("null/attribute/{id}"),
7499 &page.render(),
7500 "<span id=\"ok\"></span>",
7501 );
7502 }
7503 finish(failures);
7504 }
7505
7506 #[test]
7507 fn rule_6_the_string_near_miss_writes_the_boolean_word_and_the_idiom_its_range() {
7508 let mut failures = Vec::new();
7513 for case in cases(BOOLEAN_TEXT, "nearMissText") {
7514 let (id, expected) = (field(&case, "id"), field(&case, "expected"));
7515 let value = case["value"].as_bool().unwrap();
7516 let mut page = Page::new();
7517 let slot = page.slot(SlotValue::Bool(value));
7518 page.open("p", &[]).dyn_text(slot).close("p");
7519 check(
7520 &mut failures,
7521 &format!("boolean-text/nearMissText/{id}"),
7522 &page.render(),
7523 &format!("<p>{}</p>", marker(slot, expected)),
7524 );
7525 }
7526 for case in cases(BOOLEAN_TEXT, "idiom") {
7529 let (id, expected) = (field(&case, "id"), field(&case, "expected"));
7530 let mut page = Page::new();
7531 let slot = page.slot(if expected.is_empty() {
7532 SlotValue::Null
7533 } else {
7534 SlotValue::Text(expected.to_string())
7535 });
7536 page.open("p", &[]).dyn_text(slot).close("p");
7537 check(
7538 &mut failures,
7539 &format!("boolean-text/idiom/{id}"),
7540 &page.render(),
7541 &format!("<p>{}</p>", marker(slot, expected)),
7542 );
7543 }
7544 finish(failures);
7545 }
7546
7547 #[test]
7548 fn rule_6_attribute_values_follow_one_table() {
7549 let mut failures = Vec::new();
7553 for case in cases(ATTRIBUTES, "values") {
7554 let (id, attribute, html) = (
7555 field(&case, "id"),
7556 field(&case, "attribute"),
7557 field(&case, "html"),
7558 );
7559 let mut page = Page::new();
7560 let slot = page.slot(slot_value(&case["value"]));
7561 page.open("input", &[("id", "ok")])
7562 .dyn_attr(attribute, slot)
7563 .close("input");
7564 check(
7565 &mut failures,
7566 &format!("attributes/values/{id}"),
7567 &page.render(),
7568 &format!("<input id=\"ok\"{html}></input>"),
7569 );
7570 }
7571 for name in cases(ATTRIBUTES, "booleanAttributes") {
7574 let name = name.as_str().unwrap();
7575 for (attribute, want) in [
7576 (name.to_string(), format!(" {name}")),
7577 (format!("data-{name}"), format!(" data-{name}=\"true\"")),
7578 ] {
7579 let mut page = Page::new();
7580 let slot = page.slot(SlotValue::Bool(true));
7581 page.open("input", &[("id", "ok")])
7582 .dyn_attr(&attribute, slot)
7583 .close("input");
7584 check(
7585 &mut failures,
7586 &format!("attributes/booleanAttributes/{attribute}"),
7587 &page.render(),
7588 &format!("<input id=\"ok\"{want}></input>"),
7589 );
7590 }
7591 }
7592 finish(failures);
7593 }
7594
7595 #[test]
7596 fn rule_7_a_boolean_condition_selects_the_branch() {
7597 let mut failures = Vec::new();
7601 for case in cases(CONDITIONS, "walker") {
7602 let (id, branch) = (field(&case, "id"), field(&case, "branch"));
7603 let value = case["value"].as_bool().unwrap();
7604 let mut page = Page::new();
7605 let slot = page.slot(SlotValue::Bool(value));
7606 page.open("div", &[])
7607 .show_if(
7608 slot,
7609 |p| {
7610 p.open("b", &[]).text("then").close("b");
7611 },
7612 |p| {
7613 p.open("i", &[]).text("else").close("i");
7614 },
7615 )
7616 .close("div");
7617 let inner = if branch == "then" {
7618 "<b>then</b>"
7619 } else {
7620 "<i>else</i>"
7621 };
7622 check(
7623 &mut failures,
7624 &format!("conditions/walker/{id}"),
7625 &page.render(),
7626 &format!("<div><!--f:s{slot}-->{inner}<!--/f:s{slot}--></div>"),
7627 );
7628 }
7629 finish(failures);
7630 }
7631 }
7632
7633 mod keyed_rows {
7638 use super::*;
7639 use crate::format::{ListKeyReason, ListKeyViolation};
7640 use crate::parser::test_helpers::with_key_paths;
7641
7642 const STRINGS: &[&str] = &[
7643 "rows", "row", "li", "id", "span", "label", "input", "type", "checkbox", "data-forma-key", "authored", "tags", "b", "Child", "ul", "DATA-FORMA-KEY", "text", "user", "tag", ];
7663 const LI: u32 = 2;
7664 const ID: u32 = 3;
7665 const SPAN: u32 = 4;
7666 const INPUT: u32 = 6;
7667 const TYPE: u32 = 7;
7668 const CHECKBOX: u32 = 8;
7669 const KEY_ATTR: u32 = 9;
7670 const AUTHORED: u32 = 10;
7671 const TAGS: u32 = 11;
7672 const B: u32 = 12;
7673 const CHILD: u32 = 13;
7674 const UL: u32 = 14;
7675 const KEY_ATTR_UPPER: u32 = 15;
7676 const TEXT: u32 = 16;
7677 const USER: u32 = 17;
7678 const SLOTS: &[(u16, u32, u8, u8, &[u8])] = &[
7682 (0, 0, 0x04, 0x00, &[]),
7683 (1, 1, 0x05, 0x01, &[]),
7684 (2, 11, 0x04, 0x01, &[]),
7685 (3, 18, 0x05, 0x01, &[]),
7686 (4, 5, 0x01, 0x00, &[]),
7687 ];
7688 const LIST_HEADER: usize = 9;
7690
7691 fn keyed(opcodes: &[u8], paths: &[(u32, &[u32])]) -> IrModule {
7692 let base = build_declared_fixture(STRINGS, SLOTS, opcodes, &[]);
7693 IrModule::parse(&with_key_paths(&base, paths)).unwrap()
7694 }
7695
7696 #[test]
7697 fn static_mode_omits_row_keys_but_keeps_key_and_shape_refusals() {
7698 let body = [
7699 encode_open_tag(LI, &[]),
7700 encode_dyn_text(4, 4),
7701 encode_close_tag(LI),
7702 ]
7703 .concat();
7704 let module = keyed(&encode_list(0, 1, &body), &[(0, &[ID])]);
7705 let data = rows(vec![id(num(1.0)), id(text("two"))]);
7706 let output = walk_to_html_counted_with_mode(
7707 &module,
7708 &data,
7709 WalkLimits::default(),
7710 RenderMode::Static,
7711 )
7712 .unwrap();
7713 assert_eq!(output.html, "<li>x</li><li>x</li>");
7714 for data in [
7715 rows(vec![id(num(1.0)), id(text("1"))]),
7716 rows(vec![id(SlotValue::Null)]),
7717 ] {
7718 let expected = walk_to_html_counted(&module, &data, WalkLimits::default())
7719 .unwrap_err()
7720 .error;
7721 let actual = walk_to_html_counted_with_mode(
7722 &module,
7723 &data,
7724 WalkLimits::default(),
7725 RenderMode::Static,
7726 )
7727 .unwrap_err()
7728 .error;
7729 assert_eq!(actual, expected);
7730 }
7731 let invalid = keyed(&encode_list(0, 1, &encode_dyn_text(4, 4)), &[(0, &[ID])]);
7732 let expected = walk_to_html_counted(&invalid, &data, WalkLimits::default())
7733 .unwrap_err()
7734 .error;
7735 let actual = walk_to_html_counted_with_mode(
7736 &invalid,
7737 &data,
7738 WalkLimits::default(),
7739 RenderMode::Static,
7740 )
7741 .unwrap_err()
7742 .error;
7743 assert_eq!(actual, expected);
7744 }
7745
7746 fn keyed_with_islands(
7750 opcodes: &[u8],
7751 paths: &[(u32, &[u32])],
7752 islands: &[(u16, u8, u8, u32, u32, &[u16])],
7753 ) -> IrModule {
7754 const ISLAND_SLOTS: &[(u16, u32, u8, u8, &[u8])] =
7755 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x01, &[])];
7756 let base = build_declared_fixture(STRINGS, ISLAND_SLOTS, opcodes, islands);
7757 IrModule::parse(&with_key_paths(&base, paths)).unwrap()
7758 }
7759
7760 fn unkeyed(opcodes: &[u8]) -> IrModule {
7761 IrModule::parse(&build_declared_fixture(STRINGS, SLOTS, opcodes, &[])).unwrap()
7762 }
7763
7764 fn rows(values: Vec<SlotValue>) -> SlotData {
7765 let mut slots = SlotData::new(5);
7766 slots.set(0, SlotValue::array(values));
7767 slots.set(4, SlotValue::Text("x".into()));
7768 slots
7769 }
7770
7771 fn object(pairs: Vec<(&str, SlotValue)>) -> SlotValue {
7772 SlotValue::object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
7773 }
7774
7775 fn id(value: SlotValue) -> SlotValue {
7776 object(vec![("id", value), ("name", text("secret"))])
7777 }
7778
7779 fn text(value: &str) -> SlotValue {
7780 SlotValue::Text(value.into())
7781 }
7782
7783 fn num(value: f64) -> SlotValue {
7784 SlotValue::Number(value)
7785 }
7786
7787 fn refused(module: &IrModule, slots: &SlotData) -> ListKeyViolation {
7788 match walk_to_html(module, slots) {
7789 Err(IrError::InvalidListKey(violation)) => {
7790 let message = IrError::InvalidListKey(violation.clone()).to_string();
7791 assert!(message.starts_with("RENDER-503: LIST at bytecode offset "));
7792 assert!(!message.contains("secret"), "{message}");
7794 *violation
7795 }
7796 other => panic!("expected RENDER-503, got {other:?}"),
7797 }
7798 }
7799
7800 fn violation(
7801 list_offset: u32,
7802 row: usize,
7803 path: &str,
7804 reason: ListKeyReason,
7805 ) -> ListKeyViolation {
7806 ListKeyViolation {
7807 list_offset,
7808 row,
7809 path: path.into(),
7810 reason,
7811 }
7812 }
7813
7814 fn li_body() -> Vec<u8> {
7816 [
7817 encode_open_tag(LI, &[]),
7818 encode_open_tag(SPAN, &[]),
7819 encode_close_tag(SPAN),
7820 encode_close_tag(LI),
7821 ]
7822 .concat()
7823 }
7824
7825 #[test]
7826 fn a_keyed_list_writes_each_rows_canonical_key_once_on_its_root() {
7827 let ops = encode_list(0, 1, &li_body());
7828 let data = rows(vec![
7829 id(num(1.0)),
7830 id(text("a&\"<b>'")),
7831 id(num(-0.0)),
7832 id(text("x\r\ny\0")),
7833 id(num(-9_007_199_254_740_991.0)),
7834 id(text("")),
7835 ]);
7836 let html = walk_to_html(&keyed(&ops, &[(0, &[ID])]), &data).unwrap();
7837 assert_eq!(
7838 html,
7839 concat!(
7840 "<!--f:l0-->",
7841 "<li data-forma-key=\"1\"><span></span></li>",
7842 "<li data-forma-key=\"a&"<b>'\"><span></span></li>",
7843 "<li data-forma-key=\"0\"><span></span></li>",
7844 "<li data-forma-key=\"x\ny\u{FFFD}\"><span></span></li>",
7845 "<li data-forma-key=\"-9007199254740991\"><span></span></li>",
7846 "<li data-forma-key=\"\"><span></span></li>",
7847 "<!--/f:l0-->"
7848 )
7849 );
7850 }
7851
7852 #[test]
7853 fn an_unkeyed_list_renders_byte_identically_with_no_key_and_no_key_rules() {
7854 let ops = encode_list(0, 1, &li_body());
7855 let data = rows(vec![id(num(1.0)), id(text("a"))]);
7856 let keyed_out =
7857 walk_to_html_counted(&keyed(&ops, &[(0, &[ID])]), &data, WalkLimits::default())
7858 .unwrap();
7859 let plain = walk_to_html_counted(&unkeyed(&ops), &data, WalkLimits::default()).unwrap();
7860 assert_eq!(
7861 plain.html,
7862 "<!--f:l0--><li><span></span></li><li><span></span></li><!--/f:l0-->"
7863 );
7864 assert_eq!(
7865 keyed_out
7866 .html
7867 .replace(" data-forma-key=\"1\"", "")
7868 .replace(" data-forma-key=\"a\"", ""),
7869 plain.html
7870 );
7871 assert_eq!(
7872 keyed_out.counts.list_iterations,
7873 plain.counts.list_iterations
7874 );
7875 assert_eq!(keyed_out.counts.nodes, plain.counts.nodes);
7876 let anything = rows(vec![text("a"), text("a"), SlotValue::Null, object(vec![])]);
7880 assert_eq!(
7881 walk_to_html(&unkeyed(&ops), &anything).unwrap(),
7882 format!(
7883 "<!--f:l0-->{}<!--/f:l0-->",
7884 "<li><span></span></li>".repeat(4)
7885 )
7886 );
7887 let loose = [
7888 encode_open_tag(LI, &[(KEY_ATTR, AUTHORED)]),
7889 encode_close_tag(LI),
7890 encode_text(TEXT),
7891 encode_void_tag(INPUT, &[]),
7892 ]
7893 .concat();
7894 assert_eq!(
7895 walk_to_html(&unkeyed(&encode_list(0, 1, &loose)), &rows(vec![text("a")])).unwrap(),
7896 "<!--f:l0--><li data-forma-key=\"authored\"></li>text<input><!--/f:l0-->"
7897 );
7898 }
7899
7900 #[test]
7901 fn a_void_root_carries_the_key_and_admits_nothing_beside_it() {
7902 let void = encode_void_tag(INPUT, &[(TYPE, CHECKBOX)]);
7903 let html = walk_to_html(
7904 &keyed(&encode_list(0, 1, &void), &[(0, &[ID])]),
7905 &rows(vec![id(num(7.0)), id(num(8.0))]),
7906 )
7907 .unwrap();
7908 assert_eq!(
7909 html,
7910 "<!--f:l0--><input type=\"checkbox\" data-forma-key=\"7\"><input type=\"checkbox\" data-forma-key=\"8\"><!--/f:l0-->"
7911 );
7912 let beside = [void.clone(), encode_text(TEXT)].concat();
7913 assert_eq!(
7914 refused(
7915 &keyed(&encode_list(0, 1, &beside), &[(0, &[ID])]),
7916 &rows(vec![id(num(7.0))])
7917 ),
7918 violation(
7919 0,
7920 0,
7921 "id",
7922 ListKeyReason::RowShape {
7923 roots: 1,
7924 stray_content: true
7925 }
7926 )
7927 );
7928 }
7929
7930 #[test]
7933 fn a_registered_child_envelope_enclosing_the_root_puts_the_key_on_the_host() {
7934 let body = [
7935 encode_island_start(0),
7936 encode_open_tag(B, &[]),
7937 encode_close_tag(B),
7938 encode_island_end(0),
7939 ]
7940 .concat();
7941 let ops = encode_list(0, 1, &body);
7942 let island = (0, 0x01, 0x01, CHILD, LIST_HEADER as u32, &[][..]);
7943 let module = keyed_with_islands(&ops, &[(0, &[ID])], &[island]);
7944 let html = walk_to_html(&module, &rows(vec![id(num(1.0)), id(num(2.0))])).unwrap();
7945 let host = |occurrence: usize, key: &str| {
7946 format!("<!--f:i0--><b data-forma-island=\"0\" data-forma-component=\"Child\" data-forma-status=\"pending\" data-forma-hydrate=\"load\" data-forma-state=\"{occurrence}\" data-forma-h=\"0000000000000000\" data-forma-key=\"{key}\"></b><!--/f:i0-->")
7947 };
7948 assert_eq!(
7949 html,
7950 format!("<!--f:l0-->{}{}<!--/f:l0-->", host(0, "1"), host(1, "2"))
7951 );
7952 }
7953
7954 #[test]
7955 fn an_island_envelope_that_does_not_wholly_enclose_the_root_refuses() {
7956 let empty_island_then_root = [
7958 encode_island_start(0),
7959 encode_island_end(0),
7960 encode_open_tag(LI, &[]),
7961 encode_close_tag(LI),
7962 ]
7963 .concat();
7964 let root_then_child = [
7965 encode_open_tag(LI, &[]),
7966 encode_close_tag(LI),
7967 encode_island_start(0),
7968 encode_open_tag(B, &[]),
7969 encode_close_tag(B),
7970 encode_island_end(0),
7971 ]
7972 .concat();
7973 let cases: [(&[u8], usize, usize); 2] = [
7974 (&empty_island_then_root, 0, 1),
7975 (
7976 &root_then_child,
7977 encode_open_tag(LI, &[]).len() + encode_close_tag(LI).len(),
7978 2,
7979 ),
7980 ];
7981 for (body, at, roots) in cases {
7982 let island = (0, 0x01, 0x01, CHILD, (LIST_HEADER + at) as u32, &[][..]);
7983 let module = keyed_with_islands(&encode_list(0, 1, body), &[(0, &[ID])], &[island]);
7984 assert_eq!(
7985 refused(&module, &rows(vec![id(num(1.0))])),
7986 violation(
7987 0,
7988 0,
7989 "id",
7990 ListKeyReason::RowShape {
7991 roots,
7992 stray_content: true
7993 }
7994 )
7995 );
7996 }
7997 }
7998
7999 fn nested() -> (Vec<u8>, u32) {
8001 let inner = encode_list(
8002 2,
8003 3,
8004 &[encode_open_tag(B, &[]), encode_close_tag(B)].concat(),
8005 );
8006 let outer_body = [
8007 encode_open_tag(LI, &[]),
8008 encode_prop(1, TAGS, 2),
8009 inner,
8010 encode_close_tag(LI),
8011 ]
8012 .concat();
8013 let inner_offset =
8014 LIST_HEADER + encode_open_tag(LI, &[]).len() + encode_prop(1, TAGS, 2).len();
8015 (encode_list(0, 1, &outer_body), inner_offset as u32)
8016 }
8017
8018 fn group(key: f64, tags: Vec<SlotValue>) -> SlotValue {
8019 object(vec![("id", num(key)), ("tags", SlotValue::array(tags))])
8020 }
8021
8022 #[test]
8023 fn each_execution_of_a_list_is_its_own_duplicate_scope() {
8024 let (ops, inner) = nested();
8025 let module = keyed(&ops, &[(0, &[ID]), (inner, &[ID])]);
8026 let data = rows(vec![
8029 group(1.0, vec![id(text("a")), id(num(1.0))]),
8030 group(2.0, vec![id(text("a"))]),
8031 ]);
8032 assert_eq!(
8033 walk_to_html(&module, &data).unwrap(),
8034 concat!(
8035 "<!--f:l0-->",
8036 "<li data-forma-key=\"1\"><!--f:l2--><b data-forma-key=\"a\"></b><b data-forma-key=\"1\"></b><!--/f:l2--></li>",
8037 "<li data-forma-key=\"2\"><!--f:l2--><b data-forma-key=\"a\"></b><!--/f:l2--></li>",
8038 "<!--/f:l0-->"
8039 )
8040 );
8041 let duplicate = rows(vec![
8043 group(1.0, vec![id(text("a"))]),
8044 group(2.0, vec![id(text("b")), id(text("a")), id(text("b"))]),
8045 ]);
8046 assert_eq!(
8047 refused(&module, &duplicate),
8048 violation(inner, 2, "id", ListKeyReason::Duplicate { first_row: 0 })
8049 );
8050 let twice = [encode_list(0, 1, &li_body()), encode_list(0, 1, &li_body())].concat();
8052 let second = encode_list(0, 1, &li_body()).len() as u32;
8053 let html = walk_to_html(
8054 &keyed(&twice, &[(0, &[ID]), (second, &[ID])]),
8055 &rows(vec![id(num(1.0)), id(num(2.0))]),
8056 )
8057 .unwrap();
8058 assert_eq!(html.matches("data-forma-key=\"1\"").count(), 2, "{html}");
8059 assert_eq!(html.matches("data-forma-key=\"2\"").count(), 2, "{html}");
8060 }
8061
8062 #[test]
8063 fn keys_collide_on_their_canonical_text_not_on_their_type_or_spelling() {
8064 let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8065 let collide = [
8066 (num(1.0), text("1")),
8067 (num(0.0), num(-0.0)),
8068 (text("0"), num(-0.0)),
8069 (text("a\r\nb"), text("a\nb")),
8070 (text("a\rb"), text("a\nb")),
8071 (text("\0"), text("\u{FFFD}")),
8072 ];
8073 for (first, second) in collide {
8074 let label = format!("{first:?} {second:?}");
8075 assert_eq!(
8076 refused(&module, &rows(vec![id(first), id(second)])),
8077 violation(0, 1, "id", ListKeyReason::Duplicate { first_row: 0 }),
8078 "{label}"
8079 );
8080 }
8081 let distinct = [
8082 (num(1.0), text("01")),
8083 (text("A"), text("a")),
8084 (text("a"), text("a ")),
8085 (text("\u{e9}"), text("e\u{301}")),
8086 (text("-0"), num(0.0)),
8087 ];
8088 for (first, second) in distinct {
8089 let label = format!("{first:?} {second:?}");
8090 assert!(
8091 walk_to_html(&module, &rows(vec![id(first), id(second)])).is_ok(),
8092 "{label}"
8093 );
8094 }
8095 }
8096
8097 #[test]
8098 fn a_row_without_an_identity_at_the_path_refuses_with_its_reason() {
8099 let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8100 let not_identity = |kind| ListKeyReason::NotAnIdentity { kind };
8101 let twice = SlotValue::object(vec![("id".into(), num(1.0)), ("id".into(), num(2.0))]);
8102 let cases = [
8103 (
8104 object(vec![("name", text("secret"))]),
8105 ListKeyReason::MissingField {
8106 segment: "id".into(),
8107 },
8108 ),
8109 (id(SlotValue::Null), not_identity("null")),
8110 (id(SlotValue::Bool(true)), not_identity("a boolean")),
8111 (
8112 id(object(vec![("secret", num(1.0))])),
8113 not_identity("an object"),
8114 ),
8115 (
8116 id(SlotValue::array(vec![text("secret")])),
8117 not_identity("an array"),
8118 ),
8119 (id(num(1.5)), ListKeyReason::NotASafeInteger),
8120 (id(num(f64::NAN)), ListKeyReason::NotASafeInteger),
8121 (id(num(f64::INFINITY)), ListKeyReason::NotASafeInteger),
8122 (id(num(f64::NEG_INFINITY)), ListKeyReason::NotASafeInteger),
8123 (
8124 id(num(9_007_199_254_740_992.0)),
8125 ListKeyReason::NotASafeInteger,
8126 ),
8127 (
8128 id(num(-9_007_199_254_740_992.0)),
8129 ListKeyReason::NotASafeInteger,
8130 ),
8131 (
8132 text("secret"),
8133 ListKeyReason::NotAnObject {
8134 segment: "id".into(),
8135 },
8136 ),
8137 (
8138 twice,
8139 ListKeyReason::DuplicateField {
8140 segment: "id".into(),
8141 },
8142 ),
8143 ];
8144 for (bad, reason) in cases {
8145 assert_eq!(
8147 refused(&module, &rows(vec![id(num(1.0)), bad])),
8148 violation(0, 1, "id", reason)
8149 );
8150 }
8151 let nested = keyed(&encode_list(0, 1, &li_body()), &[(0, &[USER, ID])]);
8153 let user = |value| object(vec![("user", value)]);
8154 assert_eq!(
8155 walk_to_html(&nested, &rows(vec![user(id(text("u1")))])).unwrap(),
8156 "<!--f:l0--><li data-forma-key=\"u1\"><span></span></li><!--/f:l0-->"
8157 );
8158 for value in [
8159 text("u1"),
8160 SlotValue::Null,
8161 SlotValue::array(vec![id(num(1.0))]),
8162 ] {
8163 assert_eq!(
8164 refused(&nested, &rows(vec![user(value)])),
8165 violation(
8166 0,
8167 0,
8168 "user.id",
8169 ListKeyReason::NotAnObject {
8170 segment: "id".into()
8171 }
8172 )
8173 );
8174 }
8175 }
8176
8177 #[test]
8178 fn a_keyed_row_renders_exactly_one_balanced_root_and_nothing_beside_it() {
8179 let shape = |roots, stray_content| ListKeyReason::RowShape {
8180 roots,
8181 stray_content,
8182 };
8183 let li = [encode_open_tag(LI, &[]), encode_close_tag(LI)].concat();
8184 let cases: Vec<(&str, Vec<u8>, ListKeyReason)> = vec![
8185 ("empty row", Vec::new(), shape(0, false)),
8186 (
8187 "two roots",
8188 [li.clone(), li.clone()].concat(),
8189 shape(2, false),
8190 ),
8191 (
8192 "text beside",
8193 [li.clone(), encode_text(TEXT)].concat(),
8194 shape(1, true),
8195 ),
8196 ("text only", encode_text(TEXT), shape(0, true)),
8197 ("dynamic text only", encode_dyn_text(4, 0), shape(0, true)),
8198 (
8199 "conditional root",
8200 encode_show_if(4, &li, &li),
8201 shape(1, true),
8202 ),
8203 (
8204 "list at the row's level",
8207 encode_list(2, 3, &li),
8208 shape(1, true),
8209 ),
8210 (
8211 "closes the list's parent",
8212 encode_close_tag(UL),
8213 ListKeyReason::UnbalancedRow,
8214 ),
8215 (
8216 "closes the parent after its root",
8217 [li.clone(), encode_close_tag(UL), encode_open_tag(UL, &[])].concat(),
8218 ListKeyReason::UnbalancedRow,
8219 ),
8220 (
8221 "leaves its root open",
8222 encode_open_tag(LI, &[]),
8223 ListKeyReason::UnbalancedRow,
8224 ),
8225 ];
8226 for (name, body, reason) in cases {
8227 let ops = [
8228 encode_open_tag(UL, &[]),
8229 encode_list(0, 1, &body),
8230 encode_close_tag(UL),
8231 ]
8232 .concat();
8233 let offset = encode_open_tag(UL, &[]).len() as u32;
8234 let module = keyed(&ops, &[(offset, &[ID])]);
8235 let mut data = rows(vec![id(num(1.0))]);
8236 data.set(2, SlotValue::array(vec![text("t")]));
8237 assert_eq!(
8238 refused(&module, &data),
8239 violation(offset, 0, "id", reason),
8240 "{name}"
8241 );
8242 }
8243 }
8244
8245 #[test]
8246 fn an_authored_data_forma_key_on_the_root_refuses_and_on_a_descendant_renders() {
8247 let authored = |body: Vec<u8>| {
8248 refused(
8249 &keyed(&encode_list(0, 1, &body), &[(0, &[ID])]),
8250 &rows(vec![id(num(1.0))]),
8251 )
8252 };
8253 let expected = violation(0, 0, "id", ListKeyReason::AuthoredKey);
8254 for attr in [KEY_ATTR, KEY_ATTR_UPPER] {
8255 assert_eq!(
8256 authored(
8257 [
8258 encode_open_tag(LI, &[(attr, AUTHORED)]),
8259 encode_close_tag(LI)
8260 ]
8261 .concat()
8262 ),
8263 expected
8264 );
8265 assert_eq!(
8266 authored(encode_void_tag(INPUT, &[(attr, AUTHORED)])),
8267 expected
8268 );
8269 assert_eq!(
8270 authored(
8271 [
8272 encode_open_tag(LI, &[]),
8273 encode_dyn_attr(attr, 4),
8274 encode_close_tag(LI)
8275 ]
8276 .concat()
8277 ),
8278 expected
8279 );
8280 }
8281 let descendant = [
8282 encode_open_tag(LI, &[]),
8283 encode_open_tag(SPAN, &[(KEY_ATTR, AUTHORED)]),
8284 encode_close_tag(SPAN),
8285 encode_close_tag(LI),
8286 ]
8287 .concat();
8288 assert_eq!(
8289 walk_to_html(
8290 &keyed(&encode_list(0, 1, &descendant), &[(0, &[ID])]),
8291 &rows(vec![id(num(1.0))])
8292 )
8293 .unwrap(),
8294 "<!--f:l0--><li data-forma-key=\"1\"><span data-forma-key=\"authored\"></span></li><!--/f:l0-->"
8295 );
8296 }
8297
8298 #[test]
8299 fn a_refused_row_leaves_the_slot_journal_keyed_rows_and_list_depth_as_found() {
8300 let (ops, inner) = nested();
8301 let module = keyed(&ops, &[(0, &[ID]), (inner, &[ID])]);
8302 let bad_inner_key = rows(vec![
8303 group(1.0, vec![id(text("a"))]),
8304 group(2.0, vec![id(text("a")), id(text("a"))]),
8305 ]);
8306 let over_budget_inside = rows(vec![group(1.0, vec![id(text("a"))])]);
8309 let cases = [
8310 (
8311 bad_inner_key,
8312 WalkLimits::default(),
8313 IrError::InvalidListKey(Box::new(violation(
8314 inner,
8315 1,
8316 "id",
8317 ListKeyReason::Duplicate { first_row: 0 },
8318 ))),
8319 ),
8320 (
8321 over_budget_inside,
8322 WalkLimits {
8323 max_output_bytes: 50,
8324 ..WalkLimits::default()
8325 },
8326 IrError::OutputLimitExceeded { limit: 50 },
8327 ),
8328 ];
8329 for (data, limits, expected) in cases {
8330 let mut slots = data.clone();
8331 let before = format!("{slots:?}");
8332 let mut state = WalkState::new(limits);
8333 let mut out = String::new();
8334 let error = walk_range(
8335 &module,
8336 &mut slots,
8337 &module.opcodes,
8338 0,
8339 module.opcodes.len(),
8340 &mut out,
8341 &mut state,
8342 0,
8343 )
8344 .unwrap_err();
8345 assert_eq!(error, expected);
8346 assert_eq!(state.list_depth, 0, "{error:?}");
8347 assert!(state.keyed_rows.is_empty(), "{error:?}");
8348 assert!(state.slot_journal.is_empty(), "{error:?}");
8349 assert_eq!(format!("{slots:?}"), before, "{error:?}");
8350 }
8351 }
8352
8353 #[test]
8354 fn a_key_is_charged_to_the_output_budget_before_it_is_built() {
8355 let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8356 let long = "k".repeat(1000);
8357 let opened = "<!--f:l0-->".len();
8358 let limits = WalkLimits {
8359 max_output_bytes: opened + 999,
8360 ..WalkLimits::default()
8361 };
8362 let failure =
8363 walk_to_html_counted(&module, &rows(vec![id(text(&long))]), limits).unwrap_err();
8364 assert_eq!(
8365 failure.error,
8366 IrError::OutputLimitExceeded {
8367 limit: opened + 999
8368 }
8369 );
8370 assert_eq!(failure.counts.bytes, opened);
8372 let nul = "\0".repeat(400);
8374 let limits = WalkLimits {
8375 max_output_bytes: opened + 1000,
8376 ..WalkLimits::default()
8377 };
8378 let failure =
8379 walk_to_html_counted(&module, &rows(vec![id(text(&nul))]), limits).unwrap_err();
8380 assert_eq!(failure.counts.bytes, opened);
8381 let html = walk_to_html(&module, &rows(vec![id(text("key"))])).unwrap();
8383 let exact = WalkLimits {
8384 max_output_bytes: html.len(),
8385 ..WalkLimits::default()
8386 };
8387 assert_eq!(
8388 walk_to_html_with_limits(&module, &rows(vec![id(text("key"))]), exact).unwrap(),
8389 html
8390 );
8391 }
8392
8393 #[test]
8394 fn the_iteration_budget_refuses_a_keyed_list_before_any_key_is_read() {
8395 let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8396 let limits = WalkLimits {
8397 max_list_iterations: 1,
8398 ..WalkLimits::default()
8399 };
8400 let failure = walk_to_html_counted(
8401 &module,
8402 &rows(vec![id(SlotValue::Null), id(num(1.0))]),
8403 limits,
8404 )
8405 .unwrap_err();
8406 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 1 });
8407 assert_eq!(failure.counts.list_iterations, 0);
8408 }
8409
8410 #[test]
8416 fn every_shared_key_vector_is_written_as_its_served_markup() {
8417 let vectors: serde_json::Value =
8418 serde_json::from_str(include_str!("../tests/fixtures/parity/keys.json")).unwrap();
8419 let module = keyed(&encode_list(0, 1, &li_body()), &[(0, &[ID])]);
8420 let mut checked = 0;
8421 for case in vectors["values"].as_array().unwrap() {
8422 let Some(served) = case.get("served").and_then(serde_json::Value::as_str) else {
8423 continue;
8424 };
8425 let row = object(vec![(
8426 "id",
8427 crate::slot::json_to_slot_value(&case["value"]),
8428 )]);
8429 assert_eq!(
8430 walk_to_html(&module, &rows(vec![row])).unwrap(),
8431 format!(
8432 "<!--f:l0--><li data-forma-key=\"{served}\"><span></span></li><!--/f:l0-->"
8433 ),
8434 "{}",
8435 case["id"]
8436 );
8437 checked += 1;
8438 }
8439 assert!(
8440 checked >= 20,
8441 "the vector file lost its served markup: {checked}"
8442 );
8443 }
8444
8445 #[test]
8446 fn a_diagnostic_path_quotes_a_segment_a_dot_would_make_ambiguous() {
8447 assert_eq!(dotted_path(&["user", "id"]), "user.id");
8448 assert_eq!(dotted_path(&["user.id"]), "[\"user.id\"]");
8449 assert_eq!(dotted_path(&["a", "b.c", "d"]), "a[\"b.c\"].d");
8450 assert_eq!(dotted_path(&["", "x[0]"]), "[\"\"][\"x[0]\"]");
8451 }
8452 }
8453}