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 typst_alignment(align: &pulldown_cmark::Alignment) -> &'static str {
match align {
pulldown_cmark::Alignment::None => "auto",
pulldown_cmark::Alignment::Left => "left",
pulldown_cmark::Alignment::Center => "center",
pulldown_cmark::Alignment::Right => "right",
}
}
fn is_br_tag(html: &str) -> bool {
let lower = html.trim().to_ascii_lowercase();
lower == "<br>" || lower == "<br/>" || lower == "<br />"
}
fn sanitize_lang_tag(lang: &str) -> String {
lang.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
.collect()
}
fn longest_backtick_run(s: &str) -> usize {
let mut max_run = 0;
let mut current_run = 0;
for ch in s.chars() {
if ch == '`' {
current_run += 1;
if current_run > max_run {
max_run = current_run;
}
} else {
current_run = 0;
}
}
max_run
}
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 list_item_first_block = false; let mut in_code_block = false; let mut table_alignments: Vec<pulldown_cmark::Alignment> = Vec::new(); 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 !list_item_first_block {
let cont_indent = " ".repeat(list_stack.len());
if !end_newline {
output.push('\n');
}
output.push('\n');
output.push_str(&cont_indent);
end_newline = false;
}
}
Tag::CodeBlock(kind) => {
in_code_block = true;
if in_list_item {
let cont_indent = " ".repeat(list_stack.len());
if !list_item_first_block {
if !end_newline {
output.push('\n');
}
output.push('\n');
} else if !end_newline {
output.push('\n');
}
output.push_str(&cont_indent);
list_item_first_block = false;
} else if !end_newline {
output.push('\n');
}
output.push_str("```");
if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
let sanitized = sanitize_lang_tag(&lang);
if !sanitized.is_empty() {
output.push_str(&sanitized);
}
}
output.push('\n');
end_newline = true;
}
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;
list_item_first_block = true;
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;
}
Tag::Table(alignments) => {
if !end_newline {
output.push('\n');
}
let col_count = alignments.len();
output.push_str(&format!("#table(\n columns: {},\n", col_count));
if alignments
.iter()
.any(|a| !matches!(a, pulldown_cmark::Alignment::None))
{
output.push_str(" align: (");
for (i, align) in alignments.iter().enumerate() {
if i > 0 {
output.push_str(", ");
}
output.push_str(typst_alignment(align));
}
output.push_str("),\n");
}
table_alignments = alignments;
end_newline = false;
}
Tag::TableHead => {
output.push_str(" table.header(");
end_newline = false;
}
Tag::TableRow => {
output.push_str(" ");
end_newline = false;
}
Tag::TableCell => {
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 {
list_item_first_block = false;
if !end_newline {
output.push('\n');
end_newline = true;
}
}
}
TagEnd::CodeBlock => {
in_code_block = false;
if !end_newline {
output.push('\n');
}
if in_list_item {
let cont_indent = " ".repeat(list_stack.len());
output.push_str(&cont_indent);
}
output.push_str("```\n");
if !in_list_item {
output.push('\n');
}
end_newline = true;
list_item_first_block = false;
}
TagEnd::HtmlBlock => {
}
TagEnd::List(_) => {
list_stack.pop();
if list_stack.is_empty() {
output.push('\n');
end_newline = true;
}
}
TagEnd::Item => {
in_list_item = false;
list_item_first_block = 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 => {
}
}
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;
}
TagEnd::Table => {
output.push_str(")\n\n");
table_alignments.clear();
end_newline = true;
}
TagEnd::TableHead => {
output.push_str("),\n");
end_newline = false;
}
TagEnd::TableRow => {
output.push('\n');
end_newline = true;
}
TagEnd::TableCell => {
output.push_str("], ");
end_newline = false;
}
_ => {
}
}
}
Event::Text(text) => {
if in_code_block {
output.push_str(&text);
end_newline = text.ends_with('\n');
} else {
let escaped = escape_markup(&text);
output.push_str(&escaped);
end_newline = escaped.ends_with('\n');
}
}
Event::Code(text) => {
let max_run = longest_backtick_run(&text);
let delim_len = max_run + 1;
let delim: String = std::iter::repeat('`').take(delim_len).collect();
output.push_str(&delim);
if delim_len > 1 {
output.push(' ');
}
output.push_str(&text);
if delim_len > 1 {
output.push(' ');
}
output.push_str(&delim);
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>)>,
emph_depth: usize,
strong_depth: 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(),
emph_depth: 0,
strong_depth: 0,
}
}
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();
let starts_with_marker = bytes.len() >= 2
&& bytes[0] == b'_'
&& bytes[1] == b'_'
&& !(bytes.len() >= 3 && bytes[2] == b'_');
if starts_with_marker
&& 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 prev_is_underscore = i > 0 && bytes[i - 1] == b'_';
let next_is_underscore = i + 2 < bytes.len() && bytes[i + 2] == b'_';
if prev_is_underscore || next_is_underscore {
i += 1;
continue;
}
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 closable_star_count(&self, star_count: usize) -> usize {
let mut remaining = star_count;
let mut consumed = 0;
if remaining >= 2 && self.strong_depth > 0 {
remaining -= 2;
consumed += 2;
}
if remaining >= 1 && self.emph_depth > 0 {
consumed += 1;
}
consumed
}
fn handle_candidate(
&mut self,
candidate: (Event<'a>, Range<usize>),
) -> Option<(Event<'a>, Range<usize>)> {
let (event, range) = candidate;
match &event {
Event::Start(Tag::Emphasis) => self.emph_depth += 1,
Event::Start(Tag::Strong) => self.strong_depth += 1,
Event::End(TagEnd::Emphasis) => self.emph_depth = self.emph_depth.saturating_sub(1),
Event::End(TagEnd::Strong) => self.strong_depth = self.strong_depth.saturating_sub(1),
_ => {}
}
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 has_open_tags = self.emph_depth > 0 || self.strong_depth > 0;
if !has_open_tags {
return Some((event, range));
}
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();
let consumable = self.closable_star_count(star_count);
if consumable > 0 {
let star_events =
Self::events_for_stars(consumable, false, text_range.start);
let text_after = &s[consumable..];
if !text_after.is_empty() {
self.buffer.push((
Event::Text(text_after.to_string().into()),
text_range.start + consumable..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::InlineHtml(ref html) | Event::Html(ref html) if is_br_tag(html) => {
(Event::HardBreak, range)
}
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 chars[i] == '\\' && i + 1 < chars.len() {
i += 2;
continue;
}
if chars[i] == '`' {
let backtick_start = i;
let mut backtick_count = 0;
while i < chars.len() && chars[i] == '`' {
backtick_count += 1;
i += 1;
}
if backtick_count >= 3 {
while i < chars.len() {
if chars[i] == '`' {
let mut close_count = 0;
let close_start = i;
while i < chars.len() && chars[i] == '`' {
close_count += 1;
i += 1;
}
if close_count >= backtick_count {
break;
}
} else {
i += 1;
}
}
} else {
let mut found_close = false;
while i < chars.len() {
if chars[i] == '`' {
let mut close_count = 0;
while i < chars.len() && chars[i] == '`' {
close_count += 1;
i += 1;
}
if close_count == backtick_count {
found_close = true;
break;
}
} else {
i += 1;
}
}
if !found_close {
i = backtick_start + backtick_count;
}
}
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);
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
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() {
let markdown = "- First line.\n\n Second line.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- First line.\n\n Second line.\n\n");
}
#[test]
fn test_list_item_three_paragraphs() {
let markdown = "- Para 1.\n\n Para 2.\n\n Para 3.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- Para 1.\n\n Para 2.\n\n Para 3.\n\n");
}
#[test]
fn test_list_item_multiple_items_with_continuation() {
let markdown = "- Item 1\n\n More text.\n\n- Item 2";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- Item 1\n\n More text.\n- Item 2\n\n");
}
#[test]
fn test_ordered_list_multi_para() {
let markdown = "1. First para.\n\n Second para.\n\n2. Next item.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "+ First para.\n\n Second para.\n+ Next item.\n\n");
}
#[test]
fn test_code_block_standalone() {
let markdown = "```rust\nfn main() {}\n```";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "```rust\nfn main() {}\n```\n\n");
}
#[test]
fn test_code_block_no_lang() {
let markdown = "```\nhello\n```";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "```\nhello\n```\n\n");
}
#[test]
fn test_code_block_no_escaping() {
let markdown = "```\n*bold* #heading $math$\n```";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "```\n*bold* #heading $math$\n```\n\n");
}
#[test]
fn test_code_block_in_list_item() {
let markdown = "- Item text\n\n ```\n code here\n ```";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(typst, "- Item text\n\n ```\ncode here\n ```\n\n");
}
#[test]
fn test_code_block_between_paragraphs_in_list() {
let markdown = "- First para.\n\n ```\n code\n ```\n\n After code.";
let typst = mark_to_typst(markdown).unwrap();
assert_eq!(
typst,
"- First para.\n\n ```\ncode\n ```\n\n After code.\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"
);
}
#[test]
fn test_basic_table() {
let md = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |";
let out = mark_to_typst(md).unwrap();
assert_eq!(
out,
"#table(\n columns: 2,\n table.header([Name], [Age], ),\n [Alice], [30], \n [Bob], [25], \n)\n\n"
);
}
#[test]
fn test_table_default_alignment() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let out = mark_to_typst(md).unwrap();
assert!(
!out.contains("align:"),
"should not emit align when all default"
);
assert!(out.contains("#table(\n columns: 2,\n"));
}
#[test]
fn test_table_with_alignment() {
let md = "| L | C | R |\n|:---|:---:|---:|\n| a | b | c |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains(" align: (left, center, right),\n"));
assert!(out.contains("#table(\n columns: 3,\n"));
}
#[test]
fn test_table_header_only() {
let md = "| Name | Value |\n|------|-------|\n";
let out = mark_to_typst(md).unwrap();
assert!(out.starts_with("#table(\n columns: 2,\n"));
assert!(out.contains("table.header([Name], [Value], )"));
assert!(out.ends_with(")\n\n"));
}
#[test]
fn test_table_single_column() {
let md = "| Item |\n|------|\n| A |\n| B |";
let out = mark_to_typst(md).unwrap();
assert!(out.starts_with("#table(\n columns: 1,\n"));
assert!(out.contains("table.header([Item], )"));
assert!(out.contains("[A], \n"));
assert!(out.contains("[B], \n"));
}
#[test]
fn test_table_empty_cell() {
let md = "| A | B |\n|---|---|\n| | x |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("[], [x], "));
}
#[test]
fn test_table_with_formatting_in_cells() {
let md = "| Name | Note |\n|------|------|\n| **bold** | _italic_ |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("[#strong[bold]]"));
assert!(out.contains("[#emph[italic]]"));
}
#[test]
fn test_table_with_inline_code_in_cells() {
let md = "| Func | Desc |\n|------|------|\n| `foo()` | does stuff |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("[`foo()`]"));
}
#[test]
fn test_table_with_link_in_cell() {
let md = "| Site |\n|------|\n| [Example](https://example.com) |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("[#link(\"https://example.com\")[Example]]"));
}
#[test]
fn test_table_special_chars_in_cells() {
let md = "| Col |\n|-----|\n| use #tag |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("[use \\#tag]"));
}
#[test]
fn test_table_in_document_with_paragraphs() {
let md = "Before.\n\n| A |\n|---|\n| 1 |\n\nAfter.";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("Before.\n\n"));
assert!(out.contains("#table("));
assert!(out.contains("After.\n\n"));
}
#[test]
fn test_table_pipe_in_cell() {
let md = "| A |\n|---|\n| a\\|b |";
let out = mark_to_typst(md).unwrap();
assert!(
out.contains("[a|b]"),
"pipe should be literal in cell: {out}"
);
}
#[test]
fn test_table_strikethrough_in_cell() {
let md = "| A |\n|---|\n| ~~deleted~~ |";
let out = mark_to_typst(md).unwrap();
assert!(
out.contains("[#strike[deleted]]"),
"strikethrough should work in cells: {out}"
);
}
#[test]
fn test_table_multiple_consecutive() {
let md = "| A |\n|---|\n| 1 |\n\n| B |\n|---|\n| 2 |";
let out = mark_to_typst(md).unwrap();
assert_eq!(
out.matches("#table(").count(),
2,
"should have two tables: {out}"
);
assert!(out.contains("table.header([A]"));
assert!(out.contains("table.header([B]"));
}
#[test]
fn test_table_mixed_alignment() {
let md = "| L | D | R |\n|:---|---|---:|\n| a | b | c |";
let out = mark_to_typst(md).unwrap();
assert!(
out.contains("align: (left, auto, right),"),
"mixed alignment: {out}"
);
}
#[test]
fn test_table_all_alignment_types() {
let md = "| L | C | R | D |\n|:---|:---:|---:|---|\n| a | b | c | d |";
let out = mark_to_typst(md).unwrap();
assert!(
out.contains("align: (left, center, right, auto),"),
"all alignments: {out}"
);
}
#[test]
fn test_table_wide() {
let md = "| A | B | C | D | E | F |\n|---|---|---|---|---|---|\n| 1 | 2 | 3 | 4 | 5 | 6 |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("columns: 6,"), "wide table columns: {out}");
assert!(out.contains("[1], [2], [3], [4], [5], [6],"));
}
#[test]
fn test_table_nested_bold_italic() {
let md = "| A |\n|---|\n| **bold** and _italic_ |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("#strong[bold]"), "bold in cell: {out}");
assert!(out.contains("#emph[italic]"), "italic in cell: {out}");
}
#[test]
fn test_table_typst_special_chars() {
let md = "| A |\n|---|\n| $100 @ref ~space {brace} |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("\\$100"), "dollar escaped: {out}");
assert!(out.contains("\\@ref"), "at escaped: {out}");
assert!(out.contains("\\~space"), "tilde escaped: {out}");
assert!(out.contains("\\{brace\\}"), "braces escaped: {out}");
}
#[test]
fn test_table_angle_brackets_in_cell() {
let md = "| A |\n|---|\n| a < b > c |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("\\<"), "< escaped: {out}");
assert!(out.contains("\\>"), "> escaped: {out}");
}
#[test]
fn test_table_double_slash_in_cell() {
let md = "| A |\n|---|\n| a // comment |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("\\/\\/"), "double slash escaped: {out}");
}
#[test]
fn test_table_square_brackets_in_cell() {
let md = "| A |\n|---|\n| [item] |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("\\[item\\]"), "brackets escaped: {out}");
}
#[test]
fn test_table_curly_braces_in_cell() {
let md = "| A |\n|---|\n| {value} |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("\\{value\\}"), "braces escaped: {out}");
}
#[test]
fn test_table_br_tag_in_cell() {
let md = "| A |\n|---|\n| line1<br>line2 |";
let out = mark_to_typst(md).unwrap();
assert!(
out.contains("line1\nline2"),
"<br> should produce line break in cell: {out}"
);
}
#[test]
fn test_table_br_tag_variants() {
let md_slash = "| A |\n|---|\n| a<br/>b |";
let out_slash = mark_to_typst(md_slash).unwrap();
assert!(
out_slash.contains("a\nb"),
"<br/> should produce line break: {out_slash}"
);
let md_space_slash = "| A |\n|---|\n| a<br />b |";
let out_space_slash = mark_to_typst(md_space_slash).unwrap();
assert!(
out_space_slash.contains("a\nb"),
"<br /> should produce line break: {out_space_slash}"
);
}
#[test]
fn test_table_unicode_in_cell() {
let md = "| Status |\n|--------|\n| ✅ Done |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("✅ Done"), "unicode in cell: {out}");
}
#[test]
fn test_table_emoji_in_cell() {
let md = "| A |\n|---|\n| 🎉 Party 🚀 |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("🎉 Party 🚀"), "emoji in cell: {out}");
}
#[test]
fn test_table_after_heading() {
let md = "# Title\n\n| A |\n|---|\n| 1 |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("= Title\n\n"), "heading before table: {out}");
assert!(out.contains("#table("), "table after heading: {out}");
}
#[test]
fn test_table_after_list() {
let md = "- item1\n- item2\n\n| A |\n|---|\n| 1 |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("- item1"), "list before table: {out}");
assert!(out.contains("#table("), "table after list: {out}");
}
#[test]
fn test_table_many_rows() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |\n| 7 | 8 |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("[1], [2],"), "row 1: {out}");
assert!(out.contains("[3], [4],"), "row 2: {out}");
assert!(out.contains("[5], [6],"), "row 3: {out}");
assert!(out.contains("[7], [8],"), "row 4: {out}");
}
#[test]
fn test_table_bold_link_in_cell() {
let md = "| A |\n|---|\n| **[link](https://x.com)** |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("#strong[#link("), "bold link in cell: {out}");
}
#[test]
fn test_table_code_with_special_chars() {
let md = "| A |\n|---|\n| `a#b$c@d` |";
let out = mark_to_typst(md).unwrap();
assert!(
out.contains("`a#b$c@d`"),
"code content should be literal: {out}"
);
}
#[test]
fn test_table_empty_minimal() {
let md = "| |\n|---|";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("#table("), "should create table: {out}");
assert!(out.contains("columns: 1,"), "single column: {out}");
}
#[test]
fn test_table_multiple_empty_cells() {
let md = "| A | B | C |\n|---|---|---|\n| | | |";
let out = mark_to_typst(md).unwrap();
assert!(out.contains("[], [], [],"), "multiple empty cells: {out}");
}
}
#[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_eq!(result, "- First para\n\n Second para in same item\n\n");
}
#[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() {
let markdown = "```rust\nfn main() {}\n```";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result, "```rust\nfn main() {}\n```\n\n");
}
#[test]
fn test_indented_code_block() {
let markdown = " fn main() {}\n println!()";
let result = mark_to_typst(markdown).unwrap();
assert_eq!(result, "```\nfn main() {}\nprintln!()\n```\n\n");
}
#[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");
}
#[test]
fn test_long_underscore_run_as_literal_text() {
let input = "I acknowledge receipt and understanding of this letter on ________________ at ___________ hours.";
let result = mark_to_typst(input).unwrap();
assert!(
!result.contains('['),
"Should not contain opening brackets: {}",
result
);
assert!(
!result.contains(']'),
"Should not contain closing brackets: {}",
result
);
assert!(
result.contains("\\_"),
"Underscores should be escaped: {}",
result
);
}
#[test]
fn test_triple_underscore_as_literal() {
let input = "fill in: ___";
let result = mark_to_typst(input).unwrap();
assert!(
!result.contains('['),
"Should not contain brackets: {}",
result
);
}
#[test]
fn test_four_underscores_as_literal() {
let input = "fill in: ____";
let result = mark_to_typst(input).unwrap();
assert!(
!result.contains('['),
"Should not contain brackets: {}",
result
);
}
#[test]
fn test_double_underscore_underline_still_works() {
let input = "__underlined text__";
let result = mark_to_typst(input).unwrap();
assert!(
result.contains("#underline["),
"Should produce underline: {}",
result
);
}
#[test]
fn test_inline_code_backtick_injection() {
let result = mark_to_typst("`` `inject` ``").unwrap();
assert!(
!result.contains("``inject``"),
"Backticks should not form nested delimiters"
);
assert!(
result.contains("`` "),
"Should use double-backtick delimiters"
);
}
#[test]
fn test_inline_code_consecutive_backticks() {
let result = mark_to_typst("``` `` ```").unwrap();
assert!(
result.contains("```"),
"Should use triple-backtick delimiters for double-backtick content"
);
}
#[test]
fn test_code_block_lang_sanitized_simple() {
let result = mark_to_typst("```rust\ncode\n```").unwrap();
assert_eq!(result, "```rust\ncode\n```\n\n");
}
#[test]
fn test_code_block_lang_sanitized_special_chars() {
let result = mark_to_typst("```rust#evil\ncode\n```").unwrap();
assert!(
result.starts_with("```rust\n"),
"Lang tag should be sanitized to 'rust': got {}",
result
);
assert!(
!result.contains("#evil"),
"Special chars should be stripped from lang tag"
);
}
#[test]
fn test_code_block_lang_allows_common_identifiers() {
let result = mark_to_typst("```c++\ncode\n```").unwrap();
assert!(
result.starts_with("```c++\n"),
"c++ lang tag should be preserved"
);
let result = mark_to_typst("```objective-c\ncode\n```").unwrap();
assert!(
result.starts_with("```objective-c\n"),
"objective-c lang tag should be preserved"
);
}
#[test]
fn test_sanitize_lang_tag_basic() {
assert_eq!(sanitize_lang_tag("rust"), "rust");
assert_eq!(sanitize_lang_tag("c++"), "c++");
assert_eq!(sanitize_lang_tag("objective-c"), "objective-c");
assert_eq!(sanitize_lang_tag("file.typ"), "file.typ");
}
#[test]
fn test_sanitize_lang_tag_strips_injection() {
assert_eq!(sanitize_lang_tag("rust\n#import"), "rust");
assert_eq!(sanitize_lang_tag("rust`code`"), "rust");
assert_eq!(sanitize_lang_tag("rust[evil]"), "rust");
assert_eq!(sanitize_lang_tag("rust$math$"), "rust");
assert_eq!(sanitize_lang_tag(""), "");
}
#[test]
fn test_longest_backtick_run() {
assert_eq!(longest_backtick_run("no backticks"), 0);
assert_eq!(longest_backtick_run("one ` here"), 1);
assert_eq!(longest_backtick_run("two `` here"), 2);
assert_eq!(longest_backtick_run("mixed ` and `` here"), 2);
assert_eq!(longest_backtick_run("```"), 3);
assert_eq!(longest_backtick_run(""), 0);
}
#[test]
fn test_mismatched_asterisks_graceful_degradation() {
let result = mark_to_typst("Less formatting. More *lethality**.").unwrap();
assert!(
!result.contains("]]"),
"Should not produce unmatched closing brackets: got {:?}",
result
);
assert!(
result.contains("#emph[lethality]"),
"Should produce valid emphasis markup: got {:?}",
result
);
assert!(
result.contains("\\*."),
"Trailing asterisk should be escaped: got {:?}",
result
);
}
#[test]
fn test_mismatched_asterisks_variants() {
let cases = vec![
"Hello **world*",
"*hello** world",
"***triple* mismatch",
"text *one *two* three",
];
for input in cases {
let result = mark_to_typst(input);
assert!(
result.is_ok(),
"Should not error on {:?}: got {:?}",
input,
result
);
}
}
fn count_unmatched_brackets(typst: &str) -> (usize, usize) {
let mut depth: i64 = 0;
let mut max_negative: i64 = 0;
let chars: Vec<char> = typst.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '\\' {
i += 2; continue;
}
if chars[i] == '"' {
i += 1;
while i < chars.len() && chars[i] != '"' {
if chars[i] == '\\' {
i += 1;
}
i += 1;
}
i += 1; continue;
}
if i + 2 < chars.len() && chars[i] == '`' && chars[i + 1] == '`' && chars[i + 2] == '`'
{
i += 3;
while i + 2 < chars.len()
&& !(chars[i] == '`' && chars[i + 1] == '`' && chars[i + 2] == '`')
{
i += 1;
}
i += 3;
continue;
}
match chars[i] {
'[' => depth += 1,
']' => {
depth -= 1;
if depth < max_negative {
max_negative = depth;
}
}
_ => {}
}
i += 1;
}
let unclosed = depth.max(0) as usize;
let unmatched_close = (-max_negative).max(0) as usize;
(unclosed, unmatched_close)
}
#[test]
fn test_intraword_markers_in_code_spans() {
let result = mark_to_typst("`not__under__lined`").unwrap();
assert!(
!result.contains("#underline"),
"__ in inline code should not become underline markup: got {:?}",
result
);
let result = mark_to_typst("`not~~strike~~through`").unwrap();
assert!(
!result.contains("#strike"),
"~~ in inline code should not become strike markup: got {:?}",
result
);
let result = mark_to_typst("```\nnot~~strike~~through\n```").unwrap();
assert!(
!result.contains("#strike"),
"~~ in fenced code should not become strike markup: got {:?}",
result
);
let result = mark_to_typst("word__under__lined and `code__literal__here`").unwrap();
assert!(
result.contains("#underline"),
"__ outside code should still produce underline: got {:?}",
result
);
}
#[test]
fn test_bracket_balance_on_malformed_markdown() {
let cases = vec![
"Less formatting. More *lethality**.",
"*hello** world",
"Hello **world*",
"***triple* mismatch",
"text *one *two* three",
"**",
"*",
"***",
"text with __unclosed",
"hello __world",
"__underline without close",
"*a **b ***c",
"***triple then single*",
"mixed __under and *emph combo",
"a]b", "a[b", "pre***__content__***",
"text **bold **nested** end",
"__a__ and __b",
"~~strike~~ and ~~unclosed",
];
for input in cases {
let result = mark_to_typst(input);
assert!(
result.is_ok(),
"Should not error on {:?}: {:?}",
input,
result
);
let typst = result.unwrap();
let (unclosed, unmatched) = count_unmatched_brackets(&typst);
assert_eq!(
unclosed, 0,
"Unclosed '[' in output for {:?}: output={:?}",
input, typst
);
assert_eq!(
unmatched, 0,
"Unmatched ']' in output for {:?}: output={:?}",
input, typst
);
}
}
}