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**.
9//!
10//! Commands (first byte):
11//!   0x01  REMOVE           [nodeId: u32]
12//!   0x05  INSERT_BEFORE    [nodeId: u32][payloadType: u8][payload...]
13//!   0x06  INSERT_AFTER     [nodeId: u32][payloadType: u8][payload...]
14//!   0x07  PREPEND_CHILD    [nodeId: u32][payloadType: u8][payload...]
15//!   0x08  APPEND_CHILD     [nodeId: u32][payloadType: u8][payload...]
16//!   0x09  WRAP             [nodeId: u32][payloadType: u8][payload...]
17//!   0x0B  REPLACE          [nodeId: u32][payloadType: u8][payload...]
18//!   0x0C  SET_PROPERTY     [nodeId: u32][valueType: u8][nameLen: u32][name...][valueLen: u32][value...]
19//!
20//! Value types for SET_PROPERTY:
21//!   0  STRING     : UTF-8 value
22//!   1  BOOL_TRUE  : no value bytes
23//!   2  BOOL_FALSE : no value bytes
24//!   3  SPACE_SEP  : space-separated list (UTF-8)
25//!   4  INT        : value is decimal string, parsed to i64
26//!   5  NULL       : no value bytes
27//!
28//! Payload types:
29//!   0x10  RAW_MARKDOWN     [len: u32][utf8...]
30//!   0x11  RAW_HTML         [len: u32][utf8...]
31//!   0x12  SERDE_JSON       [len: u32][utf8...]
32//!
33//! The MDAST and HAST command paths are deliberately separate functions
34//! (`apply_mdast_commands`, `apply_hast_commands`). Numeric `node_type`
35//! values overlap between the two arenas (e.g. mdast Paragraph=1 collides
36//! with HastNodeType::Element=1), so a single dispatcher trying to handle
37//! both kinds would silently misroute nodes. The phantom-typed `Arena<K>`
38//! signature on each entry point makes a cross-kind call a compile error.
39
40use satteri_arena::{Arena, ArenaBuilder, ArenaKind, Hast, Mdast, StringRef};
41use satteri_ast::commands::{CommandError, JsNode};
42use satteri_ast::hast::HastNodeType;
43use satteri_ast::mdast::codec::*;
44use satteri_ast::mdast::MdastNodeType;
45use satteri_ast::rebuild::{Patch, REF_NODE_TYPE};
46#[cfg(feature = "mdx")]
47use satteri_ast::shared::encode_js_jsx_attrs;
48use satteri_ast::shared::{
49    PROP_BOOL_FALSE, PROP_BOOL_TRUE, PROP_INT, PROP_NULL, PROP_SPACE_SEP, PROP_STRING,
50};
51
52// Must match packages/satteri/src/command-buffer.ts
53const CMD_REMOVE: u8 = 0x01;
54const CMD_INSERT_BEFORE: u8 = 0x05;
55const CMD_INSERT_AFTER: u8 = 0x06;
56const CMD_PREPEND_CHILD: u8 = 0x07;
57const CMD_APPEND_CHILD: u8 = 0x08;
58const CMD_WRAP: u8 = 0x09;
59const CMD_REPLACE: u8 = 0x0B;
60const CMD_SET_PROPERTY: u8 = 0x0C;
61
62const PAYLOAD_RAW_MARKDOWN: u8 = 0x10;
63const PAYLOAD_RAW_HTML: u8 = 0x11;
64const PAYLOAD_SERDE_JSON: u8 = 0x12;
65
66// MDAST field IDs: internal to the set_string_ref / resolve_mdast_field dispatch
67const FIELD_DEPTH: u16 = 0x0001;
68const FIELD_URL: u16 = 0x0010;
69const FIELD_TITLE: u16 = 0x0011;
70const FIELD_LANG: u16 = 0x0020;
71const FIELD_META: u16 = 0x0021;
72const FIELD_VALUE: u16 = 0x0022;
73const FIELD_ALT: u16 = 0x0030;
74const FIELD_ORDERED: u16 = 0x0040;
75const FIELD_START: u16 = 0x0041;
76const FIELD_SPREAD: u16 = 0x0042;
77const FIELD_CHECKED: u16 = 0x0050;
78const FIELD_IDENTIFIER: u16 = 0x0060;
79const FIELD_LABEL: u16 = 0x0061;
80const FIELD_REFERENCE_TYPE: u16 = 0x0062;
81const FIELD_NAME: u16 = 0x0070;
82
83struct BufReader<'a> {
84    data: &'a [u8],
85    pos: usize,
86}
87
88impl<'a> BufReader<'a> {
89    fn new(data: &'a [u8]) -> Self {
90        Self { data, pos: 0 }
91    }
92
93    fn remaining(&self) -> usize {
94        self.data.len() - self.pos
95    }
96
97    fn read_u8(&mut self) -> Result<u8, CommandError> {
98        if self.remaining() < 1 {
99            return Err(CommandError::UnexpectedEof);
100        }
101        let v = self.data[self.pos];
102        self.pos += 1;
103        Ok(v)
104    }
105
106    fn read_u32(&mut self) -> Result<u32, CommandError> {
107        if self.remaining() < 4 {
108            return Err(CommandError::UnexpectedEof);
109        }
110        let v = u32::from_le_bytes([
111            self.data[self.pos],
112            self.data[self.pos + 1],
113            self.data[self.pos + 2],
114            self.data[self.pos + 3],
115        ]);
116        self.pos += 4;
117        Ok(v)
118    }
119
120    fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], CommandError> {
121        if self.remaining() < len {
122            return Err(CommandError::UnexpectedEof);
123        }
124        let slice = &self.data[self.pos..self.pos + len];
125        self.pos += len;
126        Ok(slice)
127    }
128
129    fn read_str(&mut self, len: usize) -> Result<&'a str, CommandError> {
130        let bytes = self.read_bytes(len)?;
131        std::str::from_utf8(bytes).map_err(|_| CommandError::InvalidUtf8)
132    }
133}
134
135/// Whether a plugin-supplied `data` blob carries the `_mdxExplicitJsx: true`
136/// marker — used to set the matching fast-path bit in `MdxJsxElementData`.
137#[cfg(feature = "mdx")]
138fn js_data_is_mdx_explicit(data: &Option<serde_json::Map<String, serde_json::Value>>) -> bool {
139    data.as_ref()
140        .and_then(|m| m.get("_mdxExplicitJsx"))
141        .and_then(serde_json::Value::as_bool)
142        .unwrap_or(false)
143}
144
145/// `data` JSON blob is stored in the per-node `node_data` map; it doesn't
146/// dispatch on node-type bytes, so it's safe under any kind.
147fn apply_data_property<K: ArenaKind>(
148    arena: &mut Arena<K>,
149    node_id: u32,
150    value_type: u8,
151    value_str: &str,
152) {
153    if value_type == PROP_NULL {
154        arena.set_node_data(node_id, Vec::new());
155    } else {
156        arena.set_node_data(node_id, value_str.as_bytes().to_vec());
157    }
158}
159
160/// Resolve an MDAST property name to its field ID for a given node type.
161fn resolve_mdast_field(node_type: u8, name: &str) -> Option<u16> {
162    match (node_type, name) {
163        (2, "depth") => Some(FIELD_DEPTH),
164        (8, "lang") => Some(FIELD_LANG),
165        (8, "meta") => Some(FIELD_META),
166        (8, "value") => Some(FIELD_VALUE),
167        (15, "url") => Some(FIELD_URL),
168        (15, "title") => Some(FIELD_TITLE),
169        (16, "url") => Some(FIELD_URL),
170        (16, "alt") => Some(FIELD_ALT),
171        (16, "title") => Some(FIELD_TITLE),
172        (10 | 13 | 7 | 25 | 26 | 28, "value") => Some(FIELD_VALUE),
173        (27, "meta") => Some(FIELD_META),
174        (27, "value") => Some(FIELD_VALUE),
175        (102..=104, "value") => Some(FIELD_VALUE),
176        (9, "url") => Some(FIELD_URL),
177        (9, "title") => Some(FIELD_TITLE),
178        (5, "ordered") => Some(FIELD_ORDERED),
179        (5, "start") => Some(FIELD_START),
180        (5 | 6, "spread") => Some(FIELD_SPREAD),
181        (6, "checked") => Some(FIELD_CHECKED),
182        (9 | 17 | 18 | 19 | 20, "identifier") => Some(FIELD_IDENTIFIER),
183        (9 | 17 | 18 | 19 | 20, "label") => Some(FIELD_LABEL),
184        (17 | 18 | 20, "referenceType") => Some(FIELD_REFERENCE_TYPE),
185        (100 | 101, "name") => Some(FIELD_NAME),
186        _ => None,
187    }
188}
189
190/// MDAST set-property: writes a typed field (or `data` JSON) onto an MDAST
191/// node. Kind-tight to `Arena<Mdast>` — the HAST element-properties writer
192/// can no longer be reached from here.
193fn apply_mdast_set_property(
194    arena: &mut Arena<Mdast>,
195    node_id: u32,
196    prop_name: &str,
197    value_type: u8,
198    value_str: &str,
199) -> Result<(), CommandError> {
200    if prop_name == "data" {
201        apply_data_property(arena, node_id, value_type, value_str);
202        return Ok(());
203    }
204
205    let node_type = arena.get_node(node_id).node_type;
206    let field_id =
207        resolve_mdast_field(node_type, prop_name).ok_or(CommandError::UnknownField(0))?;
208
209    match value_type {
210        PROP_STRING | PROP_SPACE_SEP => {
211            let sref = arena.alloc_string(value_str);
212            set_mdast_string_ref(arena, node_id, field_id, sref)
213        }
214        PROP_BOOL_TRUE => apply_mdast_bool(arena, node_id, node_type, field_id, true),
215        PROP_BOOL_FALSE => apply_mdast_bool(arena, node_id, node_type, field_id, false),
216        PROP_INT => {
217            let value: i64 = value_str.parse().unwrap_or(0);
218            apply_mdast_int(arena, node_id, node_type, field_id, value)
219        }
220        PROP_NULL => apply_mdast_null(arena, node_id, node_type, field_id),
221        _ => Err(CommandError::UnknownCommand(value_type)),
222    }
223}
224
225fn apply_mdast_int(
226    arena: &mut Arena<Mdast>,
227    node_id: u32,
228    node_type: u8,
229    field_id: u16,
230    value: i64,
231) -> Result<(), CommandError> {
232    let data_offset = arena.get_node(node_id).data_offset as usize;
233    let data_len = arena.get_node(node_id).data_len as usize;
234    match (node_type, field_id) {
235        (2, FIELD_DEPTH) => {
236            if data_len >= 1 {
237                arena.type_data[data_offset] = value as u8;
238            }
239        }
240        (5, FIELD_START) => {
241            if data_len >= 4 {
242                arena.type_data[data_offset..data_offset + 4]
243                    .copy_from_slice(&(value as u32).to_ne_bytes());
244            }
245        }
246        (6, FIELD_CHECKED) => {
247            if data_len >= 1 {
248                arena.type_data[data_offset] = value as u8;
249            }
250        }
251        _ => return Err(CommandError::UnknownField(field_id)),
252    }
253    Ok(())
254}
255
256fn apply_mdast_bool(
257    arena: &mut Arena<Mdast>,
258    node_id: u32,
259    node_type: u8,
260    field_id: u16,
261    value: bool,
262) -> Result<(), CommandError> {
263    let data_offset = arena.get_node(node_id).data_offset as usize;
264    let data_len = arena.get_node(node_id).data_len as usize;
265    match (node_type, field_id) {
266        (5, FIELD_ORDERED) => {
267            if data_len >= 5 {
268                arena.type_data[data_offset + 4] = value as u8;
269            }
270        }
271        (5, FIELD_SPREAD) => {
272            if data_len >= 6 {
273                arena.type_data[data_offset + 5] = value as u8;
274            }
275        }
276        (6, FIELD_SPREAD) => {
277            if data_len >= 2 {
278                arena.type_data[data_offset + 1] = value as u8;
279            }
280        }
281        _ => return Err(CommandError::UnknownField(field_id)),
282    }
283    Ok(())
284}
285
286fn apply_mdast_null(
287    arena: &mut Arena<Mdast>,
288    node_id: u32,
289    node_type: u8,
290    field_id: u16,
291) -> Result<(), CommandError> {
292    match (node_type, field_id) {
293        (6, FIELD_CHECKED) => {
294            let data_offset = arena.get_node(node_id).data_offset as usize;
295            let data_len = arena.get_node(node_id).data_len as usize;
296            if data_len >= 1 {
297                arena.type_data[data_offset] = 2;
298            }
299            Ok(())
300        }
301        _ => set_mdast_string_ref(arena, node_id, field_id, StringRef::empty()),
302    }
303}
304
305fn set_mdast_string_ref(
306    arena: &mut Arena<Mdast>,
307    node_id: u32,
308    field_id: u16,
309    sref: StringRef,
310) -> Result<(), CommandError> {
311    let node = arena.get_node(node_id);
312    let node_type = node.node_type;
313    let data_offset = node.data_offset as usize;
314
315    let ref_offset = match (node_type, field_id) {
316        // Text/InlineCode/Html/Yaml/Toml/InlineMath: StringRef at 0
317        (10 | 13 | 7 | 25 | 26 | 28, FIELD_VALUE) => 0,
318        // Link: LinkData { url: 0, title: 8 }
319        (15, FIELD_URL) => 0,
320        (15, FIELD_TITLE) => 8,
321        // Image: ImageData { url: 0, alt: 8, title: 16 }
322        (16, FIELD_URL) => 0,
323        (16, FIELD_ALT) => 8,
324        (16, FIELD_TITLE) => 16,
325        // Code: CodeData { lang: 0, meta: 8, value: 16 }
326        (8, FIELD_LANG) => 0,
327        (8, FIELD_META) => 8,
328        (8, FIELD_VALUE) => 16,
329        // Math: MathData { meta: 0, value: 8 }
330        (27, FIELD_META) => 0,
331        (27, FIELD_VALUE) => 8,
332        // Definition: DefinitionData { url: 0, title: 8, identifier: 16, label: 24 }
333        (9, FIELD_URL) => 0,
334        (9, FIELD_TITLE) => 8,
335        (9, FIELD_IDENTIFIER) => 16,
336        (9, FIELD_LABEL) => 24,
337        // LinkReference/ImageReference/FootnoteReference: ReferenceData { identifier: 0, label: 8 }
338        (17 | 18 | 20, FIELD_IDENTIFIER) => 0,
339        (17 | 18 | 20, FIELD_LABEL) => 8,
340        // FootnoteDefinition: FootnoteDefinitionData { identifier: 0, label: 8 }
341        (19, FIELD_IDENTIFIER) => 0,
342        (19, FIELD_LABEL) => 8,
343        // MdxJsxElement: MdxJsxElementData { name: 0 }
344        (100 | 101, FIELD_NAME) => 0,
345        // MdxExpression/MdxjsEsm: ExpressionData { value: 0 }
346        (102..=104, FIELD_VALUE) => 0,
347        _ => return Err(CommandError::UnknownField(field_id)),
348    };
349
350    let abs_offset = data_offset + ref_offset;
351    let bytes_offset = sref.offset.to_ne_bytes();
352    let bytes_len = sref.len.to_ne_bytes();
353    arena.type_data[abs_offset..abs_offset + 4].copy_from_slice(&bytes_offset);
354    arena.type_data[abs_offset + 4..abs_offset + 8].copy_from_slice(&bytes_len);
355
356    Ok(())
357}
358
359fn parse_raw_markdown(
360    markdown: &str,
361    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
362) -> Arena<Mdast> {
363    parse_markdown(markdown)
364}
365
366/// Escape `{` and `}` in HTML text content so they are not interpreted as MDX
367/// expressions when the HTML is re-parsed through the MDX parser.
368///
369/// Only braces in **text content** (outside of HTML tags) are escaped; braces
370/// inside quoted attribute values are left untouched. The escape form `{'{'}` /
371/// `{'}'}` produces a valid MDX expression that evaluates to the literal brace
372/// character.
373fn escape_braces_in_html_text(html: &str) -> String {
374    let mut result = String::with_capacity(html.len());
375    let mut in_tag = false;
376    let mut in_quote: Option<char> = None;
377
378    for ch in html.chars() {
379        if in_tag {
380            match ch {
381                '"' | '\'' if in_quote == Some(ch) => {
382                    in_quote = None;
383                    result.push(ch);
384                }
385                '"' | '\'' if in_quote.is_none() => {
386                    in_quote = Some(ch);
387                    result.push(ch);
388                }
389                '>' if in_quote.is_none() => {
390                    in_tag = false;
391                    result.push(ch);
392                }
393                _ => result.push(ch),
394            }
395        } else {
396            match ch {
397                '<' => {
398                    in_tag = true;
399                    result.push(ch);
400                }
401                '{' => result.push_str("{'{'}"),
402                '}' => result.push_str("{'}'}"),
403                _ => result.push(ch),
404            }
405        }
406    }
407    result
408}
409
410fn js_node_to_mdast_arena(js_node: &JsNode) -> Result<(Arena<Mdast>, bool), CommandError> {
411    if js_node.is_hast {
412        return Err(CommandError::UnknownNodeType(format!(
413            "expected mdast node, got hast-flagged `{}`",
414            js_node.node_type
415        )));
416    }
417    let mut builder = ArenaBuilder::<Mdast>::new(String::new());
418    emit_mdast_js_node(js_node, &mut builder)?;
419    Ok((builder.finish(), js_node.keep_children))
420}
421
422fn js_node_to_hast_arena(js_node: &JsNode) -> Result<(Arena<Hast>, bool), CommandError> {
423    if !js_node.is_hast {
424        return Err(CommandError::UnknownNodeType(format!(
425            "expected hast node, got mdast-flagged `{}`",
426            js_node.node_type
427        )));
428    }
429    let mut builder = ArenaBuilder::<Hast>::new(String::new());
430    emit_hast_js_node(js_node, &mut builder)?;
431    Ok((builder.finish(), js_node.keep_children))
432}
433
434/// Emit a reference placeholder: a `REF_NODE_TYPE` node carrying the target
435/// original id (u32 LE) in its type_data. The rebuild resolves it by splicing
436/// that original subtree and applying any pending patch on it.
437fn emit_ref_node<K: ArenaKind>(ref_id: u32, builder: &mut ArenaBuilder<K>) {
438    builder.open_node_raw(REF_NODE_TYPE);
439    builder.set_data_current(&ref_id.to_le_bytes());
440    builder.close_node();
441}
442
443fn emit_mdast_js_node(
444    js_node: &JsNode,
445    builder: &mut ArenaBuilder<Mdast>,
446) -> Result<(), CommandError> {
447    if let Some(ref_id) = js_node.ref_id {
448        emit_ref_node(ref_id, builder);
449        return Ok(());
450    }
451
452    if js_node.is_hast {
453        return Err(CommandError::UnknownNodeType(format!(
454            "expected mdast node, got hast-flagged `{}`",
455            js_node.node_type
456        )));
457    }
458
459    let node_type = name_to_node_type(&js_node.node_type)?;
460    builder.open_node(node_type as u8);
461
462    let type_data = encode_js_node_data(js_node, node_type, builder);
463    if !type_data.is_empty() {
464        builder.set_data_current(&type_data);
465    }
466
467    write_js_node_data(js_node, builder)?;
468
469    if let Some(children) = &js_node.children {
470        for child in children {
471            emit_mdast_js_node(child, builder)?;
472        }
473    }
474
475    builder.close_node();
476    Ok(())
477}
478
479fn write_js_node_data<K: ArenaKind>(
480    js_node: &JsNode,
481    builder: &mut ArenaBuilder<K>,
482) -> Result<(), CommandError> {
483    let Some(data) = &js_node.data else {
484        return Ok(());
485    };
486    let id = builder.current_node_id();
487    let json = serde_json::to_vec(data).map_err(|e| CommandError::InvalidJson(e.to_string()))?;
488    builder.arena_mut().set_node_data(id, json);
489    Ok(())
490}
491
492fn encode_js_node_data(
493    js_node: &JsNode,
494    node_type: MdastNodeType,
495    builder: &mut ArenaBuilder<Mdast>,
496) -> Vec<u8> {
497    match node_type {
498        MdastNodeType::Heading => {
499            let depth = js_node.depth.unwrap_or(1);
500            encode_heading_data(depth)
501        }
502        MdastNodeType::Text
503        | MdastNodeType::InlineCode
504        | MdastNodeType::Html
505        | MdastNodeType::Yaml
506        | MdastNodeType::Toml
507        | MdastNodeType::InlineMath => {
508            let value = js_node.value.as_deref().unwrap_or("");
509            let sref = builder.alloc_string(value);
510            encode_string_ref_data(sref)
511        }
512        MdastNodeType::Code => {
513            let lang_ref = alloc_opt_str(builder, js_node.lang.as_deref());
514            let meta_ref = alloc_opt_str(builder, js_node.meta.as_deref());
515            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
516            encode_code_data(lang_ref, meta_ref, value_ref, b'`')
517        }
518        MdastNodeType::Math => {
519            let meta_ref = alloc_opt_str(builder, js_node.meta.as_deref());
520            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
521            encode_math_data(meta_ref, value_ref)
522        }
523        MdastNodeType::Link => {
524            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
525            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
526            encode_link_data(url_ref, title_ref)
527        }
528        MdastNodeType::Image => {
529            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
530            let alt_ref = alloc_opt_str(builder, js_node.alt.as_deref());
531            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
532            encode_image_data(url_ref, alt_ref, title_ref)
533        }
534        MdastNodeType::Definition => {
535            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
536            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
537            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
538            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
539            encode_definition_data(url_ref, title_ref, id_ref, label_ref)
540        }
541        MdastNodeType::List => {
542            let ordered = js_node.ordered.unwrap_or(false);
543            let start = js_node.start.unwrap_or(1);
544            let spread = js_node.spread.unwrap_or(false);
545            encode_list_data(ordered, start, spread)
546        }
547        MdastNodeType::ListItem => {
548            let checked = match js_node.checked {
549                Some(true) => 1u8,
550                Some(false) => 0u8,
551                None => 2u8, // not a task item
552            };
553            let spread = js_node.spread.unwrap_or(false);
554            encode_list_item_data(checked, spread)
555        }
556        MdastNodeType::LinkReference
557        | MdastNodeType::ImageReference
558        | MdastNodeType::FootnoteReference => {
559            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
560            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
561            let kind = match js_node.reference_type.as_deref() {
562                Some("collapsed") => 1u8,
563                Some("full") => 2u8,
564                _ => 0u8, // shortcut
565            };
566            encode_reference_data(id_ref, label_ref, kind)
567        }
568        MdastNodeType::FootnoteDefinition => {
569            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
570            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
571            encode_footnote_definition_data(id_ref, label_ref)
572        }
573        #[cfg(feature = "mdx")]
574        MdastNodeType::MdxJsxFlowElement | MdastNodeType::MdxJsxTextElement => {
575            let name_ref = alloc_opt_str(builder, js_node.name.as_deref());
576            let attr_tuples = encode_js_jsx_attrs(
577                builder,
578                js_node.attributes.as_ref().and_then(|a| a.as_jsx()),
579            );
580            let explicit = js_data_is_mdx_explicit(&js_node.data);
581            encode_mdx_jsx_element_data(name_ref, &attr_tuples, explicit)
582        }
583        MdastNodeType::ContainerDirective
584        | MdastNodeType::LeafDirective
585        | MdastNodeType::TextDirective => {
586            let name = js_node.name.as_deref().unwrap_or("");
587            let name_ref = builder.alloc_string(name);
588            let attr_pairs = encode_js_directive_attrs(builder, js_node.attributes.as_ref());
589            encode_directive_data(name_ref, &attr_pairs)
590        }
591        #[cfg(feature = "mdx")]
592        MdastNodeType::MdxFlowExpression
593        | MdastNodeType::MdxTextExpression
594        | MdastNodeType::MdxjsEsm => {
595            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
596            encode_expression_data(value_ref)
597        }
598        // Nodes with no type-specific data
599        _ => Vec::new(),
600    }
601}
602
603fn encode_js_directive_attrs(
604    builder: &mut ArenaBuilder<Mdast>,
605    attrs: Option<&satteri_ast::commands::JsNodeAttributes>,
606) -> Vec<(StringRef, StringRef)> {
607    let Some(map) = attrs.and_then(|a| a.as_directive()) else {
608        return Vec::new();
609    };
610    map.iter()
611        .filter_map(|(k, v)| {
612            let val = v.as_str()?;
613            Some((builder.alloc_string(k), builder.alloc_string(val)))
614        })
615        .collect()
616}
617
618fn alloc_opt_str<K: ArenaKind>(builder: &mut ArenaBuilder<K>, s: Option<&str>) -> StringRef {
619    match s {
620        Some(v) if !v.is_empty() => builder.alloc_string(v),
621        _ => StringRef::empty(),
622    }
623}
624
625fn name_to_node_type(name: &str) -> Result<MdastNodeType, CommandError> {
626    match name {
627        "root" => Ok(MdastNodeType::Root),
628        "paragraph" => Ok(MdastNodeType::Paragraph),
629        "heading" => Ok(MdastNodeType::Heading),
630        "thematicBreak" => Ok(MdastNodeType::ThematicBreak),
631        "blockquote" => Ok(MdastNodeType::Blockquote),
632        "list" => Ok(MdastNodeType::List),
633        "listItem" => Ok(MdastNodeType::ListItem),
634        "html" => Ok(MdastNodeType::Html),
635        "code" => Ok(MdastNodeType::Code),
636        "definition" => Ok(MdastNodeType::Definition),
637        "text" => Ok(MdastNodeType::Text),
638        "emphasis" => Ok(MdastNodeType::Emphasis),
639        "strong" => Ok(MdastNodeType::Strong),
640        "inlineCode" => Ok(MdastNodeType::InlineCode),
641        "break" => Ok(MdastNodeType::Break),
642        "link" => Ok(MdastNodeType::Link),
643        "image" => Ok(MdastNodeType::Image),
644        "linkReference" => Ok(MdastNodeType::LinkReference),
645        "imageReference" => Ok(MdastNodeType::ImageReference),
646        "footnoteDefinition" => Ok(MdastNodeType::FootnoteDefinition),
647        "footnoteReference" => Ok(MdastNodeType::FootnoteReference),
648        "table" => Ok(MdastNodeType::Table),
649        "tableRow" => Ok(MdastNodeType::TableRow),
650        "tableCell" => Ok(MdastNodeType::TableCell),
651        "delete" => Ok(MdastNodeType::Delete),
652        "yaml" => Ok(MdastNodeType::Yaml),
653        "toml" => Ok(MdastNodeType::Toml),
654        "math" => Ok(MdastNodeType::Math),
655        "inlineMath" => Ok(MdastNodeType::InlineMath),
656        "containerDirective" => Ok(MdastNodeType::ContainerDirective),
657        "leafDirective" => Ok(MdastNodeType::LeafDirective),
658        "textDirective" => Ok(MdastNodeType::TextDirective),
659        #[cfg(feature = "mdx")]
660        "mdxJsxFlowElement" => Ok(MdastNodeType::MdxJsxFlowElement),
661        #[cfg(feature = "mdx")]
662        "mdxJsxTextElement" => Ok(MdastNodeType::MdxJsxTextElement),
663        #[cfg(feature = "mdx")]
664        "mdxFlowExpression" => Ok(MdastNodeType::MdxFlowExpression),
665        #[cfg(feature = "mdx")]
666        "mdxTextExpression" => Ok(MdastNodeType::MdxTextExpression),
667        #[cfg(feature = "mdx")]
668        "mdxjsEsm" => Ok(MdastNodeType::MdxjsEsm),
669        other => Err(CommandError::UnknownNodeType(other.to_string())),
670    }
671}
672
673// HAST command handlers
674
675/// HAST set-property: dispatches by `HastNodeType` to the matching writer.
676/// Kind-tight to `Arena<Hast>` — the MDAST field-resolver can no longer be
677/// reached from here.
678fn apply_hast_set_property(
679    arena: &mut Arena<Hast>,
680    node_id: u32,
681    prop_name: &str,
682    value_type: u8,
683    value_str: &str,
684) -> Result<(), CommandError> {
685    if prop_name == "data" {
686        apply_data_property(arena, node_id, value_type, value_str);
687        return Ok(());
688    }
689
690    let node_type = HastNodeType::from_u8(arena.get_node(node_id).node_type)
691        .ok_or(CommandError::UnknownField(0))?;
692
693    match node_type {
694        HastNodeType::Element => {
695            apply_hast_element_property(arena, node_id, prop_name, value_type, value_str)
696        }
697
698        HastNodeType::Text
699        | HastNodeType::Comment
700        | HastNodeType::Raw
701        | HastNodeType::MdxFlowExpression
702        | HastNodeType::MdxTextExpression
703        | HastNodeType::MdxEsm
704            if prop_name == "value" =>
705        {
706            let sref = arena.alloc_string(value_str);
707            let data = arena.get_type_data(node_id);
708            if data.len() >= 8 {
709                let data_offset = arena.get_node(node_id).data_offset as usize;
710                arena.type_data[data_offset..data_offset + 4]
711                    .copy_from_slice(&sref.offset.to_le_bytes());
712                arena.type_data[data_offset + 4..data_offset + 8]
713                    .copy_from_slice(&sref.len.to_le_bytes());
714                Ok(())
715            } else {
716                Err(CommandError::UnknownField(0))
717            }
718        }
719
720        _ => Err(CommandError::UnknownField(0)),
721    }
722}
723
724/// Set or add a single property on a HAST element node.
725fn apply_hast_element_property(
726    arena: &mut Arena<Hast>,
727    node_id: u32,
728    prop_name: &str,
729    value_type: u8,
730    value_str: &str,
731) -> Result<(), CommandError> {
732    let old_data = arena.get_type_data(node_id).to_vec();
733    if old_data.len() < 16 {
734        return Err(CommandError::UnexpectedEof);
735    }
736
737    let old_prop_count = u32::from_le_bytes(old_data[8..12].try_into().unwrap()) as usize;
738
739    let mut found_index: Option<usize> = None;
740    for i in 0..old_prop_count {
741        let base = 16 + i * 20;
742        let name_off = u32::from_le_bytes(old_data[base..base + 4].try_into().unwrap());
743        let name_len = u32::from_le_bytes(old_data[base + 4..base + 8].try_into().unwrap());
744        let existing_name = arena.get_str(StringRef::new(name_off, name_len));
745        if existing_name == prop_name {
746            found_index = Some(i);
747            break;
748        }
749    }
750
751    let name_ref = arena.alloc_string(prop_name);
752    let val_ref = if value_str.is_empty() {
753        StringRef::empty()
754    } else {
755        arena.alloc_string(value_str)
756    };
757
758    if let Some(idx) = found_index {
759        let mut new_data = old_data;
760        let base = 16 + idx * 20;
761        new_data[base..base + 4].copy_from_slice(&name_ref.offset.to_le_bytes());
762        new_data[base + 4..base + 8].copy_from_slice(&name_ref.len.to_le_bytes());
763        new_data[base + 8] = value_type;
764        new_data[base + 9..base + 12].copy_from_slice(&[0u8; 3]);
765        new_data[base + 12..base + 16].copy_from_slice(&val_ref.offset.to_le_bytes());
766        new_data[base + 16..base + 20].copy_from_slice(&val_ref.len.to_le_bytes());
767        arena.set_type_data(node_id, &new_data);
768    } else {
769        let new_prop_count = (old_prop_count + 1) as u32;
770        let mut new_data = Vec::with_capacity(16 + new_prop_count as usize * 20);
771        new_data.extend_from_slice(&old_data[0..8]);
772        new_data.extend_from_slice(&new_prop_count.to_le_bytes());
773        new_data.extend_from_slice(&0u32.to_le_bytes());
774        if old_prop_count > 0 {
775            new_data.extend_from_slice(&old_data[16..16 + old_prop_count * 20]);
776        }
777        new_data.extend_from_slice(&name_ref.offset.to_le_bytes());
778        new_data.extend_from_slice(&name_ref.len.to_le_bytes());
779        new_data.push(value_type);
780        new_data.extend_from_slice(&[0u8; 3]);
781        new_data.extend_from_slice(&val_ref.offset.to_le_bytes());
782        new_data.extend_from_slice(&val_ref.len.to_le_bytes());
783        arena.set_type_data(node_id, &new_data);
784    }
785
786    Ok(())
787}
788
789/// Emit a HAST JS node (from plugin JSON) into an ArenaBuilder.
790fn emit_hast_js_node(
791    js_node: &JsNode,
792    builder: &mut ArenaBuilder<Hast>,
793) -> Result<(), CommandError> {
794    if let Some(ref_id) = js_node.ref_id {
795        emit_ref_node(ref_id, builder);
796        return Ok(());
797    }
798
799    let raw_type = name_to_hast_type(&js_node.node_type)
800        .ok_or_else(|| CommandError::UnknownNodeType(js_node.node_type.clone()))?;
801    builder.open_node_raw(raw_type as u8);
802
803    let type_data = encode_hast_js_node_data(js_node, raw_type, builder);
804    if !type_data.is_empty() {
805        builder.set_data_current(&type_data);
806    }
807
808    write_js_node_data(js_node, builder)?;
809
810    if let Some(children) = &js_node.children {
811        for child in children {
812            emit_hast_js_node(child, builder)?;
813        }
814    }
815
816    builder.close_node();
817    Ok(())
818}
819
820fn name_to_hast_type(name: &str) -> Option<HastNodeType> {
821    match name {
822        "root" => Some(HastNodeType::Root),
823        "element" => Some(HastNodeType::Element),
824        "text" => Some(HastNodeType::Text),
825        "comment" => Some(HastNodeType::Comment),
826        "doctype" => Some(HastNodeType::Doctype),
827        "raw" => Some(HastNodeType::Raw),
828        #[cfg(feature = "mdx")]
829        "mdxJsxFlowElement" => Some(HastNodeType::MdxJsxElement),
830        #[cfg(feature = "mdx")]
831        "mdxJsxTextElement" => Some(HastNodeType::MdxJsxTextElement),
832        #[cfg(feature = "mdx")]
833        "mdxFlowExpression" => Some(HastNodeType::MdxFlowExpression),
834        #[cfg(feature = "mdx")]
835        "mdxTextExpression" => Some(HastNodeType::MdxTextExpression),
836        #[cfg(feature = "mdx")]
837        "mdxjsEsm" => Some(HastNodeType::MdxEsm),
838        _ => None,
839    }
840}
841
842fn encode_hast_js_node_data(
843    js_node: &JsNode,
844    node_type: HastNodeType,
845    builder: &mut ArenaBuilder<Hast>,
846) -> Vec<u8> {
847    match node_type {
848        HastNodeType::Element => {
849            let tag = js_node.tag_name.as_deref().unwrap_or("div");
850            let tag_ref = builder.alloc_string(tag);
851
852            let mut props: Vec<(StringRef, u8, StringRef)> = Vec::new();
853            if let Some(properties) = &js_node.properties {
854                for (key, value) in properties {
855                    let name_ref = builder.alloc_string(key);
856                    match value {
857                        serde_json::Value::Bool(true) => {
858                            props.push((name_ref, PROP_BOOL_TRUE, StringRef::empty()));
859                        }
860                        serde_json::Value::Bool(false) => {
861                            props.push((name_ref, PROP_BOOL_FALSE, StringRef::empty()));
862                        }
863                        serde_json::Value::String(s) => {
864                            let val_ref = builder.alloc_string(s);
865                            props.push((name_ref, PROP_STRING, val_ref));
866                        }
867                        serde_json::Value::Number(n) => {
868                            let val_ref = builder.alloc_string(&n.to_string());
869                            props.push((name_ref, PROP_INT, val_ref));
870                        }
871                        serde_json::Value::Array(arr) => {
872                            let joined: String = arr
873                                .iter()
874                                .filter_map(|v| v.as_str())
875                                .collect::<Vec<_>>()
876                                .join(" ");
877                            let val_ref = builder.alloc_string(&joined);
878                            props.push((name_ref, PROP_SPACE_SEP, val_ref));
879                        }
880                        _ => {}
881                    }
882                }
883            }
884
885            let mut out = Vec::with_capacity(16 + props.len() * 20);
886            out.extend_from_slice(&tag_ref.offset.to_le_bytes());
887            out.extend_from_slice(&tag_ref.len.to_le_bytes());
888            out.extend_from_slice(&(props.len() as u32).to_le_bytes());
889            out.extend_from_slice(&0u32.to_le_bytes());
890            for (name_ref, kind, val_ref) in &props {
891                out.extend_from_slice(&name_ref.offset.to_le_bytes());
892                out.extend_from_slice(&name_ref.len.to_le_bytes());
893                out.push(*kind);
894                out.extend_from_slice(&[0u8; 3]);
895                out.extend_from_slice(&val_ref.offset.to_le_bytes());
896                out.extend_from_slice(&val_ref.len.to_le_bytes());
897            }
898            out
899        }
900
901        HastNodeType::Text | HastNodeType::Comment | HastNodeType::Raw => {
902            let value = js_node.value.as_deref().unwrap_or("");
903            let sref = builder.alloc_string(value);
904            let mut out = [0u8; 8];
905            out[0..4].copy_from_slice(&sref.offset.to_le_bytes());
906            out[4..8].copy_from_slice(&sref.len.to_le_bytes());
907            out.to_vec()
908        }
909
910        #[cfg(feature = "mdx")]
911        HastNodeType::MdxJsxElement | HastNodeType::MdxJsxTextElement => {
912            let name = js_node
913                .name
914                .as_deref()
915                .or(js_node.tag_name.as_deref())
916                .unwrap_or("");
917            let name_ref = builder.alloc_string(name);
918            let attr_tuples = encode_js_jsx_attrs(
919                builder,
920                js_node.attributes.as_ref().and_then(|a| a.as_jsx()),
921            );
922            let explicit = js_data_is_mdx_explicit(&js_node.data);
923            encode_mdx_jsx_element_data(name_ref, &attr_tuples, explicit)
924        }
925
926        #[cfg(feature = "mdx")]
927        HastNodeType::MdxFlowExpression
928        | HastNodeType::MdxTextExpression
929        | HastNodeType::MdxEsm => {
930            let value = js_node.value.as_deref().unwrap_or("");
931            let sref = builder.alloc_string(value);
932            let mut out = [0u8; 8];
933            out[0..4].copy_from_slice(&sref.offset.to_le_bytes());
934            out[4..8].copy_from_slice(&sref.len.to_le_bytes());
935            out.to_vec()
936        }
937
938        _ => Vec::new(),
939    }
940}
941
942/// Returns (arena, keep_children) for an MDAST sub-tree payload.
943fn read_mdast_payload(
944    reader: &mut BufReader<'_>,
945    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
946) -> Result<(Arena<Mdast>, bool), CommandError> {
947    let payload_type = reader.read_u8()?;
948    let len = reader.read_u32()? as usize;
949
950    match payload_type {
951        PAYLOAD_RAW_MARKDOWN => {
952            let md = reader.read_str(len)?;
953            Ok((parse_raw_markdown(md, parse_markdown), false))
954        }
955        PAYLOAD_RAW_HTML => {
956            let html = reader.read_str(len)?;
957            let escaped = escape_braces_in_html_text(html);
958            Ok((parse_raw_markdown(&escaped, parse_markdown), false))
959        }
960        PAYLOAD_SERDE_JSON => {
961            let json_str = reader.read_str(len)?;
962            let js_node: JsNode = serde_json::from_str(json_str)
963                .map_err(|e| CommandError::InvalidJson(e.to_string()))?;
964            js_node_to_mdast_arena(&js_node)
965        }
966        other => Err(CommandError::UnknownPayloadType(other)),
967    }
968}
969
970/// Returns (arena, keep_children) for a HAST sub-tree payload. Only
971/// `PAYLOAD_SERDE_JSON` is accepted — HAST plugins emit JSON node trees,
972/// not raw markdown or raw HTML.
973fn read_hast_payload(reader: &mut BufReader<'_>) -> Result<(Arena<Hast>, bool), CommandError> {
974    let payload_type = reader.read_u8()?;
975    let len = reader.read_u32()? as usize;
976
977    match payload_type {
978        PAYLOAD_SERDE_JSON => {
979            let json_str = reader.read_str(len)?;
980            let js_node: JsNode = serde_json::from_str(json_str)
981                .map_err(|e| CommandError::InvalidJson(e.to_string()))?;
982            js_node_to_hast_arena(&js_node)
983        }
984        other => Err(CommandError::UnknownPayloadType(other)),
985    }
986}
987
988/// Apply a command buffer to an MDAST arena. Set-property mutations are
989/// applied in-place; structural mutations are collected as `Patch<Mdast>`
990/// objects and applied via `rebuild()`.
991///
992/// `parse_markdown` avoids a circular dependency on the parser crate; it
993/// is invoked for `RAW_MARKDOWN` and `RAW_HTML` payloads.
994///
995/// Passing a HAST arena is a compile error — the prior single-dispatch
996/// `apply_commands` would silently misroute MDAST nodes into the HAST
997/// element-properties writer (numeric `node_type` values overlap between
998/// the two arenas):
999///
1000/// ```compile_fail
1001/// use satteri_arena::{Arena, Hast};
1002/// use satteri_plugin_api::apply_mdast_commands;
1003///
1004/// let arena: Arena<Hast> = Arena::new(String::new());
1005/// let parse_markdown = |_: &str| -> Arena<satteri_arena::Mdast> {
1006///     Arena::new(String::new())
1007/// };
1008/// let _ = apply_mdast_commands(arena, &[], &parse_markdown);
1009/// ```
1010pub fn apply_mdast_commands(
1011    arena: Arena<Mdast>,
1012    command_buf: &[u8],
1013    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1014) -> Result<Arena<Mdast>, CommandError> {
1015    let (arena, dropped) = apply_mdast_commands_lenient(arena, command_buf, parse_markdown)?;
1016    if let Some(anchor) = dropped.first() {
1017        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1018    }
1019    Ok(arena)
1020}
1021
1022/// Like [`apply_mdast_commands`], but rather than erroring when a patch targets
1023/// a node inside a removed/replaced subtree, drops it and returns the dropped
1024/// anchors. Such a patch is moot — the plugin discarded that subtree. A
1025/// *passed-through* child is not dropped: it rides a `_ref` placeholder that
1026/// splices it back with its id intact, so a transform queued on a nested node
1027/// (e.g. a `:::tip` inside a `:::note`) still applies, in the same pass.
1028pub fn apply_mdast_commands_lenient(
1029    mut arena: Arena<Mdast>,
1030    command_buf: &[u8],
1031    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1032) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
1033    if command_buf.is_empty() {
1034        return Ok((arena, Vec::new()));
1035    }
1036
1037    let mut patches: Vec<Patch<Mdast>> = Vec::new();
1038    let mut reader = BufReader::new(command_buf);
1039
1040    while reader.remaining() > 0 {
1041        let cmd = reader.read_u8()?;
1042
1043        match cmd {
1044            CMD_REMOVE => {
1045                let node_id = reader.read_u32()?;
1046                patches.push(Patch::Remove { node_id });
1047            }
1048
1049            CMD_SET_PROPERTY => {
1050                let node_id = reader.read_u32()?;
1051                let value_type = reader.read_u8()?;
1052                let name_len = reader.read_u32()? as usize;
1053                let name = reader.read_str(name_len)?;
1054                let value_len = reader.read_u32()? as usize;
1055                let value = reader.read_str(value_len)?;
1056                apply_mdast_set_property(&mut arena, node_id, name, value_type, value)?;
1057            }
1058
1059            CMD_INSERT_BEFORE => {
1060                let node_id = reader.read_u32()?;
1061                let (new_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1062                patches.push(Patch::InsertBefore { node_id, new_tree });
1063            }
1064
1065            CMD_INSERT_AFTER => {
1066                let node_id = reader.read_u32()?;
1067                let (new_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1068                patches.push(Patch::InsertAfter { node_id, new_tree });
1069            }
1070
1071            CMD_PREPEND_CHILD => {
1072                let node_id = reader.read_u32()?;
1073                let (child_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1074                patches.push(Patch::PrependChild {
1075                    node_id,
1076                    child_tree,
1077                });
1078            }
1079
1080            CMD_APPEND_CHILD => {
1081                let node_id = reader.read_u32()?;
1082                let (child_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1083                patches.push(Patch::AppendChild {
1084                    node_id,
1085                    child_tree,
1086                });
1087            }
1088
1089            CMD_WRAP => {
1090                let node_id = reader.read_u32()?;
1091                let (parent_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1092                patches.push(Patch::Wrap {
1093                    node_id,
1094                    parent_tree,
1095                });
1096            }
1097
1098            CMD_REPLACE => {
1099                let node_id = reader.read_u32()?;
1100                let (new_tree, keep_children) = read_mdast_payload(&mut reader, parse_markdown)?;
1101                patches.push(Patch::Replace {
1102                    node_id,
1103                    new_tree,
1104                    keep_children,
1105                });
1106            }
1107
1108            other => return Err(CommandError::UnknownCommand(other)),
1109        }
1110    }
1111
1112    if patches.is_empty() {
1113        Ok((arena, Vec::new()))
1114    } else {
1115        let result = satteri_ast::rebuild::rebuild_lenient(&arena, &patches)?;
1116        Ok((result.arena, result.dropped))
1117    }
1118}
1119
1120/// Apply a command buffer to a HAST arena. Set-property mutations are
1121/// applied in-place; structural mutations are collected as `Patch<Hast>`
1122/// objects and applied via `rebuild()`. Errors if a patch is stranded inside a
1123/// removed/replaced subtree; [`apply_hast_commands_lenient`] drops it instead.
1124///
1125/// HAST plugins inject sub-trees via `PAYLOAD_SERDE_JSON` only — there is
1126/// no `parse_markdown` callback because HAST has no source-level grammar.
1127///
1128/// Passing an MDAST arena is a compile error:
1129///
1130/// ```compile_fail
1131/// use satteri_arena::{Arena, Mdast};
1132/// use satteri_plugin_api::apply_hast_commands;
1133///
1134/// let arena: Arena<Mdast> = Arena::new(String::new());
1135/// let _ = apply_hast_commands(arena, &[]);
1136/// ```
1137pub fn apply_hast_commands(
1138    arena: Arena<Hast>,
1139    command_buf: &[u8],
1140) -> Result<Arena<Hast>, CommandError> {
1141    let (arena, dropped) = apply_hast_commands_lenient(arena, command_buf)?;
1142    if let Some(anchor) = dropped.first() {
1143        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1144    }
1145    Ok(arena)
1146}
1147
1148/// Like [`apply_hast_commands`], but rather than erroring when a patch targets a
1149/// node inside a removed/replaced subtree, drops it and returns the dropped
1150/// anchors — mirroring [`apply_mdast_commands_lenient`]. Such a patch is moot:
1151/// the plugin discarded that subtree. A passed-through child keeps its identity
1152/// (via `_ref`) and so is never stranded this way.
1153pub fn apply_hast_commands_lenient(
1154    mut arena: Arena<Hast>,
1155    command_buf: &[u8],
1156) -> Result<(Arena<Hast>, Vec<u32>), CommandError> {
1157    if command_buf.is_empty() {
1158        return Ok((arena, Vec::new()));
1159    }
1160
1161    let mut patches: Vec<Patch<Hast>> = Vec::new();
1162    let mut reader = BufReader::new(command_buf);
1163
1164    while reader.remaining() > 0 {
1165        let cmd = reader.read_u8()?;
1166
1167        match cmd {
1168            CMD_REMOVE => {
1169                let node_id = reader.read_u32()?;
1170                patches.push(Patch::Remove { node_id });
1171            }
1172
1173            CMD_SET_PROPERTY => {
1174                let node_id = reader.read_u32()?;
1175                let value_type = reader.read_u8()?;
1176                let name_len = reader.read_u32()? as usize;
1177                let name = reader.read_str(name_len)?;
1178                let value_len = reader.read_u32()? as usize;
1179                let value = reader.read_str(value_len)?;
1180                apply_hast_set_property(&mut arena, node_id, name, value_type, value)?;
1181            }
1182
1183            CMD_INSERT_BEFORE => {
1184                let node_id = reader.read_u32()?;
1185                let (new_tree, _) = read_hast_payload(&mut reader)?;
1186                patches.push(Patch::InsertBefore { node_id, new_tree });
1187            }
1188
1189            CMD_INSERT_AFTER => {
1190                let node_id = reader.read_u32()?;
1191                let (new_tree, _) = read_hast_payload(&mut reader)?;
1192                patches.push(Patch::InsertAfter { node_id, new_tree });
1193            }
1194
1195            CMD_PREPEND_CHILD => {
1196                let node_id = reader.read_u32()?;
1197                let (child_tree, _) = read_hast_payload(&mut reader)?;
1198                patches.push(Patch::PrependChild {
1199                    node_id,
1200                    child_tree,
1201                });
1202            }
1203
1204            CMD_APPEND_CHILD => {
1205                let node_id = reader.read_u32()?;
1206                let (child_tree, _) = read_hast_payload(&mut reader)?;
1207                patches.push(Patch::AppendChild {
1208                    node_id,
1209                    child_tree,
1210                });
1211            }
1212
1213            CMD_WRAP => {
1214                let node_id = reader.read_u32()?;
1215                let (parent_tree, _) = read_hast_payload(&mut reader)?;
1216                patches.push(Patch::Wrap {
1217                    node_id,
1218                    parent_tree,
1219                });
1220            }
1221
1222            CMD_REPLACE => {
1223                let node_id = reader.read_u32()?;
1224                let (new_tree, keep_children) = read_hast_payload(&mut reader)?;
1225                patches.push(Patch::Replace {
1226                    node_id,
1227                    new_tree,
1228                    keep_children,
1229                });
1230            }
1231
1232            other => return Err(CommandError::UnknownCommand(other)),
1233        }
1234    }
1235
1236    if patches.is_empty() {
1237        Ok((arena, Vec::new()))
1238    } else {
1239        let result = satteri_ast::rebuild::rebuild_lenient(&arena, &patches)?;
1240        Ok((result.arena, result.dropped))
1241    }
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246    use super::*;
1247    use satteri_ast::shared::PROP_INT;
1248
1249    fn test_parse_markdown(source: &str) -> Arena<Mdast> {
1250        let mut b = ArenaBuilder::<Mdast>::new(String::new());
1251        b.open_node(MdastNodeType::Root as u8);
1252        b.open_node(MdastNodeType::Paragraph as u8);
1253        b.open_node(MdastNodeType::Text as u8);
1254        let sref = b.alloc_string(source);
1255        b.set_data_current(&satteri_arena::encode_string_ref_data(sref));
1256        b.close_node();
1257        b.close_node();
1258        b.close_node();
1259        b.finish()
1260    }
1261
1262    fn push_u32(buf: &mut Vec<u8>, v: u32) {
1263        buf.extend_from_slice(&v.to_le_bytes());
1264    }
1265
1266    /// Encode a CMD_SET_PROPERTY command into a buffer.
1267    fn push_set_property(buf: &mut Vec<u8>, node_id: u32, value_type: u8, name: &str, value: &str) {
1268        buf.push(CMD_SET_PROPERTY);
1269        push_u32(buf, node_id);
1270        buf.push(value_type);
1271        push_u32(buf, name.len() as u32);
1272        buf.extend_from_slice(name.as_bytes());
1273        push_u32(buf, value.len() as u32);
1274        buf.extend_from_slice(value.as_bytes());
1275    }
1276
1277    fn build_hello_world() -> Arena<Mdast> {
1278        use satteri_ast::mdast::codec::{encode_heading_data, encode_string_ref_data};
1279
1280        let source = "# Hello\n\nWorld".to_string();
1281        let mut b = ArenaBuilder::<Mdast>::new(source);
1282
1283        b.open_node(MdastNodeType::Root as u8);
1284        b.set_position_current(0, 14, 1, 1, 2, 6);
1285
1286        b.open_node(MdastNodeType::Heading as u8);
1287        b.set_position_current(0, 7, 1, 1, 1, 8);
1288        b.set_data_current(&encode_heading_data(1));
1289
1290        b.open_node(MdastNodeType::Text as u8);
1291        b.set_position_current(2, 7, 1, 3, 1, 8);
1292        b.set_data_current(&encode_string_ref_data(StringRef::new(2, 5)));
1293        b.close_node();
1294
1295        b.close_node();
1296
1297        b.open_node(MdastNodeType::Paragraph as u8);
1298        b.set_position_current(9, 14, 2, 1, 2, 6);
1299
1300        b.open_node(MdastNodeType::Text as u8);
1301        b.set_position_current(9, 14, 2, 1, 2, 6);
1302        b.set_data_current(&encode_string_ref_data(StringRef::new(9, 5)));
1303        b.close_node();
1304
1305        b.close_node();
1306        b.close_node();
1307
1308        b.finish()
1309    }
1310
1311    #[test]
1312    fn empty_command_buffer() {
1313        let arena = build_hello_world();
1314        let result = apply_mdast_commands(arena.clone(), &[], &test_parse_markdown).unwrap();
1315        assert_eq!(result.len(), arena.len());
1316    }
1317
1318    #[test]
1319    fn remove_command() {
1320        let arena = build_hello_world();
1321        let heading_id = arena.get_children(0)[0];
1322        let mut buf = Vec::new();
1323        buf.push(CMD_REMOVE);
1324        push_u32(&mut buf, heading_id);
1325
1326        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1327        assert_eq!(result.get_children(0).len(), 1);
1328        assert_eq!(
1329            result.get_node(result.get_children(0)[0]).node_type,
1330            MdastNodeType::Paragraph as u8
1331        );
1332    }
1333
1334    #[test]
1335    fn set_property_heading_depth() {
1336        let arena = build_hello_world();
1337        let heading_id = arena.get_children(0)[0];
1338
1339        let mut buf = Vec::new();
1340        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1341
1342        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1343        let heading_data = result.get_type_data(heading_id);
1344        let heading = decode_heading_data(heading_data);
1345        assert_eq!(heading.depth, 3);
1346    }
1347
1348    #[test]
1349    fn set_property_text_value() {
1350        let arena = build_hello_world();
1351        let heading_id = arena.get_children(0)[0];
1352        let text_id = arena.get_children(heading_id)[0];
1353
1354        let mut buf = Vec::new();
1355        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Goodbye");
1356
1357        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1358        let text_data = result.get_type_data(text_id);
1359        let sref = decode_string_ref_data(text_data);
1360        assert_eq!(result.get_str(sref), "Goodbye");
1361    }
1362
1363    #[test]
1364    fn replace_with_raw_markdown() {
1365        let arena = build_hello_world();
1366        let heading_id = arena.get_children(0)[0];
1367
1368        let raw_md = "## New Heading";
1369        let mut buf = Vec::new();
1370        buf.push(CMD_REPLACE);
1371        push_u32(&mut buf, heading_id);
1372        buf.push(PAYLOAD_RAW_MARKDOWN);
1373        push_u32(&mut buf, raw_md.len() as u32);
1374        buf.extend_from_slice(raw_md.as_bytes());
1375
1376        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1377        let root_children = result.get_children(0);
1378        assert!(root_children.len() >= 2);
1379    }
1380
1381    #[test]
1382    fn replace_with_serde_json() {
1383        let arena = build_hello_world();
1384        let heading_id = arena.get_children(0)[0];
1385
1386        let json =
1387            r#"{"type":"heading","depth":2,"children":[{"type":"text","value":"Replaced"}]}"#;
1388        let mut buf = Vec::new();
1389        buf.push(CMD_REPLACE);
1390        push_u32(&mut buf, heading_id);
1391        buf.push(PAYLOAD_SERDE_JSON);
1392        push_u32(&mut buf, json.len() as u32);
1393        buf.extend_from_slice(json.as_bytes());
1394
1395        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1396        let root_children = result.get_children(0);
1397        assert_eq!(root_children.len(), 2);
1398        let new_heading = root_children[0];
1399        assert_eq!(
1400            result.get_node(new_heading).node_type,
1401            MdastNodeType::Heading as u8
1402        );
1403        let heading_data = result.get_type_data(new_heading);
1404        assert_eq!(decode_heading_data(heading_data).depth, 2);
1405    }
1406
1407    #[test]
1408    fn replace_with_directive_child() {
1409        // Directives serialize `attributes` as a map (`{}`), not the array form
1410        // used by MDX JSX. The deserializer must accept both shapes; without
1411        // that, any plugin returning a tree containing a directive child fails
1412        // with "invalid type: map, expected a sequence".
1413        let arena = build_hello_world();
1414        let heading_id = arena.get_children(0)[0];
1415
1416        let json = r#"{"type":"paragraph","children":[{"type":"text","value":"hi "},{"type":"textDirective","name":"inline","attributes":{},"children":[]}]}"#;
1417        let mut buf = Vec::new();
1418        buf.push(CMD_REPLACE);
1419        push_u32(&mut buf, heading_id);
1420        buf.push(PAYLOAD_SERDE_JSON);
1421        push_u32(&mut buf, json.len() as u32);
1422        buf.extend_from_slice(json.as_bytes());
1423
1424        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1425        let root_children = result.get_children(0);
1426        let new_para = root_children[0];
1427        assert_eq!(
1428            result.get_node(new_para).node_type,
1429            MdastNodeType::Paragraph as u8
1430        );
1431        let para_children = result.get_children(new_para);
1432        assert_eq!(para_children.len(), 2);
1433        let directive = para_children[1];
1434        assert_eq!(
1435            result.get_node(directive).node_type,
1436            MdastNodeType::TextDirective as u8
1437        );
1438        let dir_data = result.get_type_data(directive);
1439        assert_eq!(decode_directive_attr_count(dir_data), 0);
1440    }
1441
1442    #[test]
1443    fn replace_with_directive_attrs() {
1444        // Same as above but with non-empty directive attrs to confirm the map
1445        // shape round-trips into the arena's directive type_data.
1446        let arena = build_hello_world();
1447        let heading_id = arena.get_children(0)[0];
1448
1449        let json = r#"{"type":"containerDirective","name":"tip","attributes":{"id":"foo","class":"bar"},"children":[]}"#;
1450        let mut buf = Vec::new();
1451        buf.push(CMD_REPLACE);
1452        push_u32(&mut buf, heading_id);
1453        buf.push(PAYLOAD_SERDE_JSON);
1454        push_u32(&mut buf, json.len() as u32);
1455        buf.extend_from_slice(json.as_bytes());
1456
1457        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1458        let directive = result.get_children(0)[0];
1459        assert_eq!(
1460            result.get_node(directive).node_type,
1461            MdastNodeType::ContainerDirective as u8
1462        );
1463        let dir_data = result.get_type_data(directive);
1464        assert_eq!(decode_directive_attr_count(dir_data), 2);
1465    }
1466
1467    #[test]
1468    fn multiple_commands() {
1469        let arena = build_hello_world();
1470        let heading_id = arena.get_children(0)[0];
1471        let text_id = arena.get_children(heading_id)[0];
1472
1473        let mut buf = Vec::new();
1474        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1475        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Hi");
1476
1477        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1478
1479        let heading_data = result.get_type_data(heading_id);
1480        assert_eq!(decode_heading_data(heading_data).depth, 3);
1481
1482        let text_data = result.get_type_data(text_id);
1483        let sref = decode_string_ref_data(text_data);
1484        assert_eq!(result.get_str(sref), "Hi");
1485    }
1486
1487    #[test]
1488    fn set_property_null() {
1489        let arena = build_hello_world();
1490        let heading_id = arena.get_children(0)[0];
1491        let text_id = arena.get_children(heading_id)[0];
1492
1493        let mut buf = Vec::new();
1494        push_set_property(&mut buf, text_id, PROP_NULL, "value", "");
1495
1496        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1497        let text_data = result.get_type_data(text_id);
1498        let sref = decode_string_ref_data(text_data);
1499        assert_eq!(sref.len, 0);
1500    }
1501
1502    #[test]
1503    fn js_node_to_arena_basic() {
1504        let js = JsNode {
1505            node_type: "heading".to_string(),
1506            children: Some(vec![JsNode {
1507                node_type: "text".to_string(),
1508                children: None,
1509                value: Some("Hello".to_string()),
1510                depth: None,
1511                url: None,
1512                title: None,
1513                alt: None,
1514                lang: None,
1515                meta: None,
1516                ordered: None,
1517                start: None,
1518                spread: None,
1519                checked: None,
1520                identifier: None,
1521                label: None,
1522                reference_type: None,
1523                name: None,
1524                attributes: None,
1525                tag_name: None,
1526                properties: None,
1527                is_hast: false,
1528                keep_children: false,
1529                ref_id: None,
1530                data: None,
1531            }]),
1532            depth: Some(2),
1533            value: None,
1534            url: None,
1535            title: None,
1536            alt: None,
1537            lang: None,
1538            meta: None,
1539            ordered: None,
1540            start: None,
1541            spread: None,
1542            checked: None,
1543            identifier: None,
1544            label: None,
1545            reference_type: None,
1546            name: None,
1547            attributes: None,
1548            tag_name: None,
1549            properties: None,
1550            is_hast: false,
1551            keep_children: false,
1552            ref_id: None,
1553            data: None,
1554        };
1555
1556        let (arena, _keep) = js_node_to_mdast_arena(&js).unwrap();
1557        assert_eq!(arena.len(), 2);
1558        assert_eq!(arena.get_node(0).node_type, MdastNodeType::Heading as u8);
1559        assert_eq!(arena.get_children(0).len(), 1);
1560        let text_id = arena.get_children(0)[0];
1561        assert_eq!(arena.get_node(text_id).node_type, MdastNodeType::Text as u8);
1562    }
1563
1564    #[test]
1565    fn escape_braces_in_html_text_basic() {
1566        assert_eq!(
1567            escape_braces_in_html_text("<span>{foo: 1}</span>"),
1568            "<span>{'{'}foo: 1{'}'}</span>"
1569        );
1570    }
1571
1572    #[test]
1573    fn escape_braces_preserves_attributes() {
1574        let result = escape_braces_in_html_text(r#"<span data-x="{a}">{b}</span>"#);
1575        assert!(
1576            result.contains(r#"data-x="{a}""#),
1577            "attribute braces preserved"
1578        );
1579        assert!(result.contains("{'{'}"), "text braces escaped");
1580    }
1581
1582    #[test]
1583    fn escape_braces_no_braces() {
1584        let html = r#"<pre class="shiki"><code><span style="color:red">hello</span></code></pre>"#;
1585        assert_eq!(escape_braces_in_html_text(html), html);
1586    }
1587
1588    #[test]
1589    fn escape_braces_shiki_output() {
1590        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>"#;
1591        let escaped = escape_braces_in_html_text(html);
1592        assert!(
1593            !escaped.contains(">{<"),
1594            "bare braces in text should be escaped"
1595        );
1596        assert!(
1597            !escaped.contains(">}<"),
1598            "bare braces in text should be escaped"
1599        );
1600        assert!(escaped.contains(r#"class="shiki""#));
1601        assert!(escaped.contains(r#"style="color:#E1E4E8""#));
1602    }
1603
1604    #[test]
1605    fn hast_set_property_add_new() {
1606        let arena = build_hast_element(&[]);
1607        let element_id = arena.get_children(0)[0];
1608
1609        let mut buf = Vec::new();
1610        push_set_property(&mut buf, element_id, PROP_STRING, "class", "test");
1611
1612        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1613        let data = result.get_type_data(element_id);
1614        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1615        assert_eq!(prop_count, 1);
1616        let name_ref = StringRef::new(
1617            u32::from_le_bytes(data[16..20].try_into().unwrap()),
1618            u32::from_le_bytes(data[20..24].try_into().unwrap()),
1619        );
1620        assert_eq!(result.get_str(name_ref), "class");
1621        let val_ref = StringRef::new(
1622            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1623            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1624        );
1625        assert_eq!(result.get_str(val_ref), "test");
1626        assert_eq!(data[24], PROP_STRING);
1627    }
1628
1629    #[test]
1630    fn hast_set_property_overwrite_existing() {
1631        let arena = build_hast_element(&[("class", PROP_STRING, "old")]);
1632        let element_id = arena.get_children(0)[0];
1633
1634        let mut buf = Vec::new();
1635        push_set_property(&mut buf, element_id, PROP_STRING, "class", "new-value");
1636
1637        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1638        let data = result.get_type_data(element_id);
1639        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1640        assert_eq!(prop_count, 1);
1641        let val_ref = StringRef::new(
1642            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1643            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1644        );
1645        assert_eq!(result.get_str(val_ref), "new-value");
1646    }
1647
1648    #[test]
1649    fn hast_set_property_bool_true() {
1650        let arena = build_hast_element(&[]);
1651        let element_id = arena.get_children(0)[0];
1652
1653        let mut buf = Vec::new();
1654        push_set_property(&mut buf, element_id, PROP_BOOL_TRUE, "disabled", "");
1655
1656        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1657        let data = result.get_type_data(element_id);
1658        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1659        assert_eq!(prop_count, 1);
1660        assert_eq!(data[24], PROP_BOOL_TRUE);
1661    }
1662
1663    #[test]
1664    fn hast_set_property_multiple_on_same_node() {
1665        let arena = build_hast_element(&[]);
1666        let element_id = arena.get_children(0)[0];
1667
1668        let mut buf = Vec::new();
1669        push_set_property(&mut buf, element_id, PROP_STRING, "class", "foo");
1670        push_set_property(&mut buf, element_id, PROP_STRING, "id", "bar");
1671
1672        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1673        let data = result.get_type_data(element_id);
1674        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1675        assert_eq!(prop_count, 2);
1676    }
1677
1678    /// Build a minimal HAST element arena: root(type 0) → element(type 1, tag "div")
1679    fn build_hast_element(props: &[(&str, u8, &str)]) -> Arena<Hast> {
1680        use satteri_ast::hast::node::HastNodeType;
1681
1682        let mut b = ArenaBuilder::<Hast>::new(String::new());
1683        b.open_node_raw(HastNodeType::Root as u8);
1684        b.open_node_raw(HastNodeType::Element as u8);
1685        let tag_ref = b.alloc_string("div");
1686        let prop_tuples: Vec<(StringRef, u8, StringRef)> = props
1687            .iter()
1688            .map(|(name, kind, value)| {
1689                let n = b.alloc_string(name);
1690                let v = if value.is_empty() {
1691                    StringRef::empty()
1692                } else {
1693                    b.alloc_string(value)
1694                };
1695                (n, *kind, v)
1696            })
1697            .collect();
1698        let mut type_data = Vec::with_capacity(16 + prop_tuples.len() * 20);
1699        type_data.extend_from_slice(&tag_ref.offset.to_le_bytes());
1700        type_data.extend_from_slice(&tag_ref.len.to_le_bytes());
1701        type_data.extend_from_slice(&(prop_tuples.len() as u32).to_le_bytes());
1702        type_data.extend_from_slice(&0u32.to_le_bytes());
1703        for (n, kind, v) in &prop_tuples {
1704            type_data.extend_from_slice(&n.offset.to_le_bytes());
1705            type_data.extend_from_slice(&n.len.to_le_bytes());
1706            type_data.push(*kind);
1707            type_data.extend_from_slice(&[0u8; 3]);
1708            type_data.extend_from_slice(&v.offset.to_le_bytes());
1709            type_data.extend_from_slice(&v.len.to_le_bytes());
1710        }
1711        b.set_data_current(&type_data);
1712        b.close_node();
1713        b.close_node();
1714        b.finish()
1715    }
1716}