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