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    if let Some(children) = &js_node.children {
406        for child in children {
407            emit_js_node(child, builder)?;
408        }
409    }
410
411    builder.close_node();
412    Ok(())
413}
414
415fn encode_js_node_data(
416    js_node: &JsNode,
417    node_type: MdastNodeType,
418    builder: &mut ArenaBuilder,
419) -> Vec<u8> {
420    match node_type {
421        MdastNodeType::Heading => {
422            let depth = js_node.depth.unwrap_or(1);
423            encode_heading_data(depth)
424        }
425        MdastNodeType::Text
426        | MdastNodeType::InlineCode
427        | MdastNodeType::Html
428        | MdastNodeType::Yaml
429        | MdastNodeType::Toml
430        | MdastNodeType::InlineMath => {
431            let value = js_node.value.as_deref().unwrap_or("");
432            let sref = builder.alloc_string(value);
433            encode_string_ref_data(sref)
434        }
435        MdastNodeType::Code => {
436            let lang_ref = alloc_opt_str(builder, js_node.lang.as_deref());
437            let meta_ref = alloc_opt_str(builder, js_node.meta.as_deref());
438            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
439            encode_code_data(lang_ref, meta_ref, value_ref, b'`')
440        }
441        MdastNodeType::Math => {
442            let meta_ref = alloc_opt_str(builder, js_node.meta.as_deref());
443            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
444            encode_math_data(meta_ref, value_ref)
445        }
446        MdastNodeType::Link => {
447            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
448            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
449            encode_link_data(url_ref, title_ref)
450        }
451        MdastNodeType::Image => {
452            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
453            let alt_ref = alloc_opt_str(builder, js_node.alt.as_deref());
454            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
455            encode_image_data(url_ref, alt_ref, title_ref)
456        }
457        MdastNodeType::Definition => {
458            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
459            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
460            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
461            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
462            encode_definition_data(url_ref, title_ref, id_ref, label_ref)
463        }
464        MdastNodeType::List => {
465            let ordered = js_node.ordered.unwrap_or(false);
466            let start = js_node.start.unwrap_or(1);
467            let spread = js_node.spread.unwrap_or(false);
468            encode_list_data(ordered, start, spread)
469        }
470        MdastNodeType::ListItem => {
471            let checked = match js_node.checked {
472                Some(true) => 1u8,
473                Some(false) => 0u8,
474                None => 2u8, // not a task item
475            };
476            let spread = js_node.spread.unwrap_or(false);
477            encode_list_item_data(checked, spread)
478        }
479        MdastNodeType::LinkReference
480        | MdastNodeType::ImageReference
481        | MdastNodeType::FootnoteReference => {
482            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
483            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
484            let kind = match js_node.reference_type.as_deref() {
485                Some("collapsed") => 1u8,
486                Some("full") => 2u8,
487                _ => 0u8, // shortcut
488            };
489            encode_reference_data(id_ref, label_ref, kind)
490        }
491        MdastNodeType::FootnoteDefinition => {
492            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
493            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
494            encode_footnote_definition_data(id_ref, label_ref)
495        }
496        MdastNodeType::MdxJsxFlowElement | MdastNodeType::MdxJsxTextElement => {
497            let name_ref = alloc_opt_str(builder, js_node.name.as_deref());
498            let attr_tuples = encode_js_jsx_attrs(builder, js_node.attributes.as_deref());
499            encode_mdx_jsx_element_data(name_ref, &attr_tuples)
500        }
501        MdastNodeType::MdxFlowExpression
502        | MdastNodeType::MdxTextExpression
503        | MdastNodeType::MdxjsEsm => {
504            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
505            encode_expression_data(value_ref)
506        }
507        // Nodes with no type-specific data
508        _ => Vec::new(),
509    }
510}
511
512fn alloc_opt_str(builder: &mut ArenaBuilder, s: Option<&str>) -> StringRef {
513    match s {
514        Some(v) if !v.is_empty() => builder.alloc_string(v),
515        _ => StringRef::empty(),
516    }
517}
518
519fn name_to_node_type(name: &str) -> Result<MdastNodeType, CommandError> {
520    match name {
521        "root" => Ok(MdastNodeType::Root),
522        "paragraph" => Ok(MdastNodeType::Paragraph),
523        "heading" => Ok(MdastNodeType::Heading),
524        "thematicBreak" => Ok(MdastNodeType::ThematicBreak),
525        "blockquote" => Ok(MdastNodeType::Blockquote),
526        "list" => Ok(MdastNodeType::List),
527        "listItem" => Ok(MdastNodeType::ListItem),
528        "html" => Ok(MdastNodeType::Html),
529        "code" => Ok(MdastNodeType::Code),
530        "definition" => Ok(MdastNodeType::Definition),
531        "text" => Ok(MdastNodeType::Text),
532        "emphasis" => Ok(MdastNodeType::Emphasis),
533        "strong" => Ok(MdastNodeType::Strong),
534        "inlineCode" => Ok(MdastNodeType::InlineCode),
535        "break" => Ok(MdastNodeType::Break),
536        "link" => Ok(MdastNodeType::Link),
537        "image" => Ok(MdastNodeType::Image),
538        "linkReference" => Ok(MdastNodeType::LinkReference),
539        "imageReference" => Ok(MdastNodeType::ImageReference),
540        "footnoteDefinition" => Ok(MdastNodeType::FootnoteDefinition),
541        "footnoteReference" => Ok(MdastNodeType::FootnoteReference),
542        "table" => Ok(MdastNodeType::Table),
543        "tableRow" => Ok(MdastNodeType::TableRow),
544        "tableCell" => Ok(MdastNodeType::TableCell),
545        "delete" => Ok(MdastNodeType::Delete),
546        "yaml" => Ok(MdastNodeType::Yaml),
547        "toml" => Ok(MdastNodeType::Toml),
548        "math" => Ok(MdastNodeType::Math),
549        "inlineMath" => Ok(MdastNodeType::InlineMath),
550        "mdxJsxFlowElement" => Ok(MdastNodeType::MdxJsxFlowElement),
551        "mdxJsxTextElement" => Ok(MdastNodeType::MdxJsxTextElement),
552        "mdxFlowExpression" => Ok(MdastNodeType::MdxFlowExpression),
553        "mdxTextExpression" => Ok(MdastNodeType::MdxTextExpression),
554        "mdxjsEsm" => Ok(MdastNodeType::MdxjsEsm),
555        other => Err(CommandError::UnknownNodeType(other.to_string())),
556    }
557}
558
559// HAST command handlers
560
561/// Try to handle a set-property command for a HAST node.
562///
563/// Returns `Some(Ok(()))` if the node was a known HAST type and the property
564/// was applied.  Returns `Some(Err(...))` on error.  Returns `None` if the
565/// node type is not a HAST type (caller should fall through to MDAST handling).
566fn apply_hast_set_property(
567    arena: &mut Arena,
568    node_id: u32,
569    prop_name: &str,
570    value_type: u8,
571    value_str: &str,
572) -> Option<Result<(), CommandError>> {
573    let node_type = HastNodeType::from_u8(arena.get_node(node_id).node_type)?;
574
575    match node_type {
576        HastNodeType::Element => Some(apply_hast_element_property(
577            arena, node_id, prop_name, value_type, value_str,
578        )),
579
580        HastNodeType::Text
581        | HastNodeType::Comment
582        | HastNodeType::Raw
583        | HastNodeType::MdxFlowExpression
584        | HastNodeType::MdxTextExpression
585        | HastNodeType::MdxEsm
586            if prop_name == "value" =>
587        {
588            let sref = arena.alloc_string(value_str);
589            let data = arena.get_type_data(node_id);
590            if data.len() >= 8 {
591                let data_offset = arena.get_node(node_id).data_offset as usize;
592                arena.type_data[data_offset..data_offset + 4]
593                    .copy_from_slice(&sref.offset.to_le_bytes());
594                arena.type_data[data_offset + 4..data_offset + 8]
595                    .copy_from_slice(&sref.len.to_le_bytes());
596                Some(Ok(()))
597            } else {
598                Some(Err(CommandError::UnknownField(0)))
599            }
600        }
601
602        _ => None,
603    }
604}
605
606/// Set or add a single property on a HAST element node.
607fn apply_hast_element_property(
608    arena: &mut Arena,
609    node_id: u32,
610    prop_name: &str,
611    value_type: u8,
612    value_str: &str,
613) -> Result<(), CommandError> {
614    let old_data = arena.get_type_data(node_id).to_vec();
615    if old_data.len() < 16 {
616        return Err(CommandError::UnexpectedEof);
617    }
618
619    let old_prop_count = u32::from_le_bytes(old_data[8..12].try_into().unwrap()) as usize;
620
621    let mut found_index: Option<usize> = None;
622    for i in 0..old_prop_count {
623        let base = 16 + i * 20;
624        let name_off = u32::from_le_bytes(old_data[base..base + 4].try_into().unwrap());
625        let name_len = u32::from_le_bytes(old_data[base + 4..base + 8].try_into().unwrap());
626        let existing_name = arena.get_str(StringRef::new(name_off, name_len));
627        if existing_name == prop_name {
628            found_index = Some(i);
629            break;
630        }
631    }
632
633    let name_ref = arena.alloc_string(prop_name);
634    let val_ref = if value_str.is_empty() {
635        StringRef::empty()
636    } else {
637        arena.alloc_string(value_str)
638    };
639
640    if let Some(idx) = found_index {
641        let mut new_data = old_data;
642        let base = 16 + idx * 20;
643        new_data[base..base + 4].copy_from_slice(&name_ref.offset.to_le_bytes());
644        new_data[base + 4..base + 8].copy_from_slice(&name_ref.len.to_le_bytes());
645        new_data[base + 8] = value_type;
646        new_data[base + 9..base + 12].copy_from_slice(&[0u8; 3]);
647        new_data[base + 12..base + 16].copy_from_slice(&val_ref.offset.to_le_bytes());
648        new_data[base + 16..base + 20].copy_from_slice(&val_ref.len.to_le_bytes());
649        arena.set_type_data(node_id, &new_data);
650    } else {
651        let new_prop_count = (old_prop_count + 1) as u32;
652        let mut new_data = Vec::with_capacity(16 + new_prop_count as usize * 20);
653        new_data.extend_from_slice(&old_data[0..8]);
654        new_data.extend_from_slice(&new_prop_count.to_le_bytes());
655        new_data.extend_from_slice(&0u32.to_le_bytes());
656        if old_prop_count > 0 {
657            new_data.extend_from_slice(&old_data[16..16 + old_prop_count * 20]);
658        }
659        new_data.extend_from_slice(&name_ref.offset.to_le_bytes());
660        new_data.extend_from_slice(&name_ref.len.to_le_bytes());
661        new_data.push(value_type);
662        new_data.extend_from_slice(&[0u8; 3]);
663        new_data.extend_from_slice(&val_ref.offset.to_le_bytes());
664        new_data.extend_from_slice(&val_ref.len.to_le_bytes());
665        arena.set_type_data(node_id, &new_data);
666    }
667
668    Ok(())
669}
670
671/// Emit a HAST JS node (from plugin JSON) into an ArenaBuilder.
672fn emit_hast_js_node(js_node: &JsNode, builder: &mut ArenaBuilder) -> Result<(), CommandError> {
673    let raw_type = name_to_hast_type(&js_node.node_type)
674        .ok_or_else(|| CommandError::UnknownNodeType(js_node.node_type.clone()))?;
675    builder.open_node_raw(raw_type as u8);
676
677    let type_data = encode_hast_js_node_data(js_node, raw_type, builder);
678    if !type_data.is_empty() {
679        builder.set_data_current(&type_data);
680    }
681
682    if let Some(children) = &js_node.children {
683        for child in children {
684            emit_hast_js_node(child, builder)?;
685        }
686    }
687
688    builder.close_node();
689    Ok(())
690}
691
692fn name_to_hast_type(name: &str) -> Option<HastNodeType> {
693    match name {
694        "root" => Some(HastNodeType::Root),
695        "element" => Some(HastNodeType::Element),
696        "text" => Some(HastNodeType::Text),
697        "comment" => Some(HastNodeType::Comment),
698        "doctype" => Some(HastNodeType::Doctype),
699        "raw" => Some(HastNodeType::Raw),
700        "mdxJsxFlowElement" => Some(HastNodeType::MdxJsxElement),
701        "mdxJsxTextElement" => Some(HastNodeType::MdxJsxTextElement),
702        "mdxFlowExpression" => Some(HastNodeType::MdxFlowExpression),
703        "mdxTextExpression" => Some(HastNodeType::MdxTextExpression),
704        "mdxjsEsm" => Some(HastNodeType::MdxEsm),
705        _ => None,
706    }
707}
708
709fn encode_hast_js_node_data(
710    js_node: &JsNode,
711    node_type: HastNodeType,
712    builder: &mut ArenaBuilder,
713) -> Vec<u8> {
714    match node_type {
715        HastNodeType::Element => {
716            let tag = js_node.tag_name.as_deref().unwrap_or("div");
717            let tag_ref = builder.alloc_string(tag);
718
719            let mut props: Vec<(StringRef, u8, StringRef)> = Vec::new();
720            if let Some(properties) = &js_node.properties {
721                for (key, value) in properties {
722                    let name_ref = builder.alloc_string(key);
723                    match value {
724                        serde_json::Value::Bool(true) => {
725                            props.push((name_ref, PROP_BOOL_TRUE, StringRef::empty()));
726                        }
727                        serde_json::Value::Bool(false) => {
728                            props.push((name_ref, PROP_BOOL_FALSE, StringRef::empty()));
729                        }
730                        serde_json::Value::String(s) => {
731                            let val_ref = builder.alloc_string(s);
732                            props.push((name_ref, PROP_STRING, val_ref));
733                        }
734                        serde_json::Value::Array(arr) => {
735                            let joined: String = arr
736                                .iter()
737                                .filter_map(|v| v.as_str())
738                                .collect::<Vec<_>>()
739                                .join(" ");
740                            let val_ref = builder.alloc_string(&joined);
741                            props.push((name_ref, PROP_SPACE_SEP, val_ref));
742                        }
743                        _ => {}
744                    }
745                }
746            }
747
748            let mut out = Vec::with_capacity(16 + props.len() * 20);
749            out.extend_from_slice(&tag_ref.offset.to_le_bytes());
750            out.extend_from_slice(&tag_ref.len.to_le_bytes());
751            out.extend_from_slice(&(props.len() as u32).to_le_bytes());
752            out.extend_from_slice(&0u32.to_le_bytes());
753            for (name_ref, kind, val_ref) in &props {
754                out.extend_from_slice(&name_ref.offset.to_le_bytes());
755                out.extend_from_slice(&name_ref.len.to_le_bytes());
756                out.push(*kind);
757                out.extend_from_slice(&[0u8; 3]);
758                out.extend_from_slice(&val_ref.offset.to_le_bytes());
759                out.extend_from_slice(&val_ref.len.to_le_bytes());
760            }
761            out
762        }
763
764        HastNodeType::Text | HastNodeType::Comment | HastNodeType::Raw => {
765            let value = js_node.value.as_deref().unwrap_or("");
766            let sref = builder.alloc_string(value);
767            let mut out = [0u8; 8];
768            out[0..4].copy_from_slice(&sref.offset.to_le_bytes());
769            out[4..8].copy_from_slice(&sref.len.to_le_bytes());
770            out.to_vec()
771        }
772
773        HastNodeType::MdxJsxElement | HastNodeType::MdxJsxTextElement => {
774            let name = js_node
775                .name
776                .as_deref()
777                .or(js_node.tag_name.as_deref())
778                .unwrap_or("");
779            let name_ref = builder.alloc_string(name);
780            let attr_tuples = encode_js_jsx_attrs(builder, js_node.attributes.as_deref());
781            encode_mdx_jsx_element_data(name_ref, &attr_tuples)
782        }
783
784        HastNodeType::MdxFlowExpression
785        | HastNodeType::MdxTextExpression
786        | HastNodeType::MdxEsm => {
787            let value = js_node.value.as_deref().unwrap_or("");
788            let sref = builder.alloc_string(value);
789            let mut out = [0u8; 8];
790            out[0..4].copy_from_slice(&sref.offset.to_le_bytes());
791            out[4..8].copy_from_slice(&sref.len.to_le_bytes());
792            out.to_vec()
793        }
794
795        _ => Vec::new(),
796    }
797}
798
799/// Returns (arena, keep_children).
800fn read_payload(
801    reader: &mut BufReader<'_>,
802    parse_markdown: &dyn Fn(&str) -> Arena,
803) -> Result<(Arena, bool), CommandError> {
804    let payload_type = reader.read_u8()?;
805    let len = reader.read_u32()? as usize;
806
807    match payload_type {
808        PAYLOAD_RAW_MARKDOWN => {
809            let md = reader.read_str(len)?;
810            Ok((parse_raw_markdown(md, parse_markdown), false))
811        }
812        PAYLOAD_RAW_HTML => {
813            let html = reader.read_str(len)?;
814            let escaped = escape_braces_in_html_text(html);
815            Ok((parse_raw_markdown(&escaped, parse_markdown), false))
816        }
817        PAYLOAD_SERDE_JSON => {
818            let json_str = reader.read_str(len)?;
819            let js_node: JsNode = serde_json::from_str(json_str)
820                .map_err(|e| CommandError::InvalidJson(e.to_string()))?;
821            js_node_to_arena(&js_node)
822        }
823        other => Err(CommandError::UnknownPayloadType(other)),
824    }
825}
826
827/// The `parse_markdown` callback avoids a circular dependency on the `parser`
828/// crate. Set-property mutations are applied in-place on the arena;
829/// structural mutations are collected as `Patch` objects and applied via `rebuild()`.
830///
831/// Takes ownership of the arena to avoid unnecessary cloning.
832pub fn apply_commands(
833    mut arena: Arena,
834    command_buf: &[u8],
835    parse_markdown: &dyn Fn(&str) -> Arena,
836) -> Result<Arena, CommandError> {
837    if command_buf.is_empty() {
838        return Ok(arena);
839    }
840
841    let mut patches: Vec<Patch> = Vec::new();
842    let mut reader = BufReader::new(command_buf);
843
844    while reader.remaining() > 0 {
845        let cmd = reader.read_u8()?;
846
847        match cmd {
848            CMD_REMOVE => {
849                let node_id = reader.read_u32()?;
850                patches.push(Patch::Remove { node_id });
851            }
852
853            CMD_SET_PROPERTY => {
854                let node_id = reader.read_u32()?;
855                let value_type = reader.read_u8()?;
856                let name_len = reader.read_u32()? as usize;
857                let name = reader.read_str(name_len)?;
858                let value_len = reader.read_u32()? as usize;
859                let value = reader.read_str(value_len)?;
860                apply_set_property(&mut arena, node_id, name, value_type, value)?;
861            }
862
863            CMD_INSERT_BEFORE => {
864                let node_id = reader.read_u32()?;
865                let (new_tree, _) = read_payload(&mut reader, parse_markdown)?;
866                patches.push(Patch::InsertBefore { node_id, new_tree });
867            }
868
869            CMD_INSERT_AFTER => {
870                let node_id = reader.read_u32()?;
871                let (new_tree, _) = read_payload(&mut reader, parse_markdown)?;
872                patches.push(Patch::InsertAfter { node_id, new_tree });
873            }
874
875            CMD_PREPEND_CHILD => {
876                let node_id = reader.read_u32()?;
877                let (child_tree, _) = read_payload(&mut reader, parse_markdown)?;
878                patches.push(Patch::PrependChild {
879                    node_id,
880                    child_tree,
881                });
882            }
883
884            CMD_APPEND_CHILD => {
885                let node_id = reader.read_u32()?;
886                let (child_tree, _) = read_payload(&mut reader, parse_markdown)?;
887                patches.push(Patch::AppendChild {
888                    node_id,
889                    child_tree,
890                });
891            }
892
893            CMD_WRAP => {
894                let node_id = reader.read_u32()?;
895                let (parent_tree, _) = read_payload(&mut reader, parse_markdown)?;
896                patches.push(Patch::Wrap {
897                    node_id,
898                    parent_tree,
899                });
900            }
901
902            CMD_REPLACE => {
903                let node_id = reader.read_u32()?;
904                let (new_tree, keep_children) = read_payload(&mut reader, parse_markdown)?;
905                patches.push(Patch::Replace {
906                    node_id,
907                    new_tree,
908                    keep_children,
909                });
910            }
911
912            other => return Err(CommandError::UnknownCommand(other)),
913        }
914    }
915
916    if patches.is_empty() {
917        Ok(arena)
918    } else {
919        satteri_ast::rebuild::rebuild(&arena, &patches)
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use satteri_ast::shared::PROP_INT;
927
928    fn test_parse_markdown(source: &str) -> Arena {
929        let mut b = ArenaBuilder::new(String::new());
930        b.open_node(MdastNodeType::Root as u8);
931        b.open_node(MdastNodeType::Paragraph as u8);
932        b.open_node(MdastNodeType::Text as u8);
933        let sref = b.alloc_string(source);
934        b.set_data_current(&satteri_arena::encode_string_ref_data(sref));
935        b.close_node();
936        b.close_node();
937        b.close_node();
938        b.finish()
939    }
940
941    fn push_u32(buf: &mut Vec<u8>, v: u32) {
942        buf.extend_from_slice(&v.to_le_bytes());
943    }
944
945    /// Encode a CMD_SET_PROPERTY command into a buffer.
946    fn push_set_property(buf: &mut Vec<u8>, node_id: u32, value_type: u8, name: &str, value: &str) {
947        buf.push(CMD_SET_PROPERTY);
948        push_u32(buf, node_id);
949        buf.push(value_type);
950        push_u32(buf, name.len() as u32);
951        buf.extend_from_slice(name.as_bytes());
952        push_u32(buf, value.len() as u32);
953        buf.extend_from_slice(value.as_bytes());
954    }
955
956    fn build_hello_world() -> Arena {
957        use satteri_ast::mdast::codec::{encode_heading_data, encode_string_ref_data};
958
959        let source = "# Hello\n\nWorld".to_string();
960        let mut b = ArenaBuilder::new(source);
961
962        b.open_node(MdastNodeType::Root as u8);
963        b.set_position_current(0, 14, 1, 1, 2, 6);
964
965        b.open_node(MdastNodeType::Heading as u8);
966        b.set_position_current(0, 7, 1, 1, 1, 8);
967        b.set_data_current(&encode_heading_data(1));
968
969        b.open_node(MdastNodeType::Text as u8);
970        b.set_position_current(2, 7, 1, 3, 1, 8);
971        b.set_data_current(&encode_string_ref_data(StringRef::new(2, 5)));
972        b.close_node();
973
974        b.close_node();
975
976        b.open_node(MdastNodeType::Paragraph as u8);
977        b.set_position_current(9, 14, 2, 1, 2, 6);
978
979        b.open_node(MdastNodeType::Text as u8);
980        b.set_position_current(9, 14, 2, 1, 2, 6);
981        b.set_data_current(&encode_string_ref_data(StringRef::new(9, 5)));
982        b.close_node();
983
984        b.close_node();
985        b.close_node();
986
987        b.finish()
988    }
989
990    #[test]
991    fn empty_command_buffer() {
992        let arena = build_hello_world();
993        let result = apply_commands(arena.clone(), &[], &test_parse_markdown).unwrap();
994        assert_eq!(result.len(), arena.len());
995    }
996
997    #[test]
998    fn remove_command() {
999        let arena = build_hello_world();
1000        let heading_id = arena.get_children(0)[0];
1001        let mut buf = Vec::new();
1002        buf.push(CMD_REMOVE);
1003        push_u32(&mut buf, heading_id);
1004
1005        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1006        assert_eq!(result.get_children(0).len(), 1);
1007        assert_eq!(
1008            result.get_node(result.get_children(0)[0]).node_type,
1009            MdastNodeType::Paragraph as u8
1010        );
1011    }
1012
1013    #[test]
1014    fn set_property_heading_depth() {
1015        let arena = build_hello_world();
1016        let heading_id = arena.get_children(0)[0];
1017
1018        let mut buf = Vec::new();
1019        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1020
1021        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1022        let heading_data = result.get_type_data(heading_id);
1023        let heading = decode_heading_data(heading_data);
1024        assert_eq!(heading.depth, 3);
1025    }
1026
1027    #[test]
1028    fn set_property_text_value() {
1029        let arena = build_hello_world();
1030        let heading_id = arena.get_children(0)[0];
1031        let text_id = arena.get_children(heading_id)[0];
1032
1033        let mut buf = Vec::new();
1034        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Goodbye");
1035
1036        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1037        let text_data = result.get_type_data(text_id);
1038        let sref = decode_string_ref_data(text_data);
1039        assert_eq!(result.get_str(sref), "Goodbye");
1040    }
1041
1042    #[test]
1043    fn replace_with_raw_markdown() {
1044        let arena = build_hello_world();
1045        let heading_id = arena.get_children(0)[0];
1046
1047        let raw_md = "## New Heading";
1048        let mut buf = Vec::new();
1049        buf.push(CMD_REPLACE);
1050        push_u32(&mut buf, heading_id);
1051        buf.push(PAYLOAD_RAW_MARKDOWN);
1052        push_u32(&mut buf, raw_md.len() as u32);
1053        buf.extend_from_slice(raw_md.as_bytes());
1054
1055        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1056        let root_children = result.get_children(0);
1057        assert!(root_children.len() >= 2);
1058    }
1059
1060    #[test]
1061    fn replace_with_serde_json() {
1062        let arena = build_hello_world();
1063        let heading_id = arena.get_children(0)[0];
1064
1065        let json =
1066            r#"{"type":"heading","depth":2,"children":[{"type":"text","value":"Replaced"}]}"#;
1067        let mut buf = Vec::new();
1068        buf.push(CMD_REPLACE);
1069        push_u32(&mut buf, heading_id);
1070        buf.push(PAYLOAD_SERDE_JSON);
1071        push_u32(&mut buf, json.len() as u32);
1072        buf.extend_from_slice(json.as_bytes());
1073
1074        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1075        let root_children = result.get_children(0);
1076        assert_eq!(root_children.len(), 2);
1077        let new_heading = root_children[0];
1078        assert_eq!(
1079            result.get_node(new_heading).node_type,
1080            MdastNodeType::Heading as u8
1081        );
1082        let heading_data = result.get_type_data(new_heading);
1083        assert_eq!(decode_heading_data(heading_data).depth, 2);
1084    }
1085
1086    #[test]
1087    fn multiple_commands() {
1088        let arena = build_hello_world();
1089        let heading_id = arena.get_children(0)[0];
1090        let text_id = arena.get_children(heading_id)[0];
1091
1092        let mut buf = Vec::new();
1093        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1094        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Hi");
1095
1096        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1097
1098        let heading_data = result.get_type_data(heading_id);
1099        assert_eq!(decode_heading_data(heading_data).depth, 3);
1100
1101        let text_data = result.get_type_data(text_id);
1102        let sref = decode_string_ref_data(text_data);
1103        assert_eq!(result.get_str(sref), "Hi");
1104    }
1105
1106    #[test]
1107    fn set_property_null() {
1108        let arena = build_hello_world();
1109        let heading_id = arena.get_children(0)[0];
1110        let text_id = arena.get_children(heading_id)[0];
1111
1112        let mut buf = Vec::new();
1113        push_set_property(&mut buf, text_id, PROP_NULL, "value", "");
1114
1115        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1116        let text_data = result.get_type_data(text_id);
1117        let sref = decode_string_ref_data(text_data);
1118        assert_eq!(sref.len, 0);
1119    }
1120
1121    #[test]
1122    fn js_node_to_arena_basic() {
1123        let js = JsNode {
1124            node_type: "heading".to_string(),
1125            children: Some(vec![JsNode {
1126                node_type: "text".to_string(),
1127                children: None,
1128                value: Some("Hello".to_string()),
1129                depth: None,
1130                url: None,
1131                title: None,
1132                alt: None,
1133                lang: None,
1134                meta: None,
1135                ordered: None,
1136                start: None,
1137                spread: None,
1138                checked: None,
1139                identifier: None,
1140                label: None,
1141                reference_type: None,
1142                name: None,
1143                attributes: None,
1144                tag_name: None,
1145                properties: None,
1146                is_hast: false,
1147                keep_children: false,
1148            }]),
1149            depth: Some(2),
1150            value: None,
1151            url: None,
1152            title: None,
1153            alt: None,
1154            lang: None,
1155            meta: None,
1156            ordered: None,
1157            start: None,
1158            spread: None,
1159            checked: None,
1160            identifier: None,
1161            label: None,
1162            reference_type: None,
1163            name: None,
1164            attributes: None,
1165            tag_name: None,
1166            properties: None,
1167            is_hast: false,
1168            keep_children: false,
1169        };
1170
1171        let (arena, _keep) = js_node_to_arena(&js).unwrap();
1172        assert_eq!(arena.len(), 2);
1173        assert_eq!(arena.get_node(0).node_type, MdastNodeType::Heading as u8);
1174        assert_eq!(arena.get_children(0).len(), 1);
1175        let text_id = arena.get_children(0)[0];
1176        assert_eq!(arena.get_node(text_id).node_type, MdastNodeType::Text as u8);
1177    }
1178
1179    #[test]
1180    fn escape_braces_in_html_text_basic() {
1181        assert_eq!(
1182            escape_braces_in_html_text("<span>{foo: 1}</span>"),
1183            "<span>{'{'}foo: 1{'}'}</span>"
1184        );
1185    }
1186
1187    #[test]
1188    fn escape_braces_preserves_attributes() {
1189        let result = escape_braces_in_html_text(r#"<span data-x="{a}">{b}</span>"#);
1190        assert!(
1191            result.contains(r#"data-x="{a}""#),
1192            "attribute braces preserved"
1193        );
1194        assert!(result.contains("{'{'}"), "text braces escaped");
1195    }
1196
1197    #[test]
1198    fn escape_braces_no_braces() {
1199        let html = r#"<pre class="shiki"><code><span style="color:red">hello</span></code></pre>"#;
1200        assert_eq!(escape_braces_in_html_text(html), html);
1201    }
1202
1203    #[test]
1204    fn escape_braces_shiki_output() {
1205        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>"#;
1206        let escaped = escape_braces_in_html_text(html);
1207        assert!(
1208            !escaped.contains(">{<"),
1209            "bare braces in text should be escaped"
1210        );
1211        assert!(
1212            !escaped.contains(">}<"),
1213            "bare braces in text should be escaped"
1214        );
1215        assert!(escaped.contains(r#"class="shiki""#));
1216        assert!(escaped.contains(r#"style="color:#E1E4E8""#));
1217    }
1218
1219    #[test]
1220    fn hast_set_property_add_new() {
1221        let arena = build_hast_element(&[]);
1222        let element_id = arena.get_children(0)[0];
1223
1224        let mut buf = Vec::new();
1225        push_set_property(&mut buf, element_id, PROP_STRING, "class", "test");
1226
1227        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1228        let data = result.get_type_data(element_id);
1229        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1230        assert_eq!(prop_count, 1);
1231        let name_ref = StringRef::new(
1232            u32::from_le_bytes(data[16..20].try_into().unwrap()),
1233            u32::from_le_bytes(data[20..24].try_into().unwrap()),
1234        );
1235        assert_eq!(result.get_str(name_ref), "class");
1236        let val_ref = StringRef::new(
1237            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1238            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1239        );
1240        assert_eq!(result.get_str(val_ref), "test");
1241        assert_eq!(data[24], PROP_STRING);
1242    }
1243
1244    #[test]
1245    fn hast_set_property_overwrite_existing() {
1246        let arena = build_hast_element(&[("class", PROP_STRING, "old")]);
1247        let element_id = arena.get_children(0)[0];
1248
1249        let mut buf = Vec::new();
1250        push_set_property(&mut buf, element_id, PROP_STRING, "class", "new-value");
1251
1252        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1253        let data = result.get_type_data(element_id);
1254        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1255        assert_eq!(prop_count, 1);
1256        let val_ref = StringRef::new(
1257            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1258            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1259        );
1260        assert_eq!(result.get_str(val_ref), "new-value");
1261    }
1262
1263    #[test]
1264    fn hast_set_property_bool_true() {
1265        let arena = build_hast_element(&[]);
1266        let element_id = arena.get_children(0)[0];
1267
1268        let mut buf = Vec::new();
1269        push_set_property(&mut buf, element_id, PROP_BOOL_TRUE, "disabled", "");
1270
1271        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1272        let data = result.get_type_data(element_id);
1273        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1274        assert_eq!(prop_count, 1);
1275        assert_eq!(data[24], PROP_BOOL_TRUE);
1276    }
1277
1278    #[test]
1279    fn hast_set_property_multiple_on_same_node() {
1280        let arena = build_hast_element(&[]);
1281        let element_id = arena.get_children(0)[0];
1282
1283        let mut buf = Vec::new();
1284        push_set_property(&mut buf, element_id, PROP_STRING, "class", "foo");
1285        push_set_property(&mut buf, element_id, PROP_STRING, "id", "bar");
1286
1287        let result = apply_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1288        let data = result.get_type_data(element_id);
1289        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1290        assert_eq!(prop_count, 2);
1291    }
1292
1293    /// Build a minimal HAST element arena: root(type 0) → element(type 1, tag "div")
1294    fn build_hast_element(props: &[(&str, u8, &str)]) -> Arena {
1295        use satteri_ast::hast::node::HastNodeType;
1296
1297        let mut b = ArenaBuilder::new(String::new());
1298        b.open_node_raw(HastNodeType::Root as u8);
1299        b.open_node_raw(HastNodeType::Element as u8);
1300        let tag_ref = b.alloc_string("div");
1301        let prop_tuples: Vec<(StringRef, u8, StringRef)> = props
1302            .iter()
1303            .map(|(name, kind, value)| {
1304                let n = b.alloc_string(name);
1305                let v = if value.is_empty() {
1306                    StringRef::empty()
1307                } else {
1308                    b.alloc_string(value)
1309                };
1310                (n, *kind, v)
1311            })
1312            .collect();
1313        let mut type_data = Vec::with_capacity(16 + prop_tuples.len() * 20);
1314        type_data.extend_from_slice(&tag_ref.offset.to_le_bytes());
1315        type_data.extend_from_slice(&tag_ref.len.to_le_bytes());
1316        type_data.extend_from_slice(&(prop_tuples.len() as u32).to_le_bytes());
1317        type_data.extend_from_slice(&0u32.to_le_bytes());
1318        for (n, kind, v) in &prop_tuples {
1319            type_data.extend_from_slice(&n.offset.to_le_bytes());
1320            type_data.extend_from_slice(&n.len.to_le_bytes());
1321            type_data.push(*kind);
1322            type_data.extend_from_slice(&[0u8; 3]);
1323            type_data.extend_from_slice(&v.offset.to_le_bytes());
1324            type_data.extend_from_slice(&v.len.to_le_bytes());
1325        }
1326        b.set_data_current(&type_data);
1327        b.close_node();
1328        b.close_node();
1329        b.finish()
1330    }
1331}