Skip to main content

satteri_plugin_api/
js_commands.rs

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