Skip to main content

logicaffeine_compile/semantics/
patch.rs

1//! The DOM patch stream: the byte format a compiled program writes and a host applies.
2//!
3//! Tristen's constraint is that the browser runs compiled LOGOS and JavaScript is a host SHIM,
4//! not a layer of the framework. For the DOM that means a BULK stream: the program builds a
5//! buffer of operations and the host applies it in a `for` loop, deciding nothing. **The stream
6//! is the interface** — every decision that used to live in `runtime.js` belongs to the side that
7//! emits it.
8//!
9//! # The format
10//!
11//! Little-endian, byte-oriented, self-delimiting. One record is a 1-byte OPCODE followed by its
12//! operands, each of which is one of:
13//!
14//! - a NODE, `u32` — the creation ordinal every tier already agrees on, so `u32::MAX` (the
15//!   "no such element" sentinel) rides through intact;
16//! - a FLAG, one byte, `0` or `1`;
17//! - an `i64`, 8 bytes — an opaque host handle;
18//! - TEXT, a `u32` byte length then that many UTF-8 bytes. LENGTH-PREFIXED, never delimited, so
19//!   no byte is special: a NUL, a newline and a quote are ordinary content, and an EMPTY string
20//!   is a string rather than an absence (`setAttribute(el, "disabled", "")` is how a boolean
21//!   attribute is written).
22//!
23//! # Why the decoder is strict
24//!
25//! Its input comes from linear memory a compiled program wrote. A truncated record, a length that
26//! overruns the buffer, an unknown opcode or invalid UTF-8 all mean A BUG SOMEWHERE — so the
27//! decoder REFUSES rather than reading past the end, guessing, or lossily converting. A U+FFFD
28//! substituted for a bad byte would put a character on the page that nobody wrote.
29//!
30//! # Why the opcode table is append-only
31//!
32//! The emitter is compiled into a wasm module; the decoder ships in a host. They are updated at
33//! DIFFERENT TIMES, so renumbering an opcode silently reinterprets every stream a
34//! previously-built module emits. New operations go on the END, and `OPCODES` is pinned by
35//! `patch_stream_lock` so that has to be deliberate.
36
37use super::dom::DomOp;
38
39/// The opcode table, in wire order. **Append-only** — see the module note.
40pub const OPCODES: &[&str] = &[
41    // Reserved ([`RETIRED_OPCODES`]): the boundary of a host-side staging pass, which the retained
42    // tree retired.
43    "BeginPaint",
44    "CreateElement",
45    "CreateTextNode",
46    "SetText",
47    "SetHtml",
48    "ClearChildren",
49    "SetAttribute",
50    "AppendChild",
51    "SetValue",
52    "SetChecked",
53    "AddEventListener",
54    "SetStream",
55    // APPENDED, never inserted: the emitter is compiled into a wasm module and the decoder ships
56    // in a host, and they are updated at different times — renumbering silently reinterprets
57    // every stream a previously-built module emits.
58    "PreventDefault",
59    // Reserved ([`RETIRED_OPCODES`]): a holding node's children reparented into a `{children}`
60    // slot, which the tape's ranges retired.
61    "MoveChildren",
62    // Wave 7.4 — the retained-tree primitives `vdom.lg` needs that the immediate-mode stream
63    // never did: removing/repositioning a single node, deleting an attribute, hydration's two
64    // bind ops, a generalised listener registration carrying its behavioural modifiers, and
65    // releasing a node's slot.
66    "RemoveNode",
67    "InsertBefore",
68    "RemoveAttribute",
69    "Adopt",
70    "AdoptText",
71    "Listen",
72    "Unlisten",
73    "FreeNode",
74    // Wave 7.6 — opens a hydration: the stream's `Adopt`/`AdoptText` claim the markup the root
75    // already holds.
76    "Hydrate",
77];
78
79const OP_BEGIN_PAINT: u8 = 0;
80
81/// The opcodes no emitter writes any more, each with why. Each keeps its number, so no later opcode
82/// is renumbered, and both decoders refuse it by name (`retired_opcode`) rather than as an unknown
83/// code.
84pub const RETIRED_OPCODES: &[(u8, &str)] = &[
85    (
86        OP_BEGIN_PAINT,
87        "a paint's boundary is the commit that emits the stream, and no build since the retained tree writes it",
88    ),
89    (
90        OP_MOVE_CHILDREN,
91        "a slot's children are placed by the commit that emits the stream, and no build since the retained tree writes it",
92    ),
93];
94const OP_CREATE_ELEMENT: u8 = 1;
95const OP_CREATE_TEXT_NODE: u8 = 2;
96const OP_SET_TEXT: u8 = 3;
97const OP_SET_HTML: u8 = 4;
98const OP_CLEAR_CHILDREN: u8 = 5;
99const OP_SET_ATTRIBUTE: u8 = 6;
100const OP_APPEND_CHILD: u8 = 7;
101const OP_SET_VALUE: u8 = 8;
102const OP_SET_CHECKED: u8 = 9;
103const OP_ADD_EVENT_LISTENER: u8 = 10;
104const OP_SET_STREAM: u8 = 11;
105const OP_PREVENT_DEFAULT: u8 = 12;
106const OP_MOVE_CHILDREN: u8 = 13;
107const OP_REMOVE_NODE: u8 = 14;
108const OP_INSERT_BEFORE: u8 = 15;
109const OP_REMOVE_ATTRIBUTE: u8 = 16;
110const OP_ADOPT: u8 = 17;
111const OP_ADOPT_TEXT: u8 = 18;
112const OP_LISTEN: u8 = 19;
113const OP_UNLISTEN: u8 = 20;
114const OP_FREE_NODE: u8 = 21;
115const OP_HYDRATE: u8 = 22;
116
117fn put_u32(out: &mut Vec<u8>, v: u32) {
118    out.extend_from_slice(&v.to_le_bytes());
119}
120
121fn put_text(out: &mut Vec<u8>, s: &str) {
122    put_u32(out, s.len() as u32);
123    out.extend_from_slice(s.as_bytes());
124}
125
126/// Encode `ops` as a patch stream.
127///
128/// Deterministic: the same operations always produce the same bytes, so a byte comparison over
129/// the stream is a real assertion rather than a coin flip.
130pub fn encode(ops: &[DomOp]) -> Vec<u8> {
131    let mut out = Vec::new();
132    for op in ops {
133        match op {
134            DomOp::CreateElement { node, tag } => {
135                out.push(OP_CREATE_ELEMENT);
136                put_u32(&mut out, *node);
137                put_text(&mut out, tag);
138            }
139            DomOp::CreateTextNode { node, text } => {
140                out.push(OP_CREATE_TEXT_NODE);
141                put_u32(&mut out, *node);
142                put_text(&mut out, text);
143            }
144            DomOp::SetText { node, text } => {
145                out.push(OP_SET_TEXT);
146                put_u32(&mut out, *node);
147                put_text(&mut out, text);
148            }
149            DomOp::SetHtml { node, html } => {
150                out.push(OP_SET_HTML);
151                put_u32(&mut out, *node);
152                put_text(&mut out, html);
153            }
154            DomOp::ClearChildren { node } => {
155                out.push(OP_CLEAR_CHILDREN);
156                put_u32(&mut out, *node);
157            }
158            DomOp::SetAttribute { node, name, value } => {
159                out.push(OP_SET_ATTRIBUTE);
160                put_u32(&mut out, *node);
161                put_text(&mut out, name);
162                put_text(&mut out, value);
163            }
164            DomOp::AppendChild { parent, child } => {
165                out.push(OP_APPEND_CHILD);
166                put_u32(&mut out, *parent);
167                put_u32(&mut out, *child);
168            }
169            DomOp::SetValue { node, value } => {
170                out.push(OP_SET_VALUE);
171                put_u32(&mut out, *node);
172                put_text(&mut out, value);
173            }
174            DomOp::SetChecked { node, checked } => {
175                out.push(OP_SET_CHECKED);
176                put_u32(&mut out, *node);
177                out.push(u8::from(*checked));
178            }
179            DomOp::AddEventListener { node, event } => {
180                out.push(OP_ADD_EVENT_LISTENER);
181                put_u32(&mut out, *node);
182                put_text(&mut out, event);
183            }
184            DomOp::SetStream { node, stream } => {
185                out.push(OP_SET_STREAM);
186                put_u32(&mut out, *node);
187                out.extend_from_slice(&stream.to_le_bytes());
188            }
189            DomOp::PreventDefault { node, event } => {
190                out.push(OP_PREVENT_DEFAULT);
191                put_u32(&mut out, *node);
192                put_text(&mut out, event);
193            }
194            DomOp::RemoveNode { node } => {
195                out.push(OP_REMOVE_NODE);
196                put_u32(&mut out, *node);
197            }
198            DomOp::InsertBefore {
199                parent,
200                child,
201                before,
202            } => {
203                out.push(OP_INSERT_BEFORE);
204                put_u32(&mut out, *parent);
205                put_u32(&mut out, *child);
206                put_u32(&mut out, *before);
207            }
208            DomOp::RemoveAttribute { node, name } => {
209                out.push(OP_REMOVE_ATTRIBUTE);
210                put_u32(&mut out, *node);
211                put_text(&mut out, name);
212            }
213            DomOp::Adopt { parent, node, tag } => {
214                out.push(OP_ADOPT);
215                put_u32(&mut out, *parent);
216                put_u32(&mut out, *node);
217                put_text(&mut out, tag);
218            }
219            DomOp::AdoptText { parent, node, text } => {
220                out.push(OP_ADOPT_TEXT);
221                put_u32(&mut out, *parent);
222                put_u32(&mut out, *node);
223                put_text(&mut out, text);
224            }
225            DomOp::Hydrate { root } => {
226                out.push(OP_HYDRATE);
227                put_u32(&mut out, *root);
228            }
229            DomOp::Listen { node, event, flags } => {
230                out.push(OP_LISTEN);
231                put_u32(&mut out, *node);
232                put_text(&mut out, event);
233                out.push(*flags);
234            }
235            DomOp::Unlisten { node, event } => {
236                out.push(OP_UNLISTEN);
237                put_u32(&mut out, *node);
238                put_text(&mut out, event);
239            }
240            DomOp::FreeNode { node } => {
241                out.push(OP_FREE_NODE);
242                put_u32(&mut out, *node);
243            }
244            // The WebRTC handshake is a peer-connection protocol, not a document mutation: it
245            // mints opaque host handles and exchanges SDP. It rides its own imports rather than
246            // the DOM patch stream, and encoding it here would put a second, unrelated protocol
247            // in a format whose whole job is "what to do to the document".
248            DomOp::RtcPeerConnection { .. }
249            | DomOp::RtcDataChannel { .. }
250            | DomOp::RtcCreateOffer { .. }
251            | DomOp::RtcCreateAnswer { .. }
252            | DomOp::RtcSetLocal { .. }
253            | DomOp::RtcSetRemote { .. }
254            | DomOp::RtcAddIce { .. }
255            | DomOp::RtcSend { .. } => {}
256        }
257    }
258    out
259}
260
261/// Read a patch buffer out of a LOGOS value — a `Seq of Int` whose every element is a byte.
262///
263/// A value OUTSIDE a byte's range is refused rather than truncated to its low 8 bits: a wrong
264/// number silently becoming a different, valid-looking operation is exactly the class of bug a
265/// binary format invites, and it would show up as a mangled page rather than as an error.
266pub fn bytes_from_value(value: &crate::interpreter::RuntimeValue) -> Result<Vec<u8>, String> {
267    use crate::interpreter::RuntimeValue;
268    let RuntimeValue::List(list) = value else {
269        return Err(format!(
270            "`applyPatches` wants a patch buffer — a Seq of Int, each element one byte — and got \
271             {}.",
272            value.type_name()
273        ));
274    };
275    let mut out = Vec::new();
276    for (i, item) in list.borrow().to_values().into_iter().enumerate() {
277        let RuntimeValue::Int(n) = item else {
278            return Err(format!(
279                "a patch buffer holds bytes; element {} is {}.",
280                i + 1,
281                item.type_name()
282            ));
283        };
284        let byte = u8::try_from(n).map_err(|_| {
285            format!(
286                "a patch buffer holds bytes (0 to 255); element {} is {n}.",
287                i + 1
288            )
289        })?;
290        out.push(byte);
291    }
292    Ok(out)
293}
294
295/// Apply a decoded stream to the recorder — the DUMB SHIM, in Rust.
296///
297/// It is a `for` loop with no diffing, no reconciliation and no decisions of its own: every
298/// choice was made by whoever emitted the stream. That is the whole point of the format, and it
299/// is what a browser host has to be too — a host that decided anything would be a second
300/// implementation of the framework, in a language the framework does not compile to.
301///
302/// The EMITTER is the id authority: a created node takes the id the stream names for it, and every
303/// later record addresses it by that id. A stream names ids in the range [`dom::STREAM_ID_BASE`]
304/// opens, which the recorder never mints; below it, a stream can name only the ordinal the
305/// recorder would mint next — how a recorded op log replays into a fresh document.
306///
307/// All or nothing: a stream the host cannot honour — a create naming a live id, or an id the
308/// recorder mints itself out of turn — leaves the document exactly as it was.
309///
310/// A stream that opens a hydration (`Hydrate`) ends it here: answers how many of its element claims
311/// found no element of their tag — `0` for a stream that hydrated nothing. The emitter decides what
312/// a mismatch means.
313///
314/// [`dom::STREAM_ID_BASE`]: super::dom::STREAM_ID_BASE
315pub fn apply(ops: &[DomOp]) -> Result<i64, String> {
316    super::dom::atomically(|| {
317        ops.iter().try_for_each(apply_one)?;
318        super::dom::end_hydration()
319    })
320}
321
322fn apply_one(op: &DomOp) -> Result<(), String> {
323    match op {
324        DomOp::CreateElement { node, tag } => {
325            return super::dom::create_element_as(*node as i64, tag)
326        }
327        DomOp::CreateTextNode { node, text } => {
328            return super::dom::create_text_node_as(*node as i64, text)
329        }
330        DomOp::SetText { node, text } => super::dom::set_text(*node as i64, text),
331        DomOp::SetHtml { node, html } => super::dom::set_html(*node as i64, html),
332        DomOp::ClearChildren { node } => super::dom::clear_children(*node as i64),
333        DomOp::SetAttribute { node, name, value } => {
334            super::dom::set_attribute(*node as i64, name, value)
335        }
336        DomOp::AppendChild { parent, child } => {
337            super::dom::append_child(*parent as i64, *child as i64)
338        }
339        DomOp::SetValue { node, value } => super::dom::set_value(*node as i64, value),
340        DomOp::SetChecked { node, checked } => super::dom::set_checked(*node as i64, *checked),
341        // A listener's HANDLER cannot ride the stream — it is a closure in the emitting
342        // program, not data. The stream carries the REGISTRATION, which is what a browser
343        // needs to attach; a replay into the recorder records the same fact.
344        DomOp::AddEventListener { node, event } => {
345            super::dom::note_event_listener(*node as i64, event)
346        }
347        DomOp::PreventDefault { node, event } => super::dom::prevent_default(*node as i64, event),
348        DomOp::SetStream { node, stream } => super::dom::set_stream(*node as i64, *stream),
349        DomOp::RemoveNode { node } => super::dom::remove_node(*node as i64),
350        DomOp::InsertBefore {
351            parent,
352            child,
353            before,
354        } => super::dom::insert_before(*parent as i64, *child as i64, *before as i64),
355        DomOp::RemoveAttribute { node, name } => super::dom::remove_attribute(*node as i64, name),
356        DomOp::Hydrate { root } => return super::dom::hydrate(*root as i64),
357        DomOp::Adopt { parent, node, tag } => {
358            return super::dom::adopt(*parent as i64, *node as i64, tag)
359        }
360        DomOp::AdoptText { parent, node, text } => {
361            return super::dom::adopt_text(*parent as i64, *node as i64, text)
362        }
363        DomOp::Listen { node, event, flags } => super::dom::listen(*node as i64, event, *flags),
364        DomOp::Unlisten { node, event } => super::dom::unlisten(*node as i64, event),
365        DomOp::FreeNode { node } => super::dom::free_node(*node as i64),
366        DomOp::RtcPeerConnection { .. }
367        | DomOp::RtcDataChannel { .. }
368        | DomOp::RtcCreateOffer { .. }
369        | DomOp::RtcCreateAnswer { .. }
370        | DomOp::RtcSetLocal { .. }
371        | DomOp::RtcSetRemote { .. }
372        | DomOp::RtcAddIce { .. }
373        | DomOp::RtcSend { .. } => {}
374    }
375    Ok(())
376}
377
378/// One cursor over the buffer, refusing anything that would read past its end.
379struct Reader<'a> {
380    bytes: &'a [u8],
381    at: usize,
382}
383
384impl<'a> Reader<'a> {
385    fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
386        let end = self.at.checked_add(n).ok_or_else(|| {
387            format!(
388                "a patch record at byte {} claims a length that overflows",
389                self.at
390            )
391        })?;
392        if end > self.bytes.len() {
393            return Err(format!(
394                "a patch record at byte {} needs {n} more bytes and the stream has {}",
395                self.at,
396                self.bytes.len() - self.at
397            ));
398        }
399        let slice = &self.bytes[self.at..end];
400        self.at = end;
401        Ok(slice)
402    }
403
404    fn u8(&mut self) -> Result<u8, String> {
405        Ok(self.take(1)?[0])
406    }
407
408    fn u32(&mut self) -> Result<u32, String> {
409        let b = self.take(4)?;
410        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
411    }
412
413    fn i64(&mut self) -> Result<i64, String> {
414        let b = self.take(8)?;
415        Ok(i64::from_le_bytes([
416            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
417        ]))
418    }
419
420    fn text(&mut self) -> Result<String, String> {
421        let len = self.u32()? as usize;
422        let bytes = self.take(len)?;
423        // Never lossy: a replacement character would put text on the page nobody wrote.
424        std::str::from_utf8(bytes)
425            .map(str::to_string)
426            .map_err(|e| format!("a patch record carries text that is not UTF-8: {e}"))
427    }
428
429    fn flag(&mut self) -> Result<bool, String> {
430        match self.u8()? {
431            0 => Ok(false),
432            1 => Ok(true),
433            other => Err(format!("a patch flag must be 0 or 1, got {other}")),
434        }
435    }
436}
437
438/// Why `code` is retired, when it is ([`RETIRED_OPCODES`]).
439fn retired(code: u8) -> Option<&'static str> {
440    RETIRED_OPCODES
441        .iter()
442        .find(|(c, _)| *c == code)
443        .map(|(_, why)| *why)
444}
445
446/// The refusal of a retired opcode, found at `at`.
447fn retired_opcode(code: u8, at: &str) -> String {
448    format!(
449        "patch opcode {code} ({}) at {at} is retired: {}.",
450        OPCODES[code as usize],
451        retired(code).unwrap_or_default()
452    )
453}
454
455/// Decode a patch stream, or say exactly what is wrong with it.
456pub fn decode(bytes: &[u8]) -> Result<Vec<DomOp>, String> {
457    let mut r = Reader { bytes, at: 0 };
458    let mut ops = Vec::new();
459    while r.at < bytes.len() {
460        let code = r.u8()?;
461        let op = match code {
462            code if retired(code).is_some() => {
463                return Err(retired_opcode(code, &format!("byte {}", r.at - 1)))
464            }
465            OP_CREATE_ELEMENT => DomOp::CreateElement {
466                node: r.u32()?,
467                tag: r.text()?,
468            },
469            OP_CREATE_TEXT_NODE => DomOp::CreateTextNode {
470                node: r.u32()?,
471                text: r.text()?,
472            },
473            OP_SET_TEXT => DomOp::SetText {
474                node: r.u32()?,
475                text: r.text()?,
476            },
477            OP_SET_HTML => DomOp::SetHtml {
478                node: r.u32()?,
479                html: r.text()?,
480            },
481            OP_CLEAR_CHILDREN => DomOp::ClearChildren { node: r.u32()? },
482            OP_SET_ATTRIBUTE => DomOp::SetAttribute {
483                node: r.u32()?,
484                name: r.text()?,
485                value: r.text()?,
486            },
487            OP_APPEND_CHILD => DomOp::AppendChild {
488                parent: r.u32()?,
489                child: r.u32()?,
490            },
491            OP_SET_VALUE => DomOp::SetValue {
492                node: r.u32()?,
493                value: r.text()?,
494            },
495            OP_SET_CHECKED => DomOp::SetChecked {
496                node: r.u32()?,
497                checked: r.flag()?,
498            },
499            OP_ADD_EVENT_LISTENER => DomOp::AddEventListener {
500                node: r.u32()?,
501                event: r.text()?,
502            },
503            OP_SET_STREAM => DomOp::SetStream {
504                node: r.u32()?,
505                stream: r.i64()?,
506            },
507            OP_PREVENT_DEFAULT => DomOp::PreventDefault {
508                node: r.u32()?,
509                event: r.text()?,
510            },
511            OP_REMOVE_NODE => DomOp::RemoveNode { node: r.u32()? },
512            OP_INSERT_BEFORE => DomOp::InsertBefore {
513                parent: r.u32()?,
514                child: r.u32()?,
515                before: r.u32()?,
516            },
517            OP_REMOVE_ATTRIBUTE => DomOp::RemoveAttribute {
518                node: r.u32()?,
519                name: r.text()?,
520            },
521            OP_ADOPT => DomOp::Adopt {
522                parent: r.u32()?,
523                node: r.u32()?,
524                tag: r.text()?,
525            },
526            OP_ADOPT_TEXT => DomOp::AdoptText {
527                parent: r.u32()?,
528                node: r.u32()?,
529                text: r.text()?,
530            },
531            OP_LISTEN => DomOp::Listen {
532                node: r.u32()?,
533                event: r.text()?,
534                flags: r.u8()?,
535            },
536            OP_UNLISTEN => DomOp::Unlisten {
537                node: r.u32()?,
538                event: r.text()?,
539            },
540            OP_FREE_NODE => DomOp::FreeNode { node: r.u32()? },
541            OP_HYDRATE => DomOp::Hydrate { root: r.u32()? },
542            other => {
543                return Err(format!(
544                    "unknown patch opcode {other} (0x{other:02x}) at byte {}. The opcode table is \
545                     append-only ({} operations today); a stream carrying a code this host does \
546                     not know was emitted by a newer build.",
547                    r.at - 1,
548                    OPCODES.len()
549                ))
550            }
551        };
552        ops.push(op);
553    }
554    Ok(ops)
555}
556
557/// Word-oriented wire format v2: the same opcodes as [`encode`]/[`decode`], but every operand is
558/// an `i64` WORD and every string is a 1-based index into a separate STRING TABLE — the shape
559/// `vdom.lg`'s own emitter naturally produces (`Seq of Int` for the words, `Seq of Text` for the
560/// strings), with no byte-level length-prefixing to hand-roll in LOGOS. `words`/`strings` travel
561/// as a pair; a decoder given one without its matching other cannot make sense of either.
562///
563/// Unlike [`encode`], no interning: every text operand is appended to the table in the order it
564/// is encountered, exactly as a straightforward LOGOS emitter that has no dictionary to consult
565/// would build one, so a byte-for-byte comparison against `vdom.lg`'s own output stays meaningful.
566pub fn encode_words(ops: &[DomOp]) -> (Vec<i64>, Vec<String>) {
567    let mut words = Vec::new();
568    let mut strings: Vec<String> = Vec::new();
569    fn intern(strings: &mut Vec<String>, s: &str) -> i64 {
570        strings.push(s.to_string());
571        strings.len() as i64
572    }
573    for op in ops {
574        match op {
575            DomOp::CreateElement { node, tag } => {
576                words.push(OP_CREATE_ELEMENT as i64);
577                words.push(*node as i64);
578                words.push(intern(&mut strings, tag));
579            }
580            DomOp::CreateTextNode { node, text } => {
581                words.push(OP_CREATE_TEXT_NODE as i64);
582                words.push(*node as i64);
583                words.push(intern(&mut strings, text));
584            }
585            DomOp::SetText { node, text } => {
586                words.push(OP_SET_TEXT as i64);
587                words.push(*node as i64);
588                words.push(intern(&mut strings, text));
589            }
590            DomOp::SetHtml { node, html } => {
591                words.push(OP_SET_HTML as i64);
592                words.push(*node as i64);
593                words.push(intern(&mut strings, html));
594            }
595            DomOp::ClearChildren { node } => {
596                words.push(OP_CLEAR_CHILDREN as i64);
597                words.push(*node as i64);
598            }
599            DomOp::SetAttribute { node, name, value } => {
600                words.push(OP_SET_ATTRIBUTE as i64);
601                words.push(*node as i64);
602                words.push(intern(&mut strings, name));
603                words.push(intern(&mut strings, value));
604            }
605            DomOp::AppendChild { parent, child } => {
606                words.push(OP_APPEND_CHILD as i64);
607                words.push(*parent as i64);
608                words.push(*child as i64);
609            }
610            DomOp::SetValue { node, value } => {
611                words.push(OP_SET_VALUE as i64);
612                words.push(*node as i64);
613                words.push(intern(&mut strings, value));
614            }
615            DomOp::SetChecked { node, checked } => {
616                words.push(OP_SET_CHECKED as i64);
617                words.push(*node as i64);
618                words.push(i64::from(*checked));
619            }
620            DomOp::AddEventListener { node, event } => {
621                words.push(OP_ADD_EVENT_LISTENER as i64);
622                words.push(*node as i64);
623                words.push(intern(&mut strings, event));
624            }
625            DomOp::SetStream { node, stream } => {
626                words.push(OP_SET_STREAM as i64);
627                words.push(*node as i64);
628                words.push(*stream);
629            }
630            DomOp::PreventDefault { node, event } => {
631                words.push(OP_PREVENT_DEFAULT as i64);
632                words.push(*node as i64);
633                words.push(intern(&mut strings, event));
634            }
635            DomOp::RemoveNode { node } => {
636                words.push(OP_REMOVE_NODE as i64);
637                words.push(*node as i64);
638            }
639            DomOp::InsertBefore {
640                parent,
641                child,
642                before,
643            } => {
644                words.push(OP_INSERT_BEFORE as i64);
645                words.push(*parent as i64);
646                words.push(*child as i64);
647                words.push(*before as i64);
648            }
649            DomOp::RemoveAttribute { node, name } => {
650                words.push(OP_REMOVE_ATTRIBUTE as i64);
651                words.push(*node as i64);
652                words.push(intern(&mut strings, name));
653            }
654            DomOp::Adopt { parent, node, tag } => {
655                words.push(OP_ADOPT as i64);
656                words.push(*parent as i64);
657                words.push(*node as i64);
658                words.push(intern(&mut strings, tag));
659            }
660            DomOp::AdoptText { parent, node, text } => {
661                words.push(OP_ADOPT_TEXT as i64);
662                words.push(*parent as i64);
663                words.push(*node as i64);
664                words.push(intern(&mut strings, text));
665            }
666            DomOp::Hydrate { root } => {
667                words.push(OP_HYDRATE as i64);
668                words.push(*root as i64);
669            }
670            DomOp::Listen { node, event, flags } => {
671                words.push(OP_LISTEN as i64);
672                words.push(*node as i64);
673                words.push(intern(&mut strings, event));
674                words.push(*flags as i64);
675            }
676            DomOp::Unlisten { node, event } => {
677                words.push(OP_UNLISTEN as i64);
678                words.push(*node as i64);
679                words.push(intern(&mut strings, event));
680            }
681            DomOp::FreeNode { node } => {
682                words.push(OP_FREE_NODE as i64);
683                words.push(*node as i64);
684            }
685            // Same exclusion as `encode`: a separate protocol, not a document mutation.
686            DomOp::RtcPeerConnection { .. }
687            | DomOp::RtcDataChannel { .. }
688            | DomOp::RtcCreateOffer { .. }
689            | DomOp::RtcCreateAnswer { .. }
690            | DomOp::RtcSetLocal { .. }
691            | DomOp::RtcSetRemote { .. }
692            | DomOp::RtcAddIce { .. }
693            | DomOp::RtcSend { .. } => {}
694        }
695    }
696    (words, strings)
697}
698
699/// One cursor over a word buffer, refusing anything past its end — the word-format twin of
700/// [`Reader`].
701struct WordReader<'a> {
702    words: &'a [i64],
703    strings: &'a [String],
704    at: usize,
705}
706
707impl<'a> WordReader<'a> {
708    fn word(&mut self) -> Result<i64, String> {
709        let w = *self.words.get(self.at).ok_or_else(|| {
710            format!(
711                "a word patch record at word {} needs one more word and the stream has none",
712                self.at
713            )
714        })?;
715        self.at += 1;
716        Ok(w)
717    }
718
719    fn u32(&mut self) -> Result<u32, String> {
720        let w = self.word()?;
721        u32::try_from(w).map_err(|_| {
722            format!(
723                "a word patch record at word {} carries {w}, which does not fit a node id (u32)",
724                self.at - 1
725            )
726        })
727    }
728
729    fn text(&mut self) -> Result<String, String> {
730        let idx = self.word()?;
731        let i = usize::try_from(idx)
732            .ok()
733            .filter(|&i| i >= 1 && i <= self.strings.len())
734            .ok_or_else(|| {
735                format!(
736                    "a word patch record's string index {idx} is out of range (1..={}) at word {}",
737                    self.strings.len(),
738                    self.at - 1
739                )
740            })?;
741        Ok(self.strings[i - 1].clone())
742    }
743
744    fn flag(&mut self) -> Result<bool, String> {
745        match self.word()? {
746            0 => Ok(false),
747            1 => Ok(true),
748            other => Err(format!(
749                "a word patch flag must be 0 or 1, got {other} at word {}",
750                self.at - 1
751            )),
752        }
753    }
754
755    fn u8(&mut self) -> Result<u8, String> {
756        let w = self.word()?;
757        u8::try_from(w).map_err(|_| {
758            format!(
759                "a word patch record at word {} carries {w}, which does not fit a flags byte",
760                self.at - 1
761            )
762        })
763    }
764}
765
766/// Decode a `(words, strings)` pair — [`encode_words`]'s inverse, and what a native tier applies
767/// `vdom.lg`'s own `vdCommit` output through.
768pub fn decode_words(words: &[i64], strings: &[String]) -> Result<Vec<DomOp>, String> {
769    let mut r = WordReader {
770        words,
771        strings,
772        at: 0,
773    };
774    let mut ops = Vec::new();
775    while r.at < words.len() {
776        let code = r.word()?;
777        let code = u8::try_from(code).map_err(|_| {
778            format!(
779                "a word patch opcode {code} at word {} does not fit a byte",
780                r.at - 1
781            )
782        })?;
783        let op = match code {
784            code if retired(code).is_some() => {
785                return Err(retired_opcode(code, &format!("word {}", r.at - 1)))
786            }
787            OP_CREATE_ELEMENT => DomOp::CreateElement {
788                node: r.u32()?,
789                tag: r.text()?,
790            },
791            OP_CREATE_TEXT_NODE => DomOp::CreateTextNode {
792                node: r.u32()?,
793                text: r.text()?,
794            },
795            OP_SET_TEXT => DomOp::SetText {
796                node: r.u32()?,
797                text: r.text()?,
798            },
799            OP_SET_HTML => DomOp::SetHtml {
800                node: r.u32()?,
801                html: r.text()?,
802            },
803            OP_CLEAR_CHILDREN => DomOp::ClearChildren { node: r.u32()? },
804            OP_SET_ATTRIBUTE => DomOp::SetAttribute {
805                node: r.u32()?,
806                name: r.text()?,
807                value: r.text()?,
808            },
809            OP_APPEND_CHILD => DomOp::AppendChild {
810                parent: r.u32()?,
811                child: r.u32()?,
812            },
813            OP_SET_VALUE => DomOp::SetValue {
814                node: r.u32()?,
815                value: r.text()?,
816            },
817            OP_SET_CHECKED => DomOp::SetChecked {
818                node: r.u32()?,
819                checked: r.flag()?,
820            },
821            OP_ADD_EVENT_LISTENER => DomOp::AddEventListener {
822                node: r.u32()?,
823                event: r.text()?,
824            },
825            OP_SET_STREAM => DomOp::SetStream {
826                node: r.u32()?,
827                stream: r.word()?,
828            },
829            OP_PREVENT_DEFAULT => DomOp::PreventDefault {
830                node: r.u32()?,
831                event: r.text()?,
832            },
833            OP_REMOVE_NODE => DomOp::RemoveNode { node: r.u32()? },
834            OP_INSERT_BEFORE => DomOp::InsertBefore {
835                parent: r.u32()?,
836                child: r.u32()?,
837                before: r.u32()?,
838            },
839            OP_REMOVE_ATTRIBUTE => DomOp::RemoveAttribute {
840                node: r.u32()?,
841                name: r.text()?,
842            },
843            OP_ADOPT => DomOp::Adopt {
844                parent: r.u32()?,
845                node: r.u32()?,
846                tag: r.text()?,
847            },
848            OP_ADOPT_TEXT => DomOp::AdoptText {
849                parent: r.u32()?,
850                node: r.u32()?,
851                text: r.text()?,
852            },
853            OP_LISTEN => DomOp::Listen {
854                node: r.u32()?,
855                event: r.text()?,
856                flags: r.u8()?,
857            },
858            OP_UNLISTEN => DomOp::Unlisten {
859                node: r.u32()?,
860                event: r.text()?,
861            },
862            OP_FREE_NODE => DomOp::FreeNode { node: r.u32()? },
863            OP_HYDRATE => DomOp::Hydrate { root: r.u32()? },
864            other => {
865                return Err(format!(
866                    "unknown word patch opcode {other} at word {}. The opcode table is \
867                     append-only ({} operations today); a stream carrying a code this host does \
868                     not know was emitted by a newer build.",
869                    r.at - 1,
870                    OPCODES.len()
871                ))
872            }
873        };
874        ops.push(op);
875    }
876    Ok(ops)
877}
878
879/// The first `count` elements of `value`, a LOGOS sequence — a scratch array the emitter fills
880/// only as far as its logical length. A count the sequence does not hold is refused: a stream
881/// cut short or read past is a bug in the emitter, never a shorter stream.
882fn prefix_of(
883    value: &crate::interpreter::RuntimeValue,
884    count: i64,
885    what: &str,
886) -> Result<Vec<crate::interpreter::RuntimeValue>, String> {
887    use crate::interpreter::RuntimeValue;
888    let RuntimeValue::List(list) = value else {
889        return Err(format!(
890            "a word patch stream's {what} is a sequence, and got {}.",
891            value.type_name()
892        ));
893    };
894    let items = list.borrow().to_values();
895    let n = usize::try_from(count)
896        .ok()
897        .filter(|&n| n <= items.len())
898        .ok_or_else(|| {
899            format!(
900                "a word patch stream names {count} {what}, and its sequence holds {}.",
901                items.len()
902            )
903        })?;
904    Ok(items.into_iter().take(n).collect())
905}
906
907/// The first `count` words of a `Seq of Int` — the word half of the word-oriented wire format, as
908/// `vdom.lg`'s `vdApply` hands it over.
909pub fn words_from_value(
910    value: &crate::interpreter::RuntimeValue,
911    count: i64,
912) -> Result<Vec<i64>, String> {
913    use crate::interpreter::RuntimeValue;
914    prefix_of(value, count, "words")?
915        .into_iter()
916        .enumerate()
917        .map(|(i, item)| match item {
918            RuntimeValue::Int(n) => Ok(n),
919            other => Err(format!(
920                "a word patch stream holds Ints; word {} is {}.",
921                i + 1,
922                other.type_name()
923            )),
924        })
925        .collect()
926}
927
928/// The first `count` strings of a `Seq of Text` — the string half of the word-oriented wire format.
929pub fn strings_from_value(
930    value: &crate::interpreter::RuntimeValue,
931    count: i64,
932) -> Result<Vec<String>, String> {
933    use crate::interpreter::RuntimeValue;
934    prefix_of(value, count, "strings")?
935        .into_iter()
936        .enumerate()
937        .map(|(i, item)| match item {
938            RuntimeValue::Text(s) => Ok(s.as_str().to_string()),
939            other => Err(format!(
940                "a word patch stream's string table holds Text; string {} is {}.",
941                i + 1,
942                other.type_name()
943            )),
944        })
945        .collect()
946}