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