use satteri_arena::{Arena, ArenaBuilder, ArenaKind, Hast, Mdast, StringRef};
use satteri_ast::commands::CommandError;
use satteri_ast::hast::codec::decode_element_tag;
use satteri_ast::hast::{HastNodeType, is_void_element};
use satteri_ast::mdast::MdastNodeType;
use satteri_ast::mdast::codec::*;
use satteri_ast::patch::{Patch, PatchContent, REF_NODE_TYPE};
#[cfg(feature = "mdx")]
use satteri_ast::shared::{MDX_ATTR_BOOLEAN_PROP, MDX_ATTR_LITERAL_PROP, MDX_ATTR_SPREAD};
use satteri_ast::shared::{
PROP_BOOL_FALSE, PROP_BOOL_TRUE, PROP_INT, PROP_NULL, PROP_SPACE_SEP, PROP_STRING,
};
use crate::generated::prop_slots::{MdastPropSlot, mdast_prop_slot};
use crate::generated::wire_constants::*;
struct BufReader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> BufReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn remaining(&self) -> usize {
self.data.len() - self.pos
}
fn read_u8(&mut self) -> Result<u8, CommandError> {
if self.remaining() < 1 {
return Err(CommandError::UnexpectedEof);
}
let v = self.data[self.pos];
self.pos += 1;
Ok(v)
}
fn read_u32(&mut self) -> Result<u32, CommandError> {
if self.remaining() < 4 {
return Err(CommandError::UnexpectedEof);
}
let v = u32::from_le_bytes([
self.data[self.pos],
self.data[self.pos + 1],
self.data[self.pos + 2],
self.data[self.pos + 3],
]);
self.pos += 4;
Ok(v)
}
fn read_anchor(&mut self, original_len: u32) -> Result<u32, CommandError> {
let node_id = self.read_u32()?;
if node_id >= original_len {
return Err(CommandError::InvalidNodeId(node_id));
}
Ok(node_id)
}
fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], CommandError> {
if self.remaining() < len {
return Err(CommandError::UnexpectedEof);
}
let slice = &self.data[self.pos..self.pos + len];
self.pos += len;
Ok(slice)
}
fn read_str(&mut self, len: usize) -> Result<&'a str, CommandError> {
let bytes = self.read_bytes(len)?;
std::str::from_utf8(bytes).map_err(|_| CommandError::InvalidUtf8)
}
}
fn apply_data_property<K: ArenaKind>(
arena: &mut Arena<K>,
node_id: u32,
value_type: u8,
value_str: &str,
) {
if value_type == PROP_NULL {
arena.set_node_data(node_id, Vec::new());
} else {
arena.set_node_data(node_id, value_str.as_bytes().to_vec());
}
}
fn mdast_type_name(node_type: u8) -> String {
match MdastNodeType::from_u8(node_type) {
Some(t) => t.name().to_string(),
None => format!("unknown({node_type})"),
}
}
fn apply_mdast_set_property(
arena: &mut Arena<Mdast>,
node_id: u32,
prop_name: &str,
value_type: u8,
value_str: &str,
) -> Result<(), CommandError> {
if node_id as usize >= arena.len() {
return Err(CommandError::InvalidNodeId(node_id));
}
if prop_name == "data" {
apply_data_property(arena, node_id, value_type, value_str);
return Ok(());
}
let node_type = arena.get_node(node_id).node_type;
let slot = mdast_prop_slot(node_type, prop_name).ok_or_else(|| CommandError::UnknownField {
node_type: mdast_type_name(node_type),
name: prop_name.to_string(),
})?;
let written = write_mdast_prop_slot(arena, node_id, slot, prop_name, value_type, value_str)?;
written.ok_or_else(|| CommandError::InvalidPropertyValue {
node_type: mdast_type_name(node_type),
name: prop_name.to_string(),
})
}
fn write_mdast_prop_slot(
arena: &mut Arena<Mdast>,
node_id: u32,
slot: MdastPropSlot,
prop_name: &str,
value_type: u8,
value_str: &str,
) -> Result<Option<()>, CommandError> {
use MdastPropSlot as S;
match value_type {
PROP_STRING | PROP_SPACE_SEP => match slot {
S::Str { offset } => {
let sref = arena.alloc_string(value_str);
set_mdast_string_ref(arena, node_id, offset, sref)?;
}
S::Enum8 { offset, values } => {
let Some(v) = values.iter().position(|v| *v == value_str) else {
return Ok(None);
};
set_mdast_scalar(arena, node_id, offset, &[v as u8]);
}
_ => return Ok(None),
},
PROP_BOOL_TRUE | PROP_BOOL_FALSE => match slot {
S::Bool { offset } => {
let value = value_type == PROP_BOOL_TRUE;
set_mdast_scalar(arena, node_id, offset, &[value as u8]);
}
_ => return Ok(None),
},
PROP_INT => {
let parsed = value_str.parse::<i64>().ok().or_else(|| {
value_str
.parse::<f64>()
.ok()
.filter(|f| f.is_finite() && f.fract() == 0.0)
.map(|f| f as i64)
});
let node_type = arena.get_node(node_id).node_type;
let fitted = |max: u32| -> Result<u32, CommandError> {
match parsed {
Some(v) if (0..=max as i64).contains(&v) => Ok(v as u32),
_ => Err(CommandError::PropertyValueOutOfRange {
node_type: mdast_type_name(node_type),
name: prop_name.to_string(),
value: value_str.to_string(),
max,
}),
}
};
match slot {
S::U8 { offset } | S::CheckedTri { offset } => {
let value = fitted(u8::MAX as u32)?;
set_mdast_scalar(arena, node_id, offset, &[value as u8]);
}
S::U32 { offset } => {
let value = fitted(u32::MAX)?;
set_mdast_scalar(arena, node_id, offset, &value.to_le_bytes());
}
_ => return Ok(None),
}
}
PROP_NULL => match slot {
S::CheckedTri { offset } => set_mdast_scalar(arena, node_id, offset, &[2]),
S::Str { offset } => set_mdast_string_ref(arena, node_id, offset, StringRef::empty())?,
_ => return Ok(None),
},
_ => return Err(CommandError::InvalidPropertyValueType(value_type)),
}
Ok(Some(()))
}
fn set_mdast_string_ref(
arena: &mut Arena<Mdast>,
node_id: u32,
offset: usize,
sref: StringRef,
) -> Result<(), CommandError> {
let node = arena.get_node(node_id);
let data_offset = node.data_offset as usize;
let data_len = node.data_len as usize;
if data_len < offset + 8 {
return Err(CommandError::TypeDataTooShort);
}
let abs_offset = data_offset + offset;
arena.type_data[abs_offset..abs_offset + 8].copy_from_slice(&sref.as_bytes());
Ok(())
}
fn set_mdast_scalar(arena: &mut Arena<Mdast>, node_id: u32, offset: usize, bytes: &[u8]) {
let node = arena.get_node(node_id);
let data_offset = node.data_offset as usize;
let data_len = node.data_len as usize;
if data_len >= offset + bytes.len() {
let abs_offset = data_offset + offset;
arena.type_data[abs_offset..abs_offset + bytes.len()].copy_from_slice(bytes);
}
}
fn escape_braces_in_html_text(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
let mut in_quote: Option<char> = None;
let mut chars = html.chars().peekable();
while let Some(ch) = chars.next() {
if in_tag {
match ch {
'"' | '\'' if in_quote == Some(ch) => {
in_quote = None;
result.push(ch);
}
'"' | '\'' if in_quote.is_none() => {
in_quote = Some(ch);
result.push(ch);
}
'>' if in_quote.is_none() => {
in_tag = false;
result.push(ch);
}
_ => result.push(ch),
}
} else {
match ch {
'<' if chars.peek().copied().is_some_and(can_open_tag) => {
in_tag = true;
result.push(ch);
}
'{' => result.push_str("{'{'}"),
'}' => result.push_str("{'}'}"),
_ => result.push(ch),
}
}
}
result
}
fn can_open_tag(after: char) -> bool {
after.is_ascii_alphabetic() || matches!(after, '/' | '>' | '!' | '?' | '_' | '$')
}
#[derive(Debug, Clone, Copy)]
pub struct MdastCommandOptions {
pub escape_raw_html_braces: bool,
}
impl Default for MdastCommandOptions {
fn default() -> Self {
Self {
escape_raw_html_braces: true,
}
}
}
fn emit_ref_node<K: ArenaKind>(ref_id: u32, builder: &mut ArenaBuilder<K>) -> u32 {
let id = builder.open_node_raw(REF_NODE_TYPE);
builder.set_data_current(&ref_id.to_le_bytes());
builder.close_node();
id
}
use crate::generated::encode::{
MAX_FIXED_TYPE_DATA, encode_hast_tail_from_ops, encode_mdast_tail_from_ops,
encode_mdast_type_data_from_ops,
};
pub(crate) fn alloc_opt_str<K: ArenaKind>(
builder: &mut ArenaBuilder<K>,
s: Option<&str>,
) -> StringRef {
match s {
Some(v) if !v.is_empty() => builder.alloc_string(v),
_ => StringRef::empty(),
}
}
fn apply_hast_set_property(
arena: &mut Arena<Hast>,
node_id: u32,
prop_name: &str,
value_type: u8,
value_str: &str,
) -> Result<(), CommandError> {
if node_id as usize >= arena.len() {
return Err(CommandError::InvalidNodeId(node_id));
}
if prop_name == "data" {
apply_data_property(arena, node_id, value_type, value_str);
return Ok(());
}
let raw_type = arena.get_node(node_id).node_type;
let node_type = HastNodeType::from_u8(raw_type)
.ok_or_else(|| CommandError::UnknownNodeType(format!("hast type 0x{raw_type:02x}")))?;
match node_type {
HastNodeType::Element => {
apply_hast_element_property(arena, node_id, prop_name, value_type, value_str)
}
HastNodeType::Text
| HastNodeType::Comment
| HastNodeType::Raw
| HastNodeType::MdxFlowExpression
| HastNodeType::MdxTextExpression
| HastNodeType::MdxEsm
if prop_name == "value" =>
{
let sref = arena.alloc_string(value_str);
let data = arena.get_type_data(node_id);
if data.len() >= 8 {
let data_offset = arena.get_node(node_id).data_offset as usize;
arena.type_data[data_offset..data_offset + 8].copy_from_slice(&sref.as_bytes());
Ok(())
} else {
Err(CommandError::TypeDataTooShort)
}
}
#[cfg(feature = "mdx")]
HastNodeType::MdxJsxElement | HastNodeType::MdxJsxTextElement => {
apply_hast_mdx_jsx_attribute(arena, node_id, prop_name, value_type, value_str)
}
_ => Err(CommandError::UnknownField {
node_type: node_type.name().to_string(),
name: prop_name.to_string(),
}),
}
}
#[cfg(feature = "mdx")]
fn apply_hast_mdx_jsx_attribute(
arena: &mut Arena<Hast>,
node_id: u32,
attr_name: &str,
value_type: u8,
value_str: &str,
) -> Result<(), CommandError> {
let old_data = arena.get_type_data(node_id).to_vec();
if old_data.len() < 16 {
return Err(CommandError::TypeDataTooShort);
}
let elem_name = decode_mdx_jsx_element_name(&old_data);
let explicit = decode_mdx_jsx_explicit(&old_data);
let attr_count = decode_mdx_jsx_attr_count(&old_data);
let (kind, val_ref) = match value_type {
PROP_BOOL_TRUE | PROP_NULL => (MDX_ATTR_BOOLEAN_PROP, StringRef::empty()),
PROP_BOOL_FALSE => (MDX_ATTR_LITERAL_PROP, arena.alloc_string("false")),
_ if value_str.is_empty() => (MDX_ATTR_LITERAL_PROP, StringRef::empty()),
_ => (MDX_ATTR_LITERAL_PROP, arena.alloc_string(value_str)),
};
let mut attrs: Vec<(u8, StringRef, StringRef)> = Vec::with_capacity(attr_count as usize + 1);
let mut name_ref: Option<StringRef> = None;
for i in 0..attr_count {
let (existing_kind, existing_name, existing_value) = decode_mdx_jsx_attr(&old_data, i);
if existing_kind != MDX_ATTR_SPREAD && arena.get_str(existing_name) == attr_name {
name_ref = Some(existing_name);
continue;
}
attrs.push((existing_kind, existing_name, existing_value));
}
let name_ref = name_ref.unwrap_or_else(|| arena.alloc_string(attr_name));
attrs.push((kind, name_ref, val_ref));
arena.set_type_data(
node_id,
&encode_mdx_jsx_element_data(elem_name, &attrs, explicit),
);
Ok(())
}
fn apply_hast_element_property(
arena: &mut Arena<Hast>,
node_id: u32,
prop_name: &str,
value_type: u8,
value_str: &str,
) -> Result<(), CommandError> {
let node = arena.get_node(node_id);
let data_offset = node.data_offset as usize;
let data_len = node.data_len as usize;
if data_len < 16 {
return Err(CommandError::TypeDataTooShort);
}
let header = data_offset;
let old_prop_count =
u32::from_le_bytes(arena.type_data[header + 8..header + 12].try_into().unwrap()) as usize;
let mut found_index: Option<usize> = None;
for i in 0..old_prop_count {
let base = header + 16 + i * 20;
let name_off = u32::from_le_bytes(arena.type_data[base..base + 4].try_into().unwrap());
let name_len = u32::from_le_bytes(arena.type_data[base + 4..base + 8].try_into().unwrap());
let existing_name = arena.get_str(StringRef::new(name_off, name_len));
if existing_name == prop_name {
found_index = Some(i);
break;
}
}
let val_ref = if value_str.is_empty() {
StringRef::empty()
} else {
arena.alloc_string(value_str)
};
if let Some(idx) = found_index {
let base = header + 16 + idx * 20;
arena.type_data[base + 8] = value_type;
arena.type_data[base + 9..base + 12].copy_from_slice(&[0u8; 3]);
arena.type_data[base + 12..base + 16].copy_from_slice(&val_ref.offset.to_le_bytes());
arena.type_data[base + 16..base + 20].copy_from_slice(&val_ref.len.to_le_bytes());
} else {
let name_ref = arena.alloc_string(prop_name);
let new_offset = arena.type_data.len() as u32;
let new_prop_count = (old_prop_count + 1) as u32;
arena.type_data.extend_from_within(header..header + 8);
arena
.type_data
.extend_from_slice(&new_prop_count.to_le_bytes());
arena.type_data.extend_from_slice(&0u32.to_le_bytes());
if old_prop_count > 0 {
let props_start = header + 16;
let props_end = props_start + old_prop_count * 20;
arena.type_data.extend_from_within(props_start..props_end);
}
arena
.type_data
.extend_from_slice(&name_ref.offset.to_le_bytes());
arena
.type_data
.extend_from_slice(&name_ref.len.to_le_bytes());
arena.type_data.push(value_type);
arena.type_data.extend_from_slice(&[0u8; 3]);
arena
.type_data
.extend_from_slice(&val_ref.offset.to_le_bytes());
arena
.type_data
.extend_from_slice(&val_ref.len.to_le_bytes());
let new_len = (16 + new_prop_count as usize * 20) as u32;
let node = arena.get_node_mut(node_id);
node.data_offset = new_offset;
node.data_len = new_len;
}
Ok(())
}
trait OpCollector<'a>: Sized {
type Kind: ArenaKind;
const NUMERIC_OPS: bool;
fn open(node_type: u8) -> Self;
fn check_tag(tag: u8) -> Result<(), CommandError>;
fn finalize(&mut self, builder: &mut ArenaBuilder<Self::Kind>);
fn str_field(&mut self, field: u8, value: &'a str);
fn bool_field(&mut self, field: u8, value: bool);
fn prop(&mut self, name: &'a str, kind: u8, value: &'a str);
fn data(&mut self, bytes: &'a [u8]);
fn u8_field(&mut self, _field: u8, _value: u8) {}
fn u32_field(&mut self, _field: u8, _value: u32) {}
fn align(&mut self, _bytes: &'a [u8]) {}
}
const MAX_OPSTREAM_DEPTH: usize = 128;
fn replay_opstream<'a, C: OpCollector<'a>>(
ops: &'a [u8],
builder: &mut ArenaBuilder<C::Kind>,
original_len: u32,
anchor: u32,
) -> Result<Vec<u32>, CommandError> {
let mut reader = BufReader::new(ops);
let mut stack: Vec<C> = Vec::new();
let mut roots: Vec<u32> = Vec::new();
while reader.remaining() > 0 {
match reader.read_u8()? {
OP_OPEN => {
if let Some(c) = stack.last_mut() {
c.finalize(builder);
}
let node_type = reader.read_u8()?;
C::check_tag(node_type)?;
if !stack.is_empty() && node_type == <C::Kind as ArenaKind>::ROOT_TAG {
return Err(CommandError::UnencodableNodeType("root"));
}
if stack.len() >= MAX_OPSTREAM_DEPTH {
return Err(CommandError::OpstreamTooDeep(MAX_OPSTREAM_DEPTH));
}
let id = builder.open_node(node_type);
if stack.is_empty() {
roots.push(id);
}
stack.push(C::open(node_type));
}
OP_CLOSE => {
let Some(mut c) = stack.pop() else {
return Err(CommandError::UnbalancedOpstream);
};
c.finalize(builder);
builder.close_node();
}
OP_REF => {
if let Some(c) = stack.last_mut() {
c.finalize(builder);
}
let id = reader.read_u32()?;
if id >= original_len {
return Err(CommandError::InvalidNodeId(id));
}
let ref_id = emit_ref_node(id, builder);
if stack.is_empty() {
roots.push(ref_id);
}
}
OP_KEEP_CHILDREN => {
if let Some(c) = stack.last_mut() {
c.finalize(builder);
}
if anchor >= original_len {
return Err(CommandError::InvalidNodeId(anchor));
}
let children = builder.arena_ref().get_children(anchor).to_vec();
for child in children {
let ref_id = emit_ref_node(child, builder);
if stack.is_empty() {
roots.push(ref_id);
}
}
}
OP_STR => {
let field = reader.read_u8()?;
let len = reader.read_u32()? as usize;
let value = reader.read_str(len)?;
if let Some(c) = stack.last_mut() {
c.str_field(field, value);
}
}
OP_U8 if C::NUMERIC_OPS => {
let field = reader.read_u8()?;
let value = reader.read_u8()?;
if let Some(c) = stack.last_mut() {
c.u8_field(field, value);
}
}
OP_U32 if C::NUMERIC_OPS => {
let field = reader.read_u8()?;
let value = reader.read_u32()?;
if let Some(c) = stack.last_mut() {
c.u32_field(field, value);
}
}
OP_BOOL => {
let field = reader.read_u8()?;
let value = reader.read_u8()? != 0;
if let Some(c) = stack.last_mut() {
c.bool_field(field, value);
}
}
OP_PROP => {
let name_len = reader.read_u32()? as usize;
let name = reader.read_str(name_len)?;
let kind = reader.read_u8()?;
let val_len = reader.read_u32()? as usize;
let value = reader.read_str(val_len)?;
if let Some(c) = stack.last_mut() {
c.prop(name, kind, value);
}
}
OP_ALIGN if C::NUMERIC_OPS => {
let len = reader.read_u32()? as usize;
let bytes = reader.read_bytes(len)?;
if let Some(c) = stack.last_mut() {
c.align(bytes);
}
}
OP_DATA => {
let len = reader.read_u32()? as usize;
let bytes = reader.read_bytes(len)?;
if let Some(c) = stack.last_mut() {
c.data(bytes);
}
}
other => return Err(CommandError::UnknownCommand(other)),
}
}
if !stack.is_empty() {
return Err(CommandError::UnbalancedOpstream);
}
Ok(roots)
}
#[cfg(feature = "mdx")]
pub(crate) fn intern_mdx_jsx_attrs<K: ArenaKind>(
props: &[(&str, u8, &str)],
builder: &mut ArenaBuilder<K>,
) -> Vec<(u8, StringRef, StringRef)> {
let mut attrs = Vec::with_capacity(props.len());
for &(name, kind, value) in props {
let nr = if kind == MDX_ATTR_SPREAD {
StringRef::empty()
} else {
builder.alloc_string(name)
};
let vr = if kind == MDX_ATTR_BOOLEAN_PROP {
StringRef::empty()
} else {
builder.alloc_string(value)
};
attrs.push((kind, nr, vr));
}
attrs
}
#[derive(Default)]
pub(crate) struct FieldCollector<'a> {
node_type: u8,
finalized: bool,
pub(crate) strs: [Option<&'a str>; OF_FIELD_COUNT],
pub(crate) depth: Option<u8>,
checked: Option<u8>,
start: Option<u32>,
ordered: Option<bool>,
spread: Option<bool>,
pub(crate) props: Vec<(&'a str, u8, &'a str)>,
pub(crate) align: Option<&'a [u8]>,
pub(crate) explicit: Option<bool>,
data: Option<&'a [u8]>,
}
fn finalize_collector(c: &mut FieldCollector<'_>, builder: &mut ArenaBuilder<Mdast>) {
const LIST: u8 = MdastNodeType::List as u8;
const LIST_ITEM: u8 = MdastNodeType::ListItem as u8;
const DESCRIPTION_DETAILS: u8 = MdastNodeType::DescriptionDetails as u8;
if c.finalized {
return;
}
c.finalized = true;
let mut fixed = [0u8; MAX_FIXED_TYPE_DATA];
if let Some(len) = encode_mdast_type_data_from_ops(c, c.node_type, builder, &mut fixed) {
builder.set_data_current(&fixed[..len]);
} else if let Some(type_data) = encode_mdast_tail_from_ops(c, c.node_type, builder) {
builder.set_data_current(&type_data);
} else {
let type_data: Vec<u8> = match c.node_type {
LIST => encode_list_data(
c.ordered.unwrap_or(false),
c.start.unwrap_or(1),
c.spread.unwrap_or(false),
),
LIST_ITEM => encode_list_item_data(c.checked.unwrap_or(2), c.spread.unwrap_or(false)),
DESCRIPTION_DETAILS => encode_description_details_data(c.spread.unwrap_or(false)),
_ => Vec::new(),
};
if !type_data.is_empty() {
builder.set_data_current(&type_data);
}
}
if let Some(data) = c.data {
let id = builder.current_node_id();
builder.arena_mut().set_node_data(id, data.to_vec());
}
}
impl<'a> OpCollector<'a> for FieldCollector<'a> {
type Kind = Mdast;
const NUMERIC_OPS: bool = true;
fn open(node_type: u8) -> Self {
FieldCollector {
node_type,
..Default::default()
}
}
fn check_tag(tag: u8) -> Result<(), CommandError> {
let known = MdastNodeType::from_u8(tag).is_some();
#[cfg(not(feature = "mdx"))]
let known = known
&& !matches!(
MdastNodeType::from_u8(tag),
Some(
MdastNodeType::MdxJsxFlowElement
| MdastNodeType::MdxJsxTextElement
| MdastNodeType::MdxFlowExpression
| MdastNodeType::MdxTextExpression
| MdastNodeType::MdxjsEsm
)
);
if known {
Ok(())
} else {
Err(CommandError::UnknownNodeType(format!(
"op-stream tag {tag}"
)))
}
}
fn finalize(&mut self, builder: &mut ArenaBuilder<Mdast>) {
finalize_collector(self, builder);
}
fn str_field(&mut self, field: u8, value: &'a str) {
let field = field as usize;
if field < self.strs.len() {
self.strs[field] = Some(value);
}
}
fn bool_field(&mut self, field: u8, value: bool) {
match field {
OF_ORDERED => self.ordered = Some(value),
OF_SPREAD => self.spread = Some(value),
OF_EXPLICIT => self.explicit = Some(value),
_ => {}
}
}
fn prop(&mut self, name: &'a str, kind: u8, value: &'a str) {
self.props.push((name, kind, value));
}
fn data(&mut self, bytes: &'a [u8]) {
self.data = Some(bytes);
}
fn u8_field(&mut self, field: u8, value: u8) {
match field {
OF_DEPTH => self.depth = Some(value),
OF_CHECKED => self.checked = Some(value),
_ => {}
}
}
fn u32_field(&mut self, field: u8, value: u32) {
if field == OF_START {
self.start = Some(value);
}
}
fn align(&mut self, bytes: &'a [u8]) {
self.align = Some(bytes);
}
}
fn replay_mdast_opstream(
ops: &[u8],
builder: &mut ArenaBuilder<Mdast>,
original_len: u32,
anchor: u32,
) -> Result<Vec<u32>, CommandError> {
replay_opstream::<FieldCollector>(ops, builder, original_len, anchor)
}
fn read_mdast_payload(
reader: &mut BufReader<'_>,
parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
builder: &mut ArenaBuilder<Mdast>,
original_len: u32,
anchor: u32,
options: MdastCommandOptions,
) -> Result<(PatchContent<Mdast>, bool), CommandError> {
let payload_type = reader.read_u8()?;
match payload_type {
PAYLOAD_RAW => {
let flags = reader.read_u8()?;
let len = reader.read_u32()? as usize;
let raw = reader.read_str(len)?;
let tree = if flags & RAW_LITERAL_BRACES != 0 && options.escape_raw_html_braces {
parse_markdown(&escape_braces_in_html_text(raw))
} else {
parse_markdown(raw)
};
Ok((PatchContent::Tree(tree), false))
}
PAYLOAD_OPSTREAM => {
let len = reader.read_u32()? as usize;
let ops = reader.read_bytes(len)?;
Ok((
PatchContent::Grafted(replay_mdast_opstream(ops, builder, original_len, anchor)?),
false,
))
}
other => Err(CommandError::UnknownPayloadType(other)),
}
}
#[derive(Default)]
pub(crate) struct HastFieldCollector<'a> {
node_type: u8,
finalized: bool,
pub(crate) tag: Option<&'a str>,
value: Option<&'a str>,
pub(crate) props: Vec<(&'a str, u8, &'a str)>,
pub(crate) explicit: Option<bool>,
data: Option<&'a [u8]>,
}
fn finalize_hast_collector(c: &mut HastFieldCollector<'_>, builder: &mut ArenaBuilder<Hast>) {
const TEXT: u8 = HastNodeType::Text as u8;
const COMMENT: u8 = HastNodeType::Comment as u8;
const RAW: u8 = HastNodeType::Raw as u8;
const MDX_FLOW_EXPRESSION: u8 = HastNodeType::MdxFlowExpression as u8;
const MDX_ESM: u8 = HastNodeType::MdxEsm as u8;
const MDX_TEXT_EXPRESSION: u8 = HastNodeType::MdxTextExpression as u8;
if c.finalized {
return;
}
c.finalized = true;
if let Some(type_data) = encode_hast_tail_from_ops(c, c.node_type, builder) {
builder.set_data_current(&type_data);
} else {
let type_data: Vec<u8> = match c.node_type {
TEXT | COMMENT | RAW | MDX_FLOW_EXPRESSION | MDX_ESM | MDX_TEXT_EXPRESSION => {
let sref = builder.alloc_string(c.value.unwrap_or(""));
encode_string_ref_data(sref)
}
_ => Vec::new(),
};
if !type_data.is_empty() {
builder.set_data_current(&type_data);
}
}
if let Some(data) = c.data {
let id = builder.current_node_id();
builder.arena_mut().set_node_data(id, data.to_vec());
}
}
impl<'a> OpCollector<'a> for HastFieldCollector<'a> {
type Kind = Hast;
const NUMERIC_OPS: bool = false;
fn open(node_type: u8) -> Self {
HastFieldCollector {
node_type,
..Default::default()
}
}
fn check_tag(tag: u8) -> Result<(), CommandError> {
if HastNodeType::from_u8(tag) == Some(HastNodeType::Doctype) {
return Err(CommandError::UnencodableNodeType("doctype"));
}
let known = HastNodeType::from_u8(tag).is_some();
#[cfg(not(feature = "mdx"))]
let known = known
&& !matches!(
HastNodeType::from_u8(tag),
Some(
HastNodeType::MdxJsxElement
| HastNodeType::MdxJsxTextElement
| HastNodeType::MdxFlowExpression
| HastNodeType::MdxEsm
| HastNodeType::MdxTextExpression
)
);
if known {
Ok(())
} else {
Err(CommandError::UnknownNodeType(format!(
"op-stream tag {tag}"
)))
}
}
fn finalize(&mut self, builder: &mut ArenaBuilder<Hast>) {
finalize_hast_collector(self, builder);
}
fn str_field(&mut self, field: u8, value: &'a str) {
match field {
OF_TAGNAME | OF_NAME => self.tag = Some(value),
OF_VALUE => self.value = Some(value),
_ => {}
}
}
fn bool_field(&mut self, field: u8, value: bool) {
if field == OF_EXPLICIT {
self.explicit = Some(value);
}
}
fn prop(&mut self, name: &'a str, kind: u8, value: &'a str) {
self.props.push((name, kind, value));
}
fn data(&mut self, bytes: &'a [u8]) {
self.data = Some(bytes);
}
}
fn replay_hast_opstream(
ops: &[u8],
builder: &mut ArenaBuilder<Hast>,
original_len: u32,
anchor: u32,
) -> Result<Vec<u32>, CommandError> {
replay_opstream::<HastFieldCollector>(ops, builder, original_len, anchor)
}
fn read_hast_payload(
reader: &mut BufReader<'_>,
builder: &mut ArenaBuilder<Hast>,
original_len: u32,
anchor: u32,
for_wrap: bool,
) -> Result<(PatchContent<Hast>, bool), CommandError> {
let payload_type = reader.read_u8()?;
match payload_type {
PAYLOAD_OPSTREAM => {
let len = reader.read_u32()? as usize;
let ops = reader.read_bytes(len)?;
Ok((
PatchContent::Grafted(replay_hast_opstream(ops, builder, original_len, anchor)?),
false,
))
}
PAYLOAD_RAW if for_wrap => {
let _flags = reader.read_u8()?;
let len = reader.read_u32()? as usize;
let raw = reader.read_str(len)?;
Ok((PatchContent::Tree(hast_wrap_arena_from_html(raw)?), false))
}
other => Err(CommandError::UnknownPayloadType(other)),
}
}
#[cfg(feature = "from-html")]
fn hast_wrap_arena_from_html(raw: &str) -> Result<Arena<Hast>, CommandError> {
satteri_ast::hast::html_fragment_to_wrap_arena(raw).map_err(CommandError::InvalidRawWrapper)
}
#[cfg(not(feature = "from-html"))]
fn hast_wrap_arena_from_html(_raw: &str) -> Result<Arena<Hast>, CommandError> {
Err(CommandError::InvalidRawWrapper(
"requires HTML parsing, which this build omits (from-html feature)".to_string(),
))
}
fn is_mdast_leaf(node_type: u8) -> bool {
use MdastNodeType::*;
matches!(
MdastNodeType::from_u8(node_type),
Some(
ThematicBreak
| Html
| Code
| Definition
| Text
| InlineCode
| Break
| Image
| ImageReference
| FootnoteReference
| Yaml
| Toml
| Math
| InlineMath
| MdxFlowExpression
| MdxTextExpression
| MdxjsEsm
)
)
}
fn mdast_wrap_arena_from_tree(mut tree: Arena<Mdast>) -> Result<Arena<Mdast>, CommandError> {
let roots: &[u32] = if tree.is_empty() {
&[]
} else {
tree.get_children(0)
};
let &[wrapper] = roots else {
return Err(CommandError::InvalidRawWrapper(
"must parse to exactly one block".to_string(),
));
};
let node = *tree.get_node(wrapper);
if is_mdast_leaf(node.node_type) {
let name = MdastNodeType::from_u8(node.node_type).map_or("node", MdastNodeType::name);
return Err(CommandError::InvalidRawWrapper(format!(
"parses to a {name}, which cannot hold the wrapped node"
)));
}
let children = tree.get_children(wrapper).to_vec();
let type_data = tree.get_type_data(wrapper).to_vec();
let node_data = tree.get_node_data(wrapper).map(<[u8]>::to_vec);
tree.get_node_mut(0).node_type = node.node_type;
tree.set_position(
0,
node.start_offset,
node.end_offset,
node.start_line,
node.start_column,
node.end_line,
node.end_column,
);
tree.set_type_data(0, &type_data);
tree.set_children(0, &children);
if let Some(data) = node_data {
tree.set_node_data(0, data);
}
Ok(tree)
}
fn reject_void_wrap_parent(
parent_tree: &PatchContent<Hast>,
grafted: &Arena<Hast>,
) -> Result<(), CommandError> {
let (source, wrapper) = match parent_tree {
PatchContent::Tree(tree) if !tree.is_empty() => (tree, 0),
PatchContent::Grafted(roots) => match roots.first() {
Some(&root) => (grafted, root),
None => return Ok(()),
},
PatchContent::Tree(_) => return Ok(()),
};
if source.get_node(wrapper).node_type != HastNodeType::Element as u8 {
return Ok(());
}
let tag = source.get_str(decode_element_tag(source.get_type_data(wrapper)));
if is_void_element(tag) {
return Err(CommandError::VoidWrapParent(tag.to_string()));
}
Ok(())
}
pub fn apply_mdast_commands(
arena: Arena<Mdast>,
command_buf: &[u8],
parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
) -> Result<Arena<Mdast>, CommandError> {
apply_mdast_commands_with_options(
arena,
command_buf,
parse_markdown,
MdastCommandOptions::default(),
)
}
pub fn apply_mdast_commands_with_options(
arena: Arena<Mdast>,
command_buf: &[u8],
parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
options: MdastCommandOptions,
) -> Result<Arena<Mdast>, CommandError> {
let (arena, dropped) =
apply_mdast_commands_lenient_with_options(arena, command_buf, parse_markdown, options)?;
if let Some(anchor) = dropped.first() {
return Err(CommandError::PatchOnRemovedSubtree(*anchor));
}
Ok(arena)
}
pub fn apply_mdast_commands_lenient(
arena: Arena<Mdast>,
command_buf: &[u8],
parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
apply_mdast_commands_lenient_with_options(
arena,
command_buf,
parse_markdown,
MdastCommandOptions::default(),
)
}
pub fn apply_mdast_commands_lenient_with_options(
mut arena: Arena<Mdast>,
command_buf: &[u8],
parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
options: MdastCommandOptions,
) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
if command_buf.is_empty() {
return Ok((arena, Vec::new()));
}
let original_len = arena.len() as u32;
let mut builder =
ArenaBuilder::from_arena(std::mem::replace(&mut arena, Arena::new(String::new())));
let mut patches: Vec<Patch<Mdast>> = Vec::new();
let mut reader = BufReader::new(command_buf);
while reader.remaining() > 0 {
let cmd = reader.read_u8()?;
match cmd {
CMD_REMOVE => {
let node_id = reader.read_anchor(original_len)?;
patches.push(Patch::Remove { node_id });
}
CMD_SET_PROPERTY => {
let node_id = reader.read_anchor(original_len)?;
let value_type = reader.read_u8()?;
let name_len = reader.read_u32()? as usize;
let name = reader.read_str(name_len)?;
let value_len = reader.read_u32()? as usize;
let value = reader.read_str(value_len)?;
apply_mdast_set_property(builder.arena_mut(), node_id, name, value_type, value)?;
}
CMD_INSERT_BEFORE => {
let node_id = reader.read_anchor(original_len)?;
let (new_tree, _) = read_mdast_payload(
&mut reader,
parse_markdown,
&mut builder,
original_len,
node_id,
options,
)?;
patches.push(Patch::InsertBefore { node_id, new_tree });
}
CMD_INSERT_AFTER => {
let node_id = reader.read_anchor(original_len)?;
let (new_tree, _) = read_mdast_payload(
&mut reader,
parse_markdown,
&mut builder,
original_len,
node_id,
options,
)?;
patches.push(Patch::InsertAfter { node_id, new_tree });
}
CMD_PREPEND_CHILD => {
let node_id = reader.read_anchor(original_len)?;
let (child_tree, _) = read_mdast_payload(
&mut reader,
parse_markdown,
&mut builder,
original_len,
node_id,
options,
)?;
patches.push(Patch::PrependChild {
node_id,
child_tree,
});
}
CMD_APPEND_CHILD => {
let node_id = reader.read_anchor(original_len)?;
let (child_tree, _) = read_mdast_payload(
&mut reader,
parse_markdown,
&mut builder,
original_len,
node_id,
options,
)?;
patches.push(Patch::AppendChild {
node_id,
child_tree,
});
}
CMD_WRAP => {
let node_id = reader.read_anchor(original_len)?;
let (parent_tree, _) = read_mdast_payload(
&mut reader,
parse_markdown,
&mut builder,
original_len,
node_id,
options,
)?;
let parent_tree = match parent_tree {
PatchContent::Tree(tree) => {
PatchContent::Tree(mdast_wrap_arena_from_tree(tree)?)
}
grafted => grafted,
};
patches.push(Patch::Wrap {
node_id,
parent_tree,
});
}
CMD_REPLACE => {
let node_id = reader.read_anchor(original_len)?;
let (new_tree, keep_children) = read_mdast_payload(
&mut reader,
parse_markdown,
&mut builder,
original_len,
node_id,
options,
)?;
patches.push(Patch::Replace {
node_id,
new_tree,
keep_children,
});
}
CMD_SET_CHILDREN => {
let node_id = reader.read_anchor(original_len)?;
let (new_children, _) = read_mdast_payload(
&mut reader,
parse_markdown,
&mut builder,
original_len,
node_id,
options,
)?;
patches.push(Patch::SetChildren {
node_id,
new_children,
});
}
other => return Err(CommandError::UnknownCommand(other)),
}
}
let mut arena = builder.finish();
if patches.is_empty() {
Ok((arena, Vec::new()))
} else {
let dropped = satteri_ast::patch::apply_patches_in_place(&mut arena, &patches)?;
Ok((arena, dropped))
}
}
pub fn apply_hast_commands(
arena: Arena<Hast>,
command_buf: &[u8],
) -> Result<Arena<Hast>, CommandError> {
let (arena, dropped) = apply_hast_commands_lenient(arena, command_buf)?;
if let Some(anchor) = dropped.first() {
return Err(CommandError::PatchOnRemovedSubtree(*anchor));
}
Ok(arena)
}
pub fn apply_hast_commands_lenient(
mut arena: Arena<Hast>,
command_buf: &[u8],
) -> Result<(Arena<Hast>, Vec<u32>), CommandError> {
if command_buf.is_empty() {
return Ok((arena, Vec::new()));
}
let original_len = arena.len() as u32;
let mut builder =
ArenaBuilder::from_arena(std::mem::replace(&mut arena, Arena::new(String::new())));
let mut patches: Vec<Patch<Hast>> = Vec::new();
let mut reader = BufReader::new(command_buf);
while reader.remaining() > 0 {
let cmd = reader.read_u8()?;
match cmd {
CMD_REMOVE => {
let node_id = reader.read_anchor(original_len)?;
patches.push(Patch::Remove { node_id });
}
CMD_SET_PROPERTY => {
let node_id = reader.read_anchor(original_len)?;
let value_type = reader.read_u8()?;
let name_len = reader.read_u32()? as usize;
let name = reader.read_str(name_len)?;
let value_len = reader.read_u32()? as usize;
let value = reader.read_str(value_len)?;
apply_hast_set_property(builder.arena_mut(), node_id, name, value_type, value)?;
}
CMD_INSERT_BEFORE => {
let node_id = reader.read_anchor(original_len)?;
let (new_tree, _) =
read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
patches.push(Patch::InsertBefore { node_id, new_tree });
}
CMD_INSERT_AFTER => {
let node_id = reader.read_anchor(original_len)?;
let (new_tree, _) =
read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
patches.push(Patch::InsertAfter { node_id, new_tree });
}
CMD_PREPEND_CHILD => {
let node_id = reader.read_anchor(original_len)?;
let (child_tree, _) =
read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
patches.push(Patch::PrependChild {
node_id,
child_tree,
});
}
CMD_APPEND_CHILD => {
let node_id = reader.read_anchor(original_len)?;
let (child_tree, _) =
read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
patches.push(Patch::AppendChild {
node_id,
child_tree,
});
}
CMD_WRAP => {
let node_id = reader.read_anchor(original_len)?;
let (parent_tree, _) =
read_hast_payload(&mut reader, &mut builder, original_len, node_id, true)?;
reject_void_wrap_parent(&parent_tree, builder.arena_mut())?;
patches.push(Patch::Wrap {
node_id,
parent_tree,
});
}
CMD_REPLACE => {
let node_id = reader.read_anchor(original_len)?;
let (new_tree, keep_children) =
read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
patches.push(Patch::Replace {
node_id,
new_tree,
keep_children,
});
}
CMD_SET_CHILDREN => {
let node_id = reader.read_anchor(original_len)?;
let (new_children, _) =
read_hast_payload(&mut reader, &mut builder, original_len, node_id, false)?;
patches.push(Patch::SetChildren {
node_id,
new_children,
});
}
other => return Err(CommandError::UnknownCommand(other)),
}
}
let mut arena = builder.finish();
if patches.is_empty() {
Ok((arena, Vec::new()))
} else {
let dropped = satteri_ast::patch::apply_patches_in_place(&mut arena, &patches)?;
Ok((arena, dropped))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn replay_mdast_for_test(
ops: &[u8],
orig: &Arena<Mdast>,
anchor: u32,
) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
let original_len = orig.len() as u32;
let mut builder = ArenaBuilder::from_arena(orig.clone());
let roots = replay_mdast_opstream(ops, &mut builder, original_len, anchor)?;
Ok((builder.finish(), roots))
}
fn replay_hast_for_test(
ops: &[u8],
orig: &Arena<Hast>,
anchor: u32,
) -> Result<(Arena<Hast>, Vec<u32>), CommandError> {
let original_len = orig.len() as u32;
let mut builder = ArenaBuilder::from_arena(orig.clone());
let roots = replay_hast_opstream(ops, &mut builder, original_len, anchor)?;
Ok((builder.finish(), roots))
}
use satteri_ast::shared::PROP_INT;
fn op_open(b: &mut Vec<u8>, t: MdastNodeType) {
b.push(OP_OPEN);
b.push(t as u8);
}
fn op_close(b: &mut Vec<u8>) {
b.push(OP_CLOSE);
}
fn op_str(b: &mut Vec<u8>, field: u8, s: &str) {
b.push(OP_STR);
b.push(field);
b.extend_from_slice(&(s.len() as u32).to_le_bytes());
b.extend_from_slice(s.as_bytes());
}
fn op_u8(b: &mut Vec<u8>, field: u8, v: u8) {
b.push(OP_U8);
b.push(field);
b.push(v);
}
#[test]
fn opstream_replay_builds_subtree() {
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Blockquote);
op_open(&mut ops, MdastNodeType::Heading);
op_u8(&mut ops, OF_DEPTH, 3);
op_open(&mut ops, MdastNodeType::Text);
op_str(&mut ops, OF_VALUE, "Note");
op_close(&mut ops);
op_close(&mut ops);
op_open(&mut ops, MdastNodeType::Paragraph);
op_open(&mut ops, MdastNodeType::Text);
op_str(&mut ops, OF_VALUE, "Body");
op_close(&mut ops);
op_close(&mut ops);
op_close(&mut ops);
let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
let (arena, roots) = replay_mdast_for_test(&ops, &empty, 0).unwrap();
let bq = roots[0];
assert_eq!(
arena.get_node(bq).node_type,
MdastNodeType::Blockquote as u8
);
let top = arena.get_children(bq).to_vec();
assert_eq!(top.len(), 2);
let h = top[0];
assert_eq!(arena.get_node(h).node_type, MdastNodeType::Heading as u8);
assert_eq!(decode_heading_data(arena.get_type_data(h)).depth, 3);
let h_text = arena.get_children(h)[0];
assert_eq!(arena.get_node(h_text).node_type, MdastNodeType::Text as u8);
let sref = decode_string_ref_data(arena.get_type_data(h_text));
assert_eq!(arena.get_str(sref), "Note");
let p = top[1];
assert_eq!(arena.get_node(p).node_type, MdastNodeType::Paragraph as u8);
let p_text = arena.get_children(p)[0];
assert_eq!(
arena.get_str(decode_string_ref_data(arena.get_type_data(p_text))),
"Body"
);
}
#[test]
fn opstream_replay_rejects_unbalanced_close() {
let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
let err = replay_mdast_for_test(&[OP_CLOSE], &empty, 0).unwrap_err();
assert!(matches!(err, CommandError::UnbalancedOpstream));
let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
let err = replay_hast_for_test(&[OP_CLOSE], &empty_hast, 0).unwrap_err();
assert!(matches!(err, CommandError::UnbalancedOpstream));
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Paragraph);
op_close(&mut ops);
op_close(&mut ops);
let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
assert!(matches!(err, CommandError::UnbalancedOpstream));
}
#[test]
fn opstream_replay_rejects_unclosed_node() {
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Heading);
op_u8(&mut ops, OF_DEPTH, 2);
let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
assert!(matches!(err, CommandError::UnbalancedOpstream));
let hast_ops = vec![OP_OPEN, HastNodeType::Element as u8];
let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
let err = replay_hast_for_test(&hast_ops, &empty_hast, 0).unwrap_err();
assert!(matches!(err, CommandError::UnbalancedOpstream));
}
#[test]
fn opstream_keep_children_rejects_out_of_range_anchor() {
let orig = test_parse_markdown("Hello");
let bad_anchor = orig.len() as u32;
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Heading);
ops.push(OP_KEEP_CHILDREN);
op_close(&mut ops);
let err = replay_mdast_for_test(&ops, &orig, bad_anchor).unwrap_err();
assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_anchor));
}
#[test]
fn set_property_rejects_out_of_range_node_id() {
let arena = build_hello_world();
let bad_id = arena.len() as u32;
let mut buf = Vec::new();
push_set_property(&mut buf, bad_id, PROP_INT, "depth", "3");
let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_id));
assert!(err.to_string().contains("invalid node id"));
let hast = build_hast_element(&[]);
let bad_id = hast.len() as u32;
let mut buf = Vec::new();
push_set_property(&mut buf, bad_id, PROP_STRING, "class", "x");
let err = apply_hast_commands(hast, &buf).unwrap_err();
assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad_id));
}
#[test]
fn opstream_replay_rejects_unknown_tags() {
let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
let err = replay_mdast_for_test(&[OP_OPEN, 200], &empty, 0).unwrap_err();
assert!(matches!(err, CommandError::UnknownNodeType(_)));
let empty_hast = ArenaBuilder::<Hast>::new(String::new()).finish();
let err = replay_hast_for_test(&[OP_OPEN, 200], &empty_hast, 0).unwrap_err();
assert!(matches!(err, CommandError::UnknownNodeType(_)));
}
#[test]
fn opstream_keep_children_splices_original_children() {
let orig = test_parse_markdown("Hello");
let para = orig.get_children(0)[0];
let orig_text = orig.get_children(para)[0];
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Heading);
op_u8(&mut ops, OF_DEPTH, 2);
ops.push(OP_KEEP_CHILDREN);
op_close(&mut ops);
let (arena, roots) = replay_mdast_for_test(&ops, &orig, para).unwrap();
let heading = roots[0];
assert_eq!(
arena.get_node(heading).node_type,
MdastNodeType::Heading as u8
);
assert_eq!(decode_heading_data(arena.get_type_data(heading)).depth, 2);
let children = arena.get_children(heading).to_vec();
assert_eq!(children.len(), 1);
assert_eq!(arena.get_node(children[0]).node_type, REF_NODE_TYPE);
assert_eq!(
u32::from_le_bytes(arena.get_type_data(children[0]).try_into().unwrap()),
orig_text
);
}
#[test]
fn opstream_ref_rejects_out_of_range_id() {
let orig = test_parse_markdown("Hello");
let bad = orig.len() as u32 + 100;
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Paragraph);
ops.push(OP_REF);
ops.extend_from_slice(&bad.to_le_bytes());
op_close(&mut ops);
let err = replay_mdast_for_test(&ops, &orig, 0).unwrap_err();
assert!(matches!(err, CommandError::InvalidNodeId(id) if id == bad));
}
#[test]
fn opstream_rejects_nested_root() {
let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
let mut ok = Vec::new();
op_open(&mut ok, MdastNodeType::Root);
op_close(&mut ok);
assert!(replay_mdast_for_test(&ok, &empty, 0).is_ok());
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Root);
op_open(&mut ops, MdastNodeType::Root);
let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
assert!(matches!(err, CommandError::UnencodableNodeType("root")));
}
#[test]
fn hast_opstream_rejects_doctype() {
let empty = ArenaBuilder::<Hast>::new(String::new()).finish();
let ops = vec![OP_OPEN, HastNodeType::Doctype as u8, OP_CLOSE];
let err = replay_hast_for_test(&ops, &empty, 0).unwrap_err();
assert!(matches!(err, CommandError::UnencodableNodeType("doctype")));
}
#[test]
fn opstream_rejects_over_deep_nesting() {
let empty = ArenaBuilder::<Mdast>::new(String::new()).finish();
let mut ops = Vec::new();
for _ in 0..(MAX_OPSTREAM_DEPTH + 1) {
op_open(&mut ops, MdastNodeType::Blockquote);
}
let err = replay_mdast_for_test(&ops, &empty, 0).unwrap_err();
assert!(matches!(err, CommandError::OpstreamTooDeep(_)));
}
#[test]
fn set_property_rejects_out_of_range_or_unparseable_int() {
let heading_id = 1;
let mut buf = Vec::new();
push_set_property(&mut buf, heading_id, PROP_INT, "depth", "9999");
let err =
apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap_err();
assert!(matches!(err, CommandError::PropertyValueOutOfRange { .. }));
let mut buf = Vec::new();
push_set_property(&mut buf, heading_id, PROP_INT, "depth", "not-a-number");
let err =
apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap_err();
assert!(matches!(err, CommandError::PropertyValueOutOfRange { .. }));
let mut buf = Vec::new();
push_set_property(&mut buf, heading_id, PROP_INT, "depth", "255");
let arena = apply_mdast_commands(build_hello_world(), &buf, &test_parse_markdown).unwrap();
assert_eq!(
decode_heading_data(arena.get_type_data(heading_id)).depth,
255
);
}
#[cfg(feature = "mdx")]
#[test]
fn mdx_jsx_set_property_replaces_named_attrs_and_keeps_explicit() {
use satteri_ast::shared::MDX_ATTR_EXPRESSION_PROP;
let mut b = ArenaBuilder::<Hast>::new(String::new());
b.open_node(HastNodeType::Root as u8);
b.open_node(HastNodeType::MdxJsxElement as u8);
let elem_name = b.alloc_string("Box");
let foo = b.alloc_string("foo");
let expr = b.alloc_string("1+1");
let rest = b.alloc_string("rest");
let attrs = vec![
(MDX_ATTR_EXPRESSION_PROP, foo, expr),
(MDX_ATTR_SPREAD, StringRef::empty(), rest),
];
b.set_data_current(&encode_mdx_jsx_element_data(elem_name, &attrs, true));
b.close_node();
b.close_node();
let mut arena = b.finish();
apply_hast_mdx_jsx_attribute(&mut arena, 1, "foo", PROP_STRING, "x").unwrap();
let data = arena.get_type_data(1).to_vec();
assert_eq!(decode_mdx_jsx_attr_count(&data), 2);
assert!(decode_mdx_jsx_explicit(&data));
let (k0, _, _) = decode_mdx_jsx_attr(&data, 0);
assert_eq!(k0, MDX_ATTR_SPREAD);
let (k1, n1, v1) = decode_mdx_jsx_attr(&data, 1);
assert_eq!(k1, MDX_ATTR_LITERAL_PROP);
assert_eq!(arena.get_str(n1), "foo");
assert_eq!(arena.get_str(v1), "x");
apply_hast_mdx_jsx_attribute(&mut arena, 1, "id", PROP_STRING, "intro").unwrap();
let data = arena.get_type_data(1).to_vec();
assert_eq!(decode_mdx_jsx_attr_count(&data), 3);
assert!(decode_mdx_jsx_explicit(&data));
}
fn test_parse_markdown(source: &str) -> Arena<Mdast> {
let mut b = ArenaBuilder::<Mdast>::new(String::new());
b.open_node(MdastNodeType::Root as u8);
b.open_node(MdastNodeType::Paragraph as u8);
b.open_node(MdastNodeType::Text as u8);
let sref = b.alloc_string(source);
b.set_data_current(&satteri_arena::encode_string_ref_data(sref));
b.close_node();
b.close_node();
b.close_node();
b.finish()
}
fn push_u32(buf: &mut Vec<u8>, v: u32) {
buf.extend_from_slice(&v.to_le_bytes());
}
fn push_set_property(buf: &mut Vec<u8>, node_id: u32, value_type: u8, name: &str, value: &str) {
buf.push(CMD_SET_PROPERTY);
push_u32(buf, node_id);
buf.push(value_type);
push_u32(buf, name.len() as u32);
buf.extend_from_slice(name.as_bytes());
push_u32(buf, value.len() as u32);
buf.extend_from_slice(value.as_bytes());
}
fn build_hello_world() -> Arena<Mdast> {
use satteri_ast::mdast::codec::{encode_heading_data, encode_string_ref_data};
let source = "# Hello\n\nWorld".to_string();
let mut b = ArenaBuilder::<Mdast>::new(source);
b.open_node(MdastNodeType::Root as u8);
b.set_position_current(0, 14, 1, 1, 2, 6);
b.open_node(MdastNodeType::Heading as u8);
b.set_position_current(0, 7, 1, 1, 1, 8);
b.set_data_current(&encode_heading_data(1));
b.open_node(MdastNodeType::Text as u8);
b.set_position_current(2, 7, 1, 3, 1, 8);
b.set_data_current(&encode_string_ref_data(StringRef::new(2, 5)));
b.close_node();
b.close_node();
b.open_node(MdastNodeType::Paragraph as u8);
b.set_position_current(9, 14, 2, 1, 2, 6);
b.open_node(MdastNodeType::Text as u8);
b.set_position_current(9, 14, 2, 1, 2, 6);
b.set_data_current(&encode_string_ref_data(StringRef::new(9, 5)));
b.close_node();
b.close_node();
b.close_node();
b.finish()
}
#[test]
fn empty_command_buffer() {
let arena = build_hello_world();
let result = apply_mdast_commands(arena.clone(), &[], &test_parse_markdown).unwrap();
assert_eq!(result.len(), arena.len());
}
#[test]
fn remove_command() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let mut buf = Vec::new();
buf.push(CMD_REMOVE);
push_u32(&mut buf, heading_id);
let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
assert_eq!(result.get_children(0).len(), 1);
assert_eq!(
result.get_node(result.get_children(0)[0]).node_type,
MdastNodeType::Paragraph as u8
);
}
#[test]
fn set_property_heading_depth() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
let heading_data = result.get_type_data(heading_id);
let heading = decode_heading_data(heading_data);
assert_eq!(heading.depth, 3);
}
#[test]
fn set_property_text_value() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let text_id = arena.get_children(heading_id)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, text_id, PROP_STRING, "value", "Goodbye");
let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
let text_data = result.get_type_data(text_id);
let sref = decode_string_ref_data(text_data);
assert_eq!(result.get_str(sref), "Goodbye");
}
#[test]
fn replace_with_raw_markdown() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let raw_md = "## New Heading";
let mut buf = Vec::new();
buf.push(CMD_REPLACE);
push_u32(&mut buf, heading_id);
buf.push(PAYLOAD_RAW);
buf.push(0); push_u32(&mut buf, raw_md.len() as u32);
buf.extend_from_slice(raw_md.as_bytes());
let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
let root_children = result.get_children(0);
assert!(root_children.len() >= 2);
}
#[test]
fn stale_anchor_in_grafted_orphan_range_errors() {
let arena = build_hello_world();
let original_len = arena.len() as u32;
let mut ops = Vec::new();
op_open(&mut ops, MdastNodeType::Paragraph);
op_close(&mut ops);
let mut buf = Vec::new();
buf.push(CMD_APPEND_CHILD);
push_u32(&mut buf, 0);
buf.push(PAYLOAD_OPSTREAM);
push_u32(&mut buf, ops.len() as u32);
buf.extend_from_slice(&ops);
buf.push(CMD_REMOVE);
push_u32(&mut buf, original_len);
let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
assert!(matches!(err, CommandError::InvalidNodeId(id) if id == original_len));
}
#[test]
fn multiple_commands() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let text_id = arena.get_children(heading_id)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
push_set_property(&mut buf, text_id, PROP_STRING, "value", "Hi");
let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
let heading_data = result.get_type_data(heading_id);
assert_eq!(decode_heading_data(heading_data).depth, 3);
let text_data = result.get_type_data(text_id);
let sref = decode_string_ref_data(text_data);
assert_eq!(result.get_str(sref), "Hi");
}
#[test]
fn set_property_null() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let text_id = arena.get_children(heading_id)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, text_id, PROP_NULL, "value", "");
let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
let text_data = result.get_type_data(text_id);
let sref = decode_string_ref_data(text_data);
assert_eq!(sref.len, 0);
}
#[test]
fn set_property_invalid_field_reports_property_and_node_type() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, heading_id, PROP_STRING, "value", "x");
let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
assert!(matches!(
err,
CommandError::UnknownField { ref name, ref node_type }
if name == "value" && node_type == "heading"
));
assert_eq!(
err.to_string(),
"cannot set property 'value' on a 'heading' node"
);
}
#[test]
fn set_property_wrong_value_type_reports_value_mismatch() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, heading_id, PROP_STRING, "depth", "3");
let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
assert!(matches!(
err,
CommandError::InvalidPropertyValue { ref name, ref node_type }
if name == "depth" && node_type == "heading"
));
assert_eq!(
err.to_string(),
"property 'depth' on a 'heading' node cannot hold a value of this type"
);
}
fn build_single_node(node_type: MdastNodeType, type_data: &[u8]) -> Arena<Mdast> {
let mut b = ArenaBuilder::<Mdast>::new(String::new());
b.open_node(MdastNodeType::Root as u8);
b.open_node(node_type as u8);
b.set_data_current(type_data);
b.close_node();
b.close_node();
b.finish()
}
#[test]
fn set_property_image_reference_alt_roundtrip() {
let mut b = ArenaBuilder::<Mdast>::new(String::new());
b.open_node(MdastNodeType::Root as u8);
b.open_node(MdastNodeType::ImageReference as u8);
let identifier = b.alloc_string("img");
let alt = b.alloc_string("old");
b.set_data_current(&encode_image_reference_data(identifier, identifier, 0, alt));
b.close_node();
b.close_node();
let arena = b.finish();
let image_ref_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, image_ref_id, PROP_STRING, "alt", "new alt");
let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
let alt = decode_image_reference_alt(result.get_type_data(image_ref_id));
assert_eq!(result.get_str(alt), "new alt");
}
#[test]
fn set_property_reference_type_valid_and_invalid() {
let mut b = ArenaBuilder::<Mdast>::new(String::new());
b.open_node(MdastNodeType::Root as u8);
b.open_node(MdastNodeType::LinkReference as u8);
let identifier = b.alloc_string("ref");
b.set_data_current(&encode_reference_data(identifier, identifier, 0));
b.close_node();
b.close_node();
let arena = b.finish();
let link_ref_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, link_ref_id, PROP_STRING, "referenceType", "full");
let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
let reference = decode_reference_data(result.get_type_data(link_ref_id));
assert_eq!(reference.reference_kind, 2);
let mut buf = Vec::new();
push_set_property(&mut buf, link_ref_id, PROP_STRING, "referenceType", "bogus");
let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
assert!(matches!(
err,
CommandError::InvalidPropertyValue { ref name, ref node_type }
if name == "referenceType" && node_type == "linkReference"
));
}
#[test]
fn set_property_list_start_and_ordered() {
let arena = build_single_node(MdastNodeType::List, &encode_list_data(false, 1, false));
let list_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, list_id, PROP_INT, "start", "5");
push_set_property(&mut buf, list_id, PROP_BOOL_TRUE, "ordered", "");
let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
let list = decode_list_data(result.get_type_data(list_id));
assert_eq!(list.start, 5);
assert!(list.ordered);
assert!(!list.spread);
}
#[test]
fn escape_braces_in_html_text_basic() {
assert_eq!(
escape_braces_in_html_text("<span>{foo: 1}</span>"),
"<span>{'{'}foo: 1{'}'}</span>"
);
}
#[test]
fn escape_braces_after_comparison_less_than() {
assert_eq!(
escape_braces_in_html_text("<span>5 < 6 and {literal} here</span>"),
"<span>5 < 6 and {'{'}literal{'}'} here</span>"
);
assert_eq!(
escape_braces_in_html_text("a < b {notExpr} tail"),
"a < b {'{'}notExpr{'}'} tail"
);
assert_eq!(
escape_braces_in_html_text("trailing {x} <"),
"trailing {'{'}x{'}'} <"
);
}
#[test]
fn escape_braces_preserves_attributes() {
let result = escape_braces_in_html_text(r#"<span data-x="{a}">{b}</span>"#);
assert!(
result.contains(r#"data-x="{a}""#),
"attribute braces preserved"
);
assert!(result.contains("{'{'}"), "text braces escaped");
}
#[test]
fn escape_braces_no_braces() {
let html = r#"<pre class="shiki"><code><span style="color:red">hello</span></code></pre>"#;
assert_eq!(escape_braces_in_html_text(html), html);
}
#[test]
fn escape_braces_shiki_output() {
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>"#;
let escaped = escape_braces_in_html_text(html);
assert!(
!escaped.contains(">{<"),
"bare braces in text should be escaped"
);
assert!(
!escaped.contains(">}<"),
"bare braces in text should be escaped"
);
assert!(escaped.contains(r#"class="shiki""#));
assert!(escaped.contains(r#"style="color:#E1E4E8""#));
}
#[test]
fn hast_set_property_add_new() {
let arena = build_hast_element(&[]);
let element_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, element_id, PROP_STRING, "class", "test");
let result = apply_hast_commands(arena.clone(), &buf).unwrap();
let data = result.get_type_data(element_id);
let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
assert_eq!(prop_count, 1);
let name_ref = StringRef::new(
u32::from_le_bytes(data[16..20].try_into().unwrap()),
u32::from_le_bytes(data[20..24].try_into().unwrap()),
);
assert_eq!(result.get_str(name_ref), "class");
let val_ref = StringRef::new(
u32::from_le_bytes(data[28..32].try_into().unwrap()),
u32::from_le_bytes(data[32..36].try_into().unwrap()),
);
assert_eq!(result.get_str(val_ref), "test");
assert_eq!(data[24], PROP_STRING);
}
#[test]
fn hast_set_property_overwrite_existing() {
let arena = build_hast_element(&[("class", PROP_STRING, "old")]);
let element_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, element_id, PROP_STRING, "class", "new-value");
let result = apply_hast_commands(arena.clone(), &buf).unwrap();
let data = result.get_type_data(element_id);
let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
assert_eq!(prop_count, 1);
let val_ref = StringRef::new(
u32::from_le_bytes(data[28..32].try_into().unwrap()),
u32::from_le_bytes(data[32..36].try_into().unwrap()),
);
assert_eq!(result.get_str(val_ref), "new-value");
}
#[test]
fn hast_set_property_bool_true() {
let arena = build_hast_element(&[]);
let element_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, element_id, PROP_BOOL_TRUE, "disabled", "");
let result = apply_hast_commands(arena.clone(), &buf).unwrap();
let data = result.get_type_data(element_id);
let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
assert_eq!(prop_count, 1);
assert_eq!(data[24], PROP_BOOL_TRUE);
}
#[test]
fn hast_set_property_multiple_on_same_node() {
let arena = build_hast_element(&[]);
let element_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_set_property(&mut buf, element_id, PROP_STRING, "class", "foo");
push_set_property(&mut buf, element_id, PROP_STRING, "id", "bar");
let result = apply_hast_commands(arena.clone(), &buf).unwrap();
let data = result.get_type_data(element_id);
let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
assert_eq!(prop_count, 2);
}
fn build_hast_element(props: &[(&str, u8, &str)]) -> Arena<Hast> {
use satteri_ast::hast::node::HastNodeType;
let mut b = ArenaBuilder::<Hast>::new(String::new());
b.open_node_raw(HastNodeType::Root as u8);
b.open_node_raw(HastNodeType::Element as u8);
let tag_ref = b.alloc_string("div");
let prop_tuples: Vec<(StringRef, u8, StringRef)> = props
.iter()
.map(|(name, kind, value)| {
let n = b.alloc_string(name);
let v = if value.is_empty() {
StringRef::empty()
} else {
b.alloc_string(value)
};
(n, *kind, v)
})
.collect();
let mut type_data = Vec::with_capacity(16 + prop_tuples.len() * 20);
type_data.extend_from_slice(&tag_ref.offset.to_le_bytes());
type_data.extend_from_slice(&tag_ref.len.to_le_bytes());
type_data.extend_from_slice(&(prop_tuples.len() as u32).to_le_bytes());
type_data.extend_from_slice(&0u32.to_le_bytes());
for (n, kind, v) in &prop_tuples {
type_data.extend_from_slice(&n.offset.to_le_bytes());
type_data.extend_from_slice(&n.len.to_le_bytes());
type_data.push(*kind);
type_data.extend_from_slice(&[0u8; 3]);
type_data.extend_from_slice(&v.offset.to_le_bytes());
type_data.extend_from_slice(&v.len.to_le_bytes());
}
b.set_data_current(&type_data);
b.close_node();
b.close_node();
b.finish()
}
fn push_raw_command(buf: &mut Vec<u8>, cmd: u8, node_id: u32, raw: &str) {
buf.push(cmd);
push_u32(buf, node_id);
buf.push(PAYLOAD_RAW);
buf.push(0); push_u32(buf, raw.len() as u32);
buf.extend_from_slice(raw.as_bytes());
}
fn parse_two_blocks(_source: &str) -> Arena<Mdast> {
let mut b = ArenaBuilder::<Mdast>::new(String::new());
b.open_node(MdastNodeType::Root as u8);
b.open_node(MdastNodeType::Paragraph as u8);
b.close_node();
b.open_node(MdastNodeType::Paragraph as u8);
b.close_node();
b.close_node();
b.finish()
}
fn parse_leaf_block(_source: &str) -> Arena<Mdast> {
let mut b = ArenaBuilder::<Mdast>::new(String::new());
b.open_node(MdastNodeType::Root as u8);
b.open_node(MdastNodeType::ThematicBreak as u8);
b.close_node();
b.close_node();
b.finish()
}
#[test]
fn mdast_wrap_with_raw_payload() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_raw_command(&mut buf, CMD_WRAP, heading_id, "quoted");
let result = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap();
let wrapper = result.get_children(0)[0];
assert_eq!(
result.get_node(wrapper).node_type,
MdastNodeType::Paragraph as u8
);
let wrapped = result.get_children(wrapper);
assert_eq!(wrapped.len(), 2);
assert_eq!(
result.get_node(wrapped[0]).node_type,
MdastNodeType::Heading as u8
);
assert_eq!(
result.get_node(wrapped[1]).node_type,
MdastNodeType::Text as u8
);
}
#[test]
fn mdast_wrap_with_multi_block_raw_payload_errors() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_raw_command(&mut buf, CMD_WRAP, heading_id, "one\n\ntwo");
let err = apply_mdast_commands(arena, &buf, &parse_two_blocks).unwrap_err();
assert!(
matches!(&err, CommandError::InvalidRawWrapper(r) if r.contains("exactly one block")),
"{err:?}"
);
}
#[test]
fn mdast_wrap_with_leaf_raw_payload_errors() {
let arena = build_hello_world();
let heading_id = arena.get_children(0)[0];
let mut buf = Vec::new();
push_raw_command(&mut buf, CMD_WRAP, heading_id, "---");
let err = apply_mdast_commands(arena, &buf, &parse_leaf_block).unwrap_err();
assert!(
matches!(&err, CommandError::InvalidRawWrapper(r) if r.contains("thematicBreak")),
"{err:?}"
);
}
#[cfg(feature = "from-html")]
#[test]
fn hast_wrap_with_raw_html_payload() {
let arena = build_hast_element(&[]);
let mut buf = Vec::new();
push_raw_command(&mut buf, CMD_WRAP, 1, "<section class=\"wrap\"></section>");
let result = apply_hast_commands(arena, &buf).unwrap();
let section = result.get_children(0)[0];
assert_eq!(
result.get_str(decode_element_tag(result.get_type_data(section))),
"section"
);
let wrapped = result.get_children(section);
assert_eq!(wrapped.len(), 1);
assert_eq!(
result.get_str(decode_element_tag(result.get_type_data(wrapped[0]))),
"div"
);
}
#[cfg(feature = "from-html")]
#[test]
fn hast_wrap_with_invalid_raw_html_errors() {
let arena = build_hast_element(&[]);
let mut buf = Vec::new();
push_raw_command(&mut buf, CMD_WRAP, 1, "just text");
let err = apply_hast_commands(arena, &buf).unwrap_err();
assert!(matches!(err, CommandError::InvalidRawWrapper(_)), "{err:?}");
}
fn push_element_wrap_command(buf: &mut Vec<u8>, node_id: u32, tag: &str) {
let mut ops = Vec::new();
ops.push(OP_OPEN);
ops.push(HastNodeType::Element as u8);
op_str(&mut ops, OF_TAGNAME, tag);
ops.push(OP_CLOSE);
buf.push(CMD_WRAP);
push_u32(buf, node_id);
buf.push(PAYLOAD_OPSTREAM);
push_u32(buf, ops.len() as u32);
buf.extend_from_slice(&ops);
}
#[test]
fn hast_wrap_with_void_element_payload_errors() {
let arena = build_hast_element(&[]);
let mut buf = Vec::new();
push_element_wrap_command(&mut buf, 1, "img");
let err = apply_hast_commands(arena, &buf).unwrap_err();
assert!(
matches!(&err, CommandError::VoidWrapParent(tag) if tag == "img"),
"{err:?}"
);
}
#[test]
fn hast_wrap_with_element_payload_wraps() {
let arena = build_hast_element(&[]);
let mut buf = Vec::new();
push_element_wrap_command(&mut buf, 1, "section");
let result = apply_hast_commands(arena, &buf).unwrap();
let section = result.get_children(0)[0];
assert_eq!(
result.get_str(decode_element_tag(result.get_type_data(section))),
"section"
);
let wrapped = result.get_children(section);
assert_eq!(wrapped.len(), 1);
assert_eq!(
result.get_str(decode_element_tag(result.get_type_data(wrapped[0]))),
"div"
);
}
#[test]
fn hast_raw_payload_rejected_outside_wrap() {
let arena = build_hast_element(&[]);
let mut buf = Vec::new();
push_raw_command(&mut buf, CMD_INSERT_BEFORE, 1, "<span></span>");
let err = apply_hast_commands(arena, &buf).unwrap_err();
assert!(
matches!(err, CommandError::UnknownPayloadType(p) if p == PAYLOAD_RAW),
"{err:?}"
);
}
}