Skip to main content

satteri_plugin_api/
js_commands.rs

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