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::codec::decode_element_tag;
26use satteri_ast::hast::{HastNodeType, is_void_element};
27use satteri_ast::mdast::MdastNodeType;
28use satteri_ast::mdast::codec::*;
29use satteri_ast::patch::{Patch, PatchContent, REF_NODE_TYPE};
30#[cfg(feature = "mdx")]
31use satteri_ast::shared::{MDX_ATTR_BOOLEAN_PROP, MDX_ATTR_LITERAL_PROP, MDX_ATTR_SPREAD};
32use satteri_ast::shared::{
33    PROP_BOOL_FALSE, PROP_BOOL_TRUE, PROP_INT, PROP_NULL, PROP_SPACE_SEP, PROP_STRING,
34};
35
36use crate::generated::prop_slots::{MdastPropSlot, mdast_prop_slot};
37use crate::generated::wire_constants::*;
38
39struct BufReader<'a> {
40    data: &'a [u8],
41    pos: usize,
42}
43
44impl<'a> BufReader<'a> {
45    fn new(data: &'a [u8]) -> Self {
46        Self { data, pos: 0 }
47    }
48
49    fn remaining(&self) -> usize {
50        self.data.len() - self.pos
51    }
52
53    fn read_u8(&mut self) -> Result<u8, CommandError> {
54        if self.remaining() < 1 {
55            return Err(CommandError::UnexpectedEof);
56        }
57        let v = self.data[self.pos];
58        self.pos += 1;
59        Ok(v)
60    }
61
62    fn read_u32(&mut self) -> Result<u32, CommandError> {
63        if self.remaining() < 4 {
64            return Err(CommandError::UnexpectedEof);
65        }
66        let v = u32::from_le_bytes([
67            self.data[self.pos],
68            self.data[self.pos + 1],
69            self.data[self.pos + 2],
70            self.data[self.pos + 3],
71        ]);
72        self.pos += 4;
73        Ok(v)
74    }
75
76    /// Anchors must predate the decode's orphan grafts; a stale id would panic inside the apply.
77    fn read_anchor(&mut self, original_len: u32) -> Result<u32, CommandError> {
78        let node_id = self.read_u32()?;
79        if node_id >= original_len {
80            return Err(CommandError::InvalidNodeId(node_id));
81        }
82        Ok(node_id)
83    }
84
85    fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], CommandError> {
86        if self.remaining() < len {
87            return Err(CommandError::UnexpectedEof);
88        }
89        let slice = &self.data[self.pos..self.pos + len];
90        self.pos += len;
91        Ok(slice)
92    }
93
94    fn read_str(&mut self, len: usize) -> Result<&'a str, CommandError> {
95        let bytes = self.read_bytes(len)?;
96        std::str::from_utf8(bytes).map_err(|_| CommandError::InvalidUtf8)
97    }
98}
99
100/// `data` JSON blob is stored in the per-node `node_data` map; it doesn't
101/// dispatch on node-type bytes, so it's safe under any kind.
102fn apply_data_property<K: ArenaKind>(
103    arena: &mut Arena<K>,
104    node_id: u32,
105    value_type: u8,
106    value_str: &str,
107) {
108    if value_type == PROP_NULL {
109        arena.set_node_data(node_id, Vec::new());
110    } else {
111        arena.set_node_data(node_id, value_str.as_bytes().to_vec());
112    }
113}
114
115/// The canonical MDAST type name for a node-type byte, for error messages.
116fn mdast_type_name(node_type: u8) -> String {
117    match MdastNodeType::from_u8(node_type) {
118        Some(t) => t.name().to_string(),
119        None => format!("unknown({node_type})"),
120    }
121}
122
123/// MDAST set-property: writes a typed field (or `data` JSON) onto an MDAST
124/// node. Kind-tight to `Arena<Mdast>` — the HAST element-properties writer
125/// can no longer be reached from here.
126fn apply_mdast_set_property(
127    arena: &mut Arena<Mdast>,
128    node_id: u32,
129    prop_name: &str,
130    value_type: u8,
131    value_str: &str,
132) -> Result<(), CommandError> {
133    if node_id as usize >= arena.len() {
134        return Err(CommandError::InvalidNodeId(node_id));
135    }
136    if prop_name == "data" {
137        apply_data_property(arena, node_id, value_type, value_str);
138        return Ok(());
139    }
140
141    let node_type = arena.get_node(node_id).node_type;
142
143    // The property doesn't resolve to a slot for this node type at all.
144    let slot = mdast_prop_slot(node_type, prop_name).ok_or_else(|| CommandError::UnknownField {
145        node_type: mdast_type_name(node_type),
146        name: prop_name.to_string(),
147    })?;
148
149    // The slot resolved, so a `None` from the writer means the value's type is
150    // one the slot can't hold — report that rather than "unknown".
151    let written = write_mdast_prop_slot(arena, node_id, slot, prop_name, value_type, value_str)?;
152    written.ok_or_else(|| CommandError::InvalidPropertyValue {
153        node_type: mdast_type_name(node_type),
154        name: prop_name.to_string(),
155    })
156}
157
158/// Write a resolved slot. `Ok(None)` means the slot can't hold this value
159/// type. String slots error `TypeDataTooShort` on short `type_data`; scalar
160/// slots skip the write silently instead (the historical semantics).
161fn write_mdast_prop_slot(
162    arena: &mut Arena<Mdast>,
163    node_id: u32,
164    slot: MdastPropSlot,
165    prop_name: &str,
166    value_type: u8,
167    value_str: &str,
168) -> Result<Option<()>, CommandError> {
169    use MdastPropSlot as S;
170    match value_type {
171        PROP_STRING | PROP_SPACE_SEP => match slot {
172            S::Str { offset } => {
173                let sref = arena.alloc_string(value_str);
174                set_mdast_string_ref(arena, node_id, offset, sref)?;
175            }
176            S::Enum8 { offset, values } => {
177                let Some(v) = values.iter().position(|v| *v == value_str) else {
178                    return Ok(None);
179                };
180                set_mdast_scalar(arena, node_id, offset, &[v as u8]);
181            }
182            _ => return Ok(None),
183        },
184        PROP_BOOL_TRUE | PROP_BOOL_FALSE => match slot {
185            S::Bool { offset } => {
186                let value = value_type == PROP_BOOL_TRUE;
187                set_mdast_scalar(arena, node_id, offset, &[value as u8]);
188            }
189            _ => return Ok(None),
190        },
191        PROP_INT => {
192            // Accept a float spelling like "3.0", but anything outside the
193            // slot's range must error — `as u8` would silently mask the bits.
194            let parsed = value_str.parse::<i64>().ok().or_else(|| {
195                value_str
196                    .parse::<f64>()
197                    .ok()
198                    .filter(|f| f.is_finite() && f.fract() == 0.0)
199                    .map(|f| f as i64)
200            });
201            let node_type = arena.get_node(node_id).node_type;
202            let fitted = |max: u32| -> Result<u32, CommandError> {
203                match parsed {
204                    Some(v) if (0..=max as i64).contains(&v) => Ok(v as u32),
205                    _ => Err(CommandError::PropertyValueOutOfRange {
206                        node_type: mdast_type_name(node_type),
207                        name: prop_name.to_string(),
208                        value: value_str.to_string(),
209                        max,
210                    }),
211                }
212            };
213            match slot {
214                S::U8 { offset } | S::CheckedTri { offset } => {
215                    let value = fitted(u8::MAX as u32)?;
216                    set_mdast_scalar(arena, node_id, offset, &[value as u8]);
217                }
218                S::U32 { offset } => {
219                    let value = fitted(u32::MAX)?;
220                    set_mdast_scalar(arena, node_id, offset, &value.to_le_bytes());
221                }
222                _ => return Ok(None),
223            }
224        }
225        PROP_NULL => match slot {
226            // 2 = not a task item.
227            S::CheckedTri { offset } => set_mdast_scalar(arena, node_id, offset, &[2]),
228            S::Str { offset } => set_mdast_string_ref(arena, node_id, offset, StringRef::empty())?,
229            _ => return Ok(None),
230        },
231        _ => return Err(CommandError::InvalidPropertyValueType(value_type)),
232    }
233    Ok(Some(()))
234}
235
236/// Write an 8-byte `StringRef` at `offset` into the node's `type_data`.
237fn set_mdast_string_ref(
238    arena: &mut Arena<Mdast>,
239    node_id: u32,
240    offset: usize,
241    sref: StringRef,
242) -> Result<(), CommandError> {
243    let node = arena.get_node(node_id);
244    let data_offset = node.data_offset as usize;
245    let data_len = node.data_len as usize;
246    if data_len < offset + 8 {
247        return Err(CommandError::TypeDataTooShort);
248    }
249    let abs_offset = data_offset + offset;
250    arena.type_data[abs_offset..abs_offset + 8].copy_from_slice(&sref.as_bytes());
251    Ok(())
252}
253
254/// Write a scalar at `offset` into the node's `type_data`; too-short data
255/// skips the write.
256fn set_mdast_scalar(arena: &mut Arena<Mdast>, node_id: u32, offset: usize, bytes: &[u8]) {
257    let node = arena.get_node(node_id);
258    let data_offset = node.data_offset as usize;
259    let data_len = node.data_len as usize;
260    if data_len >= offset + bytes.len() {
261        let abs_offset = data_offset + offset;
262        arena.type_data[abs_offset..abs_offset + bytes.len()].copy_from_slice(bytes);
263    }
264}
265
266/// Escape `{` and `}` in HTML text content so they are not interpreted as MDX
267/// expressions when the HTML is re-parsed through the MDX parser.
268///
269/// Only braces in **text content** (outside of HTML tags) are escaped; braces
270/// inside quoted attribute values are left untouched. The escape form `{'{'}` /
271/// `{'}'}` produces a valid MDX expression that evaluates to the literal brace
272/// character.
273fn escape_braces_in_html_text(html: &str) -> String {
274    let mut result = String::with_capacity(html.len());
275    let mut in_tag = false;
276    let mut in_quote: Option<char> = None;
277
278    let mut chars = html.chars().peekable();
279    while let Some(ch) = chars.next() {
280        if in_tag {
281            match ch {
282                '"' | '\'' if in_quote == Some(ch) => {
283                    in_quote = None;
284                    result.push(ch);
285                }
286                '"' | '\'' if in_quote.is_none() => {
287                    in_quote = Some(ch);
288                    result.push(ch);
289                }
290                '>' if in_quote.is_none() => {
291                    in_tag = false;
292                    result.push(ch);
293                }
294                _ => result.push(ch),
295            }
296        } else {
297            match ch {
298                '<' if chars.peek().copied().is_some_and(can_open_tag) => {
299                    in_tag = true;
300                    result.push(ch);
301                }
302                '{' => result.push_str("{'{'}"),
303                '}' => result.push_str("{'}'}"),
304                _ => result.push(ch),
305            }
306        }
307    }
308    result
309}
310
311/// Only a `<` that can open a tag suspends brace escaping; `5 < 6` cannot.
312fn can_open_tag(after: char) -> bool {
313    after.is_ascii_alphabetic() || matches!(after, '/' | '>' | '!' | '?' | '_' | '$')
314}
315
316/// Options controlling how MDAST command buffers are applied.
317#[derive(Debug, Clone, Copy)]
318pub struct MdastCommandOptions {
319    /// Escape raw HTML text braces before re-parsing it through an MDX parser.
320    ///
321    /// MDX needs this so literal `{` / `}` in HTML text are not interpreted as
322    /// expressions. Plain Markdown-to-HTML pipelines should leave raw HTML
323    /// opaque so the final HTML preserves those braces verbatim.
324    pub escape_raw_html_braces: bool,
325}
326
327impl Default for MdastCommandOptions {
328    fn default() -> Self {
329        Self {
330            escape_raw_html_braces: true,
331        }
332    }
333}
334
335/// Emit a reference placeholder: a `REF_NODE_TYPE` node carrying the target
336/// original id (u32 LE) in its type_data. The apply resolves it by splicing
337/// that original subtree and applying any pending patch on it.
338fn emit_ref_node<K: ArenaKind>(ref_id: u32, builder: &mut ArenaBuilder<K>) -> u32 {
339    let id = builder.open_node_raw(REF_NODE_TYPE);
340    builder.set_data_current(&ref_id.to_le_bytes());
341    builder.close_node();
342    id
343}
344
345// Generated per-type arena encoder, driven by the node registry. See
346// `crates/satteri-layout-codegen`.
347use crate::generated::encode::{
348    MAX_FIXED_TYPE_DATA, encode_hast_tail_from_ops, encode_mdast_tail_from_ops,
349    encode_mdast_type_data_from_ops,
350};
351
352pub(crate) fn alloc_opt_str<K: ArenaKind>(
353    builder: &mut ArenaBuilder<K>,
354    s: Option<&str>,
355) -> StringRef {
356    match s {
357        Some(v) if !v.is_empty() => builder.alloc_string(v),
358        _ => StringRef::empty(),
359    }
360}
361
362// HAST command handlers
363
364/// HAST set-property: dispatches by `HastNodeType` to the matching writer.
365/// Kind-tight to `Arena<Hast>` — the MDAST field-resolver can no longer be
366/// reached from here.
367fn apply_hast_set_property(
368    arena: &mut Arena<Hast>,
369    node_id: u32,
370    prop_name: &str,
371    value_type: u8,
372    value_str: &str,
373) -> Result<(), CommandError> {
374    if node_id as usize >= arena.len() {
375        return Err(CommandError::InvalidNodeId(node_id));
376    }
377    if prop_name == "data" {
378        apply_data_property(arena, node_id, value_type, value_str);
379        return Ok(());
380    }
381
382    let raw_type = arena.get_node(node_id).node_type;
383    let node_type = HastNodeType::from_u8(raw_type)
384        .ok_or_else(|| CommandError::UnknownNodeType(format!("hast type 0x{raw_type:02x}")))?;
385
386    match node_type {
387        HastNodeType::Element => {
388            apply_hast_element_property(arena, node_id, prop_name, value_type, value_str)
389        }
390
391        HastNodeType::Text
392        | HastNodeType::Comment
393        | HastNodeType::Raw
394        | HastNodeType::MdxFlowExpression
395        | HastNodeType::MdxTextExpression
396        | HastNodeType::MdxEsm
397            if prop_name == "value" =>
398        {
399            let sref = arena.alloc_string(value_str);
400            let data = arena.get_type_data(node_id);
401            if data.len() >= 8 {
402                let data_offset = arena.get_node(node_id).data_offset as usize;
403                arena.type_data[data_offset..data_offset + 8].copy_from_slice(&sref.as_bytes());
404                Ok(())
405            } else {
406                Err(CommandError::TypeDataTooShort)
407            }
408        }
409
410        #[cfg(feature = "mdx")]
411        HastNodeType::MdxJsxElement | HastNodeType::MdxJsxTextElement => {
412            apply_hast_mdx_jsx_attribute(arena, node_id, prop_name, value_type, value_str)
413        }
414
415        _ => Err(CommandError::UnknownField {
416            node_type: node_type.name().to_string(),
417            name: prop_name.to_string(),
418        }),
419    }
420}
421
422/// Upsert a single attribute on an MDX JSX flow/text element. Avoids
423/// re-serializing the whole node (and materializing its children) just to
424/// change one attribute.
425///
426/// Any existing named attribute (boolean, literal, or expression-valued) with
427/// the same name is removed and the new attribute appended at the end, so the
428/// write wins over earlier spreads — the same ordering as the JS fold path.
429/// Only spreads are never matched: they have no name.
430///
431/// Value-type mapping (matches the JS fold path this replaces):
432///   bool-true / null -> boolean attribute (no value)
433///   bool-false       -> literal `"false"`
434///   string / int / … -> literal attribute carrying the value
435#[cfg(feature = "mdx")]
436fn apply_hast_mdx_jsx_attribute(
437    arena: &mut Arena<Hast>,
438    node_id: u32,
439    attr_name: &str,
440    value_type: u8,
441    value_str: &str,
442) -> Result<(), CommandError> {
443    let old_data = arena.get_type_data(node_id).to_vec();
444    if old_data.len() < 16 {
445        return Err(CommandError::TypeDataTooShort);
446    }
447    let elem_name = decode_mdx_jsx_element_name(&old_data);
448    let explicit = decode_mdx_jsx_explicit(&old_data);
449    let attr_count = decode_mdx_jsx_attr_count(&old_data);
450
451    // Map the binary value-type to a JSX attribute (kind, value).
452    let (kind, val_ref) = match value_type {
453        PROP_BOOL_TRUE | PROP_NULL => (MDX_ATTR_BOOLEAN_PROP, StringRef::empty()),
454        PROP_BOOL_FALSE => (MDX_ATTR_LITERAL_PROP, arena.alloc_string("false")),
455        _ if value_str.is_empty() => (MDX_ATTR_LITERAL_PROP, StringRef::empty()),
456        _ => (MDX_ATTR_LITERAL_PROP, arena.alloc_string(value_str)),
457    };
458
459    let mut attrs: Vec<(u8, StringRef, StringRef)> = Vec::with_capacity(attr_count as usize + 1);
460    let mut name_ref: Option<StringRef> = None;
461    for i in 0..attr_count {
462        let (existing_kind, existing_name, existing_value) = decode_mdx_jsx_attr(&old_data, i);
463        if existing_kind != MDX_ATTR_SPREAD && arena.get_str(existing_name) == attr_name {
464            name_ref = Some(existing_name);
465            continue;
466        }
467        attrs.push((existing_kind, existing_name, existing_value));
468    }
469    let name_ref = name_ref.unwrap_or_else(|| arena.alloc_string(attr_name));
470    attrs.push((kind, name_ref, val_ref));
471
472    arena.set_type_data(
473        node_id,
474        &encode_mdx_jsx_element_data(elem_name, &attrs, explicit),
475    );
476    Ok(())
477}
478
479/// Set or add a single property on a HAST element node.
480///
481/// Phased to avoid cloning `type_data`: read existing props, allocate the value
482/// into the string pool, then write back (in place for an existing prop, or by
483/// appending a new entry and repointing the node).
484fn apply_hast_element_property(
485    arena: &mut Arena<Hast>,
486    node_id: u32,
487    prop_name: &str,
488    value_type: u8,
489    value_str: &str,
490) -> Result<(), CommandError> {
491    // Phase 1: capture node layout as Copy u32s so we can drop the &arena borrow.
492    let node = arena.get_node(node_id);
493    let data_offset = node.data_offset as usize;
494    let data_len = node.data_len as usize;
495    if data_len < 16 {
496        return Err(CommandError::TypeDataTooShort);
497    }
498    let header = data_offset;
499    let old_prop_count =
500        u32::from_le_bytes(arena.type_data[header + 8..header + 12].try_into().unwrap()) as usize;
501
502    // Phase 2: scan for an existing prop with the same name. Read-only access
503    // to both type_data and source string pool.
504    let mut found_index: Option<usize> = None;
505    for i in 0..old_prop_count {
506        let base = header + 16 + i * 20;
507        let name_off = u32::from_le_bytes(arena.type_data[base..base + 4].try_into().unwrap());
508        let name_len = u32::from_le_bytes(arena.type_data[base + 4..base + 8].try_into().unwrap());
509        let existing_name = arena.get_str(StringRef::new(name_off, name_len));
510        if existing_name == prop_name {
511            found_index = Some(i);
512            break;
513        }
514    }
515
516    // Phase 3: allocate the value string (now mutates arena.source, but
517    // type_data ranges captured above remain valid, since appending to source
518    // doesn't move type_data bytes).
519    let val_ref = if value_str.is_empty() {
520        StringRef::empty()
521    } else {
522        arena.alloc_string(value_str)
523    };
524
525    // Phase 4: write back.
526    if let Some(idx) = found_index {
527        // Overwrite the entry's value in place; the name bytes already match
528        // and node.data_offset / data_len are unchanged.
529        let base = header + 16 + idx * 20;
530        arena.type_data[base + 8] = value_type;
531        arena.type_data[base + 9..base + 12].copy_from_slice(&[0u8; 3]);
532        arena.type_data[base + 12..base + 16].copy_from_slice(&val_ref.offset.to_le_bytes());
533        arena.type_data[base + 16..base + 20].copy_from_slice(&val_ref.len.to_le_bytes());
534    } else {
535        let name_ref = arena.alloc_string(prop_name);
536        let new_offset = arena.type_data.len() as u32;
537        let new_prop_count = (old_prop_count + 1) as u32;
538
539        // Header: 8 bytes (tag StringRef) + 4 bytes (prop_count) + 4 bytes (pad)
540        arena.type_data.extend_from_within(header..header + 8);
541        arena
542            .type_data
543            .extend_from_slice(&new_prop_count.to_le_bytes());
544        arena.type_data.extend_from_slice(&0u32.to_le_bytes());
545        if old_prop_count > 0 {
546            let props_start = header + 16;
547            let props_end = props_start + old_prop_count * 20;
548            arena.type_data.extend_from_within(props_start..props_end);
549        }
550        arena
551            .type_data
552            .extend_from_slice(&name_ref.offset.to_le_bytes());
553        arena
554            .type_data
555            .extend_from_slice(&name_ref.len.to_le_bytes());
556        arena.type_data.push(value_type);
557        arena.type_data.extend_from_slice(&[0u8; 3]);
558        arena
559            .type_data
560            .extend_from_slice(&val_ref.offset.to_le_bytes());
561        arena
562            .type_data
563            .extend_from_slice(&val_ref.len.to_le_bytes());
564
565        let new_len = (16 + new_prop_count as usize * 20) as u32;
566        let node = arena.get_node_mut(node_id);
567        node.data_offset = new_offset;
568        node.data_len = new_len;
569    }
570
571    Ok(())
572}
573
574// The JS visitors compile declarative trees to an op-stream (OPEN/CLOSE/field
575// sets/REF/KEEP_CHILDREN) that we replay directly into an ArenaBuilder — no
576// intermediate node tree, no heap allocation per node beyond the arena itself. A node's
577// fields are collected after its OPEN and flushed into its type_data the moment
578// the next op needs the node finalized (a child OPEN, a CLOSE, or a spliced
579// REF/KEEP_CHILDREN).
580
581/// Per-kind hooks for [`replay_opstream`]. `Kind` ties a collector to one
582/// arena flavor, so a cross-kind replay stays a compile error (see the module
583/// header on why MDAST/HAST must not share a dispatcher).
584trait OpCollector<'a>: Sized {
585    type Kind: ArenaKind;
586    /// Whether `OP_U8` / `OP_U32` / `OP_ALIGN` are decoded. When false those
587    /// opcodes fall through to the unknown-command error *without consuming
588    /// operands*, so the reported byte is the opcode itself.
589    const NUMERIC_OPS: bool;
590
591    fn open(node_type: u8) -> Self;
592    fn check_tag(tag: u8) -> Result<(), CommandError>;
593    fn finalize(&mut self, builder: &mut ArenaBuilder<Self::Kind>);
594    fn str_field(&mut self, field: u8, value: &'a str);
595    fn bool_field(&mut self, field: u8, value: bool);
596    fn prop(&mut self, name: &'a str, kind: u8, value: &'a str);
597    fn data(&mut self, bytes: &'a [u8]);
598    fn u8_field(&mut self, _field: u8, _value: u8) {}
599    fn u32_field(&mut self, _field: u8, _value: u32) {}
600    fn align(&mut self, _bytes: &'a [u8]) {}
601}
602
603/// Deepest `OP_OPEN` nesting the replay accepts: the apply splices a
604/// replayed sub-arena recursively, so its depth must stay well inside the
605/// host stack. 128 = serde_json's default recursion limit, ample for content.
606const MAX_OPSTREAM_DEPTH: usize = 128;
607
608/// Replay an op-stream into a fresh sub-arena. `orig`/`anchor` resolve
609/// `KEEP_CHILDREN` (splice the replaced node's original children, as refs).
610fn replay_opstream<'a, C: OpCollector<'a>>(
611    ops: &'a [u8],
612    builder: &mut ArenaBuilder<C::Kind>,
613    original_len: u32,
614    anchor: u32,
615) -> Result<Vec<u32>, CommandError> {
616    let mut reader = BufReader::new(ops);
617    let mut stack: Vec<C> = Vec::new();
618    let mut roots: Vec<u32> = Vec::new();
619
620    while reader.remaining() > 0 {
621        match reader.read_u8()? {
622            OP_OPEN => {
623                if let Some(c) = stack.last_mut() {
624                    c.finalize(builder);
625                }
626                let node_type = reader.read_u8()?;
627                C::check_tag(node_type)?;
628                // A root is only valid as the stream's top-level wrapper
629                // (the apply splices its children); nested it would smuggle
630                // a node the JS visitors refuse to encode.
631                if !stack.is_empty() && node_type == <C::Kind as ArenaKind>::ROOT_TAG {
632                    return Err(CommandError::UnencodableNodeType("root"));
633                }
634                if stack.len() >= MAX_OPSTREAM_DEPTH {
635                    return Err(CommandError::OpstreamTooDeep(MAX_OPSTREAM_DEPTH));
636                }
637                let id = builder.open_node(node_type);
638                if stack.is_empty() {
639                    roots.push(id);
640                }
641                stack.push(C::open(node_type));
642            }
643            OP_CLOSE => {
644                let Some(mut c) = stack.pop() else {
645                    return Err(CommandError::UnbalancedOpstream);
646                };
647                c.finalize(builder);
648                builder.close_node();
649            }
650            OP_REF => {
651                if let Some(c) = stack.last_mut() {
652                    c.finalize(builder);
653                }
654                let id = reader.read_u32()?;
655                // A stale id (e.g. a node cached across passes) would
656                // otherwise panic deep inside the arena indexing.
657                if id >= original_len {
658                    return Err(CommandError::InvalidNodeId(id));
659                }
660                let ref_id = emit_ref_node(id, builder);
661                if stack.is_empty() {
662                    roots.push(ref_id);
663                }
664            }
665            OP_KEEP_CHILDREN => {
666                if let Some(c) = stack.last_mut() {
667                    c.finalize(builder);
668                }
669                if anchor >= original_len {
670                    return Err(CommandError::InvalidNodeId(anchor));
671                }
672                let children = builder.arena_ref().get_children(anchor).to_vec();
673                for child in children {
674                    let ref_id = emit_ref_node(child, builder);
675                    if stack.is_empty() {
676                        roots.push(ref_id);
677                    }
678                }
679            }
680            OP_STR => {
681                let field = reader.read_u8()?;
682                let len = reader.read_u32()? as usize;
683                let value = reader.read_str(len)?;
684                if let Some(c) = stack.last_mut() {
685                    c.str_field(field, value);
686                }
687            }
688            OP_U8 if C::NUMERIC_OPS => {
689                let field = reader.read_u8()?;
690                let value = reader.read_u8()?;
691                if let Some(c) = stack.last_mut() {
692                    c.u8_field(field, value);
693                }
694            }
695            OP_U32 if C::NUMERIC_OPS => {
696                let field = reader.read_u8()?;
697                let value = reader.read_u32()?;
698                if let Some(c) = stack.last_mut() {
699                    c.u32_field(field, value);
700                }
701            }
702            OP_BOOL => {
703                let field = reader.read_u8()?;
704                let value = reader.read_u8()? != 0;
705                if let Some(c) = stack.last_mut() {
706                    c.bool_field(field, value);
707                }
708            }
709            OP_PROP => {
710                let name_len = reader.read_u32()? as usize;
711                let name = reader.read_str(name_len)?;
712                let kind = reader.read_u8()?;
713                let val_len = reader.read_u32()? as usize;
714                let value = reader.read_str(val_len)?;
715                if let Some(c) = stack.last_mut() {
716                    c.prop(name, kind, value);
717                }
718            }
719            OP_ALIGN if C::NUMERIC_OPS => {
720                let len = reader.read_u32()? as usize;
721                let bytes = reader.read_bytes(len)?;
722                if let Some(c) = stack.last_mut() {
723                    c.align(bytes);
724                }
725            }
726            OP_DATA => {
727                let len = reader.read_u32()? as usize;
728                let bytes = reader.read_bytes(len)?;
729                if let Some(c) = stack.last_mut() {
730                    c.data(bytes);
731                }
732            }
733            other => return Err(CommandError::UnknownCommand(other)),
734        }
735    }
736
737    if !stack.is_empty() {
738        return Err(CommandError::UnbalancedOpstream);
739    }
740    Ok(roots)
741}
742
743/// Intern MDX-JSX attribute strings into `(kind, name, value)` rows; spreads
744/// carry no name, boolean attrs no value.
745#[cfg(feature = "mdx")]
746pub(crate) fn intern_mdx_jsx_attrs<K: ArenaKind>(
747    props: &[(&str, u8, &str)],
748    builder: &mut ArenaBuilder<K>,
749) -> Vec<(u8, StringRef, StringRef)> {
750    let mut attrs = Vec::with_capacity(props.len());
751    for &(name, kind, value) in props {
752        let nr = if kind == MDX_ATTR_SPREAD {
753            StringRef::empty()
754        } else {
755            builder.alloc_string(name)
756        };
757        let vr = if kind == MDX_ATTR_BOOLEAN_PROP {
758            StringRef::empty()
759        } else {
760            builder.alloc_string(value)
761        };
762        attrs.push((kind, nr, vr));
763    }
764    attrs
765}
766
767/// Accumulates one node's fields between its OPEN and finalization. Strings
768/// borrow the op-stream buffer; they're interned into the arena at finalize.
769#[derive(Default)]
770pub(crate) struct FieldCollector<'a> {
771    node_type: u8,
772    finalized: bool,
773    pub(crate) strs: [Option<&'a str>; OF_FIELD_COUNT],
774    pub(crate) depth: Option<u8>,
775    checked: Option<u8>,
776    start: Option<u32>,
777    ordered: Option<bool>,
778    spread: Option<bool>,
779    /// Directive / MDX JSX attributes (`OP_PROP`): (name, kind, value).
780    pub(crate) props: Vec<(&'a str, u8, &'a str)>,
781    /// Table column-alignment bytes (`OP_ALIGN`).
782    pub(crate) align: Option<&'a [u8]>,
783    /// MDX JSX `_mdxExplicitJsx` flag (`OP_BOOL` on `OF_EXPLICIT`).
784    pub(crate) explicit: Option<bool>,
785    data: Option<&'a [u8]>,
786}
787
788/// Encode a collector's fields into the current node's type_data (and node_data).
789fn finalize_collector(c: &mut FieldCollector<'_>, builder: &mut ArenaBuilder<Mdast>) {
790    const LIST: u8 = MdastNodeType::List as u8;
791    const LIST_ITEM: u8 = MdastNodeType::ListItem as u8;
792    const DESCRIPTION_DETAILS: u8 = MdastNodeType::DescriptionDetails as u8;
793
794    if c.finalized {
795        return;
796    }
797    c.finalized = true;
798    let mut fixed = [0u8; MAX_FIXED_TYPE_DATA];
799    if let Some(len) = encode_mdast_type_data_from_ops(c, c.node_type, builder, &mut fixed) {
800        builder.set_data_current(&fixed[..len]);
801    } else if let Some(type_data) = encode_mdast_tail_from_ops(c, c.node_type, builder) {
802        // Generated tail encoder (directive attributes, MDX JSX); see encode.rs.
803        builder.set_data_current(&type_data);
804    } else {
805        let type_data: Vec<u8> = match c.node_type {
806            LIST => encode_list_data(
807                c.ordered.unwrap_or(false),
808                c.start.unwrap_or(1),
809                c.spread.unwrap_or(false),
810            ),
811            // checked: 2 = not a task item
812            LIST_ITEM => encode_list_item_data(c.checked.unwrap_or(2), c.spread.unwrap_or(false)),
813            DESCRIPTION_DETAILS => encode_description_details_data(c.spread.unwrap_or(false)),
814            // Remaining tags carry no type_data.
815            _ => Vec::new(),
816        };
817        if !type_data.is_empty() {
818            builder.set_data_current(&type_data);
819        }
820    }
821    if let Some(data) = c.data {
822        let id = builder.current_node_id();
823        builder.arena_mut().set_node_data(id, data.to_vec());
824    }
825}
826
827impl<'a> OpCollector<'a> for FieldCollector<'a> {
828    type Kind = Mdast;
829    const NUMERIC_OPS: bool = true;
830
831    fn open(node_type: u8) -> Self {
832        FieldCollector {
833            node_type,
834            ..Default::default()
835        }
836    }
837
838    /// Reject op-stream tags this build can't construct — unknown bytes
839    /// always, MDX tags without the `mdx` feature.
840    fn check_tag(tag: u8) -> Result<(), CommandError> {
841        let known = MdastNodeType::from_u8(tag).is_some();
842        #[cfg(not(feature = "mdx"))]
843        let known = known
844            && !matches!(
845                MdastNodeType::from_u8(tag),
846                Some(
847                    MdastNodeType::MdxJsxFlowElement
848                        | MdastNodeType::MdxJsxTextElement
849                        | MdastNodeType::MdxFlowExpression
850                        | MdastNodeType::MdxTextExpression
851                        | MdastNodeType::MdxjsEsm
852                )
853            );
854        if known {
855            Ok(())
856        } else {
857            Err(CommandError::UnknownNodeType(format!(
858                "op-stream tag {tag}"
859            )))
860        }
861    }
862
863    fn finalize(&mut self, builder: &mut ArenaBuilder<Mdast>) {
864        finalize_collector(self, builder);
865    }
866
867    fn str_field(&mut self, field: u8, value: &'a str) {
868        let field = field as usize;
869        if field < self.strs.len() {
870            self.strs[field] = Some(value);
871        }
872    }
873
874    fn bool_field(&mut self, field: u8, value: bool) {
875        match field {
876            OF_ORDERED => self.ordered = Some(value),
877            OF_SPREAD => self.spread = Some(value),
878            OF_EXPLICIT => self.explicit = Some(value),
879            _ => {}
880        }
881    }
882
883    fn prop(&mut self, name: &'a str, kind: u8, value: &'a str) {
884        self.props.push((name, kind, value));
885    }
886
887    fn data(&mut self, bytes: &'a [u8]) {
888        self.data = Some(bytes);
889    }
890
891    fn u8_field(&mut self, field: u8, value: u8) {
892        match field {
893            OF_DEPTH => self.depth = Some(value),
894            OF_CHECKED => self.checked = Some(value),
895            _ => {}
896        }
897    }
898
899    fn u32_field(&mut self, field: u8, value: u32) {
900        if field == OF_START {
901            self.start = Some(value);
902        }
903    }
904
905    fn align(&mut self, bytes: &'a [u8]) {
906        self.align = Some(bytes);
907    }
908}
909
910fn replay_mdast_opstream(
911    ops: &[u8],
912    builder: &mut ArenaBuilder<Mdast>,
913    original_len: u32,
914    anchor: u32,
915) -> Result<Vec<u32>, CommandError> {
916    replay_opstream::<FieldCollector>(ops, builder, original_len, anchor)
917}
918
919/// Returns (arena, keep_children) for an MDAST sub-tree payload. `orig`/`anchor`
920/// are the arena and the command's target node, used by an op-stream's
921/// `KEEP_CHILDREN`.
922fn read_mdast_payload(
923    reader: &mut BufReader<'_>,
924    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
925    builder: &mut ArenaBuilder<Mdast>,
926    original_len: u32,
927    anchor: u32,
928    options: MdastCommandOptions,
929) -> Result<(PatchContent<Mdast>, bool), CommandError> {
930    let payload_type = reader.read_u8()?;
931
932    match payload_type {
933        PAYLOAD_RAW => {
934            let flags = reader.read_u8()?;
935            let len = reader.read_u32()? as usize;
936            let raw = reader.read_str(len)?;
937            // Braces are kept literal only when the caller asked (RAW_LITERAL_BRACES,
938            // set by `mdxExpressions: false` / the deprecated `{rawHtml}`) and the
939            // document is MDX; in plain markdown there are no expressions to escape.
940            let tree = if flags & RAW_LITERAL_BRACES != 0 && options.escape_raw_html_braces {
941                parse_markdown(&escape_braces_in_html_text(raw))
942            } else {
943                parse_markdown(raw)
944            };
945            Ok((PatchContent::Tree(tree), false))
946        }
947        PAYLOAD_OPSTREAM => {
948            let len = reader.read_u32()? as usize;
949            let ops = reader.read_bytes(len)?;
950            Ok((
951                PatchContent::Grafted(replay_mdast_opstream(ops, builder, original_len, anchor)?),
952                false,
953            ))
954        }
955        other => Err(CommandError::UnknownPayloadType(other)),
956    }
957}
958
959/// HAST op-stream replay (mirrors the MDAST version). Builds element type_data
960/// (tag + properties) and text/comment/raw values; `OP_PROP` carries properties,
961/// `OF_TAGNAME` the tag.
962#[derive(Default)]
963pub(crate) struct HastFieldCollector<'a> {
964    node_type: u8,
965    finalized: bool,
966    /// HAST element `tagName` or MDX JSX element `name`.
967    pub(crate) tag: Option<&'a str>,
968    value: Option<&'a str>,
969    pub(crate) props: Vec<(&'a str, u8, &'a str)>,
970    /// MDX JSX `_mdxExplicitJsx` flag (`OP_BOOL` on `OF_EXPLICIT`).
971    pub(crate) explicit: Option<bool>,
972    data: Option<&'a [u8]>,
973}
974
975fn finalize_hast_collector(c: &mut HastFieldCollector<'_>, builder: &mut ArenaBuilder<Hast>) {
976    const TEXT: u8 = HastNodeType::Text as u8;
977    const COMMENT: u8 = HastNodeType::Comment as u8;
978    const RAW: u8 = HastNodeType::Raw as u8;
979    const MDX_FLOW_EXPRESSION: u8 = HastNodeType::MdxFlowExpression as u8;
980    const MDX_ESM: u8 = HastNodeType::MdxEsm as u8;
981    const MDX_TEXT_EXPRESSION: u8 = HastNodeType::MdxTextExpression as u8;
982
983    if c.finalized {
984        return;
985    }
986    c.finalized = true;
987    if let Some(type_data) = encode_hast_tail_from_ops(c, c.node_type, builder) {
988        // Generated tail encoder (element properties, MDX JSX); see encode.rs.
989        builder.set_data_current(&type_data);
990    } else {
991        let type_data: Vec<u8> = match c.node_type {
992            TEXT | COMMENT | RAW | MDX_FLOW_EXPRESSION | MDX_ESM | MDX_TEXT_EXPRESSION => {
993                let sref = builder.alloc_string(c.value.unwrap_or(""));
994                encode_string_ref_data(sref)
995            }
996            // Remaining tags carry no type_data.
997            _ => Vec::new(),
998        };
999        if !type_data.is_empty() {
1000            builder.set_data_current(&type_data);
1001        }
1002    }
1003    if let Some(data) = c.data {
1004        let id = builder.current_node_id();
1005        builder.arena_mut().set_node_data(id, data.to_vec());
1006    }
1007}
1008
1009impl<'a> OpCollector<'a> for HastFieldCollector<'a> {
1010    type Kind = Hast;
1011    const NUMERIC_OPS: bool = false;
1012
1013    fn open(node_type: u8) -> Self {
1014        HastFieldCollector {
1015            node_type,
1016            ..Default::default()
1017        }
1018    }
1019
1020    /// HAST twin of the MDAST `check_tag`.
1021    fn check_tag(tag: u8) -> Result<(), CommandError> {
1022        // The JS visitor refuses to encode a doctype (it's not in
1023        // `HAST_OPSTREAM_TYPES`); enforce the same here so a crafted buffer
1024        // can't smuggle one in.
1025        if HastNodeType::from_u8(tag) == Some(HastNodeType::Doctype) {
1026            return Err(CommandError::UnencodableNodeType("doctype"));
1027        }
1028        let known = HastNodeType::from_u8(tag).is_some();
1029        #[cfg(not(feature = "mdx"))]
1030        let known = known
1031            && !matches!(
1032                HastNodeType::from_u8(tag),
1033                Some(
1034                    HastNodeType::MdxJsxElement
1035                        | HastNodeType::MdxJsxTextElement
1036                        | HastNodeType::MdxFlowExpression
1037                        | HastNodeType::MdxEsm
1038                        | HastNodeType::MdxTextExpression
1039                )
1040            );
1041        if known {
1042            Ok(())
1043        } else {
1044            Err(CommandError::UnknownNodeType(format!(
1045                "op-stream tag {tag}"
1046            )))
1047        }
1048    }
1049
1050    fn finalize(&mut self, builder: &mut ArenaBuilder<Hast>) {
1051        finalize_hast_collector(self, builder);
1052    }
1053
1054    fn str_field(&mut self, field: u8, value: &'a str) {
1055        match field {
1056            OF_TAGNAME | OF_NAME => self.tag = Some(value),
1057            OF_VALUE => self.value = Some(value),
1058            _ => {}
1059        }
1060    }
1061
1062    fn bool_field(&mut self, field: u8, value: bool) {
1063        if field == OF_EXPLICIT {
1064            self.explicit = Some(value);
1065        }
1066    }
1067
1068    fn prop(&mut self, name: &'a str, kind: u8, value: &'a str) {
1069        self.props.push((name, kind, value));
1070    }
1071
1072    fn data(&mut self, bytes: &'a [u8]) {
1073        self.data = Some(bytes);
1074    }
1075}
1076
1077fn replay_hast_opstream(
1078    ops: &[u8],
1079    builder: &mut ArenaBuilder<Hast>,
1080    original_len: u32,
1081    anchor: u32,
1082) -> Result<Vec<u32>, CommandError> {
1083    replay_opstream::<HastFieldCollector>(ops, builder, original_len, anchor)
1084}
1085
1086/// Returns (arena, keep_children) for a HAST sub-tree payload: an op-stream,
1087/// or (wrap only) `PAYLOAD_RAW` parsed as an HTML fragment. Other ops don't
1088/// need raw: the `raw` node type covers opaque HTML.
1089fn read_hast_payload(
1090    reader: &mut BufReader<'_>,
1091    builder: &mut ArenaBuilder<Hast>,
1092    original_len: u32,
1093    anchor: u32,
1094    for_wrap: bool,
1095) -> Result<(PatchContent<Hast>, bool), CommandError> {
1096    let payload_type = reader.read_u8()?;
1097
1098    match payload_type {
1099        PAYLOAD_OPSTREAM => {
1100            let len = reader.read_u32()? as usize;
1101            let ops = reader.read_bytes(len)?;
1102            Ok((
1103                PatchContent::Grafted(replay_hast_opstream(ops, builder, original_len, anchor)?),
1104                false,
1105            ))
1106        }
1107        PAYLOAD_RAW if for_wrap => {
1108            // Flags (RAW_LITERAL_BRACES) are an MDX concern; HTML parsing has
1109            // no expressions to escape.
1110            let _flags = reader.read_u8()?;
1111            let len = reader.read_u32()? as usize;
1112            let raw = reader.read_str(len)?;
1113            Ok((PatchContent::Tree(hast_wrap_arena_from_html(raw)?), false))
1114        }
1115        other => Err(CommandError::UnknownPayloadType(other)),
1116    }
1117}
1118
1119#[cfg(feature = "from-html")]
1120fn hast_wrap_arena_from_html(raw: &str) -> Result<Arena<Hast>, CommandError> {
1121    satteri_ast::hast::html_fragment_to_wrap_arena(raw).map_err(CommandError::InvalidRawWrapper)
1122}
1123
1124/// Erroring beats silently mis-wrapping when the parser was compiled out.
1125#[cfg(not(feature = "from-html"))]
1126fn hast_wrap_arena_from_html(_raw: &str) -> Result<Arena<Hast>, CommandError> {
1127    Err(CommandError::InvalidRawWrapper(
1128        "requires HTML parsing, which this build omits (from-html feature)".to_string(),
1129    ))
1130}
1131
1132/// Mirrors `LEAF_TYPES` in `mdast-materializer.ts`, so a raw wrapper is
1133/// judged like a declarative one.
1134fn is_mdast_leaf(node_type: u8) -> bool {
1135    use MdastNodeType::*;
1136    matches!(
1137        MdastNodeType::from_u8(node_type),
1138        Some(
1139            ThematicBreak
1140                | Html
1141                | Code
1142                | Definition
1143                | Text
1144                | InlineCode
1145                | Break
1146                | Image
1147                | ImageReference
1148                | FootnoteReference
1149                | Yaml
1150                | Toml
1151                | Math
1152                | InlineMath
1153                | MdxFlowExpression
1154                | MdxTextExpression
1155                | MdxjsEsm
1156        )
1157    )
1158}
1159
1160/// Reshape a parsed `{raw}` payload into the arena `Patch::Wrap` takes: the
1161/// single block becomes node 0, re-rooted in place so its string refs stay
1162/// valid against the arena's own pool.
1163fn mdast_wrap_arena_from_tree(mut tree: Arena<Mdast>) -> Result<Arena<Mdast>, CommandError> {
1164    let roots: &[u32] = if tree.is_empty() {
1165        &[]
1166    } else {
1167        tree.get_children(0)
1168    };
1169    let &[wrapper] = roots else {
1170        return Err(CommandError::InvalidRawWrapper(
1171            "must parse to exactly one block".to_string(),
1172        ));
1173    };
1174    let node = *tree.get_node(wrapper);
1175    if is_mdast_leaf(node.node_type) {
1176        let name = MdastNodeType::from_u8(node.node_type).map_or("node", MdastNodeType::name);
1177        return Err(CommandError::InvalidRawWrapper(format!(
1178            "parses to a {name}, which cannot hold the wrapped node"
1179        )));
1180    }
1181    let children = tree.get_children(wrapper).to_vec();
1182    let type_data = tree.get_type_data(wrapper).to_vec();
1183    let node_data = tree.get_node_data(wrapper).map(<[u8]>::to_vec);
1184    tree.get_node_mut(0).node_type = node.node_type;
1185    tree.set_position(
1186        0,
1187        node.start_offset,
1188        node.end_offset,
1189        node.start_line,
1190        node.start_column,
1191        node.end_line,
1192        node.end_column,
1193    );
1194    tree.set_type_data(0, &type_data);
1195    tree.set_children(0, &children);
1196    if let Some(data) = node_data {
1197        tree.set_node_data(0, data);
1198    }
1199    Ok(tree)
1200}
1201
1202/// A void wrapper would drop the wrapped node at render. `{raw}` payloads
1203/// are rejected at parse time; this covers op-stream element payloads.
1204fn reject_void_wrap_parent(
1205    parent_tree: &PatchContent<Hast>,
1206    grafted: &Arena<Hast>,
1207) -> Result<(), CommandError> {
1208    let (source, wrapper) = match parent_tree {
1209        PatchContent::Tree(tree) if !tree.is_empty() => (tree, 0),
1210        PatchContent::Grafted(roots) => match roots.first() {
1211            Some(&root) => (grafted, root),
1212            None => return Ok(()),
1213        },
1214        PatchContent::Tree(_) => return Ok(()),
1215    };
1216    if source.get_node(wrapper).node_type != HastNodeType::Element as u8 {
1217        return Ok(());
1218    }
1219    let tag = source.get_str(decode_element_tag(source.get_type_data(wrapper)));
1220    if is_void_element(tag) {
1221        return Err(CommandError::VoidWrapParent(tag.to_string()));
1222    }
1223    Ok(())
1224}
1225
1226/// Apply a command buffer to an MDAST arena. Set-property mutations are
1227/// applied in-place; structural mutations are collected as `Patch<Mdast>`
1228/// objects and applied in place via `apply_patches_in_place`.
1229///
1230/// `parse_markdown` avoids a circular dependency on the parser crate; it
1231/// is invoked for `RAW_MARKDOWN` and `RAW_HTML` payloads.
1232///
1233/// Passing a HAST arena is a compile error — the prior single-dispatch
1234/// `apply_commands` would silently misroute MDAST nodes into the HAST
1235/// element-properties writer (numeric `node_type` values overlap between
1236/// the two arenas):
1237///
1238/// ```compile_fail
1239/// use satteri_arena::{Arena, Hast};
1240/// use satteri_plugin_api::apply_mdast_commands;
1241///
1242/// let arena: Arena<Hast> = Arena::new(String::new());
1243/// let parse_markdown = |_: &str| -> Arena<satteri_arena::Mdast> {
1244///     Arena::new(String::new())
1245/// };
1246/// let _ = apply_mdast_commands(arena, &[], &parse_markdown);
1247/// ```
1248pub fn apply_mdast_commands(
1249    arena: Arena<Mdast>,
1250    command_buf: &[u8],
1251    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1252) -> Result<Arena<Mdast>, CommandError> {
1253    apply_mdast_commands_with_options(
1254        arena,
1255        command_buf,
1256        parse_markdown,
1257        MdastCommandOptions::default(),
1258    )
1259}
1260
1261/// Like [`apply_mdast_commands`], with explicit command application options.
1262pub fn apply_mdast_commands_with_options(
1263    arena: Arena<Mdast>,
1264    command_buf: &[u8],
1265    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1266    options: MdastCommandOptions,
1267) -> Result<Arena<Mdast>, CommandError> {
1268    let (arena, dropped) =
1269        apply_mdast_commands_lenient_with_options(arena, command_buf, parse_markdown, options)?;
1270    if let Some(anchor) = dropped.first() {
1271        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1272    }
1273    Ok(arena)
1274}
1275
1276/// Like [`apply_mdast_commands`], but rather than erroring when a patch targets
1277/// a node inside a removed/replaced subtree, drops it and returns the dropped
1278/// anchors. Such a patch is moot — the plugin discarded that subtree. A
1279/// *passed-through* child is not dropped: it rides a `_ref` placeholder that
1280/// splices it back with its id intact, so a transform queued on a nested node
1281/// (e.g. a `:::tip` inside a `:::note`) still applies, in the same pass.
1282pub fn apply_mdast_commands_lenient(
1283    arena: Arena<Mdast>,
1284    command_buf: &[u8],
1285    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1286) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
1287    apply_mdast_commands_lenient_with_options(
1288        arena,
1289        command_buf,
1290        parse_markdown,
1291        MdastCommandOptions::default(),
1292    )
1293}
1294
1295/// Like [`apply_mdast_commands_lenient`], with explicit command application
1296/// options.
1297pub fn apply_mdast_commands_lenient_with_options(
1298    mut arena: Arena<Mdast>,
1299    command_buf: &[u8],
1300    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1301    options: MdastCommandOptions,
1302) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
1303    if command_buf.is_empty() {
1304        return Ok((arena, Vec::new()));
1305    }
1306
1307    // One builder wraps the arena for the whole decode: opstream payloads
1308    // replay directly into it as orphan subtrees (strings land in the main
1309    // pool, so the apply needs no remap and no per-command mini-arena).
1310    let original_len = arena.len() as u32;
1311    let mut builder =
1312        ArenaBuilder::from_arena(std::mem::replace(&mut arena, Arena::new(String::new())));
1313    let mut patches: Vec<Patch<Mdast>> = Vec::new();
1314    let mut reader = BufReader::new(command_buf);
1315
1316    while reader.remaining() > 0 {
1317        let cmd = reader.read_u8()?;
1318
1319        match cmd {
1320            CMD_REMOVE => {
1321                let node_id = reader.read_anchor(original_len)?;
1322                patches.push(Patch::Remove { node_id });
1323            }
1324
1325            CMD_SET_PROPERTY => {
1326                let node_id = reader.read_anchor(original_len)?;
1327                let value_type = reader.read_u8()?;
1328                let name_len = reader.read_u32()? as usize;
1329                let name = reader.read_str(name_len)?;
1330                let value_len = reader.read_u32()? as usize;
1331                let value = reader.read_str(value_len)?;
1332                apply_mdast_set_property(builder.arena_mut(), node_id, name, value_type, value)?;
1333            }
1334
1335            CMD_INSERT_BEFORE => {
1336                let node_id = reader.read_anchor(original_len)?;
1337                let (new_tree, _) = read_mdast_payload(
1338                    &mut reader,
1339                    parse_markdown,
1340                    &mut builder,
1341                    original_len,
1342                    node_id,
1343                    options,
1344                )?;
1345                patches.push(Patch::InsertBefore { node_id, new_tree });
1346            }
1347
1348            CMD_INSERT_AFTER => {
1349                let node_id = reader.read_anchor(original_len)?;
1350                let (new_tree, _) = read_mdast_payload(
1351                    &mut reader,
1352                    parse_markdown,
1353                    &mut builder,
1354                    original_len,
1355                    node_id,
1356                    options,
1357                )?;
1358                patches.push(Patch::InsertAfter { node_id, new_tree });
1359            }
1360
1361            CMD_PREPEND_CHILD => {
1362                let node_id = reader.read_anchor(original_len)?;
1363                let (child_tree, _) = read_mdast_payload(
1364                    &mut reader,
1365                    parse_markdown,
1366                    &mut builder,
1367                    original_len,
1368                    node_id,
1369                    options,
1370                )?;
1371                patches.push(Patch::PrependChild {
1372                    node_id,
1373                    child_tree,
1374                });
1375            }
1376
1377            CMD_APPEND_CHILD => {
1378                let node_id = reader.read_anchor(original_len)?;
1379                let (child_tree, _) = read_mdast_payload(
1380                    &mut reader,
1381                    parse_markdown,
1382                    &mut builder,
1383                    original_len,
1384                    node_id,
1385                    options,
1386                )?;
1387                patches.push(Patch::AppendChild {
1388                    node_id,
1389                    child_tree,
1390                });
1391            }
1392
1393            CMD_WRAP => {
1394                let node_id = reader.read_anchor(original_len)?;
1395                let (parent_tree, _) = read_mdast_payload(
1396                    &mut reader,
1397                    parse_markdown,
1398                    &mut builder,
1399                    original_len,
1400                    node_id,
1401                    options,
1402                )?;
1403                let parent_tree = match parent_tree {
1404                    PatchContent::Tree(tree) => {
1405                        PatchContent::Tree(mdast_wrap_arena_from_tree(tree)?)
1406                    }
1407                    grafted => grafted,
1408                };
1409                patches.push(Patch::Wrap {
1410                    node_id,
1411                    parent_tree,
1412                });
1413            }
1414
1415            CMD_REPLACE => {
1416                let node_id = reader.read_anchor(original_len)?;
1417                let (new_tree, keep_children) = read_mdast_payload(
1418                    &mut reader,
1419                    parse_markdown,
1420                    &mut builder,
1421                    original_len,
1422                    node_id,
1423                    options,
1424                )?;
1425                patches.push(Patch::Replace {
1426                    node_id,
1427                    new_tree,
1428                    keep_children,
1429                });
1430            }
1431
1432            CMD_SET_CHILDREN => {
1433                let node_id = reader.read_anchor(original_len)?;
1434                let (new_children, _) = read_mdast_payload(
1435                    &mut reader,
1436                    parse_markdown,
1437                    &mut builder,
1438                    original_len,
1439                    node_id,
1440                    options,
1441                )?;
1442                patches.push(Patch::SetChildren {
1443                    node_id,
1444                    new_children,
1445                });
1446            }
1447
1448            other => return Err(CommandError::UnknownCommand(other)),
1449        }
1450    }
1451
1452    let mut arena = builder.finish();
1453    if patches.is_empty() {
1454        Ok((arena, Vec::new()))
1455    } else {
1456        let dropped = satteri_ast::patch::apply_patches_in_place(&mut arena, &patches)?;
1457        Ok((arena, dropped))
1458    }
1459}
1460
1461/// Apply a command buffer to a HAST arena. Set-property mutations are
1462/// applied in-place; structural mutations are collected as `Patch<Hast>`
1463/// objects and applied in place via `apply_patches_in_place`. Errors if a patch is stranded inside a
1464/// removed/replaced subtree; [`apply_hast_commands_lenient`] drops it instead.
1465///
1466/// HAST plugins inject sub-trees via `PAYLOAD_OPSTREAM` only — there is
1467/// no `parse_markdown` callback because HAST has no source-level grammar.
1468///
1469/// Passing an MDAST arena is a compile error:
1470///
1471/// ```compile_fail
1472/// use satteri_arena::{Arena, Mdast};
1473/// use satteri_plugin_api::apply_hast_commands;
1474///
1475/// let arena: Arena<Mdast> = Arena::new(String::new());
1476/// let _ = apply_hast_commands(arena, &[]);
1477/// ```
1478pub fn apply_hast_commands(
1479    arena: Arena<Hast>,
1480    command_buf: &[u8],
1481) -> Result<Arena<Hast>, CommandError> {
1482    let (arena, dropped) = apply_hast_commands_lenient(arena, command_buf)?;
1483    if let Some(anchor) = dropped.first() {
1484        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1485    }
1486    Ok(arena)
1487}
1488
1489/// Like [`apply_hast_commands`], but rather than erroring when a patch targets a
1490/// node inside a removed/replaced subtree, drops it and returns the dropped
1491/// anchors — mirroring [`apply_mdast_commands_lenient`]. Such a patch is moot:
1492/// the plugin discarded that subtree. A passed-through child keeps its identity
1493/// (via `_ref`) and so is never stranded this way.
1494pub fn apply_hast_commands_lenient(
1495    mut arena: Arena<Hast>,
1496    command_buf: &[u8],
1497) -> Result<(Arena<Hast>, Vec<u32>), CommandError> {
1498    if command_buf.is_empty() {
1499        return Ok((arena, Vec::new()));
1500    }
1501
1502    // See the MDAST twin: one builder for the whole decode, opstream payloads
1503    // replay directly into the arena as orphan subtrees.
1504    let original_len = arena.len() as u32;
1505    let mut builder =
1506        ArenaBuilder::from_arena(std::mem::replace(&mut arena, Arena::new(String::new())));
1507    let mut patches: Vec<Patch<Hast>> = Vec::new();
1508    let mut reader = BufReader::new(command_buf);
1509
1510    while reader.remaining() > 0 {
1511        let cmd = reader.read_u8()?;
1512
1513        match cmd {
1514            CMD_REMOVE => {
1515                let node_id = reader.read_anchor(original_len)?;
1516                patches.push(Patch::Remove { node_id });
1517            }
1518
1519            CMD_SET_PROPERTY => {
1520                let node_id = reader.read_anchor(original_len)?;
1521                let value_type = reader.read_u8()?;
1522                let name_len = reader.read_u32()? as usize;
1523                let name = reader.read_str(name_len)?;
1524                let value_len = reader.read_u32()? as usize;
1525                let value = reader.read_str(value_len)?;
1526                apply_hast_set_property(builder.arena_mut(), node_id, name, value_type, value)?;
1527            }
1528
1529            CMD_INSERT_BEFORE => {
1530                let node_id = reader.read_anchor(original_len)?;
1531                let (new_tree, _) =
1532                    read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
1533                patches.push(Patch::InsertBefore { node_id, new_tree });
1534            }
1535
1536            CMD_INSERT_AFTER => {
1537                let node_id = reader.read_anchor(original_len)?;
1538                let (new_tree, _) =
1539                    read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
1540                patches.push(Patch::InsertAfter { node_id, new_tree });
1541            }
1542
1543            CMD_PREPEND_CHILD => {
1544                let node_id = reader.read_anchor(original_len)?;
1545                let (child_tree, _) =
1546                    read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
1547                patches.push(Patch::PrependChild {
1548                    node_id,
1549                    child_tree,
1550                });
1551            }
1552
1553            CMD_APPEND_CHILD => {
1554                let node_id = reader.read_anchor(original_len)?;
1555                let (child_tree, _) =
1556                    read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
1557                patches.push(Patch::AppendChild {
1558                    node_id,
1559                    child_tree,
1560                });
1561            }
1562
1563            CMD_WRAP => {
1564                let node_id = reader.read_anchor(original_len)?;
1565                let (parent_tree, _) =
1566                    read_hast_payload(&mut reader, &mut builder, original_len, node_id, true)?;
1567                reject_void_wrap_parent(&parent_tree, builder.arena_mut())?;
1568                patches.push(Patch::Wrap {
1569                    node_id,
1570                    parent_tree,
1571                });
1572            }
1573
1574            CMD_REPLACE => {
1575                let node_id = reader.read_anchor(original_len)?;
1576                let (new_tree, keep_children) =
1577                    read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
1578                patches.push(Patch::Replace {
1579                    node_id,
1580                    new_tree,
1581                    keep_children,
1582                });
1583            }
1584
1585            CMD_SET_CHILDREN => {
1586                let node_id = reader.read_anchor(original_len)?;
1587                let (new_children, _) =
1588                    read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
1589                patches.push(Patch::SetChildren {
1590                    node_id,
1591                    new_children,
1592                });
1593            }
1594
1595            other => return Err(CommandError::UnknownCommand(other)),
1596        }
1597    }
1598
1599    let mut arena = builder.finish();
1600    if patches.is_empty() {
1601        Ok((arena, Vec::new()))
1602    } else {
1603        let dropped = satteri_ast::patch::apply_patches_in_place(&mut arena, &patches)?;
1604        Ok((arena, dropped))
1605    }
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610    use super::*;
1611
1612    /// Old-signature shim for the replay tests: replays into a builder over a
1613    /// clone of `orig` and returns (arena, roots).
1614    fn replay_mdast_for_test(
1615        ops: &[u8],
1616        orig: &Arena<Mdast>,
1617        anchor: u32,
1618    ) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
1619        let original_len = orig.len() as u32;
1620        let mut builder = ArenaBuilder::from_arena(orig.clone());
1621        let roots = replay_mdast_opstream(ops, &mut builder, original_len, anchor)?;
1622        Ok((builder.finish(), roots))
1623    }
1624
1625    fn replay_hast_for_test(
1626        ops: &[u8],
1627        orig: &Arena<Hast>,
1628        anchor: u32,
1629    ) -> Result<(Arena<Hast>, Vec<u32>), CommandError> {
1630        let original_len = orig.len() as u32;
1631        let mut builder = ArenaBuilder::from_arena(orig.clone());
1632        let roots = replay_hast_opstream(ops, &mut builder, original_len, anchor)?;
1633        Ok((builder.finish(), roots))
1634    }
1635
1636    use satteri_ast::shared::PROP_INT;
1637
1638    fn op_open(b: &mut Vec<u8>, t: MdastNodeType) {
1639        b.push(OP_OPEN);
1640        b.push(t as u8);
1641    }
1642    fn op_close(b: &mut Vec<u8>) {
1643        b.push(OP_CLOSE);
1644    }
1645    fn op_str(b: &mut Vec<u8>, field: u8, s: &str) {
1646        b.push(OP_STR);
1647        b.push(field);
1648        b.extend_from_slice(&(s.len() as u32).to_le_bytes());
1649        b.extend_from_slice(s.as_bytes());
1650    }
1651    fn op_u8(b: &mut Vec<u8>, field: u8, v: u8) {
1652        b.push(OP_U8);
1653        b.push(field);
1654        b.push(v);
1655    }
1656
1657    #[test]
1658    fn opstream_replay_builds_subtree() {
1659        // blockquote > [ heading(3) > text("Note"), paragraph > text("Body") ]
1660        let mut ops = Vec::new();
1661        op_open(&mut ops, MdastNodeType::Blockquote);
1662        op_open(&mut ops, MdastNodeType::Heading);
1663        op_u8(&mut ops, OF_DEPTH, 3);
1664        op_open(&mut ops, MdastNodeType::Text);
1665        op_str(&mut ops, OF_VALUE, "Note");
1666        op_close(&mut ops);
1667        op_close(&mut ops);
1668        op_open(&mut ops, MdastNodeType::Paragraph);
1669        op_open(&mut ops, MdastNodeType::Text);
1670        op_str(&mut ops, OF_VALUE, "Body");
1671        op_close(&mut ops);
1672        op_close(&mut ops);
1673        op_close(&mut ops);
1674
1675        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1676        let (arena, roots) = replay_mdast_for_test(&ops, &empty, 0).unwrap();
1677
1678        let bq = roots[0];
1679        assert_eq!(
1680            arena.get_node(bq).node_type,
1681            MdastNodeType::Blockquote as u8
1682        );
1683        let top = arena.get_children(bq).to_vec();
1684        assert_eq!(top.len(), 2);
1685        // heading depth 3, child text "Note"
1686        let h = top[0];
1687        assert_eq!(arena.get_node(h).node_type, MdastNodeType::Heading as u8);
1688        assert_eq!(decode_heading_data(arena.get_type_data(h)).depth, 3);
1689        let h_text = arena.get_children(h)[0];
1690        assert_eq!(arena.get_node(h_text).node_type, MdastNodeType::Text as u8);
1691        let sref = decode_string_ref_data(arena.get_type_data(h_text));
1692        assert_eq!(arena.get_str(sref), "Note");
1693        // paragraph > text "Body"
1694        let p = top[1];
1695        assert_eq!(arena.get_node(p).node_type, MdastNodeType::Paragraph as u8);
1696        let p_text = arena.get_children(p)[0];
1697        assert_eq!(
1698            arena.get_str(decode_string_ref_data(arena.get_type_data(p_text))),
1699            "Body"
1700        );
1701    }
1702
1703    #[test]
1704    fn opstream_replay_rejects_unbalanced_close() {
1705        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1706        let err = replay_mdast_for_test(&[OP_CLOSE], &empty, 0).unwrap_err();
1707        assert!(matches!(err, CommandError::UnbalancedOpstream));
1708
1709        let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
1710        let err = replay_hast_for_test(&[OP_CLOSE], &empty_hast, 0).unwrap_err();
1711        assert!(matches!(err, CommandError::UnbalancedOpstream));
1712
1713        // A balanced prefix doesn't excuse a trailing extra close.
1714        let mut ops = Vec::new();
1715        op_open(&mut ops, MdastNodeType::Paragraph);
1716        op_close(&mut ops);
1717        op_close(&mut ops);
1718        let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
1719        assert!(matches!(err, CommandError::UnbalancedOpstream));
1720    }
1721
1722    #[test]
1723    fn opstream_replay_rejects_unclosed_node() {
1724        // A truncated stream leaves its OPENed nodes on the stack; finishing
1725        // would hand back nodes with empty type_data.
1726        let mut ops = Vec::new();
1727        op_open(&mut ops, MdastNodeType::Heading);
1728        op_u8(&mut ops, OF_DEPTH, 2);
1729
1730        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1731        let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
1732        assert!(matches!(err, CommandError::UnbalancedOpstream));
1733
1734        let hast_ops = vec![OP_OPEN, HastNodeType::Element as u8];
1735        let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
1736        let err = replay_hast_for_test(&hast_ops, &empty_hast, 0).unwrap_err();
1737        assert!(matches!(err, CommandError::UnbalancedOpstream));
1738    }
1739
1740    #[test]
1741    fn opstream_keep_children_rejects_out_of_range_anchor() {
1742        let orig = test_parse_markdown("Hello");
1743        let bad_anchor = orig.len() as u32;
1744
1745        let mut ops = Vec::new();
1746        op_open(&mut ops, MdastNodeType::Heading);
1747        ops.push(OP_KEEP_CHILDREN);
1748        op_close(&mut ops);
1749
1750        let err = replay_mdast_for_test(&ops, &orig, bad_anchor).unwrap_err();
1751        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_anchor));
1752    }
1753
1754    #[test]
1755    fn set_property_rejects_out_of_range_node_id() {
1756        let arena = build_hello_world();
1757        let bad_id = arena.len() as u32;
1758        let mut buf = Vec::new();
1759        push_set_property(&mut buf, bad_id, PROP_INT, "depth", "3");
1760        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
1761        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_id));
1762        assert!(err.to_string().contains("invalid node id"));
1763
1764        let hast = build_hast_element(&[]);
1765        let bad_id = hast.len() as u32;
1766        let mut buf = Vec::new();
1767        push_set_property(&mut buf, bad_id, PROP_STRING, "class", "x");
1768        let err = apply_hast_commands(hast, &buf).unwrap_err();
1769        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_id));
1770    }
1771
1772    #[test]
1773    fn opstream_replay_rejects_unknown_tags() {
1774        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1775        let err = replay_mdast_for_test(&[OP_OPEN, 200], &empty, 0).unwrap_err();
1776        assert!(matches!(err, CommandError::UnknownNodeType(_)));
1777
1778        let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
1779        let err = replay_hast_for_test(&[OP_OPEN, 200], &empty_hast, 0).unwrap_err();
1780        assert!(matches!(err, CommandError::UnknownNodeType(_)));
1781    }
1782
1783    #[test]
1784    fn opstream_keep_children_splices_original_children() {
1785        // orig: root > paragraph > text("Hello"); replace the paragraph with
1786        // heading(2) keeping its children.
1787        let orig = test_parse_markdown("Hello");
1788        let para = orig.get_children(0)[0];
1789        let orig_text = orig.get_children(para)[0];
1790
1791        let mut ops = Vec::new();
1792        op_open(&mut ops, MdastNodeType::Heading);
1793        op_u8(&mut ops, OF_DEPTH, 2);
1794        ops.push(OP_KEEP_CHILDREN);
1795        op_close(&mut ops);
1796
1797        let (arena, roots) = replay_mdast_for_test(&ops, &orig, para).unwrap();
1798        let heading = roots[0];
1799        assert_eq!(
1800            arena.get_node(heading).node_type,
1801            MdastNodeType::Heading as u8
1802        );
1803        assert_eq!(decode_heading_data(arena.get_type_data(heading)).depth, 2);
1804        let children = arena.get_children(heading).to_vec();
1805        assert_eq!(children.len(), 1);
1806        assert_eq!(arena.get_node(children[0]).node_type, REF_NODE_TYPE);
1807        assert_eq!(
1808            u32::from_le_bytes(arena.get_type_data(children[0]).try_into().unwrap()),
1809            orig_text
1810        );
1811    }
1812
1813    #[test]
1814    fn opstream_ref_rejects_out_of_range_id() {
1815        // A stale id (a node cached across passes) must error at decode, not
1816        // panic inside the apply's arena indexing.
1817        let orig = test_parse_markdown("Hello");
1818        let bad = orig.len() as u32 + 100;
1819
1820        let mut ops = Vec::new();
1821        op_open(&mut ops, MdastNodeType::Paragraph);
1822        ops.push(OP_REF);
1823        ops.extend_from_slice(&bad.to_le_bytes());
1824        op_close(&mut ops);
1825
1826        let err = replay_mdast_for_test(&ops, &orig, 0).unwrap_err();
1827        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad));
1828    }
1829
1830    #[test]
1831    fn opstream_rejects_nested_root() {
1832        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1833
1834        // Top-level root is the set-children wrapper and must pass.
1835        let mut ok = Vec::new();
1836        op_open(&mut ok, MdastNodeType::Root);
1837        op_close(&mut ok);
1838        assert!(replay_mdast_for_test(&ok, &empty, 0).is_ok());
1839
1840        let mut ops = Vec::new();
1841        op_open(&mut ops, MdastNodeType::Root);
1842        op_open(&mut ops, MdastNodeType::Root);
1843        let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
1844        assert!(matches!(err, CommandError::UnencodableNodeType("root")));
1845    }
1846
1847    #[test]
1848    fn hast_opstream_rejects_doctype() {
1849        let empty = ArenaBuilder::<Hast>::new(String::new()).finish();
1850        let ops = vec![OP_OPEN, HastNodeType::Doctype as u8, OP_CLOSE];
1851        let err = replay_hast_for_test(&ops, &empty, 0).unwrap_err();
1852        assert!(matches!(err, CommandError::UnencodableNodeType("doctype")));
1853    }
1854
1855    #[test]
1856    fn opstream_rejects_over_deep_nesting() {
1857        // The apply splices replayed content recursively, so unbounded
1858        // nesting would overflow the host stack (an abort napi can't catch).
1859        let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
1860        let mut ops = Vec::new();
1861        for _ in 0..(MAX_OPSTREAM_DEPTH + 1) {
1862            op_open(&mut ops, MdastNodeType::Blockquote);
1863        }
1864        let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
1865        assert!(matches!(err, CommandError::OpstreamTooDeep(_)));
1866    }
1867
1868    #[test]
1869    fn set_property_rejects_out_of_range_or_unparseable_int() {
1870        // build_hello_world: root(0) > heading(1) > text(2), paragraph > text.
1871        let heading_id = 1;
1872
1873        let mut buf = Vec::new();
1874        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "9999");
1875        let err =
1876            apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap_err();
1877        assert!(matches!(err, CommandError::PropertyValueOutOfRange { .. }));
1878
1879        let mut buf = Vec::new();
1880        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "not-a-number");
1881        let err =
1882            apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap_err();
1883        assert!(matches!(err, CommandError::PropertyValueOutOfRange { .. }));
1884
1885        // The boundary itself still writes.
1886        let mut buf = Vec::new();
1887        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "255");
1888        let arena = apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap();
1889        assert_eq!(
1890            decode_heading_data(arena.get_type_data(heading_id)).depth,
1891            255
1892        );
1893    }
1894
1895    #[cfg(feature = "mdx")]
1896    #[test]
1897    fn mdx_jsx_set_property_replaces_named_attrs_and_keeps_explicit() {
1898        use satteri_ast::shared::MDX_ATTR_EXPRESSION_PROP;
1899
1900        let mut b = ArenaBuilder::<Hast>::new(String::new());
1901        b.open_node(HastNodeType::Root as u8);
1902        b.open_node(HastNodeType::MdxJsxElement as u8);
1903        let elem_name = b.alloc_string("Box");
1904        let foo = b.alloc_string("foo");
1905        let expr = b.alloc_string("1+1");
1906        let rest = b.alloc_string("rest");
1907        let attrs = vec![
1908            (MDX_ATTR_EXPRESSION_PROP, foo, expr),
1909            (MDX_ATTR_SPREAD, StringRef::empty(), rest),
1910        ];
1911        b.set_data_current(&encode_mdx_jsx_element_data(elem_name, &attrs, true));
1912        b.close_node();
1913        b.close_node();
1914        let mut arena = b.finish();
1915
1916        // Replaces the expression-valued `foo` (no duplicate) and appends
1917        // after the spread so the write wins.
1918        apply_hast_mdx_jsx_attribute(&mut arena, 1, "foo", PROP_STRING, "x").unwrap();
1919        let data = arena.get_type_data(1).to_vec();
1920        assert_eq!(decode_mdx_jsx_attr_count(&data), 2);
1921        assert!(decode_mdx_jsx_explicit(&data));
1922        let (k0, _, _) = decode_mdx_jsx_attr(&data, 0);
1923        assert_eq!(k0, MDX_ATTR_SPREAD);
1924        let (k1, n1, v1) = decode_mdx_jsx_attr(&data, 1);
1925        assert_eq!(k1, MDX_ATTR_LITERAL_PROP);
1926        assert_eq!(arena.get_str(n1), "foo");
1927        assert_eq!(arena.get_str(v1), "x");
1928
1929        // Appending a brand-new attribute must not clear the explicit flag.
1930        apply_hast_mdx_jsx_attribute(&mut arena, 1, "id", PROP_STRING, "intro").unwrap();
1931        let data = arena.get_type_data(1).to_vec();
1932        assert_eq!(decode_mdx_jsx_attr_count(&data), 3);
1933        assert!(decode_mdx_jsx_explicit(&data));
1934    }
1935
1936    fn test_parse_markdown(source: &str) -> Arena<Mdast> {
1937        let mut b = ArenaBuilder::<Mdast>::new(String::new());
1938        b.open_node(MdastNodeType::Root as u8);
1939        b.open_node(MdastNodeType::Paragraph as u8);
1940        b.open_node(MdastNodeType::Text as u8);
1941        let sref = b.alloc_string(source);
1942        b.set_data_current(&satteri_arena::encode_string_ref_data(sref));
1943        b.close_node();
1944        b.close_node();
1945        b.close_node();
1946        b.finish()
1947    }
1948
1949    fn push_u32(buf: &mut Vec<u8>, v: u32) {
1950        buf.extend_from_slice(&v.to_le_bytes());
1951    }
1952
1953    /// Encode a CMD_SET_PROPERTY command into a buffer.
1954    fn push_set_property(buf: &mut Vec<u8>, node_id: u32, value_type: u8, name: &str, value: &str) {
1955        buf.push(CMD_SET_PROPERTY);
1956        push_u32(buf, node_id);
1957        buf.push(value_type);
1958        push_u32(buf, name.len() as u32);
1959        buf.extend_from_slice(name.as_bytes());
1960        push_u32(buf, value.len() as u32);
1961        buf.extend_from_slice(value.as_bytes());
1962    }
1963
1964    fn build_hello_world() -> Arena<Mdast> {
1965        use satteri_ast::mdast::codec::{encode_heading_data, encode_string_ref_data};
1966
1967        let source = "# Hello\n\nWorld".to_string();
1968        let mut b = ArenaBuilder::<Mdast>::new(source);
1969
1970        b.open_node(MdastNodeType::Root as u8);
1971        b.set_position_current(0, 14, 1, 1, 2, 6);
1972
1973        b.open_node(MdastNodeType::Heading as u8);
1974        b.set_position_current(0, 7, 1, 1, 1, 8);
1975        b.set_data_current(&encode_heading_data(1));
1976
1977        b.open_node(MdastNodeType::Text as u8);
1978        b.set_position_current(2, 7, 1, 3, 1, 8);
1979        b.set_data_current(&encode_string_ref_data(StringRef::new(2, 5)));
1980        b.close_node();
1981
1982        b.close_node();
1983
1984        b.open_node(MdastNodeType::Paragraph as u8);
1985        b.set_position_current(9, 14, 2, 1, 2, 6);
1986
1987        b.open_node(MdastNodeType::Text as u8);
1988        b.set_position_current(9, 14, 2, 1, 2, 6);
1989        b.set_data_current(&encode_string_ref_data(StringRef::new(9, 5)));
1990        b.close_node();
1991
1992        b.close_node();
1993        b.close_node();
1994
1995        b.finish()
1996    }
1997
1998    #[test]
1999    fn empty_command_buffer() {
2000        let arena = build_hello_world();
2001        let result = apply_mdast_commands(arena.clone(), &[], &test_parse_markdown).unwrap();
2002        assert_eq!(result.len(), arena.len());
2003    }
2004
2005    #[test]
2006    fn remove_command() {
2007        let arena = build_hello_world();
2008        let heading_id = arena.get_children(0)[0];
2009        let mut buf = Vec::new();
2010        buf.push(CMD_REMOVE);
2011        push_u32(&mut buf, heading_id);
2012
2013        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
2014        assert_eq!(result.get_children(0).len(), 1);
2015        assert_eq!(
2016            result.get_node(result.get_children(0)[0]).node_type,
2017            MdastNodeType::Paragraph as u8
2018        );
2019    }
2020
2021    #[test]
2022    fn set_property_heading_depth() {
2023        let arena = build_hello_world();
2024        let heading_id = arena.get_children(0)[0];
2025
2026        let mut buf = Vec::new();
2027        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
2028
2029        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
2030        let heading_data = result.get_type_data(heading_id);
2031        let heading = decode_heading_data(heading_data);
2032        assert_eq!(heading.depth, 3);
2033    }
2034
2035    #[test]
2036    fn set_property_text_value() {
2037        let arena = build_hello_world();
2038        let heading_id = arena.get_children(0)[0];
2039        let text_id = arena.get_children(heading_id)[0];
2040
2041        let mut buf = Vec::new();
2042        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Goodbye");
2043
2044        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
2045        let text_data = result.get_type_data(text_id);
2046        let sref = decode_string_ref_data(text_data);
2047        assert_eq!(result.get_str(sref), "Goodbye");
2048    }
2049
2050    #[test]
2051    fn replace_with_raw_markdown() {
2052        let arena = build_hello_world();
2053        let heading_id = arena.get_children(0)[0];
2054
2055        let raw_md = "## New Heading";
2056        let mut buf = Vec::new();
2057        buf.push(CMD_REPLACE);
2058        push_u32(&mut buf, heading_id);
2059        buf.push(PAYLOAD_RAW);
2060        buf.push(0); // flags
2061        push_u32(&mut buf, raw_md.len() as u32);
2062        buf.extend_from_slice(raw_md.as_bytes());
2063
2064        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
2065        let root_children = result.get_children(0);
2066        assert!(root_children.len() >= 2);
2067    }
2068
2069    #[test]
2070    fn stale_anchor_in_grafted_orphan_range_errors() {
2071        let arena = build_hello_world();
2072        let original_len = arena.len() as u32;
2073
2074        let mut ops = Vec::new();
2075        op_open(&mut ops, MdastNodeType::Paragraph);
2076        op_close(&mut ops);
2077
2078        // the append grafts orphans at ids >= original_len; anchoring one must error, not panic
2079        let mut buf = Vec::new();
2080        buf.push(CMD_APPEND_CHILD);
2081        push_u32(&mut buf, 0);
2082        buf.push(PAYLOAD_OPSTREAM);
2083        push_u32(&mut buf, ops.len() as u32);
2084        buf.extend_from_slice(&ops);
2085        buf.push(CMD_REMOVE);
2086        push_u32(&mut buf, original_len);
2087
2088        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
2089        assert!(matches!(err, CommandError::InvalidNodeId(id) if id == original_len));
2090    }
2091
2092    #[test]
2093    fn multiple_commands() {
2094        let arena = build_hello_world();
2095        let heading_id = arena.get_children(0)[0];
2096        let text_id = arena.get_children(heading_id)[0];
2097
2098        let mut buf = Vec::new();
2099        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
2100        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Hi");
2101
2102        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
2103
2104        let heading_data = result.get_type_data(heading_id);
2105        assert_eq!(decode_heading_data(heading_data).depth, 3);
2106
2107        let text_data = result.get_type_data(text_id);
2108        let sref = decode_string_ref_data(text_data);
2109        assert_eq!(result.get_str(sref), "Hi");
2110    }
2111
2112    #[test]
2113    fn set_property_null() {
2114        let arena = build_hello_world();
2115        let heading_id = arena.get_children(0)[0];
2116        let text_id = arena.get_children(heading_id)[0];
2117
2118        let mut buf = Vec::new();
2119        push_set_property(&mut buf, text_id, PROP_NULL, "value", "");
2120
2121        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
2122        let text_data = result.get_type_data(text_id);
2123        let sref = decode_string_ref_data(text_data);
2124        assert_eq!(sref.len, 0);
2125    }
2126
2127    #[test]
2128    fn set_property_invalid_field_reports_property_and_node_type() {
2129        let arena = build_hello_world();
2130        let heading_id = arena.get_children(0)[0];
2131
2132        let mut buf = Vec::new();
2133        push_set_property(&mut buf, heading_id, PROP_STRING, "value", "x");
2134
2135        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
2136        assert!(matches!(
2137            err,
2138            CommandError::UnknownField { ref name, ref node_type }
2139                if name == "value" && node_type == "heading"
2140        ));
2141        assert_eq!(
2142            err.to_string(),
2143            "cannot set property 'value' on a 'heading' node"
2144        );
2145    }
2146
2147    #[test]
2148    fn set_property_wrong_value_type_reports_value_mismatch() {
2149        let arena = build_hello_world();
2150        let heading_id = arena.get_children(0)[0];
2151
2152        // `depth` is a valid heading field, but it holds an int, not a string.
2153        let mut buf = Vec::new();
2154        push_set_property(&mut buf, heading_id, PROP_STRING, "depth", "3");
2155
2156        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
2157        assert!(matches!(
2158            err,
2159            CommandError::InvalidPropertyValue { ref name, ref node_type }
2160                if name == "depth" && node_type == "heading"
2161        ));
2162        assert_eq!(
2163            err.to_string(),
2164            "property 'depth' on a 'heading' node cannot hold a value of this type"
2165        );
2166    }
2167
2168    /// Build root > one leaf node of `node_type` carrying `type_data`.
2169    fn build_single_node(node_type: MdastNodeType, type_data: &[u8]) -> Arena<Mdast> {
2170        let mut b = ArenaBuilder::<Mdast>::new(String::new());
2171        b.open_node(MdastNodeType::Root as u8);
2172        b.open_node(node_type as u8);
2173        b.set_data_current(type_data);
2174        b.close_node();
2175        b.close_node();
2176        b.finish()
2177    }
2178
2179    #[test]
2180    fn set_property_image_reference_alt_roundtrip() {
2181        let mut b = ArenaBuilder::<Mdast>::new(String::new());
2182        b.open_node(MdastNodeType::Root as u8);
2183        b.open_node(MdastNodeType::ImageReference as u8);
2184        let identifier = b.alloc_string("img");
2185        let alt = b.alloc_string("old");
2186        b.set_data_current(&encode_image_reference_data(identifier, identifier, 0, alt));
2187        b.close_node();
2188        b.close_node();
2189        let arena = b.finish();
2190        let image_ref_id = arena.get_children(0)[0];
2191
2192        let mut buf = Vec::new();
2193        push_set_property(&mut buf, image_ref_id, PROP_STRING, "alt", "new alt");
2194
2195        let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
2196        let alt = decode_image_reference_alt(result.get_type_data(image_ref_id));
2197        assert_eq!(result.get_str(alt), "new alt");
2198    }
2199
2200    #[test]
2201    fn set_property_reference_type_valid_and_invalid() {
2202        let mut b = ArenaBuilder::<Mdast>::new(String::new());
2203        b.open_node(MdastNodeType::Root as u8);
2204        b.open_node(MdastNodeType::LinkReference as u8);
2205        let identifier = b.alloc_string("ref");
2206        b.set_data_current(&encode_reference_data(identifier, identifier, 0));
2207        b.close_node();
2208        b.close_node();
2209        let arena = b.finish();
2210        let link_ref_id = arena.get_children(0)[0];
2211
2212        let mut buf = Vec::new();
2213        push_set_property(&mut buf, link_ref_id, PROP_STRING, "referenceType", "full");
2214        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
2215        let reference = decode_reference_data(result.get_type_data(link_ref_id));
2216        assert_eq!(reference.reference_kind, 2);
2217
2218        // A value outside the declared list is a value error, not a silent 0.
2219        let mut buf = Vec::new();
2220        push_set_property(&mut buf, link_ref_id, PROP_STRING, "referenceType", "bogus");
2221        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
2222        assert!(matches!(
2223            err,
2224            CommandError::InvalidPropertyValue { ref name, ref node_type }
2225                if name == "referenceType" && node_type == "linkReference"
2226        ));
2227    }
2228
2229    #[test]
2230    fn set_property_list_start_and_ordered() {
2231        let arena = build_single_node(MdastNodeType::List, &encode_list_data(false, 1, false));
2232        let list_id = arena.get_children(0)[0];
2233
2234        let mut buf = Vec::new();
2235        push_set_property(&mut buf, list_id, PROP_INT, "start", "5");
2236        push_set_property(&mut buf, list_id, PROP_BOOL_TRUE, "ordered", "");
2237
2238        let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
2239        let list = decode_list_data(result.get_type_data(list_id));
2240        assert_eq!(list.start, 5);
2241        assert!(list.ordered);
2242        assert!(!list.spread);
2243    }
2244
2245    #[test]
2246    fn escape_braces_in_html_text_basic() {
2247        assert_eq!(
2248            escape_braces_in_html_text("<span>{foo: 1}</span>"),
2249            "<span>{'{'}foo: 1{'}'}</span>"
2250        );
2251    }
2252
2253    #[test]
2254    fn escape_braces_after_comparison_less_than() {
2255        assert_eq!(
2256            escape_braces_in_html_text("<span>5 < 6 and {literal} here</span>"),
2257            "<span>5 < 6 and {'{'}literal{'}'} here</span>"
2258        );
2259        assert_eq!(
2260            escape_braces_in_html_text("a < b {notExpr} tail"),
2261            "a < b {'{'}notExpr{'}'} tail"
2262        );
2263        assert_eq!(
2264            escape_braces_in_html_text("trailing {x} <"),
2265            "trailing {'{'}x{'}'} <"
2266        );
2267    }
2268
2269    #[test]
2270    fn escape_braces_preserves_attributes() {
2271        let result = escape_braces_in_html_text(r#"<span data-x="{a}">{b}</span>"#);
2272        assert!(
2273            result.contains(r#"data-x="{a}""#),
2274            "attribute braces preserved"
2275        );
2276        assert!(result.contains("{'{'}"), "text braces escaped");
2277    }
2278
2279    #[test]
2280    fn escape_braces_no_braces() {
2281        let html = r#"<pre class="shiki"><code><span style="color:red">hello</span></code></pre>"#;
2282        assert_eq!(escape_braces_in_html_text(html), html);
2283    }
2284
2285    #[test]
2286    fn escape_braces_shiki_output() {
2287        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>"#;
2288        let escaped = escape_braces_in_html_text(html);
2289        assert!(
2290            !escaped.contains(">{<"),
2291            "bare braces in text should be escaped"
2292        );
2293        assert!(
2294            !escaped.contains(">}<"),
2295            "bare braces in text should be escaped"
2296        );
2297        assert!(escaped.contains(r#"class="shiki""#));
2298        assert!(escaped.contains(r#"style="color:#E1E4E8""#));
2299    }
2300
2301    #[test]
2302    fn hast_set_property_add_new() {
2303        let arena = build_hast_element(&[]);
2304        let element_id = arena.get_children(0)[0];
2305
2306        let mut buf = Vec::new();
2307        push_set_property(&mut buf, element_id, PROP_STRING, "class", "test");
2308
2309        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
2310        let data = result.get_type_data(element_id);
2311        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
2312        assert_eq!(prop_count, 1);
2313        let name_ref = StringRef::new(
2314            u32::from_le_bytes(data[16..20].try_into().unwrap()),
2315            u32::from_le_bytes(data[20..24].try_into().unwrap()),
2316        );
2317        assert_eq!(result.get_str(name_ref), "class");
2318        let val_ref = StringRef::new(
2319            u32::from_le_bytes(data[28..32].try_into().unwrap()),
2320            u32::from_le_bytes(data[32..36].try_into().unwrap()),
2321        );
2322        assert_eq!(result.get_str(val_ref), "test");
2323        assert_eq!(data[24], PROP_STRING);
2324    }
2325
2326    #[test]
2327    fn hast_set_property_overwrite_existing() {
2328        let arena = build_hast_element(&[("class", PROP_STRING, "old")]);
2329        let element_id = arena.get_children(0)[0];
2330
2331        let mut buf = Vec::new();
2332        push_set_property(&mut buf, element_id, PROP_STRING, "class", "new-value");
2333
2334        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
2335        let data = result.get_type_data(element_id);
2336        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
2337        assert_eq!(prop_count, 1);
2338        let val_ref = StringRef::new(
2339            u32::from_le_bytes(data[28..32].try_into().unwrap()),
2340            u32::from_le_bytes(data[32..36].try_into().unwrap()),
2341        );
2342        assert_eq!(result.get_str(val_ref), "new-value");
2343    }
2344
2345    #[test]
2346    fn hast_set_property_bool_true() {
2347        let arena = build_hast_element(&[]);
2348        let element_id = arena.get_children(0)[0];
2349
2350        let mut buf = Vec::new();
2351        push_set_property(&mut buf, element_id, PROP_BOOL_TRUE, "disabled", "");
2352
2353        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
2354        let data = result.get_type_data(element_id);
2355        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
2356        assert_eq!(prop_count, 1);
2357        assert_eq!(data[24], PROP_BOOL_TRUE);
2358    }
2359
2360    #[test]
2361    fn hast_set_property_multiple_on_same_node() {
2362        let arena = build_hast_element(&[]);
2363        let element_id = arena.get_children(0)[0];
2364
2365        let mut buf = Vec::new();
2366        push_set_property(&mut buf, element_id, PROP_STRING, "class", "foo");
2367        push_set_property(&mut buf, element_id, PROP_STRING, "id", "bar");
2368
2369        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
2370        let data = result.get_type_data(element_id);
2371        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
2372        assert_eq!(prop_count, 2);
2373    }
2374
2375    /// Build a minimal HAST element arena: root(type 0) → element(type 1, tag "div")
2376    fn build_hast_element(props: &[(&str, u8, &str)]) -> Arena<Hast> {
2377        use satteri_ast::hast::node::HastNodeType;
2378
2379        let mut b = ArenaBuilder::<Hast>::new(String::new());
2380        b.open_node_raw(HastNodeType::Root as u8);
2381        b.open_node_raw(HastNodeType::Element as u8);
2382        let tag_ref = b.alloc_string("div");
2383        let prop_tuples: Vec<(StringRef, u8, StringRef)> = props
2384            .iter()
2385            .map(|(name, kind, value)| {
2386                let n = b.alloc_string(name);
2387                let v = if value.is_empty() {
2388                    StringRef::empty()
2389                } else {
2390                    b.alloc_string(value)
2391                };
2392                (n, *kind, v)
2393            })
2394            .collect();
2395        let mut type_data = Vec::with_capacity(16 + prop_tuples.len() * 20);
2396        type_data.extend_from_slice(&tag_ref.offset.to_le_bytes());
2397        type_data.extend_from_slice(&tag_ref.len.to_le_bytes());
2398        type_data.extend_from_slice(&(prop_tuples.len() as u32).to_le_bytes());
2399        type_data.extend_from_slice(&0u32.to_le_bytes());
2400        for (n, kind, v) in &prop_tuples {
2401            type_data.extend_from_slice(&n.offset.to_le_bytes());
2402            type_data.extend_from_slice(&n.len.to_le_bytes());
2403            type_data.push(*kind);
2404            type_data.extend_from_slice(&[0u8; 3]);
2405            type_data.extend_from_slice(&v.offset.to_le_bytes());
2406            type_data.extend_from_slice(&v.len.to_le_bytes());
2407        }
2408        b.set_data_current(&type_data);
2409        b.close_node();
2410        b.close_node();
2411        b.finish()
2412    }
2413
2414    fn push_raw_command(buf: &mut Vec<u8>, cmd: u8, node_id: u32, raw: &str) {
2415        buf.push(cmd);
2416        push_u32(buf, node_id);
2417        buf.push(PAYLOAD_RAW);
2418        buf.push(0); // flags
2419        push_u32(buf, raw.len() as u32);
2420        buf.extend_from_slice(raw.as_bytes());
2421    }
2422
2423    fn parse_two_blocks(_source: &str) -> Arena<Mdast> {
2424        let mut b = ArenaBuilder::<Mdast>::new(String::new());
2425        b.open_node(MdastNodeType::Root as u8);
2426        b.open_node(MdastNodeType::Paragraph as u8);
2427        b.close_node();
2428        b.open_node(MdastNodeType::Paragraph as u8);
2429        b.close_node();
2430        b.close_node();
2431        b.finish()
2432    }
2433
2434    fn parse_leaf_block(_source: &str) -> Arena<Mdast> {
2435        let mut b = ArenaBuilder::<Mdast>::new(String::new());
2436        b.open_node(MdastNodeType::Root as u8);
2437        b.open_node(MdastNodeType::ThematicBreak as u8);
2438        b.close_node();
2439        b.close_node();
2440        b.finish()
2441    }
2442
2443    #[test]
2444    fn mdast_wrap_with_raw_payload() {
2445        let arena = build_hello_world();
2446        let heading_id = arena.get_children(0)[0];
2447        let mut buf = Vec::new();
2448        push_raw_command(&mut buf, CMD_WRAP, heading_id, "quoted");
2449
2450        let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
2451        let wrapper = result.get_children(0)[0];
2452        assert_eq!(
2453            result.get_node(wrapper).node_type,
2454            MdastNodeType::Paragraph as u8
2455        );
2456        let wrapped = result.get_children(wrapper);
2457        assert_eq!(wrapped.len(), 2);
2458        assert_eq!(
2459            result.get_node(wrapped[0]).node_type,
2460            MdastNodeType::Heading as u8
2461        );
2462        assert_eq!(
2463            result.get_node(wrapped[1]).node_type,
2464            MdastNodeType::Text as u8
2465        );
2466    }
2467
2468    #[test]
2469    fn mdast_wrap_with_multi_block_raw_payload_errors() {
2470        let arena = build_hello_world();
2471        let heading_id = arena.get_children(0)[0];
2472        let mut buf = Vec::new();
2473        push_raw_command(&mut buf, CMD_WRAP, heading_id, "one\n\ntwo");
2474
2475        let err = apply_mdast_commands(arena, &buf, &parse_two_blocks).unwrap_err();
2476        assert!(
2477            matches!(&err, CommandError::InvalidRawWrapper(r) if r.contains("exactly one block")),
2478            "{err:?}"
2479        );
2480    }
2481
2482    #[test]
2483    fn mdast_wrap_with_leaf_raw_payload_errors() {
2484        let arena = build_hello_world();
2485        let heading_id = arena.get_children(0)[0];
2486        let mut buf = Vec::new();
2487        push_raw_command(&mut buf, CMD_WRAP, heading_id, "---");
2488
2489        let err = apply_mdast_commands(arena, &buf, &parse_leaf_block).unwrap_err();
2490        assert!(
2491            matches!(&err, CommandError::InvalidRawWrapper(r) if r.contains("thematicBreak")),
2492            "{err:?}"
2493        );
2494    }
2495
2496    #[cfg(feature = "from-html")]
2497    #[test]
2498    fn hast_wrap_with_raw_html_payload() {
2499        let arena = build_hast_element(&[]);
2500        let mut buf = Vec::new();
2501        push_raw_command(&mut buf, CMD_WRAP, 1, "<section class=\"wrap\"></section>");
2502
2503        let result = apply_hast_commands(arena, &buf).unwrap();
2504        let section = result.get_children(0)[0];
2505        assert_eq!(
2506            result.get_str(decode_element_tag(result.get_type_data(section))),
2507            "section"
2508        );
2509        let wrapped = result.get_children(section);
2510        assert_eq!(wrapped.len(), 1);
2511        assert_eq!(
2512            result.get_str(decode_element_tag(result.get_type_data(wrapped[0]))),
2513            "div"
2514        );
2515    }
2516
2517    #[cfg(feature = "from-html")]
2518    #[test]
2519    fn hast_wrap_with_invalid_raw_html_errors() {
2520        let arena = build_hast_element(&[]);
2521        let mut buf = Vec::new();
2522        push_raw_command(&mut buf, CMD_WRAP, 1, "just text");
2523
2524        let err = apply_hast_commands(arena, &buf).unwrap_err();
2525        assert!(matches!(err, CommandError::InvalidRawWrapper(_)), "{err:?}");
2526    }
2527
2528    /// The op-stream shape a `{type: "element"}` wrapper compiles to.
2529    fn push_element_wrap_command(buf: &mut Vec<u8>, node_id: u32, tag: &str) {
2530        let mut ops = Vec::new();
2531        ops.push(OP_OPEN);
2532        ops.push(HastNodeType::Element as u8);
2533        op_str(&mut ops, OF_TAGNAME, tag);
2534        ops.push(OP_CLOSE);
2535
2536        buf.push(CMD_WRAP);
2537        push_u32(buf, node_id);
2538        buf.push(PAYLOAD_OPSTREAM);
2539        push_u32(buf, ops.len() as u32);
2540        buf.extend_from_slice(&ops);
2541    }
2542
2543    #[test]
2544    fn hast_wrap_with_void_element_payload_errors() {
2545        let arena = build_hast_element(&[]);
2546        let mut buf = Vec::new();
2547        push_element_wrap_command(&mut buf, 1, "img");
2548
2549        let err = apply_hast_commands(arena, &buf).unwrap_err();
2550        assert!(
2551            matches!(&err, CommandError::VoidWrapParent(tag) if tag == "img"),
2552            "{err:?}"
2553        );
2554    }
2555
2556    #[test]
2557    fn hast_wrap_with_element_payload_wraps() {
2558        let arena = build_hast_element(&[]);
2559        let mut buf = Vec::new();
2560        push_element_wrap_command(&mut buf, 1, "section");
2561
2562        let result = apply_hast_commands(arena, &buf).unwrap();
2563        let section = result.get_children(0)[0];
2564        assert_eq!(
2565            result.get_str(decode_element_tag(result.get_type_data(section))),
2566            "section"
2567        );
2568        let wrapped = result.get_children(section);
2569        assert_eq!(wrapped.len(), 1);
2570        assert_eq!(
2571            result.get_str(decode_element_tag(result.get_type_data(wrapped[0]))),
2572            "div"
2573        );
2574    }
2575
2576    #[test]
2577    fn hast_raw_payload_rejected_outside_wrap() {
2578        let arena = build_hast_element(&[]);
2579        let mut buf = Vec::new();
2580        push_raw_command(&mut buf, CMD_INSERT_BEFORE, 1, "<span></span>");
2581
2582        let err = apply_hast_commands(arena, &buf).unwrap_err();
2583        assert!(
2584            matches!(err, CommandError::UnknownPayloadType(p) if p == PAYLOAD_RAW),
2585            "{err:?}"
2586        );
2587    }
2588}