Skip to main content

satteri_plugin_api/
js_commands.rs

1//! Binary command buffer parser and mutation applicator.
2//!
3//! Reads a command buffer produced by the JS `CommandBuffer` class, converts
4//! commands into arena mutations, and returns the rebuilt arena.
5//!
6//! ## Wire format
7//!
8//! All multi-byte integers are **little-endian**. A command is one `CMD_*`
9//! byte plus `[nodeId: u32]`; structural commands then carry a
10//! `[payloadType: u8][len: u32][payload…]` sub-tree. The command, payload,
11//! op-stream, and property-kind byte values (with their operand layouts) are
12//! declared once in `satteri-layout-codegen/src/schema.rs` and generated into
13//! `generated/wire_constants.rs` here and
14//! `packages/satteri/src/generated/wire-constants.ts` on the JS side.
15//!
16//! The MDAST and HAST command paths are deliberately separate functions
17//! (`apply_mdast_commands`, `apply_hast_commands`). Numeric `node_type`
18//! values overlap between the two arenas (e.g. mdast Paragraph=1 collides
19//! with HastNodeType::Element=1), so a single dispatcher trying to handle
20//! both kinds would silently misroute nodes. The phantom-typed `Arena<K>`
21//! signature on each entry point makes a cross-kind call a compile error.
22
23use satteri_arena::{Arena, ArenaBuilder, ArenaKind, Hast, Mdast, StringRef};
24use satteri_ast::commands::CommandError;
25use satteri_ast::hast::HastNodeType;
26use satteri_ast::mdast::codec::*;
27use satteri_ast::mdast::MdastNodeType;
28use satteri_ast::rebuild::{Patch, REF_NODE_TYPE};
29#[cfg(feature = "mdx")]
30use satteri_ast::shared::{MDX_ATTR_BOOLEAN_PROP, MDX_ATTR_LITERAL_PROP, MDX_ATTR_SPREAD};
31use satteri_ast::shared::{
32    PROP_BOOL_FALSE, PROP_BOOL_TRUE, PROP_INT, PROP_NULL, PROP_SPACE_SEP, PROP_STRING,
33};
34
35use crate::generated::prop_slots::{mdast_prop_slot, MdastPropSlot};
36use crate::generated::wire_constants::*;
37
38struct BufReader<'a> {
39    data: &'a [u8],
40    pos: usize,
41}
42
43impl<'a> BufReader<'a> {
44    fn new(data: &'a [u8]) -> Self {
45        Self { data, pos: 0 }
46    }
47
48    fn remaining(&self) -> usize {
49        self.data.len() - self.pos
50    }
51
52    fn read_u8(&mut self) -> Result<u8, CommandError> {
53        if self.remaining() < 1 {
54            return Err(CommandError::UnexpectedEof);
55        }
56        let v = self.data[self.pos];
57        self.pos += 1;
58        Ok(v)
59    }
60
61    fn read_u32(&mut self) -> Result<u32, CommandError> {
62        if self.remaining() < 4 {
63            return Err(CommandError::UnexpectedEof);
64        }
65        let v = u32::from_le_bytes([
66            self.data[self.pos],
67            self.data[self.pos + 1],
68            self.data[self.pos + 2],
69            self.data[self.pos + 3],
70        ]);
71        self.pos += 4;
72        Ok(v)
73    }
74
75    fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], CommandError> {
76        if self.remaining() < len {
77            return Err(CommandError::UnexpectedEof);
78        }
79        let slice = &self.data[self.pos..self.pos + len];
80        self.pos += len;
81        Ok(slice)
82    }
83
84    fn read_str(&mut self, len: usize) -> Result<&'a str, CommandError> {
85        let bytes = self.read_bytes(len)?;
86        std::str::from_utf8(bytes).map_err(|_| CommandError::InvalidUtf8)
87    }
88}
89
90/// `data` JSON blob is stored in the per-node `node_data` map; it doesn't
91/// dispatch on node-type bytes, so it's safe under any kind.
92fn apply_data_property<K: ArenaKind>(
93    arena: &mut Arena<K>,
94    node_id: u32,
95    value_type: u8,
96    value_str: &str,
97) {
98    if value_type == PROP_NULL {
99        arena.set_node_data(node_id, Vec::new());
100    } else {
101        arena.set_node_data(node_id, value_str.as_bytes().to_vec());
102    }
103}
104
105/// The canonical MDAST type name for a node-type byte, for error messages.
106fn mdast_type_name(node_type: u8) -> String {
107    match MdastNodeType::from_u8(node_type) {
108        Some(t) => t.name().to_string(),
109        None => format!("unknown({node_type})"),
110    }
111}
112
113/// MDAST set-property: writes a typed field (or `data` JSON) onto an MDAST
114/// node. Kind-tight to `Arena<Mdast>` — the HAST element-properties writer
115/// can no longer be reached from here.
116fn apply_mdast_set_property(
117    arena: &mut Arena<Mdast>,
118    node_id: u32,
119    prop_name: &str,
120    value_type: u8,
121    value_str: &str,
122) -> Result<(), CommandError> {
123    if node_id as usize >= arena.len() {
124        return Err(CommandError::InvalidNodeId(node_id));
125    }
126    if prop_name == "data" {
127        apply_data_property(arena, node_id, value_type, value_str);
128        return Ok(());
129    }
130
131    let node_type = arena.get_node(node_id).node_type;
132
133    // The property doesn't resolve to a slot for this node type at all.
134    let slot = mdast_prop_slot(node_type, prop_name).ok_or_else(|| CommandError::UnknownField {
135        node_type: mdast_type_name(node_type),
136        name: prop_name.to_string(),
137    })?;
138
139    // The slot resolved, so a `None` from the writer means the value's type is
140    // one the slot can't hold — report that rather than "unknown".
141    let written = write_mdast_prop_slot(arena, node_id, slot, prop_name, value_type, value_str)?;
142    written.ok_or_else(|| CommandError::InvalidPropertyValue {
143        node_type: mdast_type_name(node_type),
144        name: prop_name.to_string(),
145    })
146}
147
148/// Write a resolved slot. `Ok(None)` means the slot can't hold this value
149/// type. String slots error `TypeDataTooShort` on short `type_data`; scalar
150/// slots skip the write silently instead (the historical semantics).
151fn write_mdast_prop_slot(
152    arena: &mut Arena<Mdast>,
153    node_id: u32,
154    slot: MdastPropSlot,
155    prop_name: &str,
156    value_type: u8,
157    value_str: &str,
158) -> Result<Option<()>, CommandError> {
159    use MdastPropSlot as S;
160    match value_type {
161        PROP_STRING | PROP_SPACE_SEP => match slot {
162            S::Str { offset } => {
163                let sref = arena.alloc_string(value_str);
164                set_mdast_string_ref(arena, node_id, offset, sref)?;
165            }
166            S::Enum8 { offset, values } => {
167                let Some(v) = values.iter().position(|v| *v == value_str) else {
168                    return Ok(None);
169                };
170                set_mdast_scalar(arena, node_id, offset, &[v as u8]);
171            }
172            _ => return Ok(None),
173        },
174        PROP_BOOL_TRUE | PROP_BOOL_FALSE => match slot {
175            S::Bool { offset } => {
176                let value = value_type == PROP_BOOL_TRUE;
177                set_mdast_scalar(arena, node_id, offset, &[value as u8]);
178            }
179            _ => return Ok(None),
180        },
181        PROP_INT => {
182            // Accept a float spelling like "3.0", but anything outside the
183            // slot's range must error — `as u8` would silently mask the bits.
184            let parsed = value_str.parse::<i64>().ok().or_else(|| {
185                value_str
186                    .parse::<f64>()
187                    .ok()
188                    .filter(|f| f.is_finite() && f.fract() == 0.0)
189                    .map(|f| f as i64)
190            });
191            let node_type = arena.get_node(node_id).node_type;
192            let fitted = |max: u32| -> Result<u32, CommandError> {
193                match parsed {
194                    Some(v) if (0..=max as i64).contains(&v) => Ok(v as u32),
195                    _ => Err(CommandError::PropertyValueOutOfRange {
196                        node_type: mdast_type_name(node_type),
197                        name: prop_name.to_string(),
198                        value: value_str.to_string(),
199                        max,
200                    }),
201                }
202            };
203            match slot {
204                S::U8 { offset } | S::CheckedTri { offset } => {
205                    let value = fitted(u8::MAX as u32)?;
206                    set_mdast_scalar(arena, node_id, offset, &[value as u8]);
207                }
208                S::U32 { offset } => {
209                    let value = fitted(u32::MAX)?;
210                    set_mdast_scalar(arena, node_id, offset, &value.to_le_bytes());
211                }
212                _ => return Ok(None),
213            }
214        }
215        PROP_NULL => match slot {
216            // 2 = not a task item.
217            S::CheckedTri { offset } => set_mdast_scalar(arena, node_id, offset, &[2]),
218            S::Str { offset } => set_mdast_string_ref(arena, node_id, offset, StringRef::empty())?,
219            _ => return Ok(None),
220        },
221        _ => return Err(CommandError::InvalidPropertyValueType(value_type)),
222    }
223    Ok(Some(()))
224}
225
226/// Write an 8-byte `StringRef` at `offset` into the node's `type_data`.
227fn set_mdast_string_ref(
228    arena: &mut Arena<Mdast>,
229    node_id: u32,
230    offset: usize,
231    sref: StringRef,
232) -> Result<(), CommandError> {
233    let node = arena.get_node(node_id);
234    let data_offset = node.data_offset as usize;
235    let data_len = node.data_len as usize;
236    if data_len < offset + 8 {
237        return Err(CommandError::TypeDataTooShort);
238    }
239    let abs_offset = data_offset + offset;
240    arena.type_data[abs_offset..abs_offset + 8].copy_from_slice(&sref.as_bytes());
241    Ok(())
242}
243
244/// Write a scalar at `offset` into the node's `type_data`; too-short data
245/// skips the write.
246fn set_mdast_scalar(arena: &mut Arena<Mdast>, node_id: u32, offset: usize, bytes: &[u8]) {
247    let node = arena.get_node(node_id);
248    let data_offset = node.data_offset as usize;
249    let data_len = node.data_len as usize;
250    if data_len >= offset + bytes.len() {
251        let abs_offset = data_offset + offset;
252        arena.type_data[abs_offset..abs_offset + bytes.len()].copy_from_slice(bytes);
253    }
254}
255
256/// Escape `{` and `}` in HTML text content so they are not interpreted as MDX
257/// expressions when the HTML is re-parsed through the MDX parser.
258///
259/// Only braces in **text content** (outside of HTML tags) are escaped; braces
260/// inside quoted attribute values are left untouched. The escape form `{'{'}` /
261/// `{'}'}` produces a valid MDX expression that evaluates to the literal brace
262/// character.
263fn escape_braces_in_html_text(html: &str) -> String {
264    let mut result = String::with_capacity(html.len());
265    let mut in_tag = false;
266    let mut in_quote: Option<char> = None;
267
268    for ch in html.chars() {
269        if in_tag {
270            match ch {
271                '"' | '\'' if in_quote == Some(ch) => {
272                    in_quote = None;
273                    result.push(ch);
274                }
275                '"' | '\'' if in_quote.is_none() => {
276                    in_quote = Some(ch);
277                    result.push(ch);
278                }
279                '>' if in_quote.is_none() => {
280                    in_tag = false;
281                    result.push(ch);
282                }
283                _ => result.push(ch),
284            }
285        } else {
286            match ch {
287                '<' => {
288                    in_tag = true;
289                    result.push(ch);
290                }
291                '{' => result.push_str("{'{'}"),
292                '}' => result.push_str("{'}'}"),
293                _ => result.push(ch),
294            }
295        }
296    }
297    result
298}
299
300/// Emit a reference placeholder: a `REF_NODE_TYPE` node carrying the target
301/// original id (u32 LE) in its type_data. The rebuild resolves it by splicing
302/// that original subtree and applying any pending patch on it.
303fn emit_ref_node<K: ArenaKind>(ref_id: u32, builder: &mut ArenaBuilder<K>) {
304    builder.open_node_raw(REF_NODE_TYPE);
305    builder.set_data_current(&ref_id.to_le_bytes());
306    builder.close_node();
307}
308
309// Generated per-type arena encoder, driven by the node registry. See
310// `crates/satteri-layout-codegen`.
311use crate::generated::encode::{
312    encode_hast_tail_from_ops, encode_mdast_tail_from_ops, encode_mdast_type_data_from_ops,
313    MAX_FIXED_TYPE_DATA,
314};
315
316pub(crate) fn alloc_opt_str<K: ArenaKind>(
317    builder: &mut ArenaBuilder<K>,
318    s: Option<&str>,
319) -> StringRef {
320    match s {
321        Some(v) if !v.is_empty() => builder.alloc_string(v),
322        _ => StringRef::empty(),
323    }
324}
325
326// HAST command handlers
327
328/// HAST set-property: dispatches by `HastNodeType` to the matching writer.
329/// Kind-tight to `Arena<Hast>` — the MDAST field-resolver can no longer be
330/// reached from here.
331fn apply_hast_set_property(
332    arena: &mut Arena<Hast>,
333    node_id: u32,
334    prop_name: &str,
335    value_type: u8,
336    value_str: &str,
337) -> Result<(), CommandError> {
338    if node_id as usize >= arena.len() {
339        return Err(CommandError::InvalidNodeId(node_id));
340    }
341    if prop_name == "data" {
342        apply_data_property(arena, node_id, value_type, value_str);
343        return Ok(());
344    }
345
346    let raw_type = arena.get_node(node_id).node_type;
347    let node_type = HastNodeType::from_u8(raw_type)
348        .ok_or_else(|| CommandError::UnknownNodeType(format!("hast type 0x{raw_type:02x}")))?;
349
350    match node_type {
351        HastNodeType::Element => {
352            apply_hast_element_property(arena, node_id, prop_name, value_type, value_str)
353        }
354
355        HastNodeType::Text
356        | HastNodeType::Comment
357        | HastNodeType::Raw
358        | HastNodeType::MdxFlowExpression
359        | HastNodeType::MdxTextExpression
360        | HastNodeType::MdxEsm
361            if prop_name == "value" =>
362        {
363            let sref = arena.alloc_string(value_str);
364            let data = arena.get_type_data(node_id);
365            if data.len() >= 8 {
366                let data_offset = arena.get_node(node_id).data_offset as usize;
367                arena.type_data[data_offset..data_offset + 8].copy_from_slice(&sref.as_bytes());
368                Ok(())
369            } else {
370                Err(CommandError::TypeDataTooShort)
371            }
372        }
373
374        #[cfg(feature = "mdx")]
375        HastNodeType::MdxJsxElement | HastNodeType::MdxJsxTextElement => {
376            apply_hast_mdx_jsx_attribute(arena, node_id, prop_name, value_type, value_str)
377        }
378
379        _ => Err(CommandError::UnknownField {
380            node_type: node_type.name().to_string(),
381            name: prop_name.to_string(),
382        }),
383    }
384}
385
386/// Upsert a single attribute on an MDX JSX flow/text element. Avoids
387/// re-serializing the whole node (and materializing its children) just to
388/// change one attribute.
389///
390/// Any existing named attribute (boolean, literal, or expression-valued) with
391/// the same name is removed and the new attribute appended at the end, so the
392/// write wins over earlier spreads — the same ordering as the JS fold path.
393/// Only spreads are never matched: they have no name.
394///
395/// Value-type mapping (matches the JS fold path this replaces):
396///   bool-true / null -> boolean attribute (no value)
397///   bool-false       -> literal `"false"`
398///   string / int / … -> literal attribute carrying the value
399#[cfg(feature = "mdx")]
400fn apply_hast_mdx_jsx_attribute(
401    arena: &mut Arena<Hast>,
402    node_id: u32,
403    attr_name: &str,
404    value_type: u8,
405    value_str: &str,
406) -> Result<(), CommandError> {
407    let old_data = arena.get_type_data(node_id).to_vec();
408    if old_data.len() < 16 {
409        return Err(CommandError::TypeDataTooShort);
410    }
411    let elem_name = decode_mdx_jsx_element_name(&old_data);
412    let explicit = decode_mdx_jsx_explicit(&old_data);
413    let attr_count = decode_mdx_jsx_attr_count(&old_data);
414
415    // Map the binary value-type to a JSX attribute (kind, value).
416    let (kind, val_ref) = match value_type {
417        PROP_BOOL_TRUE | PROP_NULL => (MDX_ATTR_BOOLEAN_PROP, StringRef::empty()),
418        PROP_BOOL_FALSE => (MDX_ATTR_LITERAL_PROP, arena.alloc_string("false")),
419        _ if value_str.is_empty() => (MDX_ATTR_LITERAL_PROP, StringRef::empty()),
420        _ => (MDX_ATTR_LITERAL_PROP, arena.alloc_string(value_str)),
421    };
422
423    let mut attrs: Vec<(u8, StringRef, StringRef)> = Vec::with_capacity(attr_count as usize + 1);
424    let mut name_ref: Option<StringRef> = None;
425    for i in 0..attr_count {
426        let (existing_kind, existing_name, existing_value) = decode_mdx_jsx_attr(&old_data, i);
427        if existing_kind != MDX_ATTR_SPREAD && arena.get_str(existing_name) == attr_name {
428            name_ref = Some(existing_name);
429            continue;
430        }
431        attrs.push((existing_kind, existing_name, existing_value));
432    }
433    let name_ref = name_ref.unwrap_or_else(|| arena.alloc_string(attr_name));
434    attrs.push((kind, name_ref, val_ref));
435
436    arena.set_type_data(
437        node_id,
438        &encode_mdx_jsx_element_data(elem_name, &attrs, explicit),
439    );
440    Ok(())
441}
442
443/// Set or add a single property on a HAST element node.
444fn apply_hast_element_property(
445    arena: &mut Arena<Hast>,
446    node_id: u32,
447    prop_name: &str,
448    value_type: u8,
449    value_str: &str,
450) -> Result<(), CommandError> {
451    let old_data = arena.get_type_data(node_id).to_vec();
452    if old_data.len() < 16 {
453        return Err(CommandError::TypeDataTooShort);
454    }
455
456    let old_prop_count = u32::from_le_bytes(old_data[8..12].try_into().unwrap()) as usize;
457
458    let mut found_index: Option<usize> = None;
459    for i in 0..old_prop_count {
460        let base = 16 + i * 20;
461        let name_off = u32::from_le_bytes(old_data[base..base + 4].try_into().unwrap());
462        let name_len = u32::from_le_bytes(old_data[base + 4..base + 8].try_into().unwrap());
463        let existing_name = arena.get_str(StringRef::new(name_off, name_len));
464        if existing_name == prop_name {
465            found_index = Some(i);
466            break;
467        }
468    }
469
470    let name_ref = arena.alloc_string(prop_name);
471    let val_ref = if value_str.is_empty() {
472        StringRef::empty()
473    } else {
474        arena.alloc_string(value_str)
475    };
476
477    if let Some(idx) = found_index {
478        let mut new_data = old_data;
479        let base = 16 + idx * 20;
480        new_data[base..base + 4].copy_from_slice(&name_ref.offset.to_le_bytes());
481        new_data[base + 4..base + 8].copy_from_slice(&name_ref.len.to_le_bytes());
482        new_data[base + 8] = value_type;
483        new_data[base + 9..base + 12].copy_from_slice(&[0u8; 3]);
484        new_data[base + 12..base + 16].copy_from_slice(&val_ref.offset.to_le_bytes());
485        new_data[base + 16..base + 20].copy_from_slice(&val_ref.len.to_le_bytes());
486        arena.set_type_data(node_id, &new_data);
487    } else {
488        let new_prop_count = (old_prop_count + 1) as u32;
489        let mut new_data = Vec::with_capacity(16 + new_prop_count as usize * 20);
490        new_data.extend_from_slice(&old_data[0..8]);
491        new_data.extend_from_slice(&new_prop_count.to_le_bytes());
492        new_data.extend_from_slice(&0u32.to_le_bytes());
493        if old_prop_count > 0 {
494            new_data.extend_from_slice(&old_data[16..16 + old_prop_count * 20]);
495        }
496        new_data.extend_from_slice(&name_ref.offset.to_le_bytes());
497        new_data.extend_from_slice(&name_ref.len.to_le_bytes());
498        new_data.push(value_type);
499        new_data.extend_from_slice(&[0u8; 3]);
500        new_data.extend_from_slice(&val_ref.offset.to_le_bytes());
501        new_data.extend_from_slice(&val_ref.len.to_le_bytes());
502        arena.set_type_data(node_id, &new_data);
503    }
504
505    Ok(())
506}
507
508// The JS visitors compile declarative trees to an op-stream (OPEN/CLOSE/field
509// sets/REF/KEEP_CHILDREN) that we replay directly into an ArenaBuilder — no
510// intermediate node tree, no heap allocation per node beyond the arena itself. A node's
511// fields are collected after its OPEN and flushed into its type_data the moment
512// the next op needs the node finalized (a child OPEN, a CLOSE, or a spliced
513// REF/KEEP_CHILDREN).
514
515/// Per-kind hooks for [`replay_opstream`]. `Kind` ties a collector to one
516/// arena flavor, so a cross-kind replay stays a compile error (see the module
517/// header on why MDAST/HAST must not share a dispatcher).
518trait OpCollector<'a>: Sized {
519    type Kind: ArenaKind;
520    /// Whether `OP_U8` / `OP_U32` / `OP_ALIGN` are decoded. When false those
521    /// opcodes fall through to the unknown-command error *without consuming
522    /// operands*, so the reported byte is the opcode itself.
523    const NUMERIC_OPS: bool;
524
525    fn open(node_type: u8) -> Self;
526    fn check_tag(tag: u8) -> Result<(), CommandError>;
527    fn finalize(&mut self, builder: &mut ArenaBuilder<Self::Kind>);
528    fn str_field(&mut self, field: u8, value: &'a str);
529    fn bool_field(&mut self, field: u8, value: bool);
530    fn prop(&mut self, name: &'a str, kind: u8, value: &'a str);
531    fn data(&mut self, bytes: &'a [u8]);
532    fn u8_field(&mut self, _field: u8, _value: u8) {}
533    fn u32_field(&mut self, _field: u8, _value: u32) {}
534    fn align(&mut self, _bytes: &'a [u8]) {}
535}
536
537/// Deepest `OP_OPEN` nesting the replay accepts: the rebuild splices a
538/// replayed sub-arena recursively, so its depth must stay well inside the
539/// host stack. 128 = serde_json's default recursion limit, ample for content.
540const MAX_OPSTREAM_DEPTH: usize = 128;
541
542/// Replay an op-stream into a fresh sub-arena. `orig`/`anchor` resolve
543/// `KEEP_CHILDREN` (splice the replaced node's original children, as refs).
544fn replay_opstream<'a, C: OpCollector<'a>>(
545    ops: &'a [u8],
546    orig: &Arena<C::Kind>,
547    anchor: u32,
548) -> Result<Arena<C::Kind>, CommandError> {
549    let mut builder = ArenaBuilder::<C::Kind>::new(String::new());
550    let mut reader = BufReader::new(ops);
551    let mut stack: Vec<C> = Vec::new();
552
553    while reader.remaining() > 0 {
554        match reader.read_u8()? {
555            OP_OPEN => {
556                if let Some(c) = stack.last_mut() {
557                    c.finalize(&mut builder);
558                }
559                let node_type = reader.read_u8()?;
560                C::check_tag(node_type)?;
561                // A root is only valid as the stream's top-level wrapper
562                // (the rebuild splices its children); nested it would smuggle
563                // a node the JS visitors refuse to encode.
564                if !stack.is_empty() && node_type == <C::Kind as ArenaKind>::ROOT_TAG {
565                    return Err(CommandError::UnencodableNodeType("root"));
566                }
567                if stack.len() >= MAX_OPSTREAM_DEPTH {
568                    return Err(CommandError::OpstreamTooDeep(MAX_OPSTREAM_DEPTH));
569                }
570                builder.open_node(node_type);
571                stack.push(C::open(node_type));
572            }
573            OP_CLOSE => {
574                let Some(mut c) = stack.pop() else {
575                    return Err(CommandError::UnbalancedOpstream);
576                };
577                c.finalize(&mut builder);
578                builder.close_node();
579            }
580            OP_REF => {
581                if let Some(c) = stack.last_mut() {
582                    c.finalize(&mut builder);
583                }
584                let id = reader.read_u32()?;
585                // A stale id (e.g. a node cached across passes) would
586                // otherwise panic deep inside the rebuild's arena indexing.
587                if id as usize >= orig.len() {
588                    return Err(CommandError::InvalidNodeId(id));
589                }
590                emit_ref_node(id, &mut builder);
591            }
592            OP_KEEP_CHILDREN => {
593                if let Some(c) = stack.last_mut() {
594                    c.finalize(&mut builder);
595                }
596                if anchor as usize >= orig.len() {
597                    return Err(CommandError::InvalidNodeId(anchor));
598                }
599                for &child in orig.get_children(anchor) {
600                    emit_ref_node(child, &mut builder);
601                }
602            }
603            OP_STR => {
604                let field = reader.read_u8()?;
605                let len = reader.read_u32()? as usize;
606                let value = reader.read_str(len)?;
607                if let Some(c) = stack.last_mut() {
608                    c.str_field(field, value);
609                }
610            }
611            OP_U8 if C::NUMERIC_OPS => {
612                let field = reader.read_u8()?;
613                let value = reader.read_u8()?;
614                if let Some(c) = stack.last_mut() {
615                    c.u8_field(field, value);
616                }
617            }
618            OP_U32 if C::NUMERIC_OPS => {
619                let field = reader.read_u8()?;
620                let value = reader.read_u32()?;
621                if let Some(c) = stack.last_mut() {
622                    c.u32_field(field, value);
623                }
624            }
625            OP_BOOL => {
626                let field = reader.read_u8()?;
627                let value = reader.read_u8()? != 0;
628                if let Some(c) = stack.last_mut() {
629                    c.bool_field(field, value);
630                }
631            }
632            OP_PROP => {
633                let name_len = reader.read_u32()? as usize;
634                let name = reader.read_str(name_len)?;
635                let kind = reader.read_u8()?;
636                let val_len = reader.read_u32()? as usize;
637                let value = reader.read_str(val_len)?;
638                if let Some(c) = stack.last_mut() {
639                    c.prop(name, kind, value);
640                }
641            }
642            OP_ALIGN if C::NUMERIC_OPS => {
643                let len = reader.read_u32()? as usize;
644                let bytes = reader.read_bytes(len)?;
645                if let Some(c) = stack.last_mut() {
646                    c.align(bytes);
647                }
648            }
649            OP_DATA => {
650                let len = reader.read_u32()? as usize;
651                let bytes = reader.read_bytes(len)?;
652                if let Some(c) = stack.last_mut() {
653                    c.data(bytes);
654                }
655            }
656            other => return Err(CommandError::UnknownCommand(other)),
657        }
658    }
659
660    if !stack.is_empty() {
661        return Err(CommandError::UnbalancedOpstream);
662    }
663    Ok(builder.finish())
664}
665
666/// Intern MDX-JSX attribute strings into `(kind, name, value)` rows; spreads
667/// carry no name, boolean attrs no value.
668#[cfg(feature = "mdx")]
669pub(crate) fn intern_mdx_jsx_attrs<K: ArenaKind>(
670    props: &[(&str, u8, &str)],
671    builder: &mut ArenaBuilder<K>,
672) -> Vec<(u8, StringRef, StringRef)> {
673    let mut attrs = Vec::with_capacity(props.len());
674    for &(name, kind, value) in props {
675        let nr = if kind == MDX_ATTR_SPREAD {
676            StringRef::empty()
677        } else {
678            builder.alloc_string(name)
679        };
680        let vr = if kind == MDX_ATTR_BOOLEAN_PROP {
681            StringRef::empty()
682        } else {
683            builder.alloc_string(value)
684        };
685        attrs.push((kind, nr, vr));
686    }
687    attrs
688}
689
690/// Accumulates one node's fields between its OPEN and finalization. Strings
691/// borrow the op-stream buffer; they're interned into the arena at finalize.
692#[derive(Default)]
693pub(crate) struct FieldCollector<'a> {
694    node_type: u8,
695    finalized: bool,
696    pub(crate) strs: [Option<&'a str>; OF_FIELD_COUNT],
697    pub(crate) depth: Option<u8>,
698    checked: Option<u8>,
699    start: Option<u32>,
700    ordered: Option<bool>,
701    spread: Option<bool>,
702    /// Directive / MDX JSX attributes (`OP_PROP`): (name, kind, value).
703    pub(crate) props: Vec<(&'a str, u8, &'a str)>,
704    /// Table column-alignment bytes (`OP_ALIGN`).
705    pub(crate) align: Option<&'a [u8]>,
706    /// MDX JSX `_mdxExplicitJsx` flag (`OP_BOOL` on `OF_EXPLICIT`).
707    pub(crate) explicit: Option<bool>,
708    data: Option<&'a [u8]>,
709}
710
711/// Encode a collector's fields into the current node's type_data (and node_data).
712fn finalize_collector(c: &mut FieldCollector<'_>, builder: &mut ArenaBuilder<Mdast>) {
713    const LIST: u8 = MdastNodeType::List as u8;
714    const LIST_ITEM: u8 = MdastNodeType::ListItem as u8;
715
716    if c.finalized {
717        return;
718    }
719    c.finalized = true;
720    let mut fixed = [0u8; MAX_FIXED_TYPE_DATA];
721    if let Some(len) = encode_mdast_type_data_from_ops(c, c.node_type, builder, &mut fixed) {
722        builder.set_data_current(&fixed[..len]);
723    } else if let Some(type_data) = encode_mdast_tail_from_ops(c, c.node_type, builder) {
724        // Generated tail encoder (directive attributes, MDX JSX); see encode.rs.
725        builder.set_data_current(&type_data);
726    } else {
727        let type_data: Vec<u8> = match c.node_type {
728            LIST => encode_list_data(
729                c.ordered.unwrap_or(false),
730                c.start.unwrap_or(1),
731                c.spread.unwrap_or(false),
732            ),
733            // checked: 2 = not a task item
734            LIST_ITEM => encode_list_item_data(c.checked.unwrap_or(2), c.spread.unwrap_or(false)),
735            // Remaining tags carry no type_data.
736            _ => Vec::new(),
737        };
738        if !type_data.is_empty() {
739            builder.set_data_current(&type_data);
740        }
741    }
742    if let Some(data) = c.data {
743        let id = builder.current_node_id();
744        builder.arena_mut().set_node_data(id, data.to_vec());
745    }
746}
747
748impl<'a> OpCollector<'a> for FieldCollector<'a> {
749    type Kind = Mdast;
750    const NUMERIC_OPS: bool = true;
751
752    fn open(node_type: u8) -> Self {
753        FieldCollector {
754            node_type,
755            ..Default::default()
756        }
757    }
758
759    /// Reject op-stream tags this build can't construct — unknown bytes
760    /// always, MDX tags without the `mdx` feature.
761    fn check_tag(tag: u8) -> Result<(), CommandError> {
762        let known = MdastNodeType::from_u8(tag).is_some();
763        #[cfg(not(feature = "mdx"))]
764        let known = known
765            && !matches!(
766                MdastNodeType::from_u8(tag),
767                Some(
768                    MdastNodeType::MdxJsxFlowElement
769                        | MdastNodeType::MdxJsxTextElement
770                        | MdastNodeType::MdxFlowExpression
771                        | MdastNodeType::MdxTextExpression
772                        | MdastNodeType::MdxjsEsm
773                )
774            );
775        if known {
776            Ok(())
777        } else {
778            Err(CommandError::UnknownNodeType(format!(
779                "op-stream tag {tag}"
780            )))
781        }
782    }
783
784    fn finalize(&mut self, builder: &mut ArenaBuilder<Mdast>) {
785        finalize_collector(self, builder);
786    }
787
788    fn str_field(&mut self, field: u8, value: &'a str) {
789        let field = field as usize;
790        if field < self.strs.len() {
791            self.strs[field] = Some(value);
792        }
793    }
794
795    fn bool_field(&mut self, field: u8, value: bool) {
796        match field {
797            OF_ORDERED => self.ordered = Some(value),
798            OF_SPREAD => self.spread = Some(value),
799            OF_EXPLICIT => self.explicit = Some(value),
800            _ => {}
801        }
802    }
803
804    fn prop(&mut self, name: &'a str, kind: u8, value: &'a str) {
805        self.props.push((name, kind, value));
806    }
807
808    fn data(&mut self, bytes: &'a [u8]) {
809        self.data = Some(bytes);
810    }
811
812    fn u8_field(&mut self, field: u8, value: u8) {
813        match field {
814            OF_DEPTH => self.depth = Some(value),
815            OF_CHECKED => self.checked = Some(value),
816            _ => {}
817        }
818    }
819
820    fn u32_field(&mut self, field: u8, value: u32) {
821        if field == OF_START {
822            self.start = Some(value);
823        }
824    }
825
826    fn align(&mut self, bytes: &'a [u8]) {
827        self.align = Some(bytes);
828    }
829}
830
831fn replay_mdast_opstream(
832    ops: &[u8],
833    orig: &Arena<Mdast>,
834    anchor: u32,
835) -> Result<Arena<Mdast>, CommandError> {
836    replay_opstream::<FieldCollector>(ops, orig, anchor)
837}
838
839/// Returns (arena, keep_children) for an MDAST sub-tree payload. `orig`/`anchor`
840/// are the arena and the command's target node, used by an op-stream's
841/// `KEEP_CHILDREN`.
842fn read_mdast_payload(
843    reader: &mut BufReader<'_>,
844    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
845    orig: &Arena<Mdast>,
846    anchor: u32,
847) -> Result<(Arena<Mdast>, bool), CommandError> {
848    let payload_type = reader.read_u8()?;
849    let len = reader.read_u32()? as usize;
850
851    match payload_type {
852        PAYLOAD_RAW_MARKDOWN => {
853            let md = reader.read_str(len)?;
854            Ok((parse_markdown(md), false))
855        }
856        PAYLOAD_RAW_HTML => {
857            let html = reader.read_str(len)?;
858            let escaped = escape_braces_in_html_text(html);
859            Ok((parse_markdown(&escaped), false))
860        }
861        PAYLOAD_OPSTREAM => {
862            let ops = reader.read_bytes(len)?;
863            Ok((replay_mdast_opstream(ops, orig, anchor)?, false))
864        }
865        other => Err(CommandError::UnknownPayloadType(other)),
866    }
867}
868
869/// HAST op-stream replay (mirrors the MDAST version). Builds element type_data
870/// (tag + properties) and text/comment/raw values; `OP_PROP` carries properties,
871/// `OF_TAGNAME` the tag.
872#[derive(Default)]
873pub(crate) struct HastFieldCollector<'a> {
874    node_type: u8,
875    finalized: bool,
876    /// HAST element `tagName` or MDX JSX element `name`.
877    pub(crate) tag: Option<&'a str>,
878    value: Option<&'a str>,
879    pub(crate) props: Vec<(&'a str, u8, &'a str)>,
880    /// MDX JSX `_mdxExplicitJsx` flag (`OP_BOOL` on `OF_EXPLICIT`).
881    pub(crate) explicit: Option<bool>,
882    data: Option<&'a [u8]>,
883}
884
885fn finalize_hast_collector(c: &mut HastFieldCollector<'_>, builder: &mut ArenaBuilder<Hast>) {
886    const TEXT: u8 = HastNodeType::Text as u8;
887    const COMMENT: u8 = HastNodeType::Comment as u8;
888    const RAW: u8 = HastNodeType::Raw as u8;
889    const MDX_FLOW_EXPRESSION: u8 = HastNodeType::MdxFlowExpression as u8;
890    const MDX_ESM: u8 = HastNodeType::MdxEsm as u8;
891    const MDX_TEXT_EXPRESSION: u8 = HastNodeType::MdxTextExpression as u8;
892
893    if c.finalized {
894        return;
895    }
896    c.finalized = true;
897    if let Some(type_data) = encode_hast_tail_from_ops(c, c.node_type, builder) {
898        // Generated tail encoder (element properties, MDX JSX); see encode.rs.
899        builder.set_data_current(&type_data);
900    } else {
901        let type_data: Vec<u8> = match c.node_type {
902            TEXT | COMMENT | RAW | MDX_FLOW_EXPRESSION | MDX_ESM | MDX_TEXT_EXPRESSION => {
903                let sref = builder.alloc_string(c.value.unwrap_or(""));
904                encode_string_ref_data(sref)
905            }
906            // Remaining tags carry no type_data.
907            _ => Vec::new(),
908        };
909        if !type_data.is_empty() {
910            builder.set_data_current(&type_data);
911        }
912    }
913    if let Some(data) = c.data {
914        let id = builder.current_node_id();
915        builder.arena_mut().set_node_data(id, data.to_vec());
916    }
917}
918
919impl<'a> OpCollector<'a> for HastFieldCollector<'a> {
920    type Kind = Hast;
921    const NUMERIC_OPS: bool = false;
922
923    fn open(node_type: u8) -> Self {
924        HastFieldCollector {
925            node_type,
926            ..Default::default()
927        }
928    }
929
930    /// HAST twin of the MDAST `check_tag`.
931    fn check_tag(tag: u8) -> Result<(), CommandError> {
932        // The JS visitor refuses to encode a doctype (it's not in
933        // `HAST_OPSTREAM_TYPES`); enforce the same here so a crafted buffer
934        // can't smuggle one in.
935        if HastNodeType::from_u8(tag) == Some(HastNodeType::Doctype) {
936            return Err(CommandError::UnencodableNodeType("doctype"));
937        }
938        let known = HastNodeType::from_u8(tag).is_some();
939        #[cfg(not(feature = "mdx"))]
940        let known = known
941            && !matches!(
942                HastNodeType::from_u8(tag),
943                Some(
944                    HastNodeType::MdxJsxElement
945                        | HastNodeType::MdxJsxTextElement
946                        | HastNodeType::MdxFlowExpression
947                        | HastNodeType::MdxEsm
948                        | HastNodeType::MdxTextExpression
949                )
950            );
951        if known {
952            Ok(())
953        } else {
954            Err(CommandError::UnknownNodeType(format!(
955                "op-stream tag {tag}"
956            )))
957        }
958    }
959
960    fn finalize(&mut self, builder: &mut ArenaBuilder<Hast>) {
961        finalize_hast_collector(self, builder);
962    }
963
964    fn str_field(&mut self, field: u8, value: &'a str) {
965        match field {
966            OF_TAGNAME | OF_NAME => self.tag = Some(value),
967            OF_VALUE => self.value = Some(value),
968            _ => {}
969        }
970    }
971
972    fn bool_field(&mut self, field: u8, value: bool) {
973        if field == OF_EXPLICIT {
974            self.explicit = Some(value);
975        }
976    }
977
978    fn prop(&mut self, name: &'a str, kind: u8, value: &'a str) {
979        self.props.push((name, kind, value));
980    }
981
982    fn data(&mut self, bytes: &'a [u8]) {
983        self.data = Some(bytes);
984    }
985}
986
987fn replay_hast_opstream(
988    ops: &[u8],
989    orig: &Arena<Hast>,
990    anchor: u32,
991) -> Result<Arena<Hast>, CommandError> {
992    replay_opstream::<HastFieldCollector>(ops, orig, anchor)
993}
994
995/// Returns (arena, keep_children) for a HAST sub-tree payload. Only
996/// `PAYLOAD_OPSTREAM` (declarative-compiled) is accepted — HAST has no source
997/// grammar, so raw markdown / HTML are not, and there is no JSON path.
998fn read_hast_payload(
999    reader: &mut BufReader<'_>,
1000    orig: &Arena<Hast>,
1001    anchor: u32,
1002) -> Result<(Arena<Hast>, bool), CommandError> {
1003    let payload_type = reader.read_u8()?;
1004    let len = reader.read_u32()? as usize;
1005
1006    match payload_type {
1007        PAYLOAD_OPSTREAM => {
1008            let ops = reader.read_bytes(len)?;
1009            Ok((replay_hast_opstream(ops, orig, anchor)?, false))
1010        }
1011        other => Err(CommandError::UnknownPayloadType(other)),
1012    }
1013}
1014
1015/// Apply a command buffer to an MDAST arena. Set-property mutations are
1016/// applied in-place; structural mutations are collected as `Patch<Mdast>`
1017/// objects and applied via `rebuild()`.
1018///
1019/// `parse_markdown` avoids a circular dependency on the parser crate; it
1020/// is invoked for `RAW_MARKDOWN` and `RAW_HTML` payloads.
1021///
1022/// Passing a HAST arena is a compile error — the prior single-dispatch
1023/// `apply_commands` would silently misroute MDAST nodes into the HAST
1024/// element-properties writer (numeric `node_type` values overlap between
1025/// the two arenas):
1026///
1027/// ```compile_fail
1028/// use satteri_arena::{Arena, Hast};
1029/// use satteri_plugin_api::apply_mdast_commands;
1030///
1031/// let arena: Arena<Hast> = Arena::new(String::new());
1032/// let parse_markdown = |_: &str| -> Arena<satteri_arena::Mdast> {
1033///     Arena::new(String::new())
1034/// };
1035/// let _ = apply_mdast_commands(arena, &[], &parse_markdown);
1036/// ```
1037pub fn apply_mdast_commands(
1038    arena: Arena<Mdast>,
1039    command_buf: &[u8],
1040    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1041) -> Result<Arena<Mdast>, CommandError> {
1042    let (arena, dropped) = apply_mdast_commands_lenient(arena, command_buf, parse_markdown)?;
1043    if let Some(anchor) = dropped.first() {
1044        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1045    }
1046    Ok(arena)
1047}
1048
1049/// Like [`apply_mdast_commands`], but rather than erroring when a patch targets
1050/// a node inside a removed/replaced subtree, drops it and returns the dropped
1051/// anchors. Such a patch is moot — the plugin discarded that subtree. A
1052/// *passed-through* child is not dropped: it rides a `_ref` placeholder that
1053/// splices it back with its id intact, so a transform queued on a nested node
1054/// (e.g. a `:::tip` inside a `:::note`) still applies, in the same pass.
1055pub fn apply_mdast_commands_lenient(
1056    mut arena: Arena<Mdast>,
1057    command_buf: &[u8],
1058    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1059) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
1060    if command_buf.is_empty() {
1061        return Ok((arena, Vec::new()));
1062    }
1063
1064    let mut patches: Vec<Patch<Mdast>> = Vec::new();
1065    let mut reader = BufReader::new(command_buf);
1066
1067    while reader.remaining() > 0 {
1068        let cmd = reader.read_u8()?;
1069
1070        match cmd {
1071            CMD_REMOVE => {
1072                let node_id = reader.read_u32()?;
1073                patches.push(Patch::Remove { node_id });
1074            }
1075
1076            CMD_SET_PROPERTY => {
1077                let node_id = reader.read_u32()?;
1078                let value_type = reader.read_u8()?;
1079                let name_len = reader.read_u32()? as usize;
1080                let name = reader.read_str(name_len)?;
1081                let value_len = reader.read_u32()? as usize;
1082                let value = reader.read_str(value_len)?;
1083                apply_mdast_set_property(&mut arena, node_id, name, value_type, value)?;
1084            }
1085
1086            CMD_INSERT_BEFORE => {
1087                let node_id = reader.read_u32()?;
1088                let (new_tree, _) =
1089                    read_mdast_payload(&mut reader, parse_markdown, &arena, node_id)?;
1090                patches.push(Patch::InsertBefore { node_id, new_tree });
1091            }
1092
1093            CMD_INSERT_AFTER => {
1094                let node_id = reader.read_u32()?;
1095                let (new_tree, _) =
1096                    read_mdast_payload(&mut reader, parse_markdown, &arena, node_id)?;
1097                patches.push(Patch::InsertAfter { node_id, new_tree });
1098            }
1099
1100            CMD_PREPEND_CHILD => {
1101                let node_id = reader.read_u32()?;
1102                let (child_tree, _) =
1103                    read_mdast_payload(&mut reader, parse_markdown, &arena, node_id)?;
1104                patches.push(Patch::PrependChild {
1105                    node_id,
1106                    child_tree,
1107                });
1108            }
1109
1110            CMD_APPEND_CHILD => {
1111                let node_id = reader.read_u32()?;
1112                let (child_tree, _) =
1113                    read_mdast_payload(&mut reader, parse_markdown, &arena, node_id)?;
1114                patches.push(Patch::AppendChild {
1115                    node_id,
1116                    child_tree,
1117                });
1118            }
1119
1120            CMD_WRAP => {
1121                let node_id = reader.read_u32()?;
1122                let (parent_tree, _) =
1123                    read_mdast_payload(&mut reader, parse_markdown, &arena, node_id)?;
1124                patches.push(Patch::Wrap {
1125                    node_id,
1126                    parent_tree,
1127                });
1128            }
1129
1130            CMD_REPLACE => {
1131                let node_id = reader.read_u32()?;
1132                let (new_tree, keep_children) =
1133                    read_mdast_payload(&mut reader, parse_markdown, &arena, node_id)?;
1134                patches.push(Patch::Replace {
1135                    node_id,
1136                    new_tree,
1137                    keep_children,
1138                });
1139            }
1140
1141            CMD_SET_CHILDREN => {
1142                let node_id = reader.read_u32()?;
1143                let (new_children, _) =
1144                    read_mdast_payload(&mut reader, parse_markdown, &arena, node_id)?;
1145                patches.push(Patch::SetChildren {
1146                    node_id,
1147                    new_children,
1148                });
1149            }
1150
1151            other => return Err(CommandError::UnknownCommand(other)),
1152        }
1153    }
1154
1155    if patches.is_empty() {
1156        Ok((arena, Vec::new()))
1157    } else {
1158        let result = satteri_ast::rebuild::rebuild_lenient(&arena, &patches)?;
1159        Ok((result.arena, result.dropped))
1160    }
1161}
1162
1163/// Apply a command buffer to a HAST arena. Set-property mutations are
1164/// applied in-place; structural mutations are collected as `Patch<Hast>`
1165/// objects and applied via `rebuild()`. Errors if a patch is stranded inside a
1166/// removed/replaced subtree; [`apply_hast_commands_lenient`] drops it instead.
1167///
1168/// HAST plugins inject sub-trees via `PAYLOAD_OPSTREAM` only — there is
1169/// no `parse_markdown` callback because HAST has no source-level grammar.
1170///
1171/// Passing an MDAST arena is a compile error:
1172///
1173/// ```compile_fail
1174/// use satteri_arena::{Arena, Mdast};
1175/// use satteri_plugin_api::apply_hast_commands;
1176///
1177/// let arena: Arena<Mdast> = Arena::new(String::new());
1178/// let _ = apply_hast_commands(arena, &[]);
1179/// ```
1180pub fn apply_hast_commands(
1181    arena: Arena<Hast>,
1182    command_buf: &[u8],
1183) -> Result<Arena<Hast>, CommandError> {
1184    let (arena, dropped) = apply_hast_commands_lenient(arena, command_buf)?;
1185    if let Some(anchor) = dropped.first() {
1186        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1187    }
1188    Ok(arena)
1189}
1190
1191/// Like [`apply_hast_commands`], but rather than erroring when a patch targets a
1192/// node inside a removed/replaced subtree, drops it and returns the dropped
1193/// anchors — mirroring [`apply_mdast_commands_lenient`]. Such a patch is moot:
1194/// the plugin discarded that subtree. A passed-through child keeps its identity
1195/// (via `_ref`) and so is never stranded this way.
1196pub fn apply_hast_commands_lenient(
1197    mut arena: Arena<Hast>,
1198    command_buf: &[u8],
1199) -> Result<(Arena<Hast>, Vec<u32>), CommandError> {
1200    if command_buf.is_empty() {
1201        return Ok((arena, Vec::new()));
1202    }
1203
1204    let mut patches: Vec<Patch<Hast>> = Vec::new();
1205    let mut reader = BufReader::new(command_buf);
1206
1207    while reader.remaining() > 0 {
1208        let cmd = reader.read_u8()?;
1209
1210        match cmd {
1211            CMD_REMOVE => {
1212                let node_id = reader.read_u32()?;
1213                patches.push(Patch::Remove { node_id });
1214            }
1215
1216            CMD_SET_PROPERTY => {
1217                let node_id = reader.read_u32()?;
1218                let value_type = reader.read_u8()?;
1219                let name_len = reader.read_u32()? as usize;
1220                let name = reader.read_str(name_len)?;
1221                let value_len = reader.read_u32()? as usize;
1222                let value = reader.read_str(value_len)?;
1223                apply_hast_set_property(&mut arena, node_id, name, value_type, value)?;
1224            }
1225
1226            CMD_INSERT_BEFORE => {
1227                let node_id = reader.read_u32()?;
1228                let (new_tree, _) = read_hast_payload(&mut reader, &arena, node_id)?;
1229                patches.push(Patch::InsertBefore { node_id, new_tree });
1230            }
1231
1232            CMD_INSERT_AFTER => {
1233                let node_id = reader.read_u32()?;
1234                let (new_tree, _) = read_hast_payload(&mut reader, &arena, node_id)?;
1235                patches.push(Patch::InsertAfter { node_id, new_tree });
1236            }
1237
1238            CMD_PREPEND_CHILD => {
1239                let node_id = reader.read_u32()?;
1240                let (child_tree, _) = read_hast_payload(&mut reader, &arena, node_id)?;
1241                patches.push(Patch::PrependChild {
1242                    node_id,
1243                    child_tree,
1244                });
1245            }
1246
1247            CMD_APPEND_CHILD => {
1248                let node_id = reader.read_u32()?;
1249                let (child_tree, _) = read_hast_payload(&mut reader, &arena, node_id)?;
1250                patches.push(Patch::AppendChild {
1251                    node_id,
1252                    child_tree,
1253                });
1254            }
1255
1256            CMD_WRAP => {
1257                let node_id = reader.read_u32()?;
1258                let (parent_tree, _) = read_hast_payload(&mut reader, &arena, node_id)?;
1259                patches.push(Patch::Wrap {
1260                    node_id,
1261                    parent_tree,
1262                });
1263            }
1264
1265            CMD_REPLACE => {
1266                let node_id = reader.read_u32()?;
1267                let (new_tree, keep_children) = read_hast_payload(&mut reader, &arena, node_id)?;
1268                patches.push(Patch::Replace {
1269                    node_id,
1270                    new_tree,
1271                    keep_children,
1272                });
1273            }
1274
1275            CMD_SET_CHILDREN => {
1276                let node_id = reader.read_u32()?;
1277                let (new_children, _) = read_hast_payload(&mut reader, &arena, node_id)?;
1278                patches.push(Patch::SetChildren {
1279                    node_id,
1280                    new_children,
1281                });
1282            }
1283
1284            other => return Err(CommandError::UnknownCommand(other)),
1285        }
1286    }
1287
1288    if patches.is_empty() {
1289        Ok((arena, Vec::new()))
1290    } else {
1291        let result = satteri_ast::rebuild::rebuild_lenient(&arena, &patches)?;
1292        Ok((result.arena, result.dropped))
1293    }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299    use satteri_ast::shared::PROP_INT;
1300
1301    fn op_open(b: &mut Vec<u8>, t: MdastNodeType) {
1302        b.push(OP_OPEN);
1303        b.push(t as u8);
1304    }
1305    fn op_close(b: &mut Vec<u8>) {
1306        b.push(OP_CLOSE);
1307    }
1308    fn op_str(b: &mut Vec<u8>, field: u8, s: &str) {
1309        b.push(OP_STR);
1310        b.push(field);
1311        b.extend_from_slice(&(s.len() as u32).to_le_bytes());
1312        b.extend_from_slice(s.as_bytes());
1313    }
1314    fn op_u8(b: &mut Vec<u8>, field: u8, v: u8) {
1315        b.push(OP_U8);
1316        b.push(field);
1317        b.push(v);
1318    }
1319
1320    #[test]
1321    fn opstream_replay_builds_subtree() {
1322        // blockquote > [ heading(3) > text("Note"), paragraph > text("Body") ]
1323        let mut ops = Vec::new();
1324        op_open(&mut ops, MdastNodeType::Blockquote);
1325        op_open(&mut ops, MdastNodeType::Heading);
1326        op_u8(&mut ops, OF_DEPTH, 3);
1327        op_open(&mut ops, MdastNodeType::Text);
1328        op_str(&mut ops, OF_VALUE, "Note");
1329        op_close(&mut ops);
1330        op_close(&mut ops);
1331        op_open(&mut ops, MdastNodeType::Paragraph);
1332        op_open(&mut ops, MdastNodeType::Text);
1333        op_str(&mut ops, OF_VALUE, "Body");
1334        op_close(&mut ops);
1335        op_close(&mut ops);
1336        op_close(&mut ops);
1337
1338        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1339        let arena = replay_mdast_opstream(&ops, &empty, 0).unwrap();
1340
1341        // node 0 = blockquote with 2 children
1342        assert_eq!(arena.get_node(0).node_type, MdastNodeType::Blockquote as u8);
1343        let top = arena.get_children(0).to_vec();
1344        assert_eq!(top.len(), 2);
1345        // heading depth 3, child text "Note"
1346        let h = top[0];
1347        assert_eq!(arena.get_node(h).node_type, MdastNodeType::Heading as u8);
1348        assert_eq!(decode_heading_data(arena.get_type_data(h)).depth, 3);
1349        let h_text = arena.get_children(h)[0];
1350        assert_eq!(arena.get_node(h_text).node_type, MdastNodeType::Text as u8);
1351        let sref = decode_string_ref_data(arena.get_type_data(h_text));
1352        assert_eq!(arena.get_str(sref), "Note");
1353        // paragraph > text "Body"
1354        let p = top[1];
1355        assert_eq!(arena.get_node(p).node_type, MdastNodeType::Paragraph as u8);
1356        let p_text = arena.get_children(p)[0];
1357        assert_eq!(
1358            arena.get_str(decode_string_ref_data(arena.get_type_data(p_text))),
1359            "Body"
1360        );
1361    }
1362
1363    #[test]
1364    fn opstream_replay_rejects_unbalanced_close() {
1365        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1366        let err = replay_mdast_opstream(&[OP_CLOSE], &empty, 0).unwrap_err();
1367        assert!(matches!(err, CommandError::UnbalancedOpstream));
1368
1369        let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
1370        let err = replay_hast_opstream(&[OP_CLOSE], &empty_hast, 0).unwrap_err();
1371        assert!(matches!(err, CommandError::UnbalancedOpstream));
1372
1373        // A balanced prefix doesn't excuse a trailing extra close.
1374        let mut ops = Vec::new();
1375        op_open(&mut ops, MdastNodeType::Paragraph);
1376        op_close(&mut ops);
1377        op_close(&mut ops);
1378        let err = replay_mdast_opstream(&ops, &empty, 0).unwrap_err();
1379        assert!(matches!(err, CommandError::UnbalancedOpstream));
1380    }
1381
1382    #[test]
1383    fn opstream_replay_rejects_unclosed_node() {
1384        // A truncated stream leaves its OPENed nodes on the stack; finishing
1385        // would hand back nodes with empty type_data.
1386        let mut ops = Vec::new();
1387        op_open(&mut ops, MdastNodeType::Heading);
1388        op_u8(&mut ops, OF_DEPTH, 2);
1389
1390        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1391        let err = replay_mdast_opstream(&ops, &empty, 0).unwrap_err();
1392        assert!(matches!(err, CommandError::UnbalancedOpstream));
1393
1394        let hast_ops = vec![OP_OPEN, HastNodeType::Element as u8];
1395        let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
1396        let err = replay_hast_opstream(&hast_ops, &empty_hast, 0).unwrap_err();
1397        assert!(matches!(err, CommandError::UnbalancedOpstream));
1398    }
1399
1400    #[test]
1401    fn opstream_keep_children_rejects_out_of_range_anchor() {
1402        let orig = test_parse_markdown("Hello");
1403        let bad_anchor = orig.len() as u32;
1404
1405        let mut ops = Vec::new();
1406        op_open(&mut ops, MdastNodeType::Heading);
1407        ops.push(OP_KEEP_CHILDREN);
1408        op_close(&mut ops);
1409
1410        let err = replay_mdast_opstream(&ops, &orig, bad_anchor).unwrap_err();
1411        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_anchor));
1412    }
1413
1414    #[test]
1415    fn set_property_rejects_out_of_range_node_id() {
1416        let arena = build_hello_world();
1417        let bad_id = arena.len() as u32;
1418        let mut buf = Vec::new();
1419        push_set_property(&mut buf, bad_id, PROP_INT, "depth", "3");
1420        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
1421        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_id));
1422
1423        let hast = build_hast_element(&[]);
1424        let bad_id = hast.len() as u32;
1425        let mut buf = Vec::new();
1426        push_set_property(&mut buf, bad_id, PROP_STRING, "class", "x");
1427        let err = apply_hast_commands(hast, &buf).unwrap_err();
1428        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_id));
1429    }
1430
1431    #[test]
1432    fn opstream_replay_rejects_unknown_tags() {
1433        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1434        let err = replay_mdast_opstream(&[OP_OPEN, 200], &empty, 0).unwrap_err();
1435        assert!(matches!(err, CommandError::UnknownNodeType(_)));
1436
1437        let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
1438        let err = replay_hast_opstream(&[OP_OPEN, 200], &empty_hast, 0).unwrap_err();
1439        assert!(matches!(err, CommandError::UnknownNodeType(_)));
1440    }
1441
1442    #[test]
1443    fn opstream_keep_children_splices_original_children() {
1444        // orig: root > paragraph > text("Hello"); replace the paragraph with
1445        // heading(2) keeping its children.
1446        let orig = test_parse_markdown("Hello");
1447        let para = orig.get_children(0)[0];
1448        let orig_text = orig.get_children(para)[0];
1449
1450        let mut ops = Vec::new();
1451        op_open(&mut ops, MdastNodeType::Heading);
1452        op_u8(&mut ops, OF_DEPTH, 2);
1453        ops.push(OP_KEEP_CHILDREN);
1454        op_close(&mut ops);
1455
1456        let arena = replay_mdast_opstream(&ops, &orig, para).unwrap();
1457        assert_eq!(arena.get_node(0).node_type, MdastNodeType::Heading as u8);
1458        assert_eq!(decode_heading_data(arena.get_type_data(0)).depth, 2);
1459        let children = arena.get_children(0).to_vec();
1460        assert_eq!(children.len(), 1);
1461        assert_eq!(arena.get_node(children[0]).node_type, REF_NODE_TYPE);
1462        assert_eq!(
1463            u32::from_le_bytes(arena.get_type_data(children[0]).try_into().unwrap()),
1464            orig_text
1465        );
1466    }
1467
1468    #[test]
1469    fn opstream_ref_rejects_out_of_range_id() {
1470        // A stale id (a node cached across passes) must error at decode, not
1471        // panic inside the rebuild's arena indexing.
1472        let orig = test_parse_markdown("Hello");
1473        let bad = orig.len() as u32 + 100;
1474
1475        let mut ops = Vec::new();
1476        op_open(&mut ops, MdastNodeType::Paragraph);
1477        ops.push(OP_REF);
1478        ops.extend_from_slice(&bad.to_le_bytes());
1479        op_close(&mut ops);
1480
1481        let err = replay_mdast_opstream(&ops, &orig, 0).unwrap_err();
1482        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad));
1483    }
1484
1485    #[test]
1486    fn opstream_rejects_nested_root() {
1487        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1488
1489        // Top-level root is the set-children wrapper and must pass.
1490        let mut ok = Vec::new();
1491        op_open(&mut ok, MdastNodeType::Root);
1492        op_close(&mut ok);
1493        assert!(replay_mdast_opstream(&ok, &empty, 0).is_ok());
1494
1495        let mut ops = Vec::new();
1496        op_open(&mut ops, MdastNodeType::Root);
1497        op_open(&mut ops, MdastNodeType::Root);
1498        let err = replay_mdast_opstream(&ops, &empty, 0).unwrap_err();
1499        assert!(matches!(err, CommandError::UnencodableNodeType("root")));
1500    }
1501
1502    #[test]
1503    fn hast_opstream_rejects_doctype() {
1504        let empty = ArenaBuilder::<Hast>::new(String::new()).finish();
1505        let ops = vec![OP_OPEN, HastNodeType::Doctype as u8, OP_CLOSE];
1506        let err = replay_hast_opstream(&ops, &empty, 0).unwrap_err();
1507        assert!(matches!(err, CommandError::UnencodableNodeType("doctype")));
1508    }
1509
1510    #[test]
1511    fn opstream_rejects_over_deep_nesting() {
1512        // The rebuild splices replayed content recursively, so unbounded
1513        // nesting would overflow the host stack (an abort napi can't catch).
1514        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1515        let mut ops = Vec::new();
1516        for _ in 0..(MAX_OPSTREAM_DEPTH + 1) {
1517            op_open(&mut ops, MdastNodeType::Blockquote);
1518        }
1519        let err = replay_mdast_opstream(&ops, &empty, 0).unwrap_err();
1520        assert!(matches!(err, CommandError::OpstreamTooDeep(_)));
1521    }
1522
1523    #[test]
1524    fn set_property_rejects_out_of_range_or_unparseable_int() {
1525        // build_hello_world: root(0) > heading(1) > text(2), paragraph > text.
1526        let heading_id = 1;
1527
1528        let mut buf = Vec::new();
1529        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "9999");
1530        let err =
1531            apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap_err();
1532        assert!(matches!(err, CommandError::PropertyValueOutOfRange { .. }));
1533
1534        let mut buf = Vec::new();
1535        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "not-a-number");
1536        let err =
1537            apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap_err();
1538        assert!(matches!(err, CommandError::PropertyValueOutOfRange { .. }));
1539
1540        // The boundary itself still writes.
1541        let mut buf = Vec::new();
1542        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "255");
1543        let arena = apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap();
1544        assert_eq!(
1545            decode_heading_data(arena.get_type_data(heading_id)).depth,
1546            255
1547        );
1548    }
1549
1550    #[cfg(feature = "mdx")]
1551    #[test]
1552    fn mdx_jsx_set_property_replaces_named_attrs_and_keeps_explicit() {
1553        use satteri_ast::shared::MDX_ATTR_EXPRESSION_PROP;
1554
1555        let mut b = ArenaBuilder::<Hast>::new(String::new());
1556        b.open_node(HastNodeType::Root as u8);
1557        b.open_node(HastNodeType::MdxJsxElement as u8);
1558        let elem_name = b.alloc_string("Box");
1559        let foo = b.alloc_string("foo");
1560        let expr = b.alloc_string("1+1");
1561        let rest = b.alloc_string("rest");
1562        let attrs = vec![
1563            (MDX_ATTR_EXPRESSION_PROP, foo, expr),
1564            (MDX_ATTR_SPREAD, StringRef::empty(), rest),
1565        ];
1566        b.set_data_current(&encode_mdx_jsx_element_data(elem_name, &attrs, true));
1567        b.close_node();
1568        b.close_node();
1569        let mut arena = b.finish();
1570
1571        // Replaces the expression-valued `foo` (no duplicate) and appends
1572        // after the spread so the write wins.
1573        apply_hast_mdx_jsx_attribute(&mut arena, 1, "foo", PROP_STRING, "x").unwrap();
1574        let data = arena.get_type_data(1).to_vec();
1575        assert_eq!(decode_mdx_jsx_attr_count(&data), 2);
1576        assert!(decode_mdx_jsx_explicit(&data));
1577        let (k0, _, _) = decode_mdx_jsx_attr(&data, 0);
1578        assert_eq!(k0, MDX_ATTR_SPREAD);
1579        let (k1, n1, v1) = decode_mdx_jsx_attr(&data, 1);
1580        assert_eq!(k1, MDX_ATTR_LITERAL_PROP);
1581        assert_eq!(arena.get_str(n1), "foo");
1582        assert_eq!(arena.get_str(v1), "x");
1583
1584        // Appending a brand-new attribute must not clear the explicit flag.
1585        apply_hast_mdx_jsx_attribute(&mut arena, 1, "id", PROP_STRING, "intro").unwrap();
1586        let data = arena.get_type_data(1).to_vec();
1587        assert_eq!(decode_mdx_jsx_attr_count(&data), 3);
1588        assert!(decode_mdx_jsx_explicit(&data));
1589    }
1590
1591    fn test_parse_markdown(source: &str) -> Arena<Mdast> {
1592        let mut b = ArenaBuilder::<Mdast>::new(String::new());
1593        b.open_node(MdastNodeType::Root as u8);
1594        b.open_node(MdastNodeType::Paragraph as u8);
1595        b.open_node(MdastNodeType::Text as u8);
1596        let sref = b.alloc_string(source);
1597        b.set_data_current(&satteri_arena::encode_string_ref_data(sref));
1598        b.close_node();
1599        b.close_node();
1600        b.close_node();
1601        b.finish()
1602    }
1603
1604    fn push_u32(buf: &mut Vec<u8>, v: u32) {
1605        buf.extend_from_slice(&v.to_le_bytes());
1606    }
1607
1608    /// Encode a CMD_SET_PROPERTY command into a buffer.
1609    fn push_set_property(buf: &mut Vec<u8>, node_id: u32, value_type: u8, name: &str, value: &str) {
1610        buf.push(CMD_SET_PROPERTY);
1611        push_u32(buf, node_id);
1612        buf.push(value_type);
1613        push_u32(buf, name.len() as u32);
1614        buf.extend_from_slice(name.as_bytes());
1615        push_u32(buf, value.len() as u32);
1616        buf.extend_from_slice(value.as_bytes());
1617    }
1618
1619    fn build_hello_world() -> Arena<Mdast> {
1620        use satteri_ast::mdast::codec::{encode_heading_data, encode_string_ref_data};
1621
1622        let source = "# Hello\n\nWorld".to_string();
1623        let mut b = ArenaBuilder::<Mdast>::new(source);
1624
1625        b.open_node(MdastNodeType::Root as u8);
1626        b.set_position_current(0, 14, 1, 1, 2, 6);
1627
1628        b.open_node(MdastNodeType::Heading as u8);
1629        b.set_position_current(0, 7, 1, 1, 1, 8);
1630        b.set_data_current(&encode_heading_data(1));
1631
1632        b.open_node(MdastNodeType::Text as u8);
1633        b.set_position_current(2, 7, 1, 3, 1, 8);
1634        b.set_data_current(&encode_string_ref_data(StringRef::new(2, 5)));
1635        b.close_node();
1636
1637        b.close_node();
1638
1639        b.open_node(MdastNodeType::Paragraph as u8);
1640        b.set_position_current(9, 14, 2, 1, 2, 6);
1641
1642        b.open_node(MdastNodeType::Text as u8);
1643        b.set_position_current(9, 14, 2, 1, 2, 6);
1644        b.set_data_current(&encode_string_ref_data(StringRef::new(9, 5)));
1645        b.close_node();
1646
1647        b.close_node();
1648        b.close_node();
1649
1650        b.finish()
1651    }
1652
1653    #[test]
1654    fn empty_command_buffer() {
1655        let arena = build_hello_world();
1656        let result = apply_mdast_commands(arena.clone(), &[], &test_parse_markdown).unwrap();
1657        assert_eq!(result.len(), arena.len());
1658    }
1659
1660    #[test]
1661    fn remove_command() {
1662        let arena = build_hello_world();
1663        let heading_id = arena.get_children(0)[0];
1664        let mut buf = Vec::new();
1665        buf.push(CMD_REMOVE);
1666        push_u32(&mut buf, heading_id);
1667
1668        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1669        assert_eq!(result.get_children(0).len(), 1);
1670        assert_eq!(
1671            result.get_node(result.get_children(0)[0]).node_type,
1672            MdastNodeType::Paragraph as u8
1673        );
1674    }
1675
1676    #[test]
1677    fn set_property_heading_depth() {
1678        let arena = build_hello_world();
1679        let heading_id = arena.get_children(0)[0];
1680
1681        let mut buf = Vec::new();
1682        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1683
1684        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1685        let heading_data = result.get_type_data(heading_id);
1686        let heading = decode_heading_data(heading_data);
1687        assert_eq!(heading.depth, 3);
1688    }
1689
1690    #[test]
1691    fn set_property_text_value() {
1692        let arena = build_hello_world();
1693        let heading_id = arena.get_children(0)[0];
1694        let text_id = arena.get_children(heading_id)[0];
1695
1696        let mut buf = Vec::new();
1697        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Goodbye");
1698
1699        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1700        let text_data = result.get_type_data(text_id);
1701        let sref = decode_string_ref_data(text_data);
1702        assert_eq!(result.get_str(sref), "Goodbye");
1703    }
1704
1705    #[test]
1706    fn replace_with_raw_markdown() {
1707        let arena = build_hello_world();
1708        let heading_id = arena.get_children(0)[0];
1709
1710        let raw_md = "## New Heading";
1711        let mut buf = Vec::new();
1712        buf.push(CMD_REPLACE);
1713        push_u32(&mut buf, heading_id);
1714        buf.push(PAYLOAD_RAW_MARKDOWN);
1715        push_u32(&mut buf, raw_md.len() as u32);
1716        buf.extend_from_slice(raw_md.as_bytes());
1717
1718        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1719        let root_children = result.get_children(0);
1720        assert!(root_children.len() >= 2);
1721    }
1722
1723    #[test]
1724    fn multiple_commands() {
1725        let arena = build_hello_world();
1726        let heading_id = arena.get_children(0)[0];
1727        let text_id = arena.get_children(heading_id)[0];
1728
1729        let mut buf = Vec::new();
1730        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1731        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Hi");
1732
1733        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1734
1735        let heading_data = result.get_type_data(heading_id);
1736        assert_eq!(decode_heading_data(heading_data).depth, 3);
1737
1738        let text_data = result.get_type_data(text_id);
1739        let sref = decode_string_ref_data(text_data);
1740        assert_eq!(result.get_str(sref), "Hi");
1741    }
1742
1743    #[test]
1744    fn set_property_null() {
1745        let arena = build_hello_world();
1746        let heading_id = arena.get_children(0)[0];
1747        let text_id = arena.get_children(heading_id)[0];
1748
1749        let mut buf = Vec::new();
1750        push_set_property(&mut buf, text_id, PROP_NULL, "value", "");
1751
1752        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1753        let text_data = result.get_type_data(text_id);
1754        let sref = decode_string_ref_data(text_data);
1755        assert_eq!(sref.len, 0);
1756    }
1757
1758    #[test]
1759    fn set_property_invalid_field_reports_property_and_node_type() {
1760        let arena = build_hello_world();
1761        let heading_id = arena.get_children(0)[0];
1762
1763        let mut buf = Vec::new();
1764        push_set_property(&mut buf, heading_id, PROP_STRING, "value", "x");
1765
1766        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
1767        assert!(matches!(
1768            err,
1769            CommandError::UnknownField { ref name, ref node_type }
1770                if name == "value" && node_type == "heading"
1771        ));
1772        assert_eq!(
1773            err.to_string(),
1774            "cannot set property 'value' on a 'heading' node"
1775        );
1776    }
1777
1778    #[test]
1779    fn set_property_wrong_value_type_reports_value_mismatch() {
1780        let arena = build_hello_world();
1781        let heading_id = arena.get_children(0)[0];
1782
1783        // `depth` is a valid heading field, but it holds an int, not a string.
1784        let mut buf = Vec::new();
1785        push_set_property(&mut buf, heading_id, PROP_STRING, "depth", "3");
1786
1787        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
1788        assert!(matches!(
1789            err,
1790            CommandError::InvalidPropertyValue { ref name, ref node_type }
1791                if name == "depth" && node_type == "heading"
1792        ));
1793        assert_eq!(
1794            err.to_string(),
1795            "property 'depth' on a 'heading' node cannot hold a value of this type"
1796        );
1797    }
1798
1799    /// Build root > one leaf node of `node_type` carrying `type_data`.
1800    fn build_single_node(node_type: MdastNodeType, type_data: &[u8]) -> Arena<Mdast> {
1801        let mut b = ArenaBuilder::<Mdast>::new(String::new());
1802        b.open_node(MdastNodeType::Root as u8);
1803        b.open_node(node_type as u8);
1804        b.set_data_current(type_data);
1805        b.close_node();
1806        b.close_node();
1807        b.finish()
1808    }
1809
1810    #[test]
1811    fn set_property_image_reference_alt_roundtrip() {
1812        let mut b = ArenaBuilder::<Mdast>::new(String::new());
1813        b.open_node(MdastNodeType::Root as u8);
1814        b.open_node(MdastNodeType::ImageReference as u8);
1815        let identifier = b.alloc_string("img");
1816        let alt = b.alloc_string("old");
1817        b.set_data_current(&encode_image_reference_data(identifier, identifier, 0, alt));
1818        b.close_node();
1819        b.close_node();
1820        let arena = b.finish();
1821        let image_ref_id = arena.get_children(0)[0];
1822
1823        let mut buf = Vec::new();
1824        push_set_property(&mut buf, image_ref_id, PROP_STRING, "alt", "new alt");
1825
1826        let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
1827        let alt = decode_image_reference_alt(result.get_type_data(image_ref_id));
1828        assert_eq!(result.get_str(alt), "new alt");
1829    }
1830
1831    #[test]
1832    fn set_property_reference_type_valid_and_invalid() {
1833        let mut b = ArenaBuilder::<Mdast>::new(String::new());
1834        b.open_node(MdastNodeType::Root as u8);
1835        b.open_node(MdastNodeType::LinkReference as u8);
1836        let identifier = b.alloc_string("ref");
1837        b.set_data_current(&encode_reference_data(identifier, identifier, 0));
1838        b.close_node();
1839        b.close_node();
1840        let arena = b.finish();
1841        let link_ref_id = arena.get_children(0)[0];
1842
1843        let mut buf = Vec::new();
1844        push_set_property(&mut buf, link_ref_id, PROP_STRING, "referenceType", "full");
1845        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1846        let reference = decode_reference_data(result.get_type_data(link_ref_id));
1847        assert_eq!(reference.reference_kind, 2);
1848
1849        // A value outside the declared list is a value error, not a silent 0.
1850        let mut buf = Vec::new();
1851        push_set_property(&mut buf, link_ref_id, PROP_STRING, "referenceType", "bogus");
1852        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
1853        assert!(matches!(
1854            err,
1855            CommandError::InvalidPropertyValue { ref name, ref node_type }
1856                if name == "referenceType" && node_type == "linkReference"
1857        ));
1858    }
1859
1860    #[test]
1861    fn set_property_list_start_and_ordered() {
1862        let arena = build_single_node(MdastNodeType::List, &encode_list_data(false, 1, false));
1863        let list_id = arena.get_children(0)[0];
1864
1865        let mut buf = Vec::new();
1866        push_set_property(&mut buf, list_id, PROP_INT, "start", "5");
1867        push_set_property(&mut buf, list_id, PROP_BOOL_TRUE, "ordered", "");
1868
1869        let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
1870        let list = decode_list_data(result.get_type_data(list_id));
1871        assert_eq!(list.start, 5);
1872        assert!(list.ordered);
1873        assert!(!list.spread);
1874    }
1875
1876    #[test]
1877    fn escape_braces_in_html_text_basic() {
1878        assert_eq!(
1879            escape_braces_in_html_text("<span>{foo: 1}</span>"),
1880            "<span>{'{'}foo: 1{'}'}</span>"
1881        );
1882    }
1883
1884    #[test]
1885    fn escape_braces_preserves_attributes() {
1886        let result = escape_braces_in_html_text(r#"<span data-x="{a}">{b}</span>"#);
1887        assert!(
1888            result.contains(r#"data-x="{a}""#),
1889            "attribute braces preserved"
1890        );
1891        assert!(result.contains("{'{'}"), "text braces escaped");
1892    }
1893
1894    #[test]
1895    fn escape_braces_no_braces() {
1896        let html = r#"<pre class="shiki"><code><span style="color:red">hello</span></code></pre>"#;
1897        assert_eq!(escape_braces_in_html_text(html), html);
1898    }
1899
1900    #[test]
1901    fn escape_braces_shiki_output() {
1902        let html = r#"<pre class="shiki"><code><span style="color:#E1E4E8">const x = </span><span style="color:#B392F0">{</span><span style="color:#E1E4E8">foo: 1</span><span style="color:#B392F0">}</span></code></pre>"#;
1903        let escaped = escape_braces_in_html_text(html);
1904        assert!(
1905            !escaped.contains(">{<"),
1906            "bare braces in text should be escaped"
1907        );
1908        assert!(
1909            !escaped.contains(">}<"),
1910            "bare braces in text should be escaped"
1911        );
1912        assert!(escaped.contains(r#"class="shiki""#));
1913        assert!(escaped.contains(r#"style="color:#E1E4E8""#));
1914    }
1915
1916    #[test]
1917    fn hast_set_property_add_new() {
1918        let arena = build_hast_element(&[]);
1919        let element_id = arena.get_children(0)[0];
1920
1921        let mut buf = Vec::new();
1922        push_set_property(&mut buf, element_id, PROP_STRING, "class", "test");
1923
1924        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1925        let data = result.get_type_data(element_id);
1926        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1927        assert_eq!(prop_count, 1);
1928        let name_ref = StringRef::new(
1929            u32::from_le_bytes(data[16..20].try_into().unwrap()),
1930            u32::from_le_bytes(data[20..24].try_into().unwrap()),
1931        );
1932        assert_eq!(result.get_str(name_ref), "class");
1933        let val_ref = StringRef::new(
1934            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1935            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1936        );
1937        assert_eq!(result.get_str(val_ref), "test");
1938        assert_eq!(data[24], PROP_STRING);
1939    }
1940
1941    #[test]
1942    fn hast_set_property_overwrite_existing() {
1943        let arena = build_hast_element(&[("class", PROP_STRING, "old")]);
1944        let element_id = arena.get_children(0)[0];
1945
1946        let mut buf = Vec::new();
1947        push_set_property(&mut buf, element_id, PROP_STRING, "class", "new-value");
1948
1949        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1950        let data = result.get_type_data(element_id);
1951        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1952        assert_eq!(prop_count, 1);
1953        let val_ref = StringRef::new(
1954            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1955            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1956        );
1957        assert_eq!(result.get_str(val_ref), "new-value");
1958    }
1959
1960    #[test]
1961    fn hast_set_property_bool_true() {
1962        let arena = build_hast_element(&[]);
1963        let element_id = arena.get_children(0)[0];
1964
1965        let mut buf = Vec::new();
1966        push_set_property(&mut buf, element_id, PROP_BOOL_TRUE, "disabled", "");
1967
1968        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1969        let data = result.get_type_data(element_id);
1970        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1971        assert_eq!(prop_count, 1);
1972        assert_eq!(data[24], PROP_BOOL_TRUE);
1973    }
1974
1975    #[test]
1976    fn hast_set_property_multiple_on_same_node() {
1977        let arena = build_hast_element(&[]);
1978        let element_id = arena.get_children(0)[0];
1979
1980        let mut buf = Vec::new();
1981        push_set_property(&mut buf, element_id, PROP_STRING, "class", "foo");
1982        push_set_property(&mut buf, element_id, PROP_STRING, "id", "bar");
1983
1984        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1985        let data = result.get_type_data(element_id);
1986        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1987        assert_eq!(prop_count, 2);
1988    }
1989
1990    /// Build a minimal HAST element arena: root(type 0) → element(type 1, tag "div")
1991    fn build_hast_element(props: &[(&str, u8, &str)]) -> Arena<Hast> {
1992        use satteri_ast::hast::node::HastNodeType;
1993
1994        let mut b = ArenaBuilder::<Hast>::new(String::new());
1995        b.open_node_raw(HastNodeType::Root as u8);
1996        b.open_node_raw(HastNodeType::Element as u8);
1997        let tag_ref = b.alloc_string("div");
1998        let prop_tuples: Vec<(StringRef, u8, StringRef)> = props
1999            .iter()
2000            .map(|(name, kind, value)| {
2001                let n = b.alloc_string(name);
2002                let v = if value.is_empty() {
2003                    StringRef::empty()
2004                } else {
2005                    b.alloc_string(value)
2006                };
2007                (n, *kind, v)
2008            })
2009            .collect();
2010        let mut type_data = Vec::with_capacity(16 + prop_tuples.len() * 20);
2011        type_data.extend_from_slice(&tag_ref.offset.to_le_bytes());
2012        type_data.extend_from_slice(&tag_ref.len.to_le_bytes());
2013        type_data.extend_from_slice(&(prop_tuples.len() as u32).to_le_bytes());
2014        type_data.extend_from_slice(&0u32.to_le_bytes());
2015        for (n, kind, v) in &prop_tuples {
2016            type_data.extend_from_slice(&n.offset.to_le_bytes());
2017            type_data.extend_from_slice(&n.len.to_le_bytes());
2018            type_data.push(*kind);
2019            type_data.extend_from_slice(&[0u8; 3]);
2020            type_data.extend_from_slice(&v.offset.to_le_bytes());
2021            type_data.extend_from_slice(&v.len.to_le_bytes());
2022        }
2023        b.set_data_current(&type_data);
2024        b.close_node();
2025        b.close_node();
2026        b.finish()
2027    }
2028}