mod error;
pub(crate) use twig_sys as ffi;
use std::ops::Range;
use std::os::raw::{c_char, c_int};
use std::ptr::NonNull;
pub use error::Error;
pub use ffi::TwigSpan as Span;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Format {
Djot,
Markdown,
Xml,
Html,
}
impl From<Format> for ffi::TwigFormat {
fn from(value: Format) -> Self {
match value {
Format::Djot => ffi::TwigFormat::Djot,
Format::Markdown => ffi::TwigFormat::Markdown,
Format::Xml => ffi::TwigFormat::Xml,
Format::Html => ffi::TwigFormat::Html,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct QueryMatch {
pub node_id: u32,
pub span: Range<usize>,
pub content_span: Option<Range<usize>>,
pub kind: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Change {
pub old: Range<usize>,
pub new: Range<usize>,
}
impl Change {
pub fn delta(&self) -> isize {
self.new.len() as isize - self.old.len() as isize
}
fn from_ffi(c: ffi::TwigChange) -> Self {
Change {
old: c.old_span.start..c.old_span.end,
new: c.new_span.start..c.new_span.end,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct FlatNode {
pub id: NodeId,
pub parent: Option<NodeId>,
pub first_child: Option<NodeId>,
pub next_sibling: Option<NodeId>,
pub span: Range<usize>,
pub content_span: Option<Range<usize>>,
pub level: Option<u32>,
pub kind: String,
pub text: Option<String>,
pub destination: Option<String>,
pub head: Option<bool>,
pub alignment: Option<Alignment>,
pub name: Option<String>,
pub directive_form: Option<DirectiveForm>,
pub attrs: Vec<(String, Option<String>)>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InlineKind {
Strong,
Emph,
Verbatim,
Mark,
Superscript,
Subscript,
Insert,
Delete,
}
impl InlineKind {
fn to_c(self) -> c_int {
match self {
InlineKind::Strong => 0,
InlineKind::Emph => 1,
InlineKind::Verbatim => 2,
InlineKind::Mark => 3,
InlineKind::Superscript => 4,
InlineKind::Subscript => 5,
InlineKind::Insert => 6,
InlineKind::Delete => 7,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BlockKind {
Paragraph,
Heading(u32),
}
impl BlockKind {
fn to_c(self) -> (c_int, u32) {
match self {
BlockKind::Paragraph => (0, 0),
BlockKind::Heading(level) => (1, level),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BlockContainerKind {
BlockQuote,
BulletList,
OrderedList,
}
impl BlockContainerKind {
fn to_c(self) -> c_int {
match self {
BlockContainerKind::BlockQuote => 0,
BlockContainerKind::BulletList => 1,
BlockContainerKind::OrderedList => 2,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Version {
pub major: u8,
pub minor: u8,
pub patch: u8,
}
pub fn version() -> Version {
let packed = unsafe { ffi::twig_version() };
Version {
major: (packed >> 16) as u8,
minor: (packed >> 8) as u8,
patch: packed as u8,
}
}
pub const ABI_VERSION: u32 = ffi::TWIG_ABI_VERSION;
pub fn abi_version() -> u32 {
unsafe { ffi::twig_abi_version() }
}
pub fn version_string() -> &'static str {
let ptr = unsafe { ffi::twig_version_string() };
unsafe { std::ffi::CStr::from_ptr(ptr) }
.to_str()
.unwrap_or("")
}
#[derive(Debug)]
pub struct Document {
raw: NonNull<ffi::TwigDocument>,
}
impl Document {
pub fn parse(input: &[u8], format: Format) -> Result<Self, Error> {
Self::parse_with(input, format, MarkdownExtensions::default())
}
pub fn parse_str(input: &str, format: Format) -> Result<Self, Error> {
Self::parse(input.as_bytes(), format)
}
pub fn parse_with(
input: &[u8],
format: Format,
extensions: MarkdownExtensions,
) -> Result<Self, Error> {
let mut raw = std::ptr::null_mut();
let ffi_format: ffi::TwigFormat = format.into();
let status = unsafe {
ffi::twig_parse_ext(
input.as_ptr(),
input.len(),
ffi_format as i32,
extensions.to_flags(),
&mut raw,
)
};
Error::from_status(status)?;
let raw = NonNull::new(raw).ok_or(Error::Internal)?;
Ok(Self { raw })
}
pub fn parse_str_with(
input: &str,
format: Format,
extensions: MarkdownExtensions,
) -> Result<Self, Error> {
Self::parse_with(input.as_bytes(), format, extensions)
}
pub fn render_html(&mut self) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
collect_bytes(|ptr, len| unsafe { ffi::twig_document_render_html(raw, ptr, len) })
}
pub fn serialize(&mut self, format: Format) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
let ffi_format: ffi::TwigFormat = format.into();
collect_bytes(|ptr, len| unsafe {
ffi::twig_document_serialize(raw, ffi_format as i32, ptr, len)
})
}
pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
collect_bytes(|ptr, len| unsafe { ffi::twig_document_ast_json(raw, ptr, len) })
}
pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
let raw = self.raw.as_ptr();
collect_matches(|ptr, len| unsafe {
ffi::twig_document_query(raw, selector.as_ptr(), selector.len(), ptr, len)
})
}
}
impl Drop for Document {
fn drop(&mut self) {
unsafe { ffi::twig_document_destroy(self.raw.as_ptr()) }
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct MarkdownExtensions {
pub directives: bool,
pub math: bool,
pub html_elements: bool,
}
impl MarkdownExtensions {
fn to_flags(self) -> u32 {
let mut flags = 0;
if self.directives {
flags |= ffi::TWIG_MD_DIRECTIVES;
}
if self.math {
flags |= ffi::TWIG_MD_MATH;
}
if self.html_elements {
flags |= ffi::TWIG_MD_HTML_ELEMENTS;
}
flags
}
}
#[derive(Debug)]
pub struct Editor {
raw: NonNull<ffi::TwigEditor>,
}
impl Editor {
pub fn new(input: &[u8], format: Format) -> Result<Self, Error> {
let mut raw = std::ptr::null_mut();
let ffi_format: ffi::TwigFormat = format.into();
let status =
unsafe { ffi::twig_editor_create(input.as_ptr(), input.len(), ffi_format as i32, &mut raw) };
Error::from_status(status)?;
let raw = NonNull::new(raw).ok_or(Error::Internal)?;
Ok(Self { raw })
}
pub fn new_str(input: &str, format: Format) -> Result<Self, Error> {
Self::new(input.as_bytes(), format)
}
pub fn new_ext(input: &[u8], format: Format, extensions: MarkdownExtensions) -> Result<Self, Error> {
let mut raw = std::ptr::null_mut();
let ffi_format: ffi::TwigFormat = format.into();
let status = unsafe {
ffi::twig_editor_create_ext(
input.as_ptr(),
input.len(),
ffi_format as i32,
extensions.to_flags(),
&mut raw,
)
};
Error::from_status(status)?;
let raw = NonNull::new(raw).ok_or(Error::Internal)?;
Ok(Self { raw })
}
pub fn replace(&mut self, locator: &str, text: &str) -> Result<(), Error> {
self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
ffi::twig_editor_replace(ed, loc, loc_len, txt, txt_len)
})
}
pub fn replace_content(&mut self, locator: &str, text: &str) -> Result<(), Error> {
self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
ffi::twig_editor_replace_content(ed, loc, loc_len, txt, txt_len)
})
}
pub fn insert_before(&mut self, locator: &str, text: &str) -> Result<(), Error> {
self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
ffi::twig_editor_insert_before(ed, loc, loc_len, txt, txt_len)
})
}
pub fn insert_after(&mut self, locator: &str, text: &str) -> Result<(), Error> {
self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
ffi::twig_editor_insert_after(ed, loc, loc_len, txt, txt_len)
})
}
pub fn insert_child(&mut self, locator: &str, index: usize, text: &str) -> Result<(), Error> {
let status = unsafe {
ffi::twig_editor_insert_child(
self.raw.as_ptr(),
locator.as_ptr(),
locator.len(),
index,
text.as_ptr(),
text.len(),
)
};
Error::from_status(status)
}
pub fn delete(&mut self, locator: &str) -> Result<(), Error> {
let status = unsafe {
ffi::twig_editor_delete(self.raw.as_ptr(), locator.as_ptr(), locator.len())
};
Error::from_status(status)
}
pub fn delete_smart(&mut self, locator: &str) -> Result<(), Error> {
let status = unsafe {
ffi::twig_editor_delete_smart(self.raw.as_ptr(), locator.as_ptr(), locator.len())
};
Error::from_status(status)
}
pub fn unwrap_node(&mut self, locator: &str) -> Result<(), Error> {
let status = unsafe {
ffi::twig_editor_unwrap(self.raw.as_ptr(), locator.as_ptr(), locator.len())
};
Error::from_status(status)
}
pub fn filter(&mut self, drop: &str, keep: Option<&str>, unwrap_kept: bool) -> Result<(), Error> {
let (keep_ptr, keep_len) = match keep {
Some(k) => (k.as_ptr(), k.len()),
None => (std::ptr::null(), 0),
};
let status = unsafe {
ffi::twig_editor_filter(
self.raw.as_ptr(),
drop.as_ptr(),
drop.len(),
keep_ptr,
keep_len,
unwrap_kept as i32,
)
};
Error::from_status(status)
}
pub fn source(&mut self) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
collect_bytes(|ptr, len| unsafe { ffi::twig_editor_source(raw, ptr, len) })
}
pub fn source_str(&mut self) -> Result<String, Error> {
String::from_utf8(self.source()?).map_err(|_| Error::Internal)
}
pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
collect_bytes(|ptr, len| unsafe { ffi::twig_editor_ast_json(raw, ptr, len) })
}
pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
let raw = self.raw.as_ptr();
collect_matches(|ptr, len| unsafe {
ffi::twig_editor_query(raw, selector.as_ptr(), selector.len(), ptr, len)
})
}
pub fn edit_range(&mut self, start: usize, end: usize, text: &str) -> Result<Change, Error> {
let mut change = ffi::TwigChange {
old_span: ffi::TwigSpan { start: 0, end: 0 },
new_span: ffi::TwigSpan { start: 0, end: 0 },
};
let status = unsafe {
ffi::twig_editor_edit_range(
self.raw.as_ptr(),
start,
end,
text.as_ptr(),
text.len(),
&mut change,
)
};
Error::from_status(status)?;
Ok(Change::from_ffi(change))
}
pub fn last_change(&mut self) -> Option<Change> {
let mut change = ffi::TwigChange {
old_span: ffi::TwigSpan { start: 0, end: 0 },
new_span: ffi::TwigSpan { start: 0, end: 0 },
};
let status = unsafe { ffi::twig_editor_last_change(self.raw.as_ptr(), &mut change) };
match status.0 {
ffi::TwigStatus::OK => Some(Change::from_ffi(change)),
_ => None,
}
}
pub fn undo(&mut self) -> Result<Option<Change>, Error> {
let mut change = ffi::TwigChange {
old_span: ffi::TwigSpan { start: 0, end: 0 },
new_span: ffi::TwigSpan { start: 0, end: 0 },
};
let status = unsafe { ffi::twig_editor_undo(self.raw.as_ptr(), &mut change) };
if status.0 == ffi::TwigStatus::NOT_FOUND {
return Ok(None);
}
Error::from_status(status)?;
Ok(Some(Change::from_ffi(change)))
}
pub fn redo(&mut self) -> Result<Option<Change>, Error> {
let mut change = ffi::TwigChange {
old_span: ffi::TwigSpan { start: 0, end: 0 },
new_span: ffi::TwigSpan { start: 0, end: 0 },
};
let status = unsafe { ffi::twig_editor_redo(self.raw.as_ptr(), &mut change) };
if status.0 == ffi::TwigStatus::NOT_FOUND {
return Ok(None);
}
Error::from_status(status)?;
Ok(Some(Change::from_ffi(change)))
}
pub fn coalesce_last_undo(&mut self) -> Result<(), Error> {
let status = unsafe { ffi::twig_editor_coalesce_last(self.raw.as_ptr()) };
Error::from_status(status)
}
pub fn revision(&mut self) -> u64 {
unsafe { ffi::twig_editor_revision(self.raw.as_ptr()) }
}
pub fn dirty_range(&mut self) -> Option<Range<usize>> {
let mut span = ffi::TwigSpan { start: 0, end: 0 };
let status = unsafe { ffi::twig_editor_dirty_range(self.raw.as_ptr(), &mut span) };
match status.0 {
ffi::TwigStatus::OK => Some(span.start..span.end),
_ => None,
}
}
pub fn clear_dirty(&mut self) {
unsafe { ffi::twig_editor_clear_dirty(self.raw.as_ptr()) };
}
pub fn set_caret_blob(&mut self, blob: &[u8]) -> Result<(), Error> {
let status =
unsafe { ffi::twig_editor_set_caret_blob(self.raw.as_ptr(), blob.as_ptr(), blob.len()) };
Error::from_status(status)
}
pub fn caret_blob(&mut self) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
collect_bytes(|ptr, len| unsafe { ffi::twig_editor_caret_blob(raw, ptr, len) })
}
pub fn nodes(&mut self) -> Result<Vec<FlatNode>, Error> {
let mut ptr: *const ffi::TwigFlatNode = std::ptr::null();
let mut len = 0usize;
let status = unsafe { ffi::twig_editor_nodes(self.raw.as_ptr(), &mut ptr, &mut len) };
Error::from_status(status)?;
if len == 0 {
return Ok(Vec::new());
}
if ptr.is_null() {
return Err(Error::Internal);
}
let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
raw.iter().map(flat_node_from_ffi).collect()
}
pub fn child_spans(&mut self, node: Option<NodeId>) -> Result<Vec<QueryMatch>, Error> {
let id = node.map_or(ffi::TWIG_NO_NODE, |n| n.0);
let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
let mut len = 0usize;
let status =
unsafe { ffi::twig_editor_child_spans(self.raw.as_ptr(), id, &mut ptr, &mut len) };
Error::from_status(status)?;
if len == 0 || ptr.is_null() {
return Ok(Vec::new());
}
let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
raw.iter().map(query_match_from_ffi).collect()
}
pub fn subtree(&mut self, node: NodeId) -> Result<Vec<FlatNode>, Error> {
let mut ptr: *const ffi::TwigFlatNode = std::ptr::null();
let mut len = 0usize;
let status =
unsafe { ffi::twig_editor_subtree(self.raw.as_ptr(), node.0, &mut ptr, &mut len) };
Error::from_status(status)?;
if len == 0 || ptr.is_null() {
return Ok(Vec::new());
}
let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
raw.iter().map(flat_node_from_ffi).collect()
}
pub fn node_at(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
let mut m = ffi::TwigQueryMatch {
node_id: 0,
span: ffi::TwigSpan { start: 0, end: 0 },
content_span: ffi::TwigSpan { start: 0, end: 0 },
has_content_span: 0,
kind: std::ptr::null(),
};
let status = unsafe { ffi::twig_editor_node_at(self.raw.as_ptr(), offset, &mut m) };
match status.0 {
ffi::TwigStatus::OK => Ok(Some(query_match_from_ffi(&m)?)),
ffi::TwigStatus::NOT_FOUND => Ok(None),
_ => Err(Error::from_status(status).unwrap_err()),
}
}
pub fn ancestors_at(&mut self, offset: usize) -> Result<Vec<QueryMatch>, Error> {
let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
let mut len = 0usize;
let status =
unsafe { ffi::twig_editor_nodes_at(self.raw.as_ptr(), offset, &mut ptr, &mut len) };
match status.0 {
ffi::TwigStatus::OK => {}
ffi::TwigStatus::NOT_FOUND => return Ok(Vec::new()),
_ => return Err(Error::from_status(status).unwrap_err()),
}
if len == 0 || ptr.is_null() {
return Ok(Vec::new());
}
let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
raw.iter().map(query_match_from_ffi).collect()
}
pub fn wrap_range(&mut self, start: usize, end: usize, kind: InlineKind) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_wrap_range(ed, start, end, kind.to_c(), out)
})
}
pub fn toggle_inline(&mut self, start: usize, end: usize, kind: InlineKind) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_toggle_inline(ed, start, end, kind.to_c(), out)
})
}
pub fn set_block(&mut self, offset: usize, kind: BlockKind) -> Result<Change, Error> {
let (block_kind, level) = kind.to_c();
self.change_op(|ed, out| unsafe {
ffi::twig_editor_set_block(ed, offset, block_kind, level, out)
})
}
pub fn toggle_block_container(
&mut self,
start: usize,
end: usize,
kind: BlockContainerKind,
) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_toggle_block_container(ed, start, end, kind.to_c(), out)
})
}
pub fn renumber_ordered_lists(&mut self, offset: usize) -> Result<(), Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_renumber_ordered_lists(ed, offset, out)
})?;
Ok(())
}
pub fn table_insert_row(&mut self, offset: usize, below: bool) -> Result<(), Error> {
self.table_edit(offset, ffi::TWIG_TABLE_INSERT_ROW, below as c_int)
}
pub fn table_delete_row(&mut self, offset: usize) -> Result<(), Error> {
self.table_edit(offset, ffi::TWIG_TABLE_DELETE_ROW, 0)
}
pub fn table_insert_column(&mut self, offset: usize, right: bool) -> Result<(), Error> {
self.table_edit(offset, ffi::TWIG_TABLE_INSERT_COLUMN, right as c_int)
}
pub fn table_delete_column(&mut self, offset: usize) -> Result<(), Error> {
self.table_edit(offset, ffi::TWIG_TABLE_DELETE_COLUMN, 0)
}
pub fn table_set_alignment(&mut self, offset: usize, alignment: Alignment) -> Result<(), Error> {
self.table_edit(offset, ffi::TWIG_TABLE_SET_ALIGNMENT, alignment.to_c())
}
pub fn table_move_row(&mut self, offset: usize, down: bool) -> Result<(), Error> {
self.table_edit(offset, ffi::TWIG_TABLE_MOVE_ROW, down as c_int)
}
pub fn table_move_column(&mut self, offset: usize, right: bool) -> Result<(), Error> {
self.table_edit(offset, ffi::TWIG_TABLE_MOVE_COLUMN, right as c_int)
}
fn table_edit(&mut self, offset: usize, op: c_int, arg: c_int) -> Result<(), Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_table_edit(ed, offset, op, arg, out)
})?;
Ok(())
}
pub fn insert_link(
&mut self,
start: usize,
end: usize,
destination: &str,
) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_insert_link(
ed,
start,
end,
destination.as_ptr(),
destination.len(),
out,
)
})
}
pub fn insert_image(
&mut self,
start: usize,
end: usize,
destination: &str,
) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_insert_image(
ed,
start,
end,
destination.as_ptr(),
destination.len(),
out,
)
})
}
pub fn insert_literal(&mut self, offset: usize, text: &str) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_insert_literal(ed, offset, text.as_ptr(), text.len(), out)
})
}
pub fn insert_line_break(&mut self, offset: usize) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe { ffi::twig_editor_insert_line_break(ed, offset, out) })
}
fn change_op(
&mut self,
op: impl FnOnce(*mut ffi::TwigEditor, *mut ffi::TwigChange) -> ffi::TwigStatus,
) -> Result<Change, Error> {
let mut change = ffi::TwigChange {
old_span: ffi::TwigSpan { start: 0, end: 0 },
new_span: ffi::TwigSpan { start: 0, end: 0 },
};
let status = op(self.raw.as_ptr(), &mut change);
Error::from_status(status)?;
Ok(Change::from_ffi(change))
}
fn apply(
&mut self,
locator: &str,
text: &str,
op: impl FnOnce(*mut ffi::TwigEditor, *const u8, usize, *const u8, usize) -> ffi::TwigStatus,
) -> Result<(), Error> {
let status = op(
self.raw.as_ptr(),
locator.as_ptr(),
locator.len(),
text.as_ptr(),
text.len(),
);
Error::from_status(status)
}
}
impl Drop for Editor {
fn drop(&mut self) {
unsafe { ffi::twig_editor_destroy(self.raw.as_ptr()) }
}
}
fn collect_bytes(
call: impl FnOnce(*mut *const u8, *mut usize) -> ffi::TwigStatus,
) -> Result<Vec<u8>, Error> {
let mut ptr = std::ptr::null();
let mut len = 0usize;
let status = call(&mut ptr, &mut len);
Error::from_status(status)?;
if len == 0 {
return Ok(Vec::new());
}
if ptr.is_null() {
return Err(Error::Internal);
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
Ok(bytes.to_vec())
}
fn collect_matches(
call: impl FnOnce(*mut *const ffi::TwigQueryMatch, *mut usize) -> ffi::TwigStatus,
) -> Result<Vec<QueryMatch>, Error> {
let mut ptr = std::ptr::null();
let mut len = 0usize;
let status = call(&mut ptr, &mut len);
Error::from_status(status)?;
if len == 0 {
return Ok(Vec::new());
}
if ptr.is_null() {
return Err(Error::Internal);
}
let matches = unsafe { std::slice::from_raw_parts(ptr, len) };
matches.iter().map(query_match_from_ffi).collect()
}
fn query_match_from_ffi(m: &ffi::TwigQueryMatch) -> Result<QueryMatch, Error> {
Ok(QueryMatch {
node_id: m.node_id,
span: m.span.start..m.span.end,
content_span: if m.has_content_span != 0 {
Some(m.content_span.start..m.content_span.end)
} else {
None
},
kind: borrowed_cstr(m.kind)?,
})
}
fn flat_node_from_ffi(n: &ffi::TwigFlatNode) -> Result<FlatNode, Error> {
let node_id = |v: u32| if v == ffi::TWIG_NO_NODE { None } else { Some(NodeId(v)) };
Ok(FlatNode {
id: NodeId(n.id),
parent: node_id(n.parent),
first_child: node_id(n.first_child),
next_sibling: node_id(n.next_sibling),
span: n.span.start..n.span.end,
content_span: if n.has_content_span != 0 {
Some(n.content_span.start..n.content_span.end)
} else {
None
},
level: if n.level != 0 { Some(n.level) } else { None },
kind: borrowed_cstr(n.kind)?,
text: borrowed_bytes(n.text_ptr, n.text_len),
destination: borrowed_bytes(n.destination_ptr, n.destination_len),
head: match n.head {
ffi::TWIG_HEAD_NONE => None,
v => Some(v != 0),
},
alignment: Alignment::from_c(n.alignment),
name: borrowed_bytes(n.name_ptr, n.name_len),
directive_form: DirectiveForm::from_c(n.directive_form),
attrs: borrowed_attrs(n.attrs_ptr, n.attrs_len),
})
}
fn borrowed_attrs(ptr: *const ffi::TwigKeyVal, len: usize) -> Vec<(String, Option<String>)> {
if ptr.is_null() || len == 0 {
return Vec::new();
}
let kvs = unsafe { std::slice::from_raw_parts(ptr, len) };
kvs.iter()
.map(|kv| {
let key = borrowed_bytes(kv.key, kv.key_len).unwrap_or_default();
(key, borrowed_bytes(kv.value, kv.value_len))
})
.collect()
}
fn borrowed_cstr(ptr: *const c_char) -> Result<String, Error> {
if ptr.is_null() {
return Err(Error::Internal);
}
Ok(unsafe { std::ffi::CStr::from_ptr(ptr) }
.to_str()
.map_err(|_| Error::Internal)?
.to_owned())
}
fn borrowed_bytes(ptr: *const u8, len: usize) -> Option<String> {
if ptr.is_null() {
return None;
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
Some(String::from_utf8_lossy(bytes).into_owned())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct NodeId(pub u32);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VoidKind {
Doc,
Para,
ThematicBreak,
Section,
Div,
BlockQuote,
DefinitionList,
Table,
ListItem,
DefinitionListItem,
Term,
Definition,
Caption,
SoftBreak,
HardBreak,
NonBreakingSpace,
Emph,
Strong,
Span,
Mark,
Superscript,
Subscript,
Insert,
Delete,
DoubleQuoted,
SingleQuoted,
}
impl VoidKind {
fn to_c(self) -> c_int {
match self {
VoidKind::Doc => 0,
VoidKind::Para => 1,
VoidKind::ThematicBreak => 3,
VoidKind::Section => 4,
VoidKind::Div => 5,
VoidKind::BlockQuote => 9,
VoidKind::DefinitionList => 13,
VoidKind::Table => 14,
VoidKind::ListItem => 15,
VoidKind::DefinitionListItem => 17,
VoidKind::Term => 18,
VoidKind::Definition => 19,
VoidKind::Caption => 22,
VoidKind::SoftBreak => 26,
VoidKind::HardBreak => 27,
VoidKind::NonBreakingSpace => 28,
VoidKind::Emph => 38,
VoidKind::Strong => 39,
VoidKind::Span => 42,
VoidKind::Mark => 43,
VoidKind::Superscript => 44,
VoidKind::Subscript => 45,
VoidKind::Insert => 46,
VoidKind::Delete => 47,
VoidKind::DoubleQuoted => 48,
VoidKind::SingleQuoted => 49,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextKind {
Str,
Symb,
Verbatim,
InlineMath,
DisplayMath,
Url,
Email,
FootnoteReference,
Comment,
Doctype,
Cdata,
}
impl TextKind {
fn to_c(self) -> c_int {
match self {
TextKind::Str => 25,
TextKind::Symb => 29,
TextKind::Verbatim => 30,
TextKind::InlineMath => 32,
TextKind::DisplayMath => 33,
TextKind::Url => 34,
TextKind::Email => 35,
TextKind::FootnoteReference => 36,
TextKind::Comment => 52,
TextKind::Doctype => 53,
TextKind::Cdata => 55,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BulletStyle {
Dash,
Plus,
Star,
}
impl BulletStyle {
fn to_c(self) -> c_int {
match self {
BulletStyle::Dash => 0,
BulletStyle::Plus => 1,
BulletStyle::Star => 2,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OrderedNumbering {
Decimal,
LowerAlpha,
UpperAlpha,
LowerRoman,
UpperRoman,
}
impl OrderedNumbering {
fn to_c(self) -> c_int {
match self {
OrderedNumbering::Decimal => 0,
OrderedNumbering::LowerAlpha => 1,
OrderedNumbering::UpperAlpha => 2,
OrderedNumbering::LowerRoman => 3,
OrderedNumbering::UpperRoman => 4,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OrderedDelim {
Period,
ParenAfter,
ParenBoth,
}
impl OrderedDelim {
fn to_c(self) -> c_int {
match self {
OrderedDelim::Period => 0,
OrderedDelim::ParenAfter => 1,
OrderedDelim::ParenBoth => 2,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Alignment {
Default,
Left,
Right,
Center,
}
impl Alignment {
fn to_c(self) -> c_int {
match self {
Alignment::Default => ffi::TWIG_ALIGN_DEFAULT,
Alignment::Left => ffi::TWIG_ALIGN_LEFT,
Alignment::Right => ffi::TWIG_ALIGN_RIGHT,
Alignment::Center => ffi::TWIG_ALIGN_CENTER,
}
}
fn from_c(v: c_int) -> Option<Self> {
match v {
ffi::TWIG_ALIGN_DEFAULT => Some(Alignment::Default),
ffi::TWIG_ALIGN_LEFT => Some(Alignment::Left),
ffi::TWIG_ALIGN_RIGHT => Some(Alignment::Right),
ffi::TWIG_ALIGN_CENTER => Some(Alignment::Center),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SmartPunctuation {
LeftSingleQuote,
RightSingleQuote,
LeftDoubleQuote,
RightDoubleQuote,
Ellipses,
EmDash,
EnDash,
}
impl SmartPunctuation {
fn to_c(self) -> c_int {
match self {
SmartPunctuation::LeftSingleQuote => 0,
SmartPunctuation::RightSingleQuote => 1,
SmartPunctuation::LeftDoubleQuote => 2,
SmartPunctuation::RightDoubleQuote => 3,
SmartPunctuation::Ellipses => 4,
SmartPunctuation::EmDash => 5,
SmartPunctuation::EnDash => 6,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DirectiveForm {
Text,
Leaf,
Container,
}
impl DirectiveForm {
fn to_c(self) -> c_int {
match self {
DirectiveForm::Text => ffi::TWIG_DIRECTIVE_TEXT,
DirectiveForm::Leaf => ffi::TWIG_DIRECTIVE_LEAF,
DirectiveForm::Container => ffi::TWIG_DIRECTIVE_CONTAINER,
}
}
fn from_c(v: c_int) -> Option<Self> {
match v {
ffi::TWIG_DIRECTIVE_TEXT => Some(DirectiveForm::Text),
ffi::TWIG_DIRECTIVE_LEAF => Some(DirectiveForm::Leaf),
ffi::TWIG_DIRECTIVE_CONTAINER => Some(DirectiveForm::Container),
_ => None,
}
}
}
fn opt_str(s: Option<&str>) -> (*const u8, usize, c_int) {
match s {
Some(x) => (x.as_ptr(), x.len(), 1),
None => (std::ptr::null(), 0, 0),
}
}
#[derive(Debug)]
pub struct Builder {
raw: NonNull<ffi::TwigBuilder>,
}
impl Builder {
pub fn new() -> Result<Self, Error> {
let mut raw = std::ptr::null_mut();
let status = unsafe { ffi::twig_builder_create(&mut raw) };
Error::from_status(status)?;
let raw = NonNull::new(raw).ok_or(Error::Internal)?;
Ok(Self { raw })
}
pub fn add(&mut self, kind: VoidKind) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add(b, kind.to_c(), out) })
}
pub fn add_text(&mut self, kind: TextKind, text: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_text(b, kind.to_c(), text.as_ptr(), text.len(), out) })
}
pub fn add_heading(&mut self, level: u32) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_heading(b, level, out) })
}
pub fn add_code_block(&mut self, lang: Option<&str>, text: &str) -> Result<NodeId, Error> {
let (lp, ll, has) = opt_str(lang);
self.emit(|b, out| unsafe { ffi::twig_builder_add_code_block(b, lp, ll, has, text.as_ptr(), text.len(), out) })
}
pub fn add_raw_block(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_raw_block(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
})
}
pub fn add_metadata(&mut self, lang: &str, text: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_metadata(b, lang.as_ptr(), lang.len(), text.as_ptr(), text.len(), out)
})
}
pub fn add_raw_inline(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_raw_inline(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
})
}
pub fn add_smart_punctuation(&mut self, kind: SmartPunctuation, text: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_smart_punctuation(b, kind.to_c(), text.as_ptr(), text.len(), out)
})
}
pub fn add_link(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
let (dp, dl, hd) = opt_str(destination);
let (rp, rl, hr) = opt_str(reference);
self.emit(|b, out| unsafe { ffi::twig_builder_add_link(b, dp, dl, hd, rp, rl, hr, out) })
}
pub fn add_image(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
let (dp, dl, hd) = opt_str(destination);
let (rp, rl, hr) = opt_str(reference);
self.emit(|b, out| unsafe { ffi::twig_builder_add_image(b, dp, dl, hd, rp, rl, hr, out) })
}
pub fn add_directive(&mut self, form: DirectiveForm, name: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_directive(b, form.to_c(), name.as_ptr(), name.len(), out) })
}
pub fn add_element(&mut self, name: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_element(b, name.as_ptr(), name.len(), out) })
}
pub fn add_processing_instruction(&mut self, target: &str, data: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_processing_instruction(b, target.as_ptr(), target.len(), data.as_ptr(), data.len(), out)
})
}
pub fn add_footnote(&mut self, label: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_footnote(b, label.as_ptr(), label.len(), out) })
}
pub fn add_reference(&mut self, label: &str, destination: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_reference(b, label.as_ptr(), label.len(), destination.as_ptr(), destination.len(), out)
})
}
pub fn add_bullet_list(&mut self, style: BulletStyle, tight: bool) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_bullet_list(b, style.to_c(), tight as c_int, out) })
}
pub fn add_ordered_list(
&mut self,
numbering: OrderedNumbering,
delim: OrderedDelim,
tight: bool,
start: Option<u32>,
) -> Result<NodeId, Error> {
let (start_val, has_start) = match start {
Some(s) => (s, 1),
None => (0, 0),
};
self.emit(|b, out| unsafe {
ffi::twig_builder_add_ordered_list(b, numbering.to_c(), delim.to_c(), tight as c_int, start_val, has_start, out)
})
}
pub fn add_task_list(&mut self, tight: bool) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list(b, tight as c_int, out) })
}
pub fn add_task_list_item(&mut self, checked: bool) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list_item(b, checked as c_int, out) })
}
pub fn add_row(&mut self, head: bool) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_row(b, head as c_int, out) })
}
pub fn add_cell(&mut self, head: bool, alignment: Alignment) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe { ffi::twig_builder_add_cell(b, head as c_int, alignment.to_c(), out) })
}
pub fn set_children(&mut self, parent: NodeId, children: &[NodeId]) -> Result<(), Error> {
let ids: Vec<u32> = children.iter().map(|n| n.0).collect();
let status = unsafe { ffi::twig_builder_set_children(self.raw.as_ptr(), parent.0, ids.as_ptr(), ids.len()) };
Error::from_status(status)
}
pub fn set_attrs(&mut self, id: NodeId, attrs: &[(&str, Option<&str>)]) -> Result<(), Error> {
let kvs: Vec<ffi::TwigKeyVal> = attrs
.iter()
.map(|(k, v)| ffi::TwigKeyVal {
key: k.as_ptr(),
key_len: k.len(),
value: v.map_or(std::ptr::null(), |s| s.as_ptr()),
value_len: v.map_or(0, |s| s.len()),
})
.collect();
let status = unsafe { ffi::twig_builder_set_attrs(self.raw.as_ptr(), id.0, kvs.as_ptr(), kvs.len()) };
Error::from_status(status)
}
pub fn render_html(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
collect_bytes(|ptr, len| unsafe { ffi::twig_builder_render_html(raw, root.0, ptr, len) })
}
pub fn serialize(&mut self, root: NodeId, format: Format) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
let ffi_format: ffi::TwigFormat = format.into();
collect_bytes(|ptr, len| unsafe { ffi::twig_builder_serialize(raw, root.0, ffi_format as i32, ptr, len) })
}
pub fn ast_json(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
collect_bytes(|ptr, len| unsafe { ffi::twig_builder_ast_json(raw, root.0, ptr, len) })
}
pub fn query(&mut self, root: NodeId, selector: &str) -> Result<Vec<QueryMatch>, Error> {
let raw = self.raw.as_ptr();
collect_matches(|ptr, len| unsafe {
ffi::twig_builder_query(raw, root.0, selector.as_ptr(), selector.len(), ptr, len)
})
}
fn emit(
&mut self,
call: impl FnOnce(*mut ffi::TwigBuilder, *mut u32) -> ffi::TwigStatus,
) -> Result<NodeId, Error> {
let mut id: u32 = 0;
let status = call(self.raw.as_ptr(), &mut id);
Error::from_status(status)?;
Ok(NodeId(id))
}
}
impl Drop for Builder {
fn drop(&mut self) {
unsafe { ffi::twig_builder_destroy(self.raw.as_ptr()) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abi_version_matches() {
assert_eq!(abi_version(), ffi::TWIG_ABI_VERSION);
}
#[test]
fn parses_and_renders_markdown_html() {
let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
let html = doc.render_html().expect("render html");
assert_eq!(String::from_utf8_lossy(&html), "<h1>hi</h1>\n");
}
#[test]
fn parses_html_input() {
let mut doc = Document::parse_str("<p>hi</p>", Format::Html).expect("parse html");
let html = doc.render_html().expect("render html");
assert!(String::from_utf8_lossy(&html).contains("hi"));
}
#[test]
fn serialize_round_trips_and_cross_converts() {
let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
let canonical = doc.serialize(Format::Markdown).expect("serialize markdown");
assert!(String::from_utf8_lossy(&canonical).contains("# hi"));
assert_eq!(doc.serialize(Format::Xml), Err(Error::UnsupportedFormat));
}
#[test]
fn serialize_markdown_to_djot() {
let mut doc =
Document::parse_str("This is *markdown*.\n", Format::Markdown).expect("parse markdown");
let djot = doc.serialize(Format::Djot).expect("serialize djot");
assert!(String::from_utf8_lossy(&djot).contains("_markdown_"));
}
#[test]
fn ast_json_dumps_the_tree() {
let mut doc = Document::parse_str("hello\n", Format::Djot).expect("parse djot");
let json = doc.ast_json().expect("ast json");
assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
}
#[test]
fn query_finds_nodes_by_selector() {
let source = "# One\n\n## Two\n";
let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
let matches = doc.query("heading").expect("query");
assert_eq!(matches.len(), 2);
for m in &matches {
assert_eq!(m.kind, "heading");
assert!(m.span.start < m.span.end);
}
}
#[test]
fn query_recovers_code_spans() {
let source = "prose `code` more prose\n";
let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
let matches = doc.query("verbatim").expect("query");
assert_eq!(matches.len(), 1);
assert_eq!(&source[matches[0].span.clone()], "`code`");
}
#[test]
fn query_rejects_a_malformed_selector() {
let mut doc = Document::parse_str("hi\n", Format::Markdown).expect("parse markdown");
assert_eq!(doc.query("list >"), Err(Error::InvalidArgument));
}
#[test]
fn editor_edits_by_index_path() {
let mut ed = Editor::new_str("<a><b>hi</b></a>", Format::Xml).expect("editor");
ed.replace_content("0.0", "bye").expect("replace_content");
assert_eq!(ed.source_str().expect("source"), "<a><b>bye</b></a>");
}
#[test]
fn flat_nodes_expose_element_name_and_attrs() {
let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"x\"></picture>\n";
let mut ed = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
MarkdownExtensions { html_elements: true, ..Default::default() },
)
.expect("editor");
let nodes = ed.nodes().expect("nodes");
let source = nodes
.iter()
.find(|n| n.name.as_deref() == Some("source"))
.expect("a <source> element node");
assert_eq!(
source.attrs,
vec![
("media".to_string(), Some("(prefers-color-scheme: dark)".to_string())),
("srcset".to_string(), Some("d.svg".to_string())),
]
);
let img = nodes.iter().find(|n| n.kind == "image").expect("an image node");
assert!(img.name.is_none());
assert_eq!(img.destination.as_deref(), Some("l.svg"));
let picture_kids_str = nodes.iter().find(|n| n.kind == "str");
if let Some(s) = picture_kids_str {
assert!(s.name.is_none() && s.attrs.is_empty());
}
}
#[test]
fn flat_nodes_expose_directive_name_and_form() {
let src = ":::note{.warning}\nBody\n:::\n\n::embed{src=\"demo.html\"}\n\nSee :abbr[HTML]{title=\"HyperText\"} inline.\n";
let mut ed = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
MarkdownExtensions { directives: true, ..Default::default() },
)
.expect("editor");
let nodes = ed.nodes().expect("nodes");
let forms: Vec<(Option<&str>, Option<DirectiveForm>)> = nodes
.iter()
.filter(|n| n.kind == "directive")
.map(|n| (n.name.as_deref(), n.directive_form))
.collect();
assert_eq!(
forms,
vec![
(Some("note"), Some(DirectiveForm::Container)),
(Some("embed"), Some(DirectiveForm::Leaf)),
(Some("abbr"), Some(DirectiveForm::Text)),
]
);
let embed = nodes.iter().find(|n| n.name.as_deref() == Some("embed")).expect("embed");
assert_eq!(embed.attrs, vec![("src".to_string(), Some("demo.html".to_string()))]);
let para = nodes.iter().find(|n| n.kind == "para").expect("a para");
assert!(para.directive_form.is_none() && para.name.is_none());
}
#[test]
fn editor_insert_child_and_delete() {
let mut ed = Editor::new_str("<r><a/><c/></r>", Format::Xml).expect("editor");
ed.insert_child("0", 1, "<b/>").expect("insert_child");
assert_eq!(ed.source_str().expect("source"), "<r><a/><b/><c/></r>");
ed.delete("0.1").expect("delete");
assert_eq!(ed.source_str().expect("source"), "<r><a/><c/></r>");
}
#[test]
fn editor_edits_by_selector() {
let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
assert_eq!(ed.source_str().expect("source"), "# One\n\n## Renamed\n");
}
#[test]
fn editor_locator_errors_are_distinct() {
let mut ed = Editor::new_str("<r><a/><a/></r>", Format::Xml).expect("editor");
assert_eq!(ed.replace("0.9", "x"), Err(Error::NotFound));
assert_eq!(ed.replace("element", "x"), Err(Error::Ambiguous));
assert_eq!(ed.replace("element(", "x"), Err(Error::InvalidArgument));
assert_eq!(ed.source_str().expect("source"), "<r><a/><a/></r>");
}
#[test]
fn editor_reparse_break_rolls_back() {
let mut ed = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
assert_eq!(ed.replace_content("0", "<b>"), Err(Error::EditConflict));
assert_eq!(ed.source_str().expect("source"), "<a>ok</a>");
}
#[test]
fn editor_leaf_content_is_not_editable() {
let mut ed = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
assert_eq!(ed.replace_content("0.0", "x"), Err(Error::NotEditable));
}
#[test]
fn editor_query_reflects_current_tree() {
let mut ed = Editor::new_str("<r><a/></r>", Format::Xml).expect("editor");
ed.insert_child("0", 1, "<b/>").expect("insert_child");
assert_eq!(ed.query("element").expect("query").len(), 3);
let json = ed.ast_json().expect("ast_json");
assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
}
#[test]
fn editor_edit_range_types_backspaces_and_reports_change() {
let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
let c = ed.edit_range(1, 1, "X").expect("edit_range insert");
assert_eq!(ed.source_str().unwrap(), "aXb\n");
assert_eq!(c.old, 1..1);
assert_eq!(c.new, 1..2);
assert_eq!(c.delta(), 1);
let c2 = ed.edit_range(1, 2, "").expect("edit_range delete");
assert_eq!(ed.source_str().unwrap(), "ab\n");
assert_eq!(c2.old, 1..2);
assert_eq!(c2.new, 1..1);
assert_eq!(c2.delta(), -1);
}
#[test]
fn editor_edit_range_rejects_bad_ranges() {
let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
assert_eq!(ed.edit_range(0, 99, "x"), Err(Error::InvalidArgument)); assert_eq!(ed.edit_range(2, 1, "x"), Err(Error::InvalidArgument)); assert_eq!(ed.source_str().unwrap(), "hi\n"); }
#[test]
fn editor_last_change_reports_locator_ops_too() {
let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
assert_eq!(ed.last_change(), None);
ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
assert_eq!(ed.source_str().unwrap(), "# One\n\n## Renamed\n");
let c = ed.last_change().expect("a change was recorded");
assert_eq!(c.old, 7..13);
assert_eq!(c.new, 7..17);
}
#[test]
fn editor_nodes_is_a_walkable_flat_tree() {
let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
let nodes = ed.nodes().expect("nodes");
assert!(!nodes.is_empty());
for (i, n) in nodes.iter().enumerate() {
assert_eq!(n.id, NodeId(i as u32));
}
let roots: Vec<_> = nodes.iter().filter(|n| n.parent.is_none()).collect();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].kind, "doc");
let heading = nodes.iter().find(|n| n.kind == "heading").expect("a heading");
assert_eq!(heading.level, Some(1));
assert!(nodes.iter().any(|n| n.text.as_deref() == Some("Hi")));
assert_eq!(heading.head, None);
assert_eq!(heading.alignment, None);
for n in nodes.iter().filter(|n| n.parent.is_some()) {
let p = &nodes[n.parent.unwrap().0 as usize];
let mut kid = p.first_child;
let mut seen = false;
while let Some(NodeId(k)) = kid {
if k == n.id.0 {
seen = true;
break;
}
kid = nodes[k as usize].next_sibling;
}
assert!(seen, "node {:?} not found among its parent's children", n.id);
}
}
#[test]
fn editor_child_spans_and_subtree_agree_with_nodes() {
let src = "# Title\n\nHello **world** and more.\n\n- one\n- two\n";
let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
let all = ed.nodes().expect("nodes");
let doc = all.iter().find(|n| n.kind == "doc").expect("doc");
let top = ed.child_spans(None).expect("child_spans");
let mut want = Vec::new();
let mut c = doc.first_child;
while let Some(id) = c {
want.push(id);
c = all[id.0 as usize].next_sibling;
}
assert_eq!(top.len(), want.len(), "top-level count");
for (m, id) in top.iter().zip(&want) {
assert_eq!(m.node_id, id.0, "child id");
assert_eq!(m.kind, all[id.0 as usize].kind, "child kind");
assert_eq!(m.span, all[id.0 as usize].span, "child span");
}
assert!(src[top[0].span.clone()].starts_with('#'), "first block is the heading");
let list = top.iter().find(|m| m.kind.ends_with("list")).expect("a list");
let items = ed.child_spans(Some(NodeId(list.node_id))).expect("items");
assert_eq!(items.len(), 2);
assert!(items.iter().all(|m| m.kind == "list_item"), "items: {items:?}");
let para = top.iter().find(|m| m.kind == "para").expect("a para").node_id;
let sub = ed.subtree(NodeId(para)).expect("subtree");
assert_eq!(sub[0].id, NodeId(0), "root is local id 0");
assert_eq!(sub[0].parent, None, "root has no parent inside the subtree");
assert_eq!(sub[0].next_sibling, None, "root's sibling is severed");
assert_eq!(sub[0].kind, "para");
for (i, n) in sub.iter().enumerate() {
assert_eq!(n.id, NodeId(i as u32), "dense local ids");
for link in [n.parent, n.first_child, n.next_sibling].into_iter().flatten() {
assert!((link.0 as usize) < sub.len(), "link {link:?} escapes the subtree");
}
}
assert!(
src[sub[0].span.clone()].starts_with("Hello"),
"absolute span: {:?}",
&src[sub[0].span.clone()]
);
fn arena_kinds(all: &[FlatNode], root: NodeId) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
let n = &all[id.0 as usize];
out.push(n.kind.clone());
let mut c = n.first_child;
while let Some(cid) = c {
stack.push(cid);
c = all[cid.0 as usize].next_sibling;
}
}
out
}
let mut want_kinds = arena_kinds(&all, NodeId(para));
let mut got_kinds: Vec<String> = sub.iter().map(|n| n.kind.clone()).collect();
want_kinds.sort();
got_kinds.sort();
assert_eq!(got_kinds, want_kinds, "subtree kinds match the arena");
assert!(matches!(ed.subtree(NodeId(9999)), Err(Error::InvalidArgument)));
}
#[test]
fn flat_nodes_carry_table_head_and_alignment() {
let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
let nodes = ed.nodes().expect("nodes");
let rows: Vec<_> = nodes.iter().filter(|n| n.kind == "row").collect();
assert_eq!(rows.len(), 2, "a header row and one body row");
assert_eq!(rows[0].head, Some(true), "first row is the header");
assert_eq!(rows[1].head, Some(false), "second row is a body row");
let cells: Vec<_> = nodes.iter().filter(|n| n.kind == "cell").collect();
assert_eq!(cells.len(), 4);
assert_eq!(cells[0].alignment, Some(Alignment::Left));
assert_eq!(cells[1].alignment, Some(Alignment::Right));
assert_eq!(cells[2].alignment, Some(Alignment::Left));
assert_eq!(cells[3].alignment, Some(Alignment::Right));
assert_eq!(cells[0].head, Some(true));
assert_eq!(cells[2].head, Some(false));
let mut plain = Editor::new_str("| A |\n| --- |\n| b |\n", Format::Markdown).expect("editor");
let pnodes = plain.nodes().expect("nodes");
let pcell = pnodes.iter().find(|n| n.kind == "cell").expect("a cell");
assert_eq!(pcell.alignment, Some(Alignment::Default));
}
#[test]
fn editor_node_at_and_ancestors_hit_test_offsets() {
let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
let m = ed.node_at(2).expect("node_at").expect("a node covers offset 2");
assert!(m.span.contains(&2));
let chain = ed.ancestors_at(2).expect("ancestors_at");
assert!(!chain.is_empty());
assert_eq!(chain[0].kind, "doc");
assert_eq!(chain.last().unwrap().node_id, m.node_id);
assert_eq!(ed.node_at(999), Err(Error::InvalidArgument));
}
#[test]
fn editor_wrap_and_toggle_inline_round_trip() {
let mut ed = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
let c = ed.wrap_range(2, 6, InlineKind::Strong).expect("wrap");
assert_eq!(ed.source_str().unwrap(), "a **word** b\n");
assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "**word**");
ed.toggle_inline(4, 8, InlineKind::Strong).expect("toggle off");
assert_eq!(ed.source_str().unwrap(), "a word b\n");
ed.toggle_inline(2, 6, InlineKind::Emph).expect("toggle on");
assert_eq!(ed.source_str().unwrap(), "a *word* b\n");
}
#[test]
fn editor_inline_kind_support_is_format_specific() {
let mut md = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
assert_eq!(md.wrap_range(2, 6, InlineKind::Mark), Err(Error::UnsupportedFormat));
let mut dj = Editor::new_str("a word b\n", Format::Djot).expect("editor");
dj.wrap_range(2, 6, InlineKind::Mark).expect("djot mark");
assert_eq!(dj.source_str().unwrap(), "a {=word=} b\n");
}
#[test]
fn editor_toggle_strips_verbatim_via_content_span() {
let mut ed = Editor::new_str("a `code` b\n", Format::Markdown).expect("editor");
ed.toggle_inline(2, 8, InlineKind::Verbatim).expect("toggle code off");
assert_eq!(ed.source_str().unwrap(), "a code b\n");
let mut ed2 = Editor::new_str("a ``x`` b\n", Format::Markdown).expect("editor");
ed2.toggle_inline(2, 7, InlineKind::Verbatim).expect("toggle multi off");
assert_eq!(ed2.source_str().unwrap(), "a x b\n");
}
#[test]
fn editor_set_block_switches_para_and_heading_levels() {
let mut ed = Editor::new_str("Title\n\nbody text\n", Format::Markdown).expect("editor");
ed.set_block(0, BlockKind::Heading(2)).expect("to h2");
assert_eq!(ed.source_str().unwrap(), "## Title\n\nbody text\n");
ed.set_block(3, BlockKind::Heading(1)).expect("to h1");
assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody text\n");
ed.set_block(2, BlockKind::Paragraph).expect("to para");
assert_eq!(ed.source_str().unwrap(), "Title\n\nbody text\n");
}
#[test]
fn editor_set_block_rejects_bad_level_and_format() {
let mut md = Editor::new_str("hi\n", Format::Markdown).expect("editor");
assert_eq!(md.set_block(0, BlockKind::Heading(9)), Err(Error::InvalidArgument));
let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
assert_eq!(xml.set_block(1, BlockKind::Heading(1)), Err(Error::UnsupportedFormat));
}
#[test]
fn editor_toggle_block_container_round_trips() {
let mut ed = Editor::new_str("a\n", Format::Djot).expect("editor");
let c = ed
.toggle_block_container(0, 1, BlockContainerKind::BlockQuote)
.expect("quote on");
assert_eq!(ed.source_str().unwrap(), "> a\n");
assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "> a\n");
ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
.expect("quote off");
assert_eq!(ed.source_str().unwrap(), "a\n");
}
#[test]
fn editor_toggle_block_container_nests_a_partial_selection() {
let mut ed = Editor::new_str("> a\n>\n> b\n", Format::Djot).expect("editor");
ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
.expect("nest");
assert_eq!(ed.source_str().unwrap(), "> > a\n>\n> b\n");
ed.toggle_block_container(4, 5, BlockContainerKind::BlockQuote)
.expect("peel");
assert_eq!(ed.source_str().unwrap(), "> a\n>\n> b\n");
}
#[test]
fn editor_toggle_block_container_numbers_and_converts_lists() {
let mut ed = Editor::new_str("a\n\nb\n", Format::Djot).expect("editor");
ed.toggle_block_container(0, 4, BlockContainerKind::OrderedList)
.expect("ordered on");
assert_eq!(ed.source_str().unwrap(), "1. a\n\n2. b\n");
ed.toggle_block_container(3, 9, BlockContainerKind::BulletList)
.expect("convert");
assert_eq!(ed.source_str().unwrap(), "- a\n\n- b\n");
}
#[test]
fn editor_toggle_block_container_rejects_unspellable_format() {
let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
assert_eq!(
xml.toggle_block_container(3, 5, BlockContainerKind::BlockQuote),
Err(Error::UnsupportedFormat)
);
}
#[test]
fn editor_insert_link_wraps_and_repoints() {
let mut ed = Editor::new_str("a word b\n", Format::Djot).expect("editor");
ed.insert_link(2, 6, "http://x.dev").expect("link");
assert_eq!(ed.source_str().unwrap(), "a [word](http://x.dev) b\n");
ed.insert_link(3, 7, "http://y.dev").expect("re-point");
assert_eq!(ed.source_str().unwrap(), "a [word](http://y.dev) b\n");
}
#[test]
fn editor_insert_link_repoints_an_autolink() {
for format in [Format::Markdown, Format::Djot] {
let mut ed = Editor::new_str("see <https://x.dev> ok\n", format).expect("editor");
ed.insert_link(10, 10, "https://y.dev").expect("re-point");
assert_eq!(ed.source_str().unwrap(), "see <https://y.dev> ok\n");
let nodes = ed.nodes().expect("nodes");
let url = nodes
.iter()
.find(|n| n.kind == "url")
.expect("still an autolink");
assert_eq!(url.text.as_deref(), Some("https://y.dev"));
assert!(!nodes.iter().any(|n| n.kind == "link"));
}
}
#[test]
fn editor_insert_link_escapes_the_destination() {
let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
dj.insert_link(0, 1, "a)b").expect("link");
assert_eq!(dj.source_str().unwrap(), "[w](a\\)b)\n");
let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
md.insert_link(0, 1, "a b").expect("link");
assert_eq!(md.source_str().unwrap(), "[w](<a b>)\n");
let mut dj2 = Editor::new_str("w\n", Format::Djot).expect("editor");
dj2.insert_link(0, 1, "a b").expect("link");
assert_eq!(dj2.source_str().unwrap(), "[w](a b)\n");
}
#[test]
fn editor_insert_image_escapes_the_destination_per_format() {
let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
md.insert_image(0, 1, "my cat.png").expect("image");
assert_eq!(md.source_str().unwrap(), "\n");
let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
dj.insert_image(0, 1, "my cat.png").expect("image");
assert_eq!(dj.source_str().unwrap(), "\n");
let mut paren = Editor::new_str("w\n", Format::Djot).expect("editor");
paren.insert_image(0, 1, "a)b.png").expect("image");
assert_eq!(paren.source_str().unwrap(), "b.png)\n");
}
#[test]
fn editor_insert_image_keeps_an_empty_alt_empty() {
let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
ed.insert_image(1, 1, "cat.png").expect("image");
assert_eq!(ed.source_str().unwrap(), "ab\n");
}
#[test]
fn editor_insert_image_rejects_a_newline_destination() {
let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
assert_eq!(ed.insert_image(0, 1, "a\nb.png"), Err(Error::InvalidArgument));
let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
assert_eq!(xml.insert_image(3, 5, "x.png"), Err(Error::UnsupportedFormat));
}
#[test]
fn editor_insert_link_rejects_a_newline_destination() {
let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
assert_eq!(ed.insert_link(0, 1, "a\nb"), Err(Error::InvalidArgument));
let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
assert_eq!(xml.insert_link(3, 5, "u"), Err(Error::UnsupportedFormat));
}
#[test]
fn editor_insert_literal_keeps_typed_specials_literal() {
for format in [Format::Markdown, Format::Djot] {
let mut ed = Editor::new_str("z\n", format).expect("editor");
ed.insert_literal(0, "*hi*").expect("literal");
let nodes = ed.nodes().expect("nodes");
assert!(!nodes.iter().any(|n| n.kind == "emph" || n.kind == "strong"));
let text: String = nodes
.iter()
.filter(|n| n.kind == "str")
.filter_map(|n| n.text.clone())
.collect();
assert_eq!(text, "*hi*z");
}
}
#[test]
fn editor_insert_literal_escapes_block_markers_only_at_line_start() {
let mut ed = Editor::new_str("az\n", Format::Markdown).expect("editor");
ed.insert_literal(1, "# ").expect("literal");
assert_eq!(ed.source_str().unwrap(), "a# z\n");
let mut ed2 = Editor::new_str("z\n", Format::Markdown).expect("editor");
ed2.insert_literal(0, "# ").expect("literal");
assert_eq!(ed2.source_str().unwrap(), "\\# z\n");
assert!(!ed2.nodes().expect("nodes").iter().any(|n| n.kind == "heading"));
}
#[test]
fn editor_insert_literal_rejects_bad_offset_and_parse_only_format() {
let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
assert_eq!(ed.insert_literal(99, "x"), Err(Error::InvalidArgument));
let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
assert_eq!(xml.insert_literal(3, "x"), Err(Error::UnsupportedFormat));
}
#[test]
fn editor_insert_line_break_splices_in_cell_br() {
let mut ed =
Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
ed.insert_line_break(3).expect("line break");
assert_eq!(ed.source_str().unwrap(), "| a<br> | b |\n| --- | --- |\n");
let nodes = ed.nodes().expect("nodes");
assert!(nodes.iter().any(|n| n.kind == "hard_break"));
assert!(!nodes.iter().any(|n| n.kind == "raw_inline"));
}
#[test]
fn editor_insert_line_break_rejects_off_cell_off_format_and_bad_offset() {
let mut para = Editor::new_str("just text\n", Format::Markdown).expect("editor");
assert_eq!(para.insert_line_break(3), Err(Error::NotFound));
let mut dj =
Editor::new_str("| a | b |\n| --- | --- |\n", Format::Djot).expect("editor");
assert_eq!(dj.insert_line_break(3), Err(Error::UnsupportedFormat));
let mut ed =
Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
assert_eq!(ed.insert_line_break(9999), Err(Error::InvalidArgument));
}
#[test]
fn editor_undo_redo_round_trip() {
let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
ed.edit_range(5, 5, "!").expect("edit");
assert_eq!(ed.source_str().unwrap(), "hello!\n");
let change = ed.undo().expect("undo ok").expect("something to undo");
assert_eq!(ed.source_str().unwrap(), "hello\n");
assert_eq!(change.new.end, 5);
assert!(ed.undo().expect("undo ok").is_none(), "history exhausted");
ed.redo().expect("redo ok").expect("something to redo");
assert_eq!(ed.source_str().unwrap(), "hello!\n");
}
#[test]
fn editor_coalesce_folds_a_run() {
let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
ed.edit_range(0, 0, "a").expect("edit");
ed.edit_range(1, 1, "b").expect("edit");
ed.coalesce_last_undo().expect("coalesce");
assert_eq!(ed.source_str().unwrap(), "ab\n");
ed.undo().expect("undo ok").expect("something to undo");
assert_eq!(ed.source_str().unwrap(), "\n");
assert!(ed.undo().expect("undo ok").is_none());
}
#[test]
fn editor_revision_bumps_per_successful_mutation() {
let mut ed = Editor::new_str("x\n", Format::Markdown).expect("editor");
assert_eq!(ed.revision(), 0);
ed.edit_range(1, 1, "y").expect("edit");
assert_eq!(ed.revision(), 1);
let mut xml = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
assert_eq!(xml.revision(), 0);
assert!(xml.replace_content("0", "<b>").is_err());
assert_eq!(xml.revision(), 0);
ed.undo().expect("undo ok").expect("something to undo");
assert_eq!(ed.revision(), 2);
ed.redo().expect("redo ok").expect("something to redo");
assert_eq!(ed.revision(), 3);
}
#[test]
fn editor_dirty_range_tracks_and_clears() {
let mut ed = Editor::new_str("abcdefgh\n", Format::Markdown).expect("editor");
assert_eq!(ed.dirty_range(), None);
ed.edit_range(2, 2, "XY").expect("edit");
assert_eq!(ed.dirty_range(), Some(2..4));
ed.edit_range(9, 9, "Z").expect("edit"); let d = ed.dirty_range().expect("dirty");
assert!(d.start <= 2 && d.end >= 10, "range {d:?} must cover both edits");
let rev = ed.revision();
ed.clear_dirty();
assert_eq!(ed.dirty_range(), None);
assert_eq!(ed.revision(), rev);
ed.undo().expect("undo ok").expect("something to undo");
assert!(ed.dirty_range().is_some());
}
#[test]
fn editor_caret_blob_follows_undo_and_redo() {
let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
assert!(ed.caret_blob().unwrap().is_empty());
ed.set_caret_blob(b"before").expect("set caret");
ed.edit_range(5, 5, "!").expect("edit");
assert!(ed.caret_blob().unwrap().is_empty());
ed.set_caret_blob(b"after").expect("set caret");
ed.undo().expect("undo ok").expect("something to undo");
assert_eq!(ed.source_str().unwrap(), "hello\n");
assert_eq!(ed.caret_blob().unwrap(), b"before");
ed.redo().expect("redo ok").expect("something to redo");
assert_eq!(ed.source_str().unwrap(), "hello!\n");
assert_eq!(ed.caret_blob().unwrap(), b"after");
}
#[test]
fn editor_coalesced_run_keeps_the_pre_run_caret() {
let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
ed.set_caret_blob(b"c0").expect("set caret");
ed.edit_range(0, 0, "a").expect("edit");
ed.set_caret_blob(b"c1").expect("set caret");
ed.edit_range(1, 1, "b").expect("edit");
ed.coalesce_last_undo().expect("coalesce");
ed.set_caret_blob(b"c2").expect("set caret");
ed.undo().expect("undo ok").expect("something to undo");
assert_eq!(ed.source_str().unwrap(), "\n");
assert_eq!(ed.caret_blob().unwrap(), b"c0");
}
#[test]
fn editor_renumber_ordered_lists_fixes_a_stale_sequence() {
let mut ed =
Editor::new_str("1. a\n2. x\n2. b\n3. c\n", Format::Markdown).expect("editor");
ed.renumber_ordered_lists(0).expect("renumber ok");
assert_eq!(ed.source_str().unwrap(), "1. a\n2. x\n3. b\n4. c\n");
}
#[test]
fn editor_renumber_ordered_lists_off_a_list_is_not_found() {
let mut ed = Editor::new_str("a paragraph\n", Format::Markdown).expect("editor");
assert!(matches!(ed.renumber_ordered_lists(2), Err(Error::NotFound)));
}
#[test]
fn editor_table_insert_row_and_set_alignment() {
let src = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
ed.table_insert_row(24, true).expect("insert row"); assert_eq!(
ed.source_str().unwrap(),
"| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n"
);
ed.table_set_alignment(6, Alignment::Center).expect("align"); assert!(ed.source_str().unwrap().contains("| --- | :---: |"));
}
#[test]
fn editor_table_edit_off_a_table_is_not_found() {
let mut ed = Editor::new_str("nope\n", Format::Markdown).expect("editor");
assert!(matches!(ed.table_delete_row(2), Err(Error::NotFound)));
}
#[test]
fn editor_set_block_converts_setext_heading() {
let mut ed = Editor::new_str("Title\n=====\n\nbody\n", Format::Markdown).expect("editor");
ed.set_block(0, BlockKind::Heading(1)).expect("setext to atx");
assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody\n");
}
#[test]
fn editor_unwrap_and_smart_delete() {
let mut ed = Editor::new_str("<r><box><b/><c/></box></r>", Format::Xml).expect("editor");
ed.unwrap_node("0.0").expect("unwrap"); assert_eq!(ed.source_str().expect("source"), "<r><b/><c/></r>");
let mut md = Editor::new_str("A\n\nB\n\nC\n", Format::Markdown).expect("editor");
md.delete_smart("1").expect("delete_smart"); assert_eq!(md.source_str().expect("source"), "A\n\nC\n");
}
#[test]
fn editor_directives_require_the_extension_flag() {
let src = ":::vis{.public}\nhi\n:::\n";
let mut plain = Editor::new_str(src, Format::Markdown).expect("editor");
assert_eq!(plain.query("directive").expect("query").len(), 0);
let mut ext = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
MarkdownExtensions { directives: true, ..Default::default() },
)
.expect("editor");
assert_eq!(ext.query("directive").expect("query").len(), 1);
}
#[test]
fn document_html_elements_make_embedded_img_queryable() {
let src = "text <img src=\"a.png\" alt=\"x\"> more\n";
let mut plain = Document::parse_str(src, Format::Markdown).expect("parse");
assert_eq!(plain.query("image").expect("query").len(), 0);
let mut ext = Document::parse_str_with(
src,
Format::Markdown,
MarkdownExtensions { html_elements: true, ..Default::default() },
)
.expect("parse");
let images = ext.query("image").expect("query");
assert_eq!(images.len(), 1);
assert_eq!(images[0].kind, "image");
}
#[test]
fn editor_filter_public_audience_view() {
let src = "# Archive\n\n:::vis{.public}\nPublic.\n:::\n\n:::vis{.family}\nPrivate.\n:::\n";
let mut ed = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
MarkdownExtensions { directives: true, ..Default::default() },
)
.expect("editor");
ed.filter(
"directive[name=vis]",
Some("directive[class~=public]"),
true,
)
.expect("filter");
assert_eq!(ed.source_str().expect("source"), "# Archive\n\nPublic.\n");
}
#[test]
fn editor_filter_rejects_a_malformed_selector() {
let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
assert_eq!(ed.filter("list >", None, false), Err(Error::InvalidArgument));
}
#[test]
fn builder_builds_and_renders_a_document() {
let mut b = Builder::new().expect("builder");
let title = b.add_text(TextKind::Str, "Title").unwrap();
let heading = b.add_heading(1).unwrap();
b.set_children(heading, &[title]).unwrap();
let hello = b.add_text(TextKind::Str, "hello ").unwrap();
let world = b.add_text(TextKind::Str, "world").unwrap();
let emph = b.add(VoidKind::Emph).unwrap();
b.set_children(emph, &[world]).unwrap();
let para = b.add(VoidKind::Para).unwrap();
b.set_children(para, &[hello, emph]).unwrap();
let doc = b.add(VoidKind::Doc).unwrap();
b.set_children(doc, &[heading, para]).unwrap();
let html = String::from_utf8(b.render_html(doc).unwrap()).unwrap();
assert!(html.contains("<h1>Title</h1>"), "{html}");
assert!(html.contains("<em>world</em>"), "{html}");
let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
assert!(md.contains("# Title"), "{md}");
assert!(md.contains("*world*"), "{md}");
let matches = b.query(doc, "heading").unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].kind, "heading");
let json = String::from_utf8(b.ast_json(doc).unwrap()).unwrap();
assert!(json.contains("\"kind\": \"doc\""), "{json}");
}
#[test]
fn builder_element_with_attributes() {
let mut b = Builder::new().expect("builder");
let inner = b.add_text(TextKind::Str, "hi").unwrap();
let el = b.add_element("section").unwrap();
b.set_children(el, &[inner]).unwrap();
b.set_attrs(el, &[("class", Some("note")), ("hidden", None)]).unwrap();
let html = String::from_utf8(b.render_html(el).unwrap()).unwrap();
assert!(html.contains("<section"), "{html}");
assert!(html.contains("class=\"note\""), "{html}");
assert!(html.contains("hidden"), "{html}");
}
#[test]
fn builder_lists_round_trip_to_markdown() {
let mut b = Builder::new().expect("builder");
let one_txt = b.add_text(TextKind::Str, "one").unwrap();
let one_para = b.add(VoidKind::Para).unwrap();
b.set_children(one_para, &[one_txt]).unwrap();
let one = b.add(VoidKind::ListItem).unwrap();
b.set_children(one, &[one_para]).unwrap();
let two_txt = b.add_text(TextKind::Str, "two").unwrap();
let two_para = b.add(VoidKind::Para).unwrap();
b.set_children(two_para, &[two_txt]).unwrap();
let two = b.add(VoidKind::ListItem).unwrap();
b.set_children(two, &[two_para]).unwrap();
let list = b
.add_ordered_list(OrderedNumbering::Decimal, OrderedDelim::Period, true, Some(1))
.unwrap();
b.set_children(list, &[one, two]).unwrap();
let doc = b.add(VoidKind::Doc).unwrap();
b.set_children(doc, &[list]).unwrap();
let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
assert!(md.contains("1. one"), "{md}");
assert!(md.contains("2. two"), "{md}");
}
#[test]
fn builder_rejects_invalid_kind_and_id() {
let b = Builder::new().expect("builder");
let mut id = 0u32;
let status = unsafe { ffi::twig_builder_add(b.raw.as_ptr(), 2, &mut id) };
assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
let mut ptr = std::ptr::null();
let mut len = 0usize;
let status = unsafe { ffi::twig_builder_render_html(b.raw.as_ptr(), 4242, &mut ptr, &mut len) };
assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
}
}