use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use quillmark_core::error::MAX_NESTING_DEPTH;
use std::ops::Range;
#[derive(Debug, thiserror::Error)]
pub enum ConversionError {
#[error("Nesting too deep: {depth} levels (max: {max} levels)")]
NestingTooDeep {
depth: usize,
max: usize,
},
}
pub fn escape_markup(s: &str) -> String {
s.replace('\\', "\\\\")
.replace("//", "\\/\\/")
.replace('~', "\\~") .replace('*', "\\*")
.replace('_', "\\_")
.replace('`', "\\`")
.replace('#', "\\#")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('{', "\\{")
.replace('}', "\\}")
.replace('$', "\\$")
.replace('<', "\\<")
.replace('>', "\\>")
.replace('@', "\\@")
}
pub fn escape_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{{{:x}}}", c as u32);
}
c => out.push(c),
}
}
out
}
#[derive(Debug, Clone)]
enum ListType {
Bullet,
Ordered,
}
#[derive(Debug, Clone, Copy)]
enum StrongKind {
Bold, Underline, }
fn push_typst<'a, I>(output: &mut String, source: &str, iter: I) -> Result<(), ConversionError>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
let mut end_newline = true;
let mut list_stack: Vec<ListType> = Vec::new();
let mut strong_stack: Vec<StrongKind> = Vec::new();
let mut in_list_item = false; let mut need_para_space = false; let mut depth = 0; let iter = iter.peekable();
for (event, range) in iter {
match event {
Event::Start(tag) => {
depth += 1;
if depth > MAX_NESTING_DEPTH {
return Err(ConversionError::NestingTooDeep {
depth,
max: MAX_NESTING_DEPTH,
});
}
match tag {
Tag::Paragraph => {
if !in_list_item {
if !end_newline {
output.push('\n');
end_newline = true;
}
} else if need_para_space {
output.push(' ');
end_newline = false;
}
}
Tag::CodeBlock(_) => {
}
Tag::HtmlBlock => {
}
Tag::List(start_number) => {
if !end_newline {
output.push('\n');
end_newline = true;
}
let list_type = if start_number.is_some() {
ListType::Ordered
} else {
ListType::Bullet
};
list_stack.push(list_type);
}
Tag::Item => {
in_list_item = true; need_para_space = false; if let Some(list_type) = list_stack.last() {
let indent = " ".repeat(list_stack.len().saturating_sub(1));
match list_type {
ListType::Bullet => {
output.push_str(&format!("{}- ", indent));
}
ListType::Ordered => {
output.push_str(&format!("{}+ ", indent));
}
}
end_newline = false;
}
}
Tag::Emphasis => {
output.push_str("#emph[");
end_newline = false;
}
Tag::Strong => {
let kind = if range.start + 2 <= source.len() {
match &source[range.start..range.start + 2] {
"__" => StrongKind::Underline,
_ => StrongKind::Bold, }
} else {
StrongKind::Bold };
strong_stack.push(kind);
match kind {
StrongKind::Underline => output.push_str("#underline["),
StrongKind::Bold => output.push_str("#strong["),
}
end_newline = false;
}
Tag::Strikethrough => {
output.push_str("#strike[");
end_newline = false;
}
Tag::Link {
dest_url, title: _, ..
} => {
output.push_str("#link(\"");
output.push_str(&escape_string(&dest_url));
output.push_str("\")[");
end_newline = false;
}
Tag::Heading { level, .. } => {
if !end_newline {
output.push('\n');
}
let equals = "=".repeat(level as usize);
output.push_str(&equals);
output.push(' ');
end_newline = false;
}
_ => {
}
}
}
Event::End(tag) => {
depth = depth.saturating_sub(1);
match tag {
TagEnd::Paragraph => {
if !in_list_item {
output.push('\n');
output.push('\n'); end_newline = true;
} else {
need_para_space = true;
}
}
TagEnd::CodeBlock => {
}
TagEnd::HtmlBlock => {
}
TagEnd::List(_) => {
list_stack.pop();
if list_stack.is_empty() {
output.push('\n');
end_newline = true;
}
}
TagEnd::Item => {
in_list_item = false; if !end_newline {
output.push('\n');
end_newline = true;
}
}
TagEnd::Emphasis => {
output.push(']');
end_newline = false;
}
TagEnd::Strong => {
match strong_stack.pop() {
Some(StrongKind::Bold) | Some(StrongKind::Underline) => {
output.push(']');
}
None => {
output.push(']');
}
}
end_newline = false;
}
TagEnd::Strikethrough => {
output.push(']');
end_newline = false;
}
TagEnd::Link => {
output.push(']');
end_newline = false;
}
TagEnd::Heading(_) => {
output.push('\n');
output.push('\n'); end_newline = true;
}
_ => {
}
}
}
Event::Text(text) => {
let escaped = escape_markup(&text);
output.push_str(&escaped);
end_newline = escaped.ends_with('\n');
}
Event::Code(text) => {
output.push('`');
output.push_str(&text);
output.push('`');
end_newline = false;
}
Event::HardBreak => {
output.push('\n');
end_newline = true;
}
Event::SoftBreak => {
output.push(' ');
end_newline = false;
}
_ => {
}
}
}
Ok(())
}
struct MarkdownFixer<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>> {
inner: std::iter::Peekable<I>,
source: &'a str,
buffer: Vec<(Event<'a>, Range<usize>)>,
in_setext_heading: bool,
pending_markers: Vec<(&'static str, String, Range<usize>)>,
}
impl<'a, I> MarkdownFixer<'a, I>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
fn new(inner: I, source: &'a str) -> Self {
Self {
inner: inner.peekable(),
source,
buffer: Vec::new(),
in_setext_heading: false,
pending_markers: Vec::new(),
}
}
fn is_setext_heading(&self, range: &Range<usize>) -> bool {
let source_slice = &self.source[range.clone()];
if let Some(newline_pos) = source_slice.find('\n') {
let after_newline = &source_slice[newline_pos + 1..];
let trimmed = after_newline.trim_start();
!trimmed.is_empty()
&& trimmed
.chars()
.all(|c| c == '=' || c == '-' || c.is_whitespace())
} else {
false
}
}
fn events_for_stars(
star_count: usize,
is_start: bool,
start_idx: usize,
) -> Vec<(Event<'a>, Range<usize>)> {
let mut events = Vec::new();
let mut offset = 0;
let mut remaining = star_count;
if remaining >= 2 {
let len = 2;
let range = start_idx + offset..start_idx + offset + len;
let event = if is_start {
Event::Start(Tag::Strong)
} else {
Event::End(TagEnd::Strong)
};
events.push((event, range));
remaining -= 2;
offset += 2;
}
if remaining >= 1 {
let len = 1;
let range = start_idx + offset..start_idx + offset + len;
let event = if is_start {
Event::Start(Tag::Emphasis)
} else {
Event::End(TagEnd::Emphasis)
};
events.push((event, range));
}
if !is_start {
events.reverse();
}
events
}
fn coalesce_text_range(&mut self, initial_range: Range<usize>) -> Range<usize> {
let mut merged_range = initial_range;
while let Some((next_event, next_range)) = self.inner.peek() {
if matches!(next_event, Event::Text(_)) && next_range.start == merged_range.end {
merged_range.end = next_range.end;
self.inner.next(); } else {
break;
}
}
merged_range
}
fn process_text_from_source(&mut self, range: Range<usize>) {
let source_slice = &self.source[range.clone()];
if source_slice.contains('&') {
self.buffer.push((Event::Text(source_slice.into()), range));
return;
}
let preceded_by_escape =
range.start > 0 && self.source.as_bytes().get(range.start - 1) == Some(&b'\\');
if preceded_by_escape && source_slice.starts_with("__") {
self.buffer.push((Event::Text(source_slice.into()), range));
return;
}
let mut events: Vec<(Event<'a>, Range<usize>)> = Vec::new();
let mut in_underline = false;
let mut last_end = 0;
let mut i = 0;
let bytes = source_slice.as_bytes();
if bytes.len() >= 2
&& bytes[0] == b'_'
&& bytes[1] == b'_'
&& self
.pending_markers
.last()
.map(|(m, _, _)| *m == "__")
.unwrap_or(false)
{
let (_, pending_text, pending_range) = self.pending_markers.pop().unwrap();
if !pending_text.is_empty() {
events.push((Event::Text(pending_text.into()), pending_range.clone()));
}
events.push((Event::Start(Tag::Strong), pending_range));
let marker_range = range.start..range.start + 2;
events.push((Event::End(TagEnd::Strong), marker_range));
i = 2;
last_end = 2;
}
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'_' && bytes[i + 1] == b'_' {
let before = &source_slice[last_end..i];
if !before.is_empty() {
events.push((
Event::Text(before.into()),
range.start + last_end..range.start + i,
));
}
let marker_range = range.start + i..range.start + i + 2;
if in_underline {
events.push((Event::End(TagEnd::Strong), marker_range));
in_underline = false;
} else {
events.push((Event::Start(Tag::Strong), marker_range));
in_underline = true;
}
i += 2;
last_end = i;
} else {
i += 1;
}
}
let remaining = &source_slice[last_end..];
if in_underline {
let mut text_before_opener = String::new();
let mut opener_range = range.clone();
let mut final_events: Vec<(Event<'a>, Range<usize>)> = Vec::new();
for ev in events.into_iter() {
match &ev.0 {
Event::Start(Tag::Strong) => {
opener_range = ev.1.clone();
}
Event::Text(t) => {
if ev.1.end <= opener_range.start {
final_events.push(ev);
} else {
text_before_opener.push_str(t);
}
}
Event::End(TagEnd::Strong) => {
final_events.push(ev);
}
_ => {
final_events.push(ev);
}
}
}
if !remaining.is_empty() {
text_before_opener.push_str(remaining);
}
self.pending_markers
.push(("__", text_before_opener, opener_range));
final_events.reverse();
self.buffer.extend(final_events);
} else if events.is_empty() && remaining == source_slice {
self.buffer.push((Event::Text(source_slice.into()), range));
} else {
if !remaining.is_empty() {
events.push((
Event::Text(remaining.into()),
range.start + last_end..range.end,
));
}
events.reverse();
self.buffer.extend(events);
}
}
fn handle_candidate(
&mut self,
candidate: (Event<'a>, Range<usize>),
) -> Option<(Event<'a>, Range<usize>)> {
let (event, range) = candidate;
match &event {
Event::Text(cow_str) => {
let s = cow_str.as_ref();
if s.ends_with('*') {
let is_strong_start = if let Some(next) = self.buffer.last() {
matches!(next.0, Event::Start(Tag::Strong))
} else {
matches!(self.inner.peek(), Some((Event::Start(Tag::Strong), _)))
};
if is_strong_start {
let star_count = s.chars().rev().take_while(|c| *c == '*').count();
if star_count > 0 && star_count <= 3 {
let text_len = s.len() - star_count;
let text_content = &s[..text_len];
let star_events =
Self::events_for_stars(star_count, true, range.start + text_len);
let next_event = if !self.buffer.is_empty() {
self.buffer.pop().unwrap()
} else {
self.inner.next().unwrap()
};
self.buffer.push(next_event);
for ev in star_events.into_iter().rev() {
self.buffer.push(ev);
}
if !text_content.is_empty() {
return Some((
Event::Text(text_content.to_string().into()),
range.start..range.start + text_len,
));
} else {
return None;
}
}
}
}
}
Event::End(TagEnd::Strong) | Event::End(TagEnd::Emphasis) => {
let next_is_star_text = if let Some((Event::Text(cow_str), _)) = self.buffer.last()
{
cow_str.starts_with('*')
} else if let Some((Event::Text(cow_str), _)) = self.inner.peek() {
cow_str.starts_with('*')
} else {
false
};
if next_is_star_text {
let (text_event, text_range) = if !self.buffer.is_empty() {
self.buffer.pop().unwrap()
} else {
let (_ev, rng) = self.inner.next().unwrap();
let merged_range = self.coalesce_text_range(rng);
let text = self.source[merged_range.clone()].into();
(Event::Text(text), merged_range)
};
if let Event::Text(cow_str) = text_event {
let s = cow_str.as_ref();
let star_count = s.chars().take_while(|c| *c == '*').count();
if star_count > 0 && star_count <= 3 {
let star_events =
Self::events_for_stars(star_count, false, text_range.start);
let text_after = &s[star_count..];
if !text_after.is_empty() {
self.buffer.push((
Event::Text(text_after.to_string().into()),
text_range.start + star_count..text_range.end,
));
}
for ev in star_events.into_iter().rev() {
self.buffer.push(ev);
}
return Some((event, range));
} else {
self.buffer.push((Event::Text(cow_str), text_range));
}
}
}
}
_ => {}
}
Some((event, range))
}
}
impl<'a, I> Iterator for MarkdownFixer<'a, I>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
type Item = (Event<'a>, Range<usize>);
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(event) = self.buffer.pop() {
if let Some(result) = self.handle_candidate(event) {
return Some(result);
} else {
continue;
}
}
let (event, range) = self.inner.next()?;
let (event, range) = match event {
Event::Html(_) | Event::InlineHtml(_) => continue,
other => (other, range),
};
match &event {
Event::Start(Tag::Heading { .. }) => {
if self.is_setext_heading(&range) {
self.in_setext_heading = true;
continue;
}
}
Event::End(TagEnd::Heading(_)) => {
if self.in_setext_heading {
self.in_setext_heading = false;
continue;
}
}
_ => {}
}
if let Event::Text(_) = &event {
let merged_range = self.coalesce_text_range(range);
let source_slice = &self.source[merged_range.clone()];
if source_slice.contains("__") {
self.process_text_from_source(merged_range);
continue;
} else {
if let Some(result) =
self.handle_candidate((Event::Text(source_slice.into()), merged_range))
{
return Some(result);
} else {
continue;
}
}
}
if let Some(result) = self.handle_candidate((event, range)) {
return Some(result);
} else {
continue;
}
}
}
}
const UNDERLINE_OPEN: &str = "\u{FFF9}"; const UNDERLINE_CLOSE: &str = "\u{FFFA}"; const STRIKE_OPEN: &str = "\u{FFFB}"; const STRIKE_CLOSE: &str = "\u{2060}";
fn is_word_char(c: char) -> bool {
c.is_ascii_alphanumeric()
}
fn preprocess_intraword_formatting(source: &str) -> String {
let mut result = source.to_string();
result = replace_intraword_marker_pairs(&result, "__", UNDERLINE_OPEN, UNDERLINE_CLOSE);
result = replace_intraword_marker_pairs(&result, "~~", STRIKE_OPEN, STRIKE_CLOSE);
result
}
fn replace_intraword_marker_pairs(source: &str, marker: &str, open: &str, close: &str) -> String {
let chars: Vec<char> = source.chars().collect();
let marker_chars: Vec<char> = marker.chars().collect();
let marker_len = marker_chars.len();
struct MarkerInfo {
pos: usize, prev_is_word: bool, next_is_word: bool, }
let mut markers: Vec<MarkerInfo> = Vec::new();
let mut i = 0;
while i < chars.len() {
if i > 0 && chars[i - 1] == '\\' {
i += 1;
continue;
}
if i + marker_len <= chars.len()
&& chars[i..i + marker_len]
.iter()
.zip(marker_chars.iter())
.all(|(a, b)| a == b)
{
let prev_char = if i > 0 { Some(chars[i - 1]) } else { None };
let next_char = if i + marker_len < chars.len() {
Some(chars[i + marker_len])
} else {
None
};
markers.push(MarkerInfo {
pos: i,
prev_is_word: prev_char.map(is_word_char).unwrap_or(false),
next_is_word: next_char.map(is_word_char).unwrap_or(false),
});
i += marker_len;
} else {
i += 1;
}
}
let mut transform_positions: std::collections::HashSet<usize> =
std::collections::HashSet::new();
if markers.len() == 2 {
let opener = &markers[0];
let closer = &markers[1];
let is_truly_intraword = opener.prev_is_word || closer.next_is_word;
if is_truly_intraword {
transform_positions.insert(opener.pos);
transform_positions.insert(closer.pos);
}
}
let mut result = String::with_capacity(source.len());
let mut char_idx = 0;
let mut marker_iter = markers.iter().peekable();
while char_idx < chars.len() {
if let Some(marker_info) = marker_iter.peek() {
if marker_info.pos == char_idx {
marker_iter.next();
if transform_positions.contains(&char_idx) {
let transformed_before = markers
.iter()
.filter(|m| m.pos < char_idx && transform_positions.contains(&m.pos))
.count();
if transformed_before % 2 == 0 {
result.push_str(open);
} else {
result.push_str(close);
}
} else {
for c in marker_chars.iter() {
result.push(*c);
}
}
char_idx += marker_len;
continue;
}
}
result.push(chars[char_idx]);
char_idx += 1;
}
result
}
fn convert_placeholders(text: &str) -> String {
text.replace(UNDERLINE_OPEN, "#underline[")
.replace(UNDERLINE_CLOSE, "]")
.replace(STRIKE_OPEN, "#strike[")
.replace(STRIKE_CLOSE, "]")
}
pub fn mark_to_typst(markdown: &str) -> Result<String, ConversionError> {
let preprocessed = preprocess_intraword_formatting(markdown);
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(&preprocessed, options);
let fixer = MarkdownFixer::new(parser.into_offset_iter(), &preprocessed);
let mut typst_output = String::new();
push_typst(&mut typst_output, &preprocessed, fixer)?;
Ok(convert_placeholders(&typst_output))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_markup_basic() {
assert_eq!(escape_markup("plain text"), "plain text");
}
#[test]
fn test_escape_markup_backslash() {
assert_eq!(escape_markup("\\"), "\\\\");
assert_eq!(escape_markup("C:\\Users\\file"), "C:\\\\Users\\\\file");
}
#[test]
fn test_escape_markup_formatting_chars() {
assert_eq!(escape_markup("*bold*"), "\\*bold\\*");
assert_eq!(escape_markup("_italic_"), "\\_italic\\_");
assert_eq!(escape_markup("`code`"), "\\`code\\`");
}
#[test]
fn test_escape_markup_typst_special_chars() {
assert_eq!(escape_markup("#function"), "\\#function");
assert_eq!(escape_markup("[link]"), "\\[link\\]");
assert_eq!(escape_markup("$math$"), "\\$math\\$");
assert_eq!(escape_markup("<tag>"), "\\<tag\\>");
assert_eq!(escape_markup("@ref"), "\\@ref");
}
#[test]
fn test_escape_markup_combined() {
assert_eq!(
escape_markup("Use * for bold and # for functions"),
"Use \\* for bold and \\# for functions"
);
}
#[test]
fn test_escape_markup_tilde() {
assert_eq!(escape_markup("Hello~World"), "Hello\\~World");
assert_eq!(escape_markup("a~b~c"), "a\\~b\\~c");
}
#[test]
fn test_escape_string_basic() {
assert_eq!(escape_string("plain text"), "plain text");
}
#[test]
fn test_escape_string_quotes_and_backslash() {
assert_eq!(escape_string("\"quoted\""), "\\\"quoted\\\"");
assert_eq!(escape_string("\\"), "\\\\");
}
#[test]
fn test_escape_markup_double_curly_brackets() {
assert_eq!(escape_markup("{{"), "\\{\\{");
assert_eq!(escape_markup("}}"), "\\}\\}");
}
#[test]
fn test_mark_to_typst_double_curly_brackets() {
let output = mark_to_typst("Text {{ content }}").unwrap();
assert_eq!(output, "Text \\{\\{ content \\}\\}\n\n");
}
#[test]
fn test_escape_string_control_chars() {
assert_eq!(escape_string("\x00"), "\\u{0}");
assert_eq!(escape_string("\x01"), "\\u{1}");
}
#[test]
fn test_basic_text_formatting() {
let markdown = "This is **bold**, _italic_, and ~~strikethrough~~ text.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(
typst,
"This is #strong[bold], #emph[italic], and #strike[strikethrough] text.\n\n"
);
}
#[test]
fn test_bold_formatting() {
assert_eq!(mark_to_typst("**bold**").unwrap(), "#strong[bold]\n\n");
assert_eq!(
mark_to_typst("This is **bold** text").unwrap(),
"This is #strong[bold] text\n\n"
);
}
#[test]
fn test_italic_formatting() {
assert_eq!(mark_to_typst("_italic_").unwrap(), "#emph[italic]\n\n");
assert_eq!(mark_to_typst("*italic*").unwrap(), "#emph[italic]\n\n");
}
#[test]
fn test_strikethrough_formatting() {
assert_eq!(mark_to_typst("~~strike~~").unwrap(), "#strike[strike]\n\n");
}
#[test]
fn test_inline_code() {
assert_eq!(mark_to_typst("`code`").unwrap(), "`code`\n\n");
assert_eq!(
mark_to_typst("Text with `inline code` here").unwrap(),
"Text with `inline code` here\n\n"
);
}
#[test]
fn test_unordered_list() {
let markdown = "- Item 1\n- Item 2\n- Item 3";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- Item 1\n- Item 2\n- Item 3\n\n");
}
#[test]
fn test_ordered_list() {
let markdown = "1. First\n2. Second\n3. Third";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "+ First\n+ Second\n+ Third\n\n");
}
#[test]
fn test_nested_list() {
let markdown = "- Item 1\n- Item 2\n - Nested item\n- Item 3";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- Item 1\n- Item 2\n - Nested item\n- Item 3\n\n");
}
#[test]
fn test_deeply_nested_list() {
let markdown = "- Level 1\n - Level 2\n - Level 3";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- Level 1\n - Level 2\n - Level 3\n\n");
}
#[test]
fn test_link() {
let markdown = "[Link text](https://example.com)";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#link(\"https://example.com\")[Link text]\n\n");
}
#[test]
fn test_link_in_sentence() {
let markdown = "Visit [our site](https://example.com) for more.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(
typst,
"Visit #link(\"https://example.com\")[our site] for more.\n\n"
);
}
#[test]
fn test_mixed_content() {
let markdown = "A paragraph with **bold** and a [link](https://example.com).\n\nAnother paragraph with `inline code`.\n\n- A list item\n- Another item";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(
typst,
"A paragraph with #strong[bold] and a #link(\"https://example.com\")[link].\n\nAnother paragraph with `inline code`.\n\n- A list item\n- Another item\n\n"
);
}
#[test]
fn test_single_paragraph() {
let markdown = "This is a paragraph.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "This is a paragraph.\n\n");
}
#[test]
fn test_multiple_paragraphs() {
let markdown = "First paragraph.\n\nSecond paragraph.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "First paragraph.\n\nSecond paragraph.\n\n");
}
#[test]
fn test_hard_break() {
let markdown = "Line one \nLine two";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "Line one\nLine two\n\n");
}
#[test]
fn test_soft_break() {
let markdown = "Line one\nLine two";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "Line one Line two\n\n");
}
#[test]
fn test_soft_break_multiple_lines() {
let markdown = "This is some\ntext on multiple\nlines";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "This is some text on multiple lines\n\n");
}
#[test]
fn test_escaping_special_characters() {
let markdown = "Typst uses * for bold and # for functions.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "Typst uses \\* for bold and \\# for functions.\n\n");
}
#[test]
fn test_escaping_in_text() {
let markdown = "Use [brackets] and $math$ symbols.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "Use \\[brackets\\] and \\$math\\$ symbols.\n\n");
}
#[test]
fn test_empty_string() {
assert_eq!(mark_to_typst("").unwrap(), "");
}
#[test]
fn test_only_whitespace() {
let markdown = " ";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "");
}
#[test]
fn test_consecutive_formatting() {
let markdown = "**bold** _italic_ ~~strike~~";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#strong[bold] #emph[italic] #strike[strike]\n\n");
}
#[test]
fn test_nested_formatting() {
let markdown = "**bold _and italic_**";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#strong[bold #emph[and italic]]\n\n");
}
#[test]
fn test_list_with_formatting() {
let markdown = "- **Bold** item\n- _Italic_ item\n- `Code` item";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(
typst,
"- #strong[Bold] item\n- #emph[Italic] item\n- `Code` item\n\n"
);
}
#[test]
fn test_mixed_list_types() {
let markdown = "- Bullet item\n\n1. Ordered item\n2. Another ordered";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(
typst,
"- Bullet item\n\n+ Ordered item\n+ Another ordered\n\n"
);
}
#[test]
fn test_list_item_paragraph_separation_with_space() {
let markdown = "- First line.\n\n Second line.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- First line. Second line.\n\n");
}
#[test]
fn test_link_with_special_chars_in_url() {
let markdown = "[Link](https://example.com/foo_bar)";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#link(\"https://example.com/foo_bar\")[Link]\n\n");
}
#[test]
fn test_emphasis_sandwich_bug() {
let markdown = "***__Underlined__***suffix";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#strong[#emph[#underline[Underlined]]]suffix\n\n");
}
#[test]
fn test_text_before_strong_emph_underline() {
let markdown = "pre***__content__***";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "pre#strong[#emph[#underline[content]]]\n\n");
}
#[test]
fn test_text_before_strong_emph_underline_variations() {
assert_eq!(
mark_to_typst("pre__content__").unwrap(),
"pre#underline[content]\n\n"
);
assert_eq!(
mark_to_typst("***__content__***").unwrap(),
"#emph[#strong[#underline[content]]]\n\n"
);
assert_eq!(
mark_to_typst("pre**__content__**").unwrap(),
"pre#strong[#underline[content]]\n\n"
);
assert_eq!(
mark_to_typst("pre*__content__*").unwrap(),
"pre#emph[#underline[content]]\n\n"
);
assert_eq!(
mark_to_typst("some text ***__content__***").unwrap(),
"some text #emph[#strong[#underline[content]]]\n\n"
);
assert_eq!(
mark_to_typst("pre***__content__*** suffix").unwrap(),
"pre#strong[#emph[#underline[content]]] suffix\n\n"
);
}
#[test]
fn test_link_with_anchor() {
let markdown = "[Link](#anchor)";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#link(\"#anchor\")[Link]\n\n");
}
#[test]
fn test_markdown_escapes() {
let markdown = "Use \\* for lists";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "Use \\* for lists\n\n");
}
#[test]
fn test_double_backslash() {
let markdown = "Path: C:\\\\Users\\\\file";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "Path: C:\\\\Users\\\\file\n\n");
}
#[test]
fn test_nesting_depth_limit() {
let mut markdown = String::new();
for _ in 0..=MAX_NESTING_DEPTH {
markdown.push('>');
markdown.push(' ');
}
markdown.push_str("text");
let result = mark_to_typst(&markdown);
assert!(result.is_err());
if let Err(ConversionError::NestingTooDeep { depth, max }) = result {
assert!(depth > max);
assert_eq!(max, MAX_NESTING_DEPTH);
} else {
panic!("Expected NestingTooDeep error");
}
}
#[test]
fn test_nesting_depth_within_limit() {
let mut markdown = String::new();
for _ in 0..50 {
markdown.push('>');
markdown.push(' ');
}
markdown.push_str("text");
let result = mark_to_typst(&markdown);
assert!(result.is_ok());
}
#[test]
fn test_slash_comment_in_url() {
let markdown = "Check out https://example.com for more.";
let typst = mark_to_typst(markdown).unwrap();
assert!(typst.contains("https:\\/\\/example.com"));
}
#[test]
fn test_slash_comment_at_line_start() {
let markdown = "// This should not be a comment";
let typst = mark_to_typst(markdown).unwrap();
assert!(typst.contains("\\/\\/"));
}
#[test]
fn test_slash_comment_in_middle() {
let markdown = "Some text // with slashes in the middle";
let typst = mark_to_typst(markdown).unwrap();
assert!(typst.contains("text \\/\\/"));
}
#[test]
fn test_file_protocol() {
let markdown = "Use file://path/to/file protocol";
let typst = mark_to_typst(markdown).unwrap();
assert!(typst.contains("file:\\/\\/"));
}
#[test]
fn test_single_slash() {
let markdown = "Use path/to/file for the file";
let typst = mark_to_typst(markdown).unwrap();
assert!(typst.contains("path/to/file"));
}
#[test]
fn test_italic_followed_by_alphanumeric() {
let markdown = "*Write y*our paragraphs here.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#emph[Write y]our paragraphs here.\n\n");
}
#[test]
fn test_italic_followed_by_space() {
let markdown = "*italic* text";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#emph[italic] text\n\n");
}
#[test]
fn test_italic_followed_by_punctuation() {
let markdown = "*italic*.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#emph[italic].\n\n");
}
#[test]
fn test_bold_followed_by_alphanumeric() {
let markdown = "**bold**text";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "#strong[bold]text\n\n");
}
#[test]
fn test_heading_level_1() {
let markdown = "# Heading 1";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "= Heading 1\n\n");
}
#[test]
fn test_heading_level_2() {
let markdown = "## Heading 2";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "== Heading 2\n\n");
}
#[test]
fn test_heading_level_3() {
let markdown = "### Heading 3";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "=== Heading 3\n\n");
}
#[test]
fn test_heading_level_4() {
let markdown = "#### Heading 4";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "==== Heading 4\n\n");
}
#[test]
fn test_heading_level_5() {
let markdown = "##### Heading 5";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "===== Heading 5\n\n");
}
#[test]
fn test_heading_level_6() {
let markdown = "###### Heading 6";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "====== Heading 6\n\n");
}
#[test]
fn test_heading_with_formatting() {
let markdown = "## Heading with **bold** and _italic_";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "== Heading with #strong[bold] and #emph[italic]\n\n");
}
#[test]
fn test_multiple_headings() {
let markdown = "# First\n\n## Second\n\n### Third";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "= First\n\n== Second\n\n=== Third\n\n");
}
#[test]
fn test_heading_followed_by_paragraph() {
let markdown = "# Heading\n\nThis is a paragraph.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "= Heading\n\nThis is a paragraph.\n\n");
}
#[test]
fn test_heading_with_special_chars() {
let markdown = "# Heading with $math$ and #functions";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "= Heading with \\$math\\$ and \\#functions\n\n");
}
#[test]
fn test_paragraph_then_heading() {
let markdown = "A paragraph.\n\n# A Heading";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "A paragraph.\n\n= A Heading\n\n");
}
#[test]
fn test_heading_with_inline_code() {
let markdown = "## Code example: `fn main()`";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "== Code example: `fn main()`\n\n");
}
#[test]
fn test_underline_basic() {
assert_eq!(
mark_to_typst("__underlined__").unwrap(),
"#underline[underlined]\n\n"
);
}
#[test]
fn test_underline_with_text() {
assert_eq!(
mark_to_typst("This is __underlined__ text").unwrap(),
"This is #underline[underlined] text\n\n"
);
}
#[test]
fn test_bold_unchanged() {
assert_eq!(mark_to_typst("**bold**").unwrap(), "#strong[bold]\n\n");
}
#[test]
fn test_underline_containing_bold() {
assert_eq!(
mark_to_typst("__A **B** A__").unwrap(),
"#underline[A #strong[B] A]\n\n"
);
}
#[test]
fn test_bold_containing_underline() {
assert_eq!(
mark_to_typst("**A __B__ A**").unwrap(),
"#strong[A #underline[B] A]\n\n"
);
}
#[test]
fn test_deep_nesting() {
assert_eq!(
mark_to_typst("__A **B __C__ B** A__").unwrap(),
"#underline[A #strong[B #underline[C] B] A]\n\n"
);
}
#[test]
fn test_adjacent_underline_bold() {
assert_eq!(
mark_to_typst("__A__**B**").unwrap(),
"#underline[A]#strong[B]\n\n"
);
}
#[test]
fn test_adjacent_bold_underline() {
assert_eq!(
mark_to_typst("**A**__B__").unwrap(),
"#strong[A]#underline[B]\n\n"
);
}
#[test]
fn test_underline_special_chars() {
assert_eq!(mark_to_typst("__#1__").unwrap(), "#underline[\\#1]\n\n");
}
#[test]
fn test_underline_with_brackets() {
assert_eq!(
mark_to_typst("__[text]__").unwrap(),
"#underline[\\[text\\]]\n\n"
);
}
#[test]
fn test_underline_with_asterisk() {
assert_eq!(
mark_to_typst("__a * b__").unwrap(),
"#underline[a \\* b]\n\n"
);
}
#[test]
fn test_empty_underline() {
let result = mark_to_typst("____").unwrap();
assert_eq!(result, "");
}
#[test]
fn test_underline_in_list() {
assert_eq!(
mark_to_typst("- __underlined__ item").unwrap(),
"- #underline[underlined] item\n\n"
);
}
#[test]
fn test_underline_in_heading() {
assert_eq!(
mark_to_typst("# Heading with __underline__").unwrap(),
"= Heading with #underline[underline]\n\n"
);
}
#[test]
fn test_underline_followed_by_alphanumeric() {
assert_eq!(
mark_to_typst("__under__ line").unwrap(),
"#underline[under] line\n\n"
);
}
#[test]
fn test_underline_with_italic() {
assert_eq!(
mark_to_typst("__underline *italic*__").unwrap(),
"#underline[underline #emph[italic]]\n\n"
);
}
#[test]
fn test_underline_with_code() {
assert_eq!(
mark_to_typst("__underline `code`__").unwrap(),
"#underline[underline `code`]\n\n"
);
}
#[test]
fn test_underline_with_strikethrough() {
assert_eq!(
mark_to_typst("__underline ~~strike~~__").unwrap(),
"#underline[underline #strike[strike]]\n\n"
);
}
#[test]
fn test_intraword_underscore_emphasis() {
assert_eq!(
mark_to_typst("the cow __jumped over the mo__on").unwrap(),
"the cow #underline[jumped over the mo]on\n\n"
);
}
#[test]
fn test_intraword_underscore_start_of_word() {
assert_eq!(
mark_to_typst("foo__bar__baz").unwrap(),
"foo#underline[bar]baz\n\n"
);
}
#[test]
fn test_escaped_intraword_underscore() {
let result = mark_to_typst("foo\\__bar").unwrap();
assert!(result.contains("\\_"));
assert!(!result.contains("#underline"));
}
#[test]
fn test_intraword_underline_wrapping_strikethrough() {
assert_eq!(
mark_to_typst("outer__~~inner~~__asdf").unwrap(),
"outer#underline[#strike[inner]]asdf\n\n"
);
}
#[test]
fn test_intraword_strikethrough_wrapping_underline() {
assert_eq!(
mark_to_typst("outer~~__inner__~~asdf").unwrap(),
"outer#strike[#underline[inner]]asdf\n\n"
);
}
#[test]
fn test_intraword_strikethrough_only() {
assert_eq!(
mark_to_typst("outer~~inner~~asdf").unwrap(),
"outer#strike[inner]asdf\n\n"
);
}
}
#[cfg(test)]
mod robustness_tests {
use super::*;
#[test]
fn test_only_newlines() {
let result = mark_to_typst("\n\n\n").unwrap();
assert_eq!(result, "");
}
#[test]
fn test_only_spaces_and_newlines() {
let result = mark_to_typst(" \n \n ").unwrap();
assert_eq!(result, "");
}
#[test]
fn test_single_character() {
assert_eq!(mark_to_typst("a").unwrap(), "a\n\n");
}
#[test]
fn test_single_special_character() {
assert_eq!(mark_to_typst("a # b").unwrap(), "a \\# b\n\n");
assert_eq!(mark_to_typst("$").unwrap(), "\\$\n\n");
assert_eq!(mark_to_typst("a * b").unwrap(), "a \\* b\n\n");
}
#[test]
fn test_unicode_text() {
let result = mark_to_typst("ä½ å¥½ä¸–ç•Œ").unwrap();
assert_eq!(result, "ä½ å¥½ä¸–ç•Œ\n\n");
}
#[test]
fn test_unicode_with_formatting() {
let result = mark_to_typst("**ä½ å¥½** _世界_").unwrap();
assert_eq!(result, "#strong[ä½ å¥½] #emph[世界]\n\n");
}
#[test]
fn test_emoji() {
let result = mark_to_typst("Hello 🎉 World 🚀").unwrap();
assert_eq!(result, "Hello 🎉 World 🚀\n\n");
}
#[test]
fn test_emoji_in_link() {
let result = mark_to_typst("[Click 🎉](https://example.com)").unwrap();
assert_eq!(result, "#link(\"https://example.com\")[Click 🎉]\n\n");
}
#[test]
fn test_rtl_text() {
let result = mark_to_typst("Ù…Ø±ØØ¨Ø§ بالعالم").unwrap();
assert_eq!(result, "Ù…Ø±ØØ¨Ø§ بالعالم\n\n");
}
#[test]
fn test_multiple_consecutive_slashes() {
let result = mark_to_typst("a///b").unwrap();
assert!(result.contains("\\/\\/"));
}
#[test]
fn test_escape_at_string_boundaries() {
assert!(mark_to_typst("*start").unwrap().starts_with("\\*"));
assert!(mark_to_typst("end*").unwrap().contains("end\\*"));
}
#[test]
fn test_backslash_before_special_char() {
let result = mark_to_typst("\\*").unwrap();
assert!(result.contains("\\*"));
}
#[test]
fn test_all_special_chars_together() {
let result = mark_to_typst("*_`#[]$<>@\\").unwrap();
assert!(result.contains("\\*"));
assert!(result.contains("\\_"));
assert!(result.contains("\\`"));
assert!(result.contains("\\#"));
assert!(result.contains("\\["));
assert!(result.contains("\\]"));
assert!(result.contains("\\$"));
assert!(result.contains("\\<"));
assert!(result.contains("\\>"));
assert!(result.contains("\\@"));
assert!(result.contains("\\\\"));
}
#[test]
fn test_link_with_quotes_in_url() {
let result = mark_to_typst("[link](https://example.com?q=\"test\")").unwrap();
assert!(result.contains("\\\"test\\\""));
}
#[test]
fn test_link_with_backslash_in_url() {
let result = mark_to_typst("[link](https://example.com\\path)").unwrap();
assert!(result.contains("\\\\"));
}
#[test]
fn test_link_with_newline_in_text() {
let result = mark_to_typst("[multi\nline](https://example.com)").unwrap();
assert!(result.contains("[multi line]"));
}
#[test]
fn test_empty_link_text() {
let result = mark_to_typst("[](https://example.com)").unwrap();
assert_eq!(result, "#link(\"https://example.com\")[]\n\n");
}
#[test]
fn test_link_with_special_chars_in_text() {
let result = mark_to_typst("[*bold* link](https://example.com)").unwrap();
assert!(result.contains("#emph[bold]"));
}
#[test]
fn test_empty_list_item() {
let result = mark_to_typst("- \n- item").unwrap();
assert!(result.contains("- "));
}
#[test]
fn test_list_with_multiple_paragraphs() {
let markdown = "- First para\n\n Second para in same item";
let result = mark_to_typst(markdown).unwrap();
assert!(result.contains("First para"));
}
#[test]
fn test_very_deeply_nested_list() {
let mut markdown = String::new();
for i in 0..10 {
markdown.push_str(&" ".repeat(i));
markdown.push_str("- item\n");
}
let result = mark_to_typst(&markdown);
assert!(result.is_ok());
}
#[test]
fn test_mixed_ordered_unordered_nested() {
let markdown = "1. First\n - Nested bullet\n - Another bullet\n2. Second";
let result = mark_to_typst(markdown).unwrap();
assert!(result.contains("+ First"));
assert!(result.contains("- Nested bullet"));
assert!(result.contains("+ Second"));
}
#[test]
fn test_heading_with_only_special_chars() {
let result = mark_to_typst("# ***").unwrap();
assert!(result.contains("= "));
}
#[test]
fn test_heading_followed_by_list() {
let result = mark_to_typst("# Heading\n\n- Item").unwrap();
assert!(result.contains("= Heading\n\n"));
assert!(result.contains("- Item"));
}
#[test]
fn test_consecutive_headings() {
let result = mark_to_typst("# One\n## Two\n### Three").unwrap();
assert!(result.contains("= One"));
assert!(result.contains("== Two"));
assert!(result.contains("=== Three"));
}
#[test]
fn test_setext_h1_suppressed() {
let result = mark_to_typst("My Heading\n==========").unwrap();
assert!(!result.contains("= My Heading")); assert!(result.contains("My Heading")); }
#[test]
fn test_setext_h2_suppressed() {
let result = mark_to_typst("My Heading\n----------").unwrap();
assert!(!result.contains("== My Heading")); assert!(result.contains("My Heading")); }
#[test]
fn test_unclosed_nested_bullet_not_setext() {
let result = mark_to_typst("- parent\n - ").unwrap();
assert!(!result.contains("== parent")); assert!(result.contains("- parent")); }
#[test]
fn test_atx_headings_still_work() {
let result = mark_to_typst("# H1\n## H2\n### H3").unwrap();
assert!(result.contains("= H1"));
assert!(result.contains("== H2"));
assert!(result.contains("=== H3"));
}
#[test]
fn test_nested_list_empty_item_deep() {
let result = mark_to_typst("- parent\n - child\n - ").unwrap();
assert!(!result.contains("== child")); assert!(result.contains("- parent"));
assert!(result.contains("- child"));
}
#[test]
fn test_fenced_code_block_ignored() {
let markdown = "```rust\nfn main() {}\n```";
let result = mark_to_typst(markdown);
assert!(result.is_ok());
}
#[test]
fn test_indented_code_block_ignored() {
let markdown = " fn main() {}\n println!()";
let result = mark_to_typst(markdown);
assert!(result.is_ok());
}
#[test]
fn test_inline_code_with_backticks() {
let result = mark_to_typst("`` `code` ``").unwrap();
assert!(result.contains("`"));
}
#[test]
fn test_inline_code_with_special_chars() {
let result = mark_to_typst("`*#$<>`").unwrap();
assert_eq!(result, "`*#$<>`\n\n");
}
#[test]
fn test_empty_inline_code() {
let result = mark_to_typst("` `").unwrap();
assert!(result.contains("`")); }
#[test]
fn test_adjacent_emphasis() {
let result = mark_to_typst("*a**b*").unwrap();
assert!(result.contains("#emph["));
}
#[test]
fn test_emphasis_across_words() {
let result = mark_to_typst("*multiple words here*").unwrap();
assert_eq!(result, "#emph[multiple words here]\n\n");
}
#[test]
fn test_strong_across_lines() {
let result = mark_to_typst("**bold\nacross\nlines**").unwrap();
assert!(result.contains("bold across lines"));
}
#[test]
fn test_strikethrough_with_special_chars() {
let result = mark_to_typst("~~*text*~~").unwrap();
assert!(result.contains("#strike["));
}
#[test]
fn test_multiple_nested_strong() {
let result = mark_to_typst("**a **b** a**");
assert!(result.is_ok());
}
#[test]
fn test_alternating_bold_underline() {
let result = mark_to_typst("**bold** __under__ **bold**").unwrap();
assert!(result.contains("#strong[bold]"));
assert!(result.contains("#underline[under]"));
}
#[test]
fn test_escape_string_unicode() {
assert_eq!(escape_string("ä½ å¥½"), "ä½ å¥½");
assert_eq!(escape_string("🎉"), "🎉");
}
#[test]
fn test_escape_string_all_escapes() {
assert_eq!(escape_string("\\\"\n\r\t"), "\\\\\\\"\\n\\r\\t");
}
#[test]
fn test_escape_string_nul_character() {
assert_eq!(escape_string("\x00"), "\\u{0}");
}
#[test]
fn test_escape_string_bell_character() {
assert_eq!(escape_string("\x07"), "\\u{7}");
}
#[test]
fn test_escape_string_mixed() {
assert_eq!(
escape_string("Hello\nWorld\t\"quoted\""),
"Hello\\nWorld\\t\\\"quoted\\\""
);
}
#[test]
fn test_escape_markup_empty() {
assert_eq!(escape_markup(""), "");
}
#[test]
fn test_escape_markup_unicode() {
assert_eq!(escape_markup("ä½ å¥½ä¸–ç•Œ"), "ä½ å¥½ä¸–ç•Œ");
}
#[test]
fn test_escape_markup_triple_slash() {
assert_eq!(escape_markup("///"), "\\/\\//");
}
#[test]
fn test_escape_markup_url() {
assert_eq!(
escape_markup("https://example.com"),
"https:\\/\\/example.com"
);
}
#[test]
fn test_many_paragraphs() {
let markdown = "P1.\n\nP2.\n\nP3.\n\nP4.\n\nP5.";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result.matches("P").count(), 5);
assert!(result.contains("P1.\n\nP2."));
}
#[test]
fn test_paragraph_with_only_formatting() {
let result = mark_to_typst("**bold only**").unwrap();
assert_eq!(result, "#strong[bold only]\n\n");
}
#[test]
fn test_hard_break_in_list() {
let result = mark_to_typst("- line one \n line two").unwrap();
assert!(result.contains("line one"));
}
#[test]
fn test_multiple_hard_breaks() {
let result = mark_to_typst("a \nb \nc").unwrap();
assert_eq!(result, "a\nb\nc\n\n");
}
#[test]
fn test_italic_before_number() {
let result = mark_to_typst("*italic*1").unwrap();
assert!(result.contains("#emph[italic]1"));
}
#[test]
fn test_bold_before_underscore() {
let result = mark_to_typst("**bold**_after").unwrap();
assert!(result.contains("#strong[bold]\\_after"));
}
#[test]
fn test_emphasis_at_end_of_text() {
let result = mark_to_typst("*italic*").unwrap();
assert_eq!(result, "#emph[italic]\n\n");
}
#[test]
fn test_markdown_document() {
let markdown = r#"# Title
This is a paragraph with **bold** and *italic* text.
## Section
- List item 1
- List item 2 with [link](https://example.com)
More text with `inline code`."#;
let result = mark_to_typst(markdown).unwrap();
assert!(result.contains("= Title"));
assert!(result.contains("== Section"));
assert!(result.contains("#strong[bold]"));
assert!(result.contains("#emph[italic]"));
assert!(result.contains("- List item"));
assert!(result.contains("#link"));
assert!(result.contains("`inline code`"));
}
#[test]
fn test_typst_syntax_in_content() {
let markdown = "Use #set for settings and $x^2$ for math.";
let result = mark_to_typst(markdown).unwrap();
assert!(result.contains("\\#set"));
assert!(result.contains("\\$x^2\\$"));
}
#[test]
fn test_midword_italic() {
let markdown = "a*sdfasd*f";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result, "a#emph[sdfasd]f\n\n");
}
#[test]
fn test_midword_bold() {
let markdown = "word**bold**more";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result, "word#strong[bold]more\n\n");
}
#[test]
fn test_emphasis_preceded_by_alphanumeric() {
let markdown = "text*emph*";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result, "text#emph[emph]\n\n");
}
#[test]
fn test_emphasis_after_space() {
let markdown = "some *italic* text";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result, "some #emph[italic] text\n\n");
}
#[test]
fn test_emphasis_after_punctuation() {
let markdown = "(*italic*)";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result, "(#emph[italic])\n\n");
}
}