mod error;
pub(crate) use twig_sys as ffi;
use std::marker::PhantomData;
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)]
#[non_exhaustive]
pub enum Format {
Djot,
Markdown,
Xml,
Html,
Asciidoc,
}
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,
Format::Asciidoc => ffi::TwigFormat::Asciidoc,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Target {
Djot,
Markdown,
Xml,
Html,
Asciidoc,
}
impl Target {
pub fn as_format(self) -> Option<Format> {
match self {
Target::Djot => Some(Format::Djot),
Target::Markdown => Some(Format::Markdown),
Target::Xml => Some(Format::Xml),
Target::Html => Some(Format::Html),
Target::Asciidoc => Some(Format::Asciidoc),
}
}
}
impl From<Format> for Target {
fn from(value: Format) -> Self {
match value {
Format::Djot => Target::Djot,
Format::Markdown => Target::Markdown,
Format::Xml => Target::Xml,
Format::Html => Target::Html,
Format::Asciidoc => Target::Asciidoc,
}
}
}
impl From<Target> for ffi::TwigFormat {
fn from(value: Target) -> Self {
match value {
Target::Djot => ffi::TwigFormat::Djot,
Target::Markdown => ffi::TwigFormat::Markdown,
Target::Xml => ffi::TwigFormat::Xml,
Target::Html => ffi::TwigFormat::Html,
Target::Asciidoc => ffi::TwigFormat::Asciidoc,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum Kind {
Doc,
Para,
Heading,
ThematicBreak,
Section,
CodeBlock,
RawBlock,
Metadata,
BlockQuote,
BulletList,
OrderedList,
TaskList,
DefinitionList,
LineBlock,
Table,
ListItem,
TaskListItem,
DefinitionListItem,
Term,
Definition,
Line,
Row,
Cell,
Column,
Caption,
Footnote,
Reference,
Citation,
Substitution,
Str,
SoftBreak,
HardBreak,
NonBreakingSpace,
RawInline,
SmartPunctuation,
Link,
Image,
Emph,
Strong,
Mark,
Superscript,
Subscript,
Insert,
Delete,
DoubleQuoted,
SingleQuoted,
Symb,
Verbatim,
InlineMath,
DisplayMath,
Url,
Email,
FootnoteReference,
CitationReference,
SubstitutionReference,
Container,
ProcessingInstruction,
Comment,
Doctype,
Cdata,
Other(String),
}
impl Kind {
pub fn as_str(&self) -> &str {
match self {
Kind::Doc => "doc",
Kind::Para => "para",
Kind::Heading => "heading",
Kind::ThematicBreak => "thematic_break",
Kind::Section => "section",
Kind::CodeBlock => "code_block",
Kind::RawBlock => "raw_block",
Kind::Metadata => "metadata",
Kind::BlockQuote => "block_quote",
Kind::BulletList => "bullet_list",
Kind::OrderedList => "ordered_list",
Kind::TaskList => "task_list",
Kind::DefinitionList => "definition_list",
Kind::LineBlock => "line_block",
Kind::Table => "table",
Kind::ListItem => "list_item",
Kind::TaskListItem => "task_list_item",
Kind::DefinitionListItem => "definition_list_item",
Kind::Term => "term",
Kind::Definition => "definition",
Kind::Line => "line",
Kind::Row => "row",
Kind::Cell => "cell",
Kind::Column => "column",
Kind::Caption => "caption",
Kind::Footnote => "footnote",
Kind::Reference => "reference",
Kind::Citation => "citation",
Kind::Substitution => "substitution",
Kind::Str => "str",
Kind::SoftBreak => "soft_break",
Kind::HardBreak => "hard_break",
Kind::NonBreakingSpace => "non_breaking_space",
Kind::RawInline => "raw_inline",
Kind::SmartPunctuation => "smart_punctuation",
Kind::Link => "link",
Kind::Image => "image",
Kind::Container => "container",
Kind::ProcessingInstruction => "processing_instruction",
Kind::Emph => "emph",
Kind::Strong => "strong",
Kind::Mark => "mark",
Kind::Superscript => "superscript",
Kind::Subscript => "subscript",
Kind::Insert => "insert",
Kind::Delete => "delete",
Kind::DoubleQuoted => "double_quoted",
Kind::SingleQuoted => "single_quoted",
Kind::Symb => "symb",
Kind::Verbatim => "verbatim",
Kind::InlineMath => "inline_math",
Kind::DisplayMath => "display_math",
Kind::Url => "url",
Kind::Email => "email",
Kind::FootnoteReference => "footnote_reference",
Kind::CitationReference => "citation_reference",
Kind::SubstitutionReference => "substitution_reference",
Kind::Comment => "comment",
Kind::Doctype => "doctype",
Kind::Cdata => "cdata",
Kind::Other(name) => name.as_str(),
}
}
pub fn is_unknown(&self) -> bool {
matches!(self, Kind::Other(_))
}
}
impl From<&str> for Kind {
fn from(name: &str) -> Self {
match name {
"doc" => Kind::Doc,
"para" => Kind::Para,
"heading" => Kind::Heading,
"thematic_break" => Kind::ThematicBreak,
"section" => Kind::Section,
"code_block" => Kind::CodeBlock,
"raw_block" => Kind::RawBlock,
"metadata" => Kind::Metadata,
"block_quote" => Kind::BlockQuote,
"bullet_list" => Kind::BulletList,
"ordered_list" => Kind::OrderedList,
"task_list" => Kind::TaskList,
"definition_list" => Kind::DefinitionList,
"line_block" => Kind::LineBlock,
"table" => Kind::Table,
"list_item" => Kind::ListItem,
"task_list_item" => Kind::TaskListItem,
"definition_list_item" => Kind::DefinitionListItem,
"term" => Kind::Term,
"definition" => Kind::Definition,
"line" => Kind::Line,
"row" => Kind::Row,
"cell" => Kind::Cell,
"column" => Kind::Column,
"caption" => Kind::Caption,
"footnote" => Kind::Footnote,
"reference" => Kind::Reference,
"citation" => Kind::Citation,
"substitution" => Kind::Substitution,
"str" => Kind::Str,
"soft_break" => Kind::SoftBreak,
"hard_break" => Kind::HardBreak,
"non_breaking_space" => Kind::NonBreakingSpace,
"raw_inline" => Kind::RawInline,
"smart_punctuation" => Kind::SmartPunctuation,
"link" => Kind::Link,
"image" => Kind::Image,
"container" => Kind::Container,
"processing_instruction" => Kind::ProcessingInstruction,
"emph" => Kind::Emph,
"strong" => Kind::Strong,
"mark" => Kind::Mark,
"superscript" => Kind::Superscript,
"subscript" => Kind::Subscript,
"insert" => Kind::Insert,
"delete" => Kind::Delete,
"double_quoted" => Kind::DoubleQuoted,
"single_quoted" => Kind::SingleQuoted,
"symb" => Kind::Symb,
"verbatim" => Kind::Verbatim,
"inline_math" => Kind::InlineMath,
"display_math" => Kind::DisplayMath,
"url" => Kind::Url,
"email" => Kind::Email,
"footnote_reference" => Kind::FootnoteReference,
"citation_reference" => Kind::CitationReference,
"substitution_reference" => Kind::SubstitutionReference,
"comment" => Kind::Comment,
"doctype" => Kind::Doctype,
"cdata" => Kind::Cdata,
other => Kind::Other(other.to_string()),
}
}
}
impl std::fmt::Display for Kind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct QueryMatch {
pub node_id: u32,
pub span: Range<usize>,
pub content_span: Option<Range<usize>>,
pub kind: Kind,
}
#[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: Kind,
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 origin: Option<ContainerOrigin>,
pub marker_span: Option<Range<usize>>,
pub checked: Option<bool>,
pub attrs: Vec<(String, Option<String>)>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct LinePrefix {
pub text: String,
pub columns: usize,
}
#[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)]
#[non_exhaustive]
pub enum Gesture {
WrapRange(InlineKind),
ToggleInline(InlineKind),
SetBlock,
ToggleBlockContainer(BlockContainerKind),
InsertThematicBreak,
ToggleCodeBlock,
SetCodeLanguage,
ToggleTaskItem,
SetTaskChecked,
ToggleTaskChecked,
InsertLink,
InsertImage,
InsertFootnote,
InsertLiteral,
InsertLineBreak,
}
impl Gesture {
fn to_c(self) -> (c_int, c_int) {
match self {
Gesture::WrapRange(k) => (0, k.to_c()),
Gesture::ToggleInline(k) => (1, k.to_c()),
Gesture::SetBlock => (2, 0),
Gesture::ToggleBlockContainer(k) => (3, k.to_c()),
Gesture::InsertThematicBreak => (4, 0),
Gesture::ToggleCodeBlock => (5, 0),
Gesture::SetCodeLanguage => (6, 0),
Gesture::ToggleTaskItem => (7, 0),
Gesture::SetTaskChecked => (8, 0),
Gesture::ToggleTaskChecked => (9, 0),
Gesture::InsertLink => (10, 0),
Gesture::InsertImage => (11, 0),
Gesture::InsertFootnote => (12, 0),
Gesture::InsertLiteral => (13, 0),
Gesture::InsertLineBreak => (14, 0),
}
}
}
impl Format {
pub fn supports(self, gesture: Gesture) -> bool {
let (g, k) = gesture.to_c();
let mut supported: c_int = 0;
let status = unsafe {
ffi::twig_format_supports(ffi::TwigFormat::from(self) as c_int, g, k, &mut supported)
};
debug_assert!(
Error::from_status(status).is_ok(),
"twig_format_supports rejected a combination the Rust types make unrepresentable",
);
supported == 1
}
pub fn is_authorable(self) -> bool {
let mut authorable: c_int = 0;
let status = unsafe {
ffi::twig_format_is_authorable(ffi::TwigFormat::from(self) as c_int, &mut authorable)
};
debug_assert!(Error::from_status(status).is_ok(), "unknown format code");
authorable == 1
}
}
#[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_to(&mut self, target: Target) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
let ffi_target: ffi::TwigFormat = target.into();
collect_bytes(|ptr, len| unsafe {
ffi::twig_document_serialize(raw, ffi_target as i32, ptr, len)
})
}
pub fn serialize(&mut self, format: Format) -> Result<Vec<u8>, Error> {
self.serialize_to(format.into())
}
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)
})
}
pub fn span(&mut self, node: NodeId) -> Result<Range<usize>, Error> {
let mut span = ffi::TwigSpan { start: 0, end: 0 };
let status = unsafe { ffi::twig_document_node_span(self.raw.as_ptr(), node.0, &mut span) };
Error::from_status(status)?;
Ok(span.start..span.end)
}
pub fn content_span(&mut self, node: NodeId) -> Result<Option<Range<usize>>, Error> {
let mut span = ffi::TwigSpan { start: 0, end: 0 };
let status =
unsafe { ffi::twig_document_node_content_span(self.raw.as_ptr(), node.0, &mut span) };
match status.0 {
ffi::TwigStatus::OK => Ok(Some(span.start..span.end)),
ffi::TwigStatus::NOT_FOUND => Ok(None),
_ => Err(Error::from_status(status).unwrap_err()),
}
}
pub fn marker_span(&mut self, node: NodeId) -> Result<Option<Range<usize>>, Error> {
let mut span = ffi::TwigSpan { start: 0, end: 0 };
let status =
unsafe { ffi::twig_document_node_marker_span(self.raw.as_ptr(), node.0, &mut span) };
match status.0 {
ffi::TwigStatus::OK => Ok(Some(span.start..span.end)),
ffi::TwigStatus::NOT_FOUND => Ok(None),
_ => Err(Error::from_status(status).unwrap_err()),
}
}
pub fn line_prefix(&mut self, offset: usize) -> Result<Option<Range<usize>>, Error> {
let mut span = ffi::TwigSpan { start: 0, end: 0 };
let status =
unsafe { ffi::twig_document_line_prefix(self.raw.as_ptr(), offset, &mut span) };
match status.0 {
ffi::TwigStatus::OK => Ok(Some(span.start..span.end)),
ffi::TwigStatus::NOT_FOUND => Ok(None),
_ => Err(Error::from_status(status).unwrap_err()),
}
}
pub fn continuation_prefix(&mut self, offset: usize) -> Result<LinePrefix, Error> {
self.prefix_via(offset, ffi::twig_document_continuation_prefix)
}
pub fn blank_line_prefix(&mut self, offset: usize) -> Result<LinePrefix, Error> {
self.prefix_via(offset, ffi::twig_document_blank_line_prefix)
}
fn prefix_via(
&mut self,
offset: usize,
f: unsafe extern "C" fn(
*mut ffi::TwigDocument,
usize,
*mut *const u8,
*mut usize,
*mut usize,
) -> ffi::TwigStatus,
) -> Result<LinePrefix, Error> {
let mut ptr: *const u8 = std::ptr::null();
let mut len = 0usize;
let mut columns = 0usize;
let status = unsafe { f(self.raw.as_ptr(), offset, &mut ptr, &mut len, &mut columns) };
Error::from_status(status)?;
let text = if ptr.is_null() || len == 0 {
String::new()
} else {
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
String::from_utf8(bytes.to_vec()).map_err(|_| Error::Internal)?
};
Ok(LinePrefix { text, columns })
}
pub fn cell_extent(&mut self, node: NodeId) -> Result<Option<(u32, u32)>, Error> {
let raw = self.raw.as_ptr();
let mut colspan: u32 = 0;
let status = unsafe { ffi::twig_document_cell_colspan(raw, node.0, &mut colspan) };
match status.0 {
ffi::TwigStatus::OK => {}
ffi::TwigStatus::NOT_FOUND => return Ok(None),
_ => return Err(Error::from_status(status).unwrap_err()),
}
let mut rowspan: u32 = 0;
Error::from_status(unsafe { ffi::twig_document_cell_rowspan(raw, node.0, &mut rowspan) })?;
Ok(Some((colspan, rowspan)))
}
pub fn nodes(&mut self) -> Result<Vec<FlatNode>, Error> {
let raw = self.raw.as_ptr();
collect_flat_nodes(|ptr, len| unsafe { ffi::twig_document_nodes(raw, ptr, len) })
}
pub fn definitions(&mut self) -> Result<Vec<QueryMatch>, Error> {
let raw = self.raw.as_ptr();
collect_matches(|ptr, len| unsafe { ffi::twig_document_definitions(raw, ptr, len) })
}
pub fn diagnostics(&mut self, target: Target) -> Result<Vec<Warning>, Error> {
let raw = self.raw.as_ptr();
let code = ffi::TwigFormat::from(target) as c_int;
let mut ptr: *const ffi::TwigWarning = std::ptr::null();
let mut len = 0usize;
let status = unsafe { ffi::twig_document_diagnostics(raw, code, &mut ptr, &mut len) };
Error::from_status(status)?;
if len == 0 || ptr.is_null() {
return Ok(Vec::new());
}
let raw_warnings = unsafe { std::slice::from_raw_parts(ptr, len) };
Ok(raw_warnings
.iter()
.map(|w| Warning {
fidelity: Fidelity::from_c(w.fidelity),
path: borrowed_bytes(w.path_ptr, w.path_len).unwrap_or_default(),
kind: Kind::from(borrowed_cstr(w.kind).unwrap_or_default().as_str()),
})
.collect())
}
pub fn children(&mut self, node: Option<NodeId>) -> Result<Vec<QueryMatch>, Error> {
let raw = self.raw.as_ptr();
let id = node.map_or(ffi::TWIG_NO_NODE, |n| n.0);
collect_matches(|ptr, len| unsafe { ffi::twig_document_children(raw, id, ptr, len) })
}
pub fn subtree(&mut self, node: NodeId) -> Result<Vec<FlatNode>, Error> {
let raw = self.raw.as_ptr();
collect_flat_nodes(|ptr, len| unsafe { ffi::twig_document_subtree(raw, node.0, ptr, len) })
}
pub fn node_at(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
let mut m = empty_ffi_match();
let status = unsafe { ffi::twig_document_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 raw = self.raw.as_ptr();
let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
let mut len = 0usize;
let status = unsafe { ffi::twig_document_nodes_at(raw, 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_matches = unsafe { std::slice::from_raw_parts(ptr, len) };
raw_matches.iter().map(query_match_from_ffi).collect()
}
pub fn node_at_caret(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
let mut m = empty_ffi_match();
let status = unsafe { ffi::twig_document_node_at_caret(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_caret(&mut self, offset: usize) -> Result<Vec<QueryMatch>, Error> {
let raw = self.raw.as_ptr();
let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
let mut len = 0usize;
let status = unsafe { ffi::twig_document_nodes_at_caret(raw, 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_matches = unsafe { std::slice::from_raw_parts(ptr, len) };
raw_matches.iter().map(query_match_from_ffi).collect()
}
}
#[derive(Debug)]
pub struct DocumentView<'a> {
doc: Document,
_editor: PhantomData<&'a mut Editor>,
}
impl std::ops::Deref for DocumentView<'_> {
type Target = Document;
fn deref(&self) -> &Document {
&self.doc
}
}
impl std::ops::DerefMut for DocumentView<'_> {
fn deref_mut(&mut self) -> &mut Document {
&mut self.doc
}
}
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 document(&mut self) -> Result<DocumentView<'_>, Error> {
let mut raw = std::ptr::null_mut();
let status = unsafe { ffi::twig_editor_document(self.raw.as_ptr(), &mut raw) };
Error::from_status(status)?;
let raw = NonNull::new(raw).ok_or(Error::Internal)?;
Ok(DocumentView {
doc: Document { raw },
_editor: PhantomData,
})
}
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) })
}
pub fn insert_thematic_break(&mut self, offset: usize) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe { ffi::twig_editor_insert_thematic_break(ed, offset, out) })
}
pub fn split_block(&mut self, offset: usize) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe { ffi::twig_editor_split_block(ed, offset, out) })
}
pub fn toggle_code_block(
&mut self,
start: usize,
end: usize,
language: Option<&str>,
) -> Result<Change, Error> {
let (ptr, len, has) = opt_str(language);
self.change_op(|ed, out| unsafe {
ffi::twig_editor_toggle_code_block(ed, start, end, ptr, len, has, out)
})
}
pub fn set_code_language(
&mut self,
offset: usize,
language: Option<&str>,
) -> Result<Change, Error> {
let (ptr, len, has) = opt_str(language);
self.change_op(|ed, out| unsafe {
ffi::twig_editor_set_code_language(ed, offset, ptr, len, has, out)
})
}
pub fn toggle_task_item(&mut self, offset: usize) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe { ffi::twig_editor_toggle_task_item(ed, offset, out) })
}
pub fn set_task_checked(&mut self, offset: usize, checked: bool) -> Result<(), Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_set_task_checked(ed, offset, checked as c_int, out)
})?;
Ok(())
}
pub fn toggle_task_checked(&mut self, offset: usize) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe { ffi::twig_editor_toggle_task_checked(ed, offset, out) })
}
pub fn insert_footnote(&mut self, offset: usize, label: &str) -> Result<Change, Error> {
self.change_op(|ed, out| unsafe {
ffi::twig_editor_insert_footnote(ed, offset, label.as_ptr(), label.len(), 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 collect_flat_nodes(
call: impl FnOnce(*mut *const ffi::TwigFlatNode, *mut usize) -> ffi::TwigStatus,
) -> Result<Vec<FlatNode>, 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 nodes = unsafe { std::slice::from_raw_parts(ptr, len) };
nodes.iter().map(flat_node_from_ffi).collect()
}
fn empty_ffi_match() -> ffi::TwigQueryMatch {
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(),
}
}
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: Kind::from(borrowed_cstr(m.kind)?.as_str()),
})
}
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: Kind::from(borrowed_cstr(n.kind)?.as_str()),
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),
origin: ContainerOrigin::from_c(n.container_origin),
marker_span: if n.has_marker_span != 0 {
Some(n.marker_span.start..n.marker_span.end)
} else {
None
},
checked: match n.checked {
ffi::TWIG_TASK_CHECKED_NONE => None,
v => Some(v != 0),
},
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,
CitationReference,
SubstitutionReference,
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::CitationReference => 58,
TextKind::SubstitutionReference => 59,
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, Debug, Eq, PartialEq)]
pub struct Warning {
pub fidelity: Fidelity,
pub path: String,
pub kind: Kind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Fidelity {
Degraded,
Dropped,
}
impl Fidelity {
fn from_c(v: c_int) -> Self {
match v {
ffi::TWIG_FIDELITY_DROPPED => Fidelity::Dropped,
_ => Fidelity::Degraded,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ContainerOrigin {
Element,
Directive,
}
impl ContainerOrigin {
fn from_c(v: c_int) -> Option<Self> {
match v {
ffi::TWIG_CONTAINER_ORIGIN_ELEMENT => Some(ContainerOrigin::Element),
ffi::TWIG_CONTAINER_ORIGIN_DIRECTIVE => Some(ContainerOrigin::Directive),
_ => None,
}
}
}
#[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_citation(&mut self, label: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_citation(b, label.as_ptr(), label.len(), out)
})
}
pub fn add_substitution(&mut self, label: &str) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_substitution(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 add_cell_spanning(
&mut self,
head: bool,
alignment: Alignment,
colspan: u32,
rowspan: u32,
) -> Result<NodeId, Error> {
self.emit(|b, out| unsafe {
ffi::twig_builder_add_cell_spanning(
b,
head as c_int,
alignment.to_c(),
colspan,
rowspan,
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_to(&mut self, root: NodeId, target: Target) -> Result<Vec<u8>, Error> {
let raw = self.raw.as_ptr();
let ffi_target: ffi::TwigFormat = target.into();
collect_bytes(|ptr, len| unsafe {
ffi::twig_builder_serialize(raw, root.0, ffi_target as i32, ptr, len)
})
}
pub fn serialize(&mut self, root: NodeId, format: Format) -> Result<Vec<u8>, Error> {
self.serialize_to(root, format.into())
}
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 parses_asciidoc_and_refuses_to_write_it() {
let mut doc = Document::parse_str("= Title\n\nsome *bold* text\n", Format::Asciidoc)
.expect("parse asciidoc");
let html = String::from_utf8_lossy(&doc.render_html().expect("render html")).into_owned();
assert!(html.contains("<h1>Title</h1>"), "got {html:?}");
assert!(html.contains("<strong>bold</strong>"), "got {html:?}");
assert_eq!(
doc.serialize_to(Target::Asciidoc),
Err(Error::UnsupportedFormat)
);
assert_eq!(Target::from(Format::Asciidoc), Target::Asciidoc);
assert_eq!(Target::Asciidoc.as_format(), Some(Format::Asciidoc));
}
#[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 serialize_to_takes_the_output_axis() {
let mut doc =
Document::parse_str("This is *markdown*.\n", Format::Markdown).expect("parse markdown");
let djot = doc.serialize_to(Target::Djot).expect("serialize djot");
assert!(String::from_utf8_lossy(&djot).contains("_markdown_"));
assert_eq!(doc.serialize_to(Target::Xml), Err(Error::UnsupportedFormat));
}
#[test]
fn serialize_and_serialize_to_agree() {
let mut a = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
let mut b = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
for format in [Format::Markdown, Format::Djot, Format::Html] {
assert_eq!(a.serialize(format), b.serialize_to(Target::from(format)));
}
}
#[test]
fn every_format_is_a_target_that_names_it_back() {
for format in [Format::Djot, Format::Markdown, Format::Xml, Format::Html] {
assert_eq!(Target::from(format).as_format(), Some(format));
}
}
#[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, 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 document_span_accessors_read_by_node_id() {
let source = "# hi\n\ntext\n";
let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
let heading = doc.query("heading").expect("query").pop().expect("heading");
assert_eq!(
doc.span(NodeId(heading.node_id)).expect("span"),
heading.span
);
assert_eq!(
doc.content_span(NodeId(heading.node_id))
.expect("content span"),
heading.content_span
);
assert_eq!(doc.span(NodeId(u32::MAX)), Err(Error::InvalidArgument));
}
#[test]
fn document_walks_its_tree_without_an_editor() {
let source = "# hi\n\ntext\n";
let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
let nodes = doc.nodes().expect("nodes");
assert!(nodes.len() >= 3);
for (i, n) in nodes.iter().enumerate() {
assert_eq!(n.id, NodeId(i as u32));
}
let kids = doc.children(None).expect("children");
assert_eq!(kids.len(), 2);
assert_eq!(kids[0].kind, Kind::Heading);
let sub = doc.subtree(NodeId(kids[0].node_id)).expect("subtree");
assert_eq!(sub[0].id, NodeId(0));
assert_eq!(sub[0].parent, None);
assert_eq!(sub[0].span, kids[0].span);
let hit = doc.node_at(2).expect("node_at").expect("a node at 2");
let chain = doc.ancestors_at(2).expect("ancestors");
assert_eq!(chain.last().expect("deepest").node_id, hit.node_id);
assert_eq!(chain[0].kind, Kind::Doc);
assert_eq!(doc.subtree(NodeId(u32::MAX)), Err(Error::InvalidArgument));
}
#[test]
fn editor_document_view_reads_the_live_tree() {
let mut ed = Editor::new_str("# one\n\ntwo\n", Format::Markdown).expect("editor");
{
let mut view = ed.document().expect("view");
let kids = view.children(None).expect("children");
assert_eq!(kids.len(), 2);
assert_eq!(kids[0].kind, Kind::Heading);
assert_eq!(view.span(NodeId(kids[0].node_id)).expect("span"), 0..5);
assert_eq!(view.render_html(), Err(Error::UnsupportedFormat));
assert_eq!(
view.serialize(Format::Markdown),
Err(Error::UnsupportedFormat)
);
}
ed.replace("0", "# one and a half").expect("replace");
let mut view = ed.document().expect("view");
let kids = view.children(None).expect("children");
assert_eq!(view.span(NodeId(kids[0].node_id)).expect("span"), 0..16);
}
#[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 == 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 == Kind::Str);
if let Some(s) = picture_kids_str {
assert!(s.name.is_none() && s.attrs.is_empty());
}
}
#[test]
fn definitions_finds_what_a_walk_from_the_root_cannot() {
let mut doc = Document::parse_str(
"text[^1] [x][a]\n\n[^1]: note\n\n[a]: /u\n",
Format::Markdown,
)
.expect("parse markdown");
let defs = doc.definitions().expect("definitions");
let mut kinds: Vec<Kind> = defs.iter().map(|m| m.kind.clone()).collect();
kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
assert_eq!(kinds, vec![Kind::Footnote, Kind::Reference]);
let all = doc.nodes().expect("nodes");
let root = all
.iter()
.find(|n| n.kind == Kind::Doc)
.expect("a doc root");
let mut reachable = vec![root.id];
let mut i = 0;
while i < reachable.len() {
let n = &all[reachable[i].0 as usize];
let mut c = n.first_child;
while let Some(cid) = c {
reachable.push(cid);
c = all[cid.0 as usize].next_sibling;
}
i += 1;
}
for d in &defs {
assert!(
!reachable.contains(&NodeId(d.node_id)),
"{} should be unreachable from the root",
d.kind
);
}
let mut plain = Document::parse_str("just text\n", Format::Markdown).expect("parse");
assert_eq!(plain.definitions().expect("definitions"), Vec::new());
}
#[test]
fn kind_round_trips_through_its_published_name() {
for k in [
Kind::Doc,
Kind::Para,
Kind::Heading,
Kind::Container,
Kind::TaskListItem,
Kind::Superscript,
Kind::FootnoteReference,
Kind::ProcessingInstruction,
Kind::Cdata,
] {
assert_eq!(Kind::from(k.as_str()), k, "{k} did not round-trip");
assert!(!k.is_unknown());
}
}
#[test]
fn an_unknown_kind_name_is_carried_rather_than_lost() {
let k = Kind::from("some_future_kind");
assert!(k.is_unknown());
assert_eq!(k.as_str(), "some_future_kind");
assert_eq!(k, Kind::Other("some_future_kind".to_string()));
}
#[test]
fn every_kind_the_library_publishes_has_a_variant() {
let cases: &[(&str, Format, MarkdownExtensions)] = &[
(
"# h\n\npara *emph* **strong** `code`\n\n- a\n- b\n\n1. c\n\n> q\n\n---\n\n```zig\nx\n```\n",
Format::Markdown,
MarkdownExtensions {
directives: false,
math: false,
html_elements: false,
},
),
(
"| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- [ ] task\n- [x] done\n\nfoot[^1]\n\n[^1]: note\n\n[l]: /u\n\n[x][l]\n",
Format::Markdown,
MarkdownExtensions::default(),
),
(
":::note\nbody\n:::\n\n:role[x]\n\n$a+b$\n",
Format::Markdown,
MarkdownExtensions {
directives: true,
math: true,
html_elements: false,
},
),
(
"a^b^ c~d~ {=e=} {+f+} {-g-} 'q' \"dq\"\n\n\n\n<https://e.com>\n",
Format::Djot,
MarkdownExtensions::default(),
),
(
"<!-- c --><!DOCTYPE html><video controls><p>x</p></video>",
Format::Html,
MarkdownExtensions::default(),
),
];
let mut unknown: Vec<String> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for (src, format, ext) in cases {
let mut ed = Editor::new_ext(src.as_bytes(), *format, *ext).expect("editor");
for n in ed.nodes().expect("nodes") {
if n.kind.is_unknown() {
unknown.push(n.kind.as_str().to_string());
}
seen.push(n.kind.as_str().to_string());
}
}
unknown.sort();
unknown.dedup();
assert!(unknown.is_empty(), "kinds with no variant: {unknown:?}");
seen.sort();
seen.dedup();
assert!(
seen.len() >= 30,
"only {} distinct kinds reached: {seen:?}",
seen.len()
);
}
#[test]
fn diagnostics_report_what_a_conversion_would_lose() {
let mut doc = Document::parse_str("a^b^ c\n", Format::Djot).expect("parse djot");
let to_md = doc
.diagnostics(Target::Markdown)
.expect("markdown diagnostics");
assert_eq!(
to_md,
vec![Warning {
fidelity: Fidelity::Degraded,
path: "0/1".to_string(),
kind: Kind::Superscript,
}]
);
assert_eq!(
doc.diagnostics(Target::Djot).expect("djot diagnostics"),
Vec::new()
);
}
#[test]
fn diagnostics_separate_a_droppable_node_from_a_degradable_one() {
let mut doc =
Document::parse_str("<p>hi</p><!-- secret -->", Format::Html).expect("parse html");
let warnings = doc.diagnostics(Target::Djot).expect("djot diagnostics");
let comment = warnings
.iter()
.find(|w| w.kind == Kind::Comment)
.expect("a warning about the comment");
assert_eq!(comment.fidelity, Fidelity::Dropped);
}
#[test]
fn diagnostics_refuse_a_target_with_no_serializer() {
let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
assert_eq!(doc.diagnostics(Target::Xml), Err(Error::UnsupportedFormat));
assert_eq!(
doc.diagnostics(Target::Asciidoc),
Err(Error::UnsupportedFormat)
);
}
#[test]
fn diagnostics_flag_a_header_less_table_and_leave_a_headed_one_alone() {
let mut headed = Document::parse_str(
"<table><tr><th>H</th></tr><tr><td>a</td></tr></table>",
Format::Html,
)
.expect("parse headed table");
assert!(
headed
.diagnostics(Target::Markdown)
.expect("diagnostics")
.iter()
.all(|w| w.kind != Kind::Table)
);
let mut headless = Document::parse_str("<table><tr><td>a</td></tr></table>", Format::Html)
.expect("parse header-less table");
let table_warning = headless
.diagnostics(Target::Markdown)
.expect("diagnostics")
.into_iter()
.find(|w| w.kind == Kind::Table)
.expect("a warning about the table");
assert_eq!(table_warning.fidelity, Fidelity::Degraded);
}
#[test]
fn container_origin_separates_a_div_from_a_div() {
let mut html =
Editor::new("<div>hi</div>\n".as_bytes(), Format::Html).expect("html editor");
let mut md = Editor::new_ext(
":::div\nhi\n:::\n".as_bytes(),
Format::Markdown,
MarkdownExtensions {
directives: true,
..Default::default()
},
)
.expect("markdown editor");
let html_nodes = html.nodes().expect("html nodes");
let md_nodes = md.nodes().expect("markdown nodes");
let tag = html_nodes
.iter()
.find(|n| n.name.as_deref() == Some("div"))
.expect("a <div> container");
let directive = md_nodes
.iter()
.find(|n| n.name.as_deref() == Some("div"))
.expect("a :::div container");
assert_eq!(tag.kind, directive.kind);
assert_eq!(tag.name, directive.name);
assert_eq!(tag.directive_form, directive.directive_form);
assert_eq!(tag.directive_form, Some(DirectiveForm::Container));
assert_eq!(tag.origin, Some(ContainerOrigin::Element));
assert_eq!(directive.origin, Some(ContainerOrigin::Directive));
}
fn for_both_formats(src: &str, check: impl Fn(&mut Document, Format)) {
for format in [Format::Markdown, Format::Djot] {
let mut doc = Document::parse(src.as_bytes(), format).expect("parse");
check(&mut doc, format);
}
}
#[test]
fn marker_span_is_what_a_rich_view_hides() {
for_both_formats("> - [x] done\n", |doc, format| {
let nodes = doc.nodes().expect("nodes");
let quote = nodes
.iter()
.find(|n| n.kind == Kind::BlockQuote)
.expect("a block quote");
let item = nodes
.iter()
.find(|n| n.kind == Kind::TaskListItem)
.expect("a task item");
assert_eq!(quote.marker_span, Some(0..2), "{format:?}");
assert_eq!(item.marker_span, Some(2..8), "{format:?}");
assert_eq!(quote.content_span, Some(quote.span.clone()), "{format:?}");
let para = nodes
.iter()
.find(|n| n.kind == Kind::Para)
.expect("a paragraph");
assert_eq!(para.marker_span, None, "{format:?}");
});
}
#[test]
fn line_prefix_assembles_every_marker_on_the_line() {
for_both_formats("> - [x] done\n", |doc, format| {
assert_eq!(doc.line_prefix(9).expect("prefix"), Some(0..8), "{format:?}");
});
}
#[test]
fn line_prefix_is_none_on_a_continuation_line() {
for_both_formats("> c\n> d\n", |doc, format| {
assert_eq!(doc.line_prefix(2).expect("prefix"), Some(0..2), "{format:?}");
assert_eq!(doc.line_prefix(6).expect("prefix"), None, "{format:?}");
});
}
#[test]
fn a_caret_at_a_blocks_end_is_in_that_block_in_both_formats() {
for_both_formats("a\n\nb\n", |doc, format| {
for offset in [0usize, 1, 3, 4] {
let hit = doc
.node_at_caret(offset)
.expect("caret hit")
.expect("some node");
assert_eq!(hit.kind, Kind::Str, "{format:?} at {offset}");
}
for offset in [2usize, 5] {
let hit = doc
.node_at_caret(offset)
.expect("caret hit")
.expect("some node");
assert_eq!(hit.kind, Kind::Doc, "{format:?} at {offset}");
}
});
}
#[test]
fn the_caret_chain_ends_at_the_node_the_scalar_call_returns() {
for_both_formats("- a\n", |doc, format| {
let hit = doc.node_at_caret(3).expect("hit").expect("some node");
let chain = doc.ancestors_at_caret(3).expect("chain");
assert_eq!(chain.last().map(|m| m.node_id), Some(hit.node_id), "{format:?}");
assert!(
chain.iter().any(|m| m.kind == Kind::ListItem),
"{format:?}: chain should reach the list item"
);
});
}
#[test]
fn continuation_prefix_repeats_a_quote_and_indents_past_an_item() {
for_both_formats("> - a\n", |doc, format| {
assert_eq!(doc.line_prefix(4).expect("prefix"), Some(0..4), "{format:?}");
let cont = doc.continuation_prefix(4).expect("continuation");
assert_eq!(cont.text, "> ", "{format:?}");
assert_eq!(cont.columns, 4, "{format:?}");
});
}
#[test]
fn continuation_prefix_answers_on_a_line_that_opens_nothing() {
for_both_formats("> c\n> d\n", |doc, format| {
assert_eq!(doc.line_prefix(6).expect("prefix"), None, "{format:?}");
assert_eq!(
doc.continuation_prefix(6).expect("continuation").text,
"> ",
"{format:?}"
);
});
}
#[test]
fn continuation_prefix_takes_an_ordered_markers_own_width() {
for_both_formats("10. x\n", |doc, format| {
assert_eq!(
doc.continuation_prefix(4).expect("continuation").columns,
4,
"{format:?}"
);
});
for_both_formats("1. x\n", |doc, format| {
assert_eq!(
doc.continuation_prefix(3).expect("continuation").columns,
3,
"{format:?}"
);
});
}
#[test]
fn a_blank_line_keeps_a_quote_alive_and_drops_an_items_indent() {
for_both_formats("> - a\n", |doc, format| {
let blank = doc.blank_line_prefix(4).expect("blank");
assert_eq!(blank.text, ">", "{format:?}");
assert_eq!(blank.columns, 1, "{format:?}");
});
for_both_formats("- a\n", |doc, format| {
assert_eq!(doc.blank_line_prefix(3).expect("blank").text, "", "{format:?}");
});
}
#[test]
fn a_prefix_column_count_is_not_its_byte_length() {
let mut doc = Document::parse("- x
".as_bytes(), Format::Markdown).expect("parse");
let cont = doc.continuation_prefix(2).expect("continuation");
assert_eq!(cont.columns, 4);
}
#[test]
fn set_block_opens_a_heading_on_a_blank_line() {
for format in [Format::Markdown, Format::Djot] {
let mut ed = Editor::new("a\n\n".as_bytes(), format).expect("editor");
ed.set_block(3, BlockKind::Heading(2)).expect("set_block");
assert_eq!(ed.source().expect("source"), b"a\n\n## ", "{format:?}");
let nodes = ed.nodes().expect("nodes");
assert!(
nodes.iter().any(|n| n.kind == Kind::Heading),
"{format:?}: should have parsed a heading"
);
}
}
#[test]
fn set_block_refuses_a_blank_line_inside_a_code_block() {
for format in [Format::Markdown, Format::Djot] {
let src = "```\nx\n\ny\n```\n";
let mut ed = Editor::new(src.as_bytes(), format).expect("editor");
let blank = src.find("\n\n").expect("a blank line") + 1;
assert!(
matches!(
ed.set_block(blank, BlockKind::Heading(1)),
Err(Error::NotEditable)
),
"{format:?}"
);
assert_eq!(ed.source().expect("source"), src.as_bytes(), "{format:?}");
}
}
#[test]
fn task_items_report_their_checkbox_state() {
for_both_formats("- [ ] a\n- [x] b\n- [X] c\n- d\n", |doc, format| {
let nodes = doc.nodes().expect("nodes");
let states: Vec<Option<bool>> = nodes
.iter()
.filter(|n| matches!(n.kind, Kind::TaskListItem | Kind::ListItem))
.map(|n| n.checked)
.collect();
assert_eq!(
states,
vec![Some(false), Some(true), Some(true), None],
"{format:?}"
);
for n in nodes.iter().filter(|n| n.kind == Kind::Para) {
assert_eq!(n.checked, None, "{format:?}");
}
});
}
#[test]
fn an_editor_reaches_the_caret_reads_through_its_document_view() {
let mut ed = Editor::new("- a\n".as_bytes(), Format::Markdown).expect("editor");
let mut view = ed.document().expect("document view");
assert_eq!(view.line_prefix(3).expect("prefix"), Some(0..2));
let hit = view.node_at_caret(3).expect("hit").expect("some node");
assert_eq!(hit.kind, Kind::Str);
}
#[test]
fn container_origin_is_none_for_non_containers() {
let mut ed = Editor::new("# hi\n\npara\n".as_bytes(), Format::Markdown).expect("editor");
for n in ed.nodes().expect("nodes") {
assert_eq!(n.origin, None, "{} should carry no origin", n.kind);
}
}
#[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 == Kind::Container)
.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 == 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, Kind::Doc);
let heading = nodes
.iter()
.find(|n| n.kind == 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 == 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| {
matches!(
m.kind,
Kind::BulletList | Kind::OrderedList | Kind::TaskList
)
})
.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 == Kind::ListItem),
"items: {items:?}"
);
let para = top
.iter()
.find(|m| m.kind == 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, 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<Kind> {
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<Kind> = sub.iter().map(|n| n.kind.clone()).collect();
want_kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
got_kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
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 == 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 == 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 == Kind::Cell)
.expect("a cell");
assert_eq!(pcell.alignment, Some(Alignment::Default));
}
#[test]
fn cell_extent_reports_merged_cells_and_nothing_else() {
let src = "<table><tr><td colspan=\"2\" rowspan=\"3\">a</td><td>b</td></tr></table>";
let mut doc = Document::parse_str(src, Format::Html).expect("parse");
let cells: Vec<NodeId> = doc
.nodes()
.expect("nodes")
.iter()
.filter(|n| n.kind == Kind::Cell)
.map(|n| n.id)
.collect();
assert_eq!(cells.len(), 2);
assert_eq!(doc.cell_extent(cells[0]).expect("extent"), Some((2, 3)));
assert_eq!(doc.cell_extent(cells[1]).expect("extent"), Some((1, 1)));
let mut pipe =
Document::parse_str("| a |\n| --- |\n| b |\n", Format::Markdown).expect("parse");
let pipe_cell = pipe
.nodes()
.expect("nodes")
.iter()
.find(|n| n.kind == Kind::Cell)
.expect("a cell")
.id;
assert_eq!(pipe.cell_extent(pipe_cell).expect("extent"), Some((1, 1)));
let root = NodeId(0);
assert_eq!(pipe.cell_extent(root).expect("extent"), None);
}
#[test]
fn builder_add_cell_spanning_renders_colspan_and_rowspan() {
let mut b = Builder::new().expect("builder");
let wide_text = b.add_text(TextKind::Str, "wide").expect("str");
let wide = b
.add_cell_spanning(false, Alignment::Default, 2, 3)
.expect("cell");
b.set_children(wide, &[wide_text]).expect("children");
let plain_text = b.add_text(TextKind::Str, "one").expect("str");
let plain = b.add_cell(false, Alignment::Default).expect("cell");
b.set_children(plain, &[plain_text]).expect("children");
let row = b.add_row(false).expect("row");
b.set_children(row, &[wide, plain]).expect("children");
let table = b.add(VoidKind::Table).expect("table");
b.set_children(table, &[row]).expect("children");
let html = String::from_utf8(b.render_html(table).expect("html")).expect("utf-8");
assert!(
html.contains("<td colspan=\"2\" rowspan=\"3\">wide</td>"),
"{html}"
);
assert!(html.contains("<td>one</td>"), "{html}");
assert!(matches!(
b.add_cell_spanning(false, Alignment::Default, 0, 1),
Err(Error::InvalidArgument)
));
}
#[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, 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 == Kind::Url)
.expect("still an autolink");
assert_eq!(url.text.as_deref(), Some("https://y.dev"));
assert!(!nodes.iter().any(|n| n.kind == 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 == Kind::Emph || n.kind == Kind::Strong)
);
let text: String = nodes
.iter()
.filter(|n| n.kind == 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 == 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 == Kind::HardBreak));
assert!(!nodes.iter().any(|n| n.kind == Kind::RawInline));
}
#[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_insert_thematic_break_is_blank_separated_per_format() {
let mut md = Editor::new_str("a\n", Format::Markdown).expect("editor");
md.insert_thematic_break(0).expect("rule");
assert_eq!(md.source_str().unwrap(), "a\n\n---\n");
let nodes = md.nodes().expect("nodes");
assert!(nodes.iter().any(|n| n.kind == Kind::ThematicBreak));
assert!(!nodes.iter().any(|n| n.kind == Kind::Heading));
let mut dj = Editor::new_str("a\n", Format::Djot).expect("editor");
dj.insert_thematic_break(0).expect("rule");
assert_eq!(dj.source_str().unwrap(), "a\n\n* * *\n");
let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
assert_eq!(xml.insert_thematic_break(3), Err(Error::UnsupportedFormat));
}
#[test]
fn editor_split_block_keeps_both_halves_the_same_kind() {
let mut item = Editor::new_str("- this is a list item\n", Format::Markdown).expect("editor");
item.split_block(10).expect("split");
assert_eq!(item.source_str().unwrap(), "- this is \n- a list item\n");
let nodes = item.nodes().expect("nodes");
assert_eq!(nodes.iter().filter(|n| n.kind == Kind::ListItem).count(), 2);
let mut tail = Editor::new_str("- a\n", Format::Markdown).expect("editor");
tail.split_block(3).expect("split");
assert_eq!(tail.source_str().unwrap(), "- a\n- \n");
let mut para = Editor::new_str("ab\n", Format::Markdown).expect("editor");
para.split_block(1).expect("split");
assert_eq!(para.source_str().unwrap(), "a\n\nb\n");
let mut table =
Editor::new_str("| a | b |\n|---|---|\n| c | d |\n", Format::Markdown).expect("editor");
assert_eq!(table.split_block(3), Err(Error::NotEditable));
let mut empty = Editor::new_str("", Format::Markdown).expect("editor");
assert_eq!(empty.split_block(0), Err(Error::NotFound));
}
#[test]
fn editor_toggle_code_block_round_trips_and_measures_the_fence() {
let mut ed = Editor::new_str("a\n", Format::Markdown).expect("editor");
ed.toggle_code_block(0, 1, Some("zig")).expect("fence");
assert_eq!(ed.source_str().unwrap(), "```zig\na\n```\n");
let nodes = ed.nodes().expect("nodes");
assert!(nodes.iter().any(|n| n.kind == Kind::CodeBlock));
ed.toggle_code_block(0, 0, None).expect("unfence");
assert_eq!(ed.source_str().unwrap(), "a\n");
let mut runs = Editor::new_str("a ``` b\n", Format::Markdown).expect("editor");
runs.toggle_code_block(0, 7, None).expect("fence");
assert_eq!(runs.source_str().unwrap(), "````\na ``` b\n````\n");
}
#[test]
fn editor_toggle_code_block_refuses_inside_a_list_item() {
let mut ed = Editor::new_str("- a\n- b\n", Format::Markdown).expect("editor");
assert_eq!(ed.toggle_code_block(2, 3, None), Err(Error::NotEditable));
assert_eq!(ed.source_str().unwrap(), "- a\n- b\n");
}
#[test]
fn editor_set_code_language_retags_clears_and_refuses() {
let mut ed = Editor::new_str("```zig\na\n```\n", Format::Markdown).expect("editor");
ed.set_code_language(0, Some("rust")).expect("retag");
assert_eq!(ed.source_str().unwrap(), "```rust\na\n```\n");
ed.set_code_language(0, None).expect("clear");
assert_eq!(ed.source_str().unwrap(), "```\na\n```\n");
ed.set_code_language(0, Some("")).expect("empty");
assert_eq!(ed.source_str().unwrap(), "```\na\n```\n");
assert_eq!(
ed.set_code_language(0, Some("a b")),
Err(Error::InvalidArgument)
);
let mut dj = Editor::new_str("```\na\n```\n", Format::Djot).expect("editor");
dj.set_code_language(0, Some("a b"))
.expect("djot info string");
assert_eq!(dj.source_str().unwrap(), "```a b\na\n```\n");
let mut para = Editor::new_str("x\n", Format::Markdown).expect("editor");
assert_eq!(para.set_code_language(0, Some("zig")), Err(Error::NotFound));
}
#[test]
fn editor_task_checkbox_gestures() {
let mut ed = Editor::new_str("- a\n", Format::Markdown).expect("editor");
ed.toggle_task_item(2).expect("add box");
assert_eq!(ed.source_str().unwrap(), "- [ ] a\n");
assert!(
ed.nodes()
.unwrap()
.iter()
.any(|n| n.kind == Kind::TaskListItem)
);
ed.set_task_checked(6, true).expect("tick");
assert_eq!(ed.source_str().unwrap(), "- [x] a\n");
ed.set_task_checked(6, true).expect("no-op");
assert_eq!(ed.source_str().unwrap(), "- [x] a\n");
ed.toggle_task_checked(6).expect("flip");
assert_eq!(ed.source_str().unwrap(), "- [ ] a\n");
ed.toggle_task_item(6).expect("remove box");
assert_eq!(ed.source_str().unwrap(), "- a\n");
assert_eq!(ed.set_task_checked(2, true), Err(Error::NotEditable));
let mut para = Editor::new_str("a\n", Format::Markdown).expect("editor");
assert_eq!(para.toggle_task_item(0), Err(Error::NotFound));
}
#[test]
fn editor_insert_footnote_writes_both_halves_as_one_edit() {
for format in [Format::Markdown, Format::Djot] {
let mut ed = Editor::new_str("see\n", format).expect("editor");
ed.insert_footnote(3, "a").expect("footnote");
assert_eq!(ed.source_str().unwrap(), "see[^a]\n\n[^a]: \n");
let nodes = ed.nodes().expect("nodes");
assert!(nodes.iter().any(|n| n.kind == Kind::FootnoteReference));
assert!(nodes.iter().any(|n| n.kind == Kind::Footnote));
ed.undo().expect("undo");
assert_eq!(ed.source_str().unwrap(), "see\n");
}
}
#[test]
fn editor_insert_footnote_reuses_an_existing_definition() {
let mut ed = Editor::new_str("see\n", Format::Markdown).expect("editor");
ed.insert_footnote(3, "a").expect("first");
ed.insert_footnote(7, "a").expect("second reference");
assert_eq!(ed.source_str().unwrap(), "see[^a][^a]\n\n[^a]: \n");
let defs = ed
.nodes()
.unwrap()
.iter()
.filter(|n| n.kind == Kind::Footnote)
.count();
assert_eq!(defs, 1);
assert_eq!(ed.insert_footnote(3, ""), Err(Error::InvalidArgument));
assert_eq!(ed.insert_footnote(3, "a]b"), 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_leaves_djot_prose_alone() {
let src = "1. a\n 2. b\n2. c\n";
let mut dj = Editor::new_str(src, Format::Djot).expect("editor");
dj.renumber_ordered_lists(0).expect("renumber ok");
assert_eq!(dj.source_str().unwrap(), src);
let mut md = Editor::new_str(src, Format::Markdown).expect("editor");
md.renumber_ordered_lists(0).expect("renumber ok");
assert_eq!(md.source_str().unwrap(), "1. a\n 1. b\n2. 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, 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, 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));
}
fn all_gestures() -> Vec<Gesture> {
let inline = [
InlineKind::Strong,
InlineKind::Emph,
InlineKind::Verbatim,
InlineKind::Mark,
InlineKind::Superscript,
InlineKind::Subscript,
InlineKind::Insert,
InlineKind::Delete,
];
let mut all: Vec<Gesture> = Vec::new();
for k in inline {
all.push(Gesture::WrapRange(k));
all.push(Gesture::ToggleInline(k));
}
for k in [
BlockContainerKind::BlockQuote,
BlockContainerKind::BulletList,
BlockContainerKind::OrderedList,
] {
all.push(Gesture::ToggleBlockContainer(k));
}
all.extend([
Gesture::SetBlock,
Gesture::InsertThematicBreak,
Gesture::ToggleCodeBlock,
Gesture::SetCodeLanguage,
Gesture::ToggleTaskItem,
Gesture::SetTaskChecked,
Gesture::ToggleTaskChecked,
Gesture::InsertLink,
Gesture::InsertImage,
Gesture::InsertFootnote,
Gesture::InsertLiteral,
Gesture::InsertLineBreak,
]);
all
}
#[test]
fn supports_answers_per_gesture_where_authorable_cannot() {
assert!(Format::Html.is_authorable());
assert!(Format::Html.supports(Gesture::ToggleInline(InlineKind::Strong)));
assert!(!Format::Html.supports(Gesture::SetBlock));
assert!(!Format::Html.supports(Gesture::ToggleBlockContainer(
BlockContainerKind::BlockQuote
)));
assert!(!Format::Html.supports(Gesture::ToggleCodeBlock));
assert!(!Format::Html.supports(Gesture::InsertLiteral));
for fmt in [Format::Xml, Format::Asciidoc] {
assert!(!fmt.is_authorable());
for g in all_gestures() {
assert!(!fmt.supports(g), "{fmt:?} claims to spell {g:?}");
}
}
assert!(Format::Djot.supports(Gesture::ToggleInline(InlineKind::Mark)));
assert!(!Format::Markdown.supports(Gesture::ToggleInline(InlineKind::Mark)));
assert!(Format::Markdown.supports(Gesture::InsertLineBreak));
assert!(!Format::Djot.supports(Gesture::InsertLineBreak));
}
#[test]
fn supports_agrees_with_what_the_editor_then_does() {
for fmt in [Format::Djot, Format::Markdown, Format::Html] {
let mut ed = Editor::new_str("ab\n", fmt).expect("editor");
let claimed = fmt.supports(Gesture::ToggleInline(InlineKind::Mark));
let observed = ed.toggle_inline(0, 2, InlineKind::Mark);
assert_eq!(
claimed,
!matches!(observed, Err(Error::UnsupportedFormat)),
"{fmt:?}: supports said {claimed}, gesture said {observed:?}",
);
let mut ed = Editor::new_str("ab\n", fmt).expect("editor");
let claimed = fmt.supports(Gesture::SetBlock);
let observed = ed.set_block(0, BlockKind::Heading(1));
assert_eq!(
claimed,
!matches!(observed, Err(Error::UnsupportedFormat)),
"{fmt:?}: supports said {claimed}, gesture said {observed:?}",
);
}
}
#[test]
fn supports_rides_the_gestures_own_kind_space() {
let (g, k) = Gesture::ToggleBlockContainer(BlockContainerKind::BulletList).to_c();
assert_eq!((g, k), (3, 1));
let (g, k) = Gesture::ToggleInline(InlineKind::Emph).to_c();
assert_eq!((g, k), (1, 1));
assert_eq!(Gesture::InsertLink.to_c(), (10, 0));
let mut out: c_int = 0;
let status = unsafe {
ffi::twig_format_supports(ffi::TwigFormat::Markdown as c_int, 10, 3, &mut out)
};
assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
let status = unsafe {
ffi::twig_format_supports(ffi::TwigFormat::Markdown as c_int, 9999, 0, &mut out)
};
assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
}
}