use regex::Regex;
use std::sync::LazyLock;
use crate::utils::skip_context::ByteRange;
static DIV_OPEN_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*):::\s*(?:\{[^}]+\}|\S+)").unwrap());
static DIV_CLOSE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*):::\s*$").unwrap());
static CALLOUT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\s*):::\s*\{[^}]*\.callout-(?:note|warning|tip|important|caution)[^}]*\}").unwrap()
});
static PANDOC_ATTR_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[^}]+\}").unwrap());
pub fn is_div_open(line: &str) -> bool {
DIV_OPEN_PATTERN.is_match(line)
}
pub fn is_div_close(line: &str) -> bool {
DIV_CLOSE_PATTERN.is_match(line)
}
pub fn is_callout_open(line: &str) -> bool {
CALLOUT_PATTERN.is_match(line)
}
pub fn has_pandoc_attributes(line: &str) -> bool {
PANDOC_ATTR_PATTERN.is_match(line)
}
pub fn is_pandoc_raw_block_lang(lang: &str) -> bool {
let l = lang.trim();
l.starts_with("{=") && l.ends_with('}') && {
let inner = &l[2..l.len() - 1];
!inner.trim().is_empty() && inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
}
pub fn is_pandoc_code_class_attr(lang: &str) -> bool {
let l = lang.trim();
if !l.starts_with('{') || !l.ends_with('}') || l.len() < 2 {
return false;
}
let inner = &l[1..l.len() - 1];
inner.split_whitespace().any(|tok| {
tok.len() > 1
&& tok.starts_with('.')
&& tok[1..]
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
})
}
pub fn get_div_indent(line: &str) -> usize {
let mut indent = 0;
for c in line.chars() {
match c {
' ' => indent += 1,
'\t' => indent += 4, _ => break,
}
}
indent
}
#[derive(Debug, Clone, Default)]
pub struct DivTracker {
indent_stack: Vec<usize>,
}
impl DivTracker {
pub fn new() -> Self {
Self::default()
}
pub fn process_line(&mut self, line: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with(":::") {
let indent = get_div_indent(line);
if is_div_close(line) {
if let Some(&top_indent) = self.indent_stack.last()
&& top_indent >= indent
{
self.indent_stack.pop();
}
} else if is_div_open(line) {
self.indent_stack.push(indent);
}
}
!self.indent_stack.is_empty()
}
pub fn is_inside_div(&self) -> bool {
!self.indent_stack.is_empty()
}
}
pub fn detect_div_block_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
let mut tracker = DivTracker::new();
let mut div_start: Option<usize> = None;
let mut byte_offset = 0;
for line in content.lines() {
let line_len = line.len();
let was_inside = tracker.is_inside_div();
let is_inside = tracker.process_line(line);
if !was_inside && is_inside {
div_start = Some(byte_offset);
}
else if was_inside
&& !is_inside
&& let Some(start) = div_start.take()
{
ranges.push(ByteRange {
start,
end: byte_offset + line_len,
});
}
byte_offset += line_len + 1;
}
if let Some(start) = div_start {
ranges.push(ByteRange {
start,
end: content.len(),
});
}
ranges
}
pub fn is_within_div_block_ranges(ranges: &[ByteRange], position: usize) -> bool {
ranges.iter().any(|r| position >= r.start && position < r.end)
}
static BRACKETED_CITATION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[(?:[^\]@]*[^A-Za-z0-9_])?@[a-zA-Z0-9_][a-zA-Z0-9_:.#$%&\-+?<>~/]*[^\]]*\]").unwrap()
});
static INLINE_CITATION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|[\s\(\[\{,;:])(@[a-zA-Z0-9_][a-zA-Z0-9_:.#$%&\-+?<>~/]*)").unwrap()
});
static LINK_LABEL_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\[[^\]]*\])(?:\(|\[)").unwrap());
#[inline]
pub fn has_citations(text: &str) -> bool {
text.contains('@')
}
static INLINE_FOOTNOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:^|[^\w!])(\^\[[^\]]*\])").unwrap());
pub fn pandoc_header_slug(text: &str) -> String {
let mut s = String::with_capacity(text.len());
for c in text.chars() {
if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
for lc in c.to_lowercase() {
s.push(lc);
}
} else if c.is_whitespace() {
if !s.ends_with('-') {
s.push('-');
}
}
}
let trimmed = s.trim_matches('-').to_string();
if trimmed.is_empty() {
"section".to_string()
} else {
trimmed
}
}
pub fn collect_pandoc_header_slugs(content: &str) -> std::collections::HashSet<String> {
use std::collections::{HashMap, HashSet};
let mut slugs = HashSet::new();
let mut base_counts: HashMap<String, usize> = HashMap::new();
let mut in_fence = false;
let mut fence_marker: Option<char> = None;
for line in content.lines() {
let trimmed = line.trim_start();
if let Some(c) = trimmed.chars().next()
&& (c == '`' || c == '~')
{
let count = trimmed.chars().take_while(|&ch| ch == c).count();
if count >= 3 {
match fence_marker {
None => {
in_fence = true;
fence_marker = Some(c);
}
Some(m) if m == c => {
in_fence = false;
fence_marker = None;
}
_ => {}
}
continue;
}
}
if in_fence {
continue;
}
if let Some(rest) = trimmed.strip_prefix('#') {
let mut text = rest.trim_start_matches('#').trim();
if let Some(idx) = text.rfind(" {")
&& let Some(close_rel) = text[idx + 2..].find('}')
&& text[idx + 2 + close_rel + 1..].trim().is_empty()
{
text = &text[..idx];
}
let base = pandoc_header_slug(text);
let count = base_counts.entry(base.clone()).or_insert(0);
let slug = if *count == 0 {
base.clone()
} else {
format!("{base}-{count}")
};
*count += 1;
slugs.insert(slug);
}
}
slugs
}
static SUBSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~[^\s~]+~").unwrap());
static SUPERSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\^[^\s^]+\^").unwrap());
pub fn detect_subscript_superscript_ranges(content: &str) -> Vec<ByteRange> {
let bytes = content.as_bytes();
let mut ranges = Vec::new();
for m in SUBSCRIPT_PATTERN.find_iter(content) {
let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
let next = bytes.get(m.end()).copied().unwrap_or(0);
if prev != b'~' && next != b'~' {
ranges.push(ByteRange {
start: m.start(),
end: m.end(),
});
}
}
for m in SUPERSCRIPT_PATTERN.find_iter(content) {
let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
let next = bytes.get(m.end()).copied().unwrap_or(0);
if prev != b'^' && next != b'^' {
ranges.push(ByteRange {
start: m.start(),
end: m.end(),
});
}
}
ranges.sort_by_key(|r| r.start);
ranges
}
static INLINE_CODE_ATTR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]*`(\{[^}]+\})").unwrap());
pub fn detect_inline_code_attr_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
for caps in INLINE_CODE_ATTR.captures_iter(content) {
let m = caps.get(1).unwrap();
ranges.push(ByteRange {
start: m.start(),
end: m.end(),
});
}
ranges
}
static EXAMPLE_LIST_MARKER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^[ \t]*(\(@[A-Za-z0-9_-]*\))[ \t]+").unwrap());
static EXAMPLE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\(@[A-Za-z0-9_-]+\))").unwrap());
pub fn detect_example_list_marker_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
for caps in EXAMPLE_LIST_MARKER.captures_iter(content) {
let m = caps.get(1).unwrap();
ranges.push(ByteRange {
start: m.start(),
end: m.end(),
});
}
ranges
}
pub fn detect_example_reference_ranges(content: &str, marker_ranges: &[ByteRange]) -> Vec<ByteRange> {
let mut ranges = Vec::new();
let marker_starts: std::collections::HashSet<usize> = marker_ranges.iter().map(|r| r.start).collect();
for caps in EXAMPLE_REFERENCE.captures_iter(content) {
let m = caps.get(1).unwrap();
if !marker_starts.contains(&m.start()) {
ranges.push(ByteRange {
start: m.start(),
end: m.end(),
});
}
}
ranges
}
static BRACKETED_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[[^\]]+\]\{[^}]+\}").unwrap());
pub fn detect_bracketed_span_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
for m in BRACKETED_SPAN.find_iter(content) {
ranges.push(ByteRange {
start: m.start(),
end: m.end(),
});
}
ranges
}
pub fn detect_line_block_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
let mut in_block = false;
let mut block_start = 0usize;
let mut block_end = 0usize;
let mut byte_offset = 0usize;
for line in content.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
let is_line_block_line = trimmed.starts_with("| ") && !trimmed.trim_end().ends_with('|');
let is_continuation = in_block
&& !trimmed.is_empty()
&& trimmed.starts_with(|c: char| c.is_whitespace())
&& !trimmed.trim_start().starts_with('|');
if is_line_block_line || is_continuation {
if !in_block {
block_start = byte_offset;
in_block = true;
}
block_end = byte_offset + line.len();
} else if in_block {
ranges.push(ByteRange {
start: block_start,
end: block_end,
});
in_block = false;
}
byte_offset += line.len();
}
if in_block {
ranges.push(ByteRange {
start: block_start,
end: block_end,
});
}
ranges
}
pub fn detect_pipe_table_caption_ranges(content: &str) -> Vec<ByteRange> {
let mut lines: Vec<&str> = Vec::new();
let mut line_offsets: Vec<usize> = Vec::new();
let mut offset = 0usize;
for line in content.split_inclusive('\n') {
line_offsets.push(offset);
lines.push(line);
offset += line.len();
}
line_offsets.push(offset);
fn line_body(line: &str) -> &str {
line.trim_end_matches('\n').trim_end_matches('\r')
}
fn is_pipe_table_row(line: &str) -> bool {
let t = line_body(line).trim();
t.starts_with('|') && t.ends_with('|') && t.len() >= 3
}
fn is_caption_line(line: &str) -> bool {
line_body(line).trim_start().starts_with(": ")
}
fn is_blank(line: &str) -> bool {
line_body(line).trim().is_empty()
}
let mut ranges = Vec::new();
for (i, line) in lines.iter().enumerate() {
if !is_caption_line(line) {
continue;
}
let table_below = i + 2 < lines.len() && is_blank(lines[i + 1]) && is_pipe_table_row(lines[i + 2]);
let table_above = i >= 2 && is_blank(lines[i - 1]) && is_pipe_table_row(lines[i - 2]);
if table_below || table_above {
ranges.push(ByteRange {
start: line_offsets[i],
end: line_offsets[i + 1],
});
}
}
ranges
}
pub fn detect_yaml_metadata_block_ranges(content: &str) -> Vec<ByteRange> {
let mut lines: Vec<&str> = Vec::new();
let mut line_offsets: Vec<usize> = Vec::new();
let mut offset = 0usize;
for line in content.split_inclusive('\n') {
line_offsets.push(offset);
lines.push(line);
offset += line.len();
}
line_offsets.push(offset);
fn line_body(line: &str) -> &str {
line.trim_end_matches('\n').trim_end_matches('\r')
}
fn is_blank(line: &str) -> bool {
line_body(line).trim().is_empty()
}
fn is_opener(line: &str) -> bool {
line_body(line).trim_end() == "---"
}
fn is_closer(line: &str) -> bool {
let t = line_body(line).trim_end();
t == "---" || t == "..."
}
let mut ranges = Vec::new();
let mut i = 0;
while i < lines.len() {
let preceded_by_blank = i == 0 || is_blank(lines[i - 1]);
if preceded_by_blank && is_opener(lines[i]) {
let mut j = i + 1;
let mut found_closer = false;
while j < lines.len() {
if is_closer(lines[j]) {
ranges.push(ByteRange {
start: line_offsets[i],
end: line_offsets[j + 1],
});
i = j + 1;
found_closer = true;
break;
}
j += 1;
}
if !found_closer {
i += 1;
}
} else {
i += 1;
}
}
ranges
}
static GRID_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\+(?:[-=]+\+)+\s*$").unwrap());
static GRID_CONTENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\|.*\|\s*$").unwrap());
pub fn detect_grid_table_ranges(content: &str) -> Vec<ByteRange> {
let mut lines: Vec<&str> = Vec::new();
let mut line_offsets: Vec<usize> = Vec::new();
let mut offset = 0usize;
for line in content.split_inclusive('\n') {
line_offsets.push(offset);
lines.push(line);
offset += line.len();
}
line_offsets.push(offset);
fn line_body(line: &str) -> &str {
line.trim_end_matches('\n').trim_end_matches('\r')
}
fn is_border(line: &str) -> bool {
GRID_BORDER.is_match(line_body(line))
}
fn is_content(line: &str) -> bool {
GRID_CONTENT.is_match(line_body(line))
}
let mut ranges = Vec::new();
let mut i = 0;
while i < lines.len() {
if is_border(lines[i]) {
let start_line = i;
let mut j = i + 1;
let mut last_border = i;
let mut saw_content = false;
while j < lines.len() {
if is_border(lines[j]) {
last_border = j;
j += 1;
} else if is_content(lines[j]) {
saw_content = true;
j += 1;
} else {
break;
}
}
if saw_content && last_border > start_line {
ranges.push(ByteRange {
start: line_offsets[start_line],
end: line_offsets[last_border + 1],
});
i = last_border + 1;
continue;
}
}
i += 1;
}
ranges
}
static MULTI_LINE_UNDERLINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{2,}(?:\s+-{2,})+\s*$").unwrap());
static MULTI_LINE_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{10,}\s*$").unwrap());
pub fn detect_multi_line_table_ranges(content: &str) -> Vec<ByteRange> {
let mut lines: Vec<&str> = Vec::new();
let mut line_offsets: Vec<usize> = Vec::new();
let mut offset = 0usize;
for line in content.split_inclusive('\n') {
line_offsets.push(offset);
lines.push(line);
offset += line.len();
}
line_offsets.push(offset);
fn line_body(line: &str) -> &str {
line.trim_end_matches('\n').trim_end_matches('\r')
}
fn is_underline(line: &str) -> bool {
MULTI_LINE_UNDERLINE.is_match(line_body(line))
}
fn is_border(line: &str) -> bool {
MULTI_LINE_BORDER.is_match(line_body(line))
}
let mut ranges = Vec::new();
let mut i = 0;
while i < lines.len() {
if i >= 1 && is_underline(lines[i]) && !line_body(lines[i - 1]).is_empty() {
let mut header_start = i - 1;
while header_start > 0
&& !line_body(lines[header_start - 1]).is_empty()
&& !is_border(lines[header_start - 1])
&& !is_underline(lines[header_start - 1])
{
header_start -= 1;
}
let start_line = if header_start > 0 && is_border(lines[header_start - 1]) {
header_start - 1
} else {
header_start
};
let mut j = i + 1;
let mut end_line: Option<usize> = None;
while j < lines.len() {
if is_border(lines[j]) {
end_line = Some(j);
break;
} else if j > i + 1 && is_underline(lines[j]) {
end_line = Some(j - 1);
break;
}
j += 1;
}
if let Some(end) = end_line {
ranges.push(ByteRange {
start: line_offsets[start_line],
end: line_offsets[end + 1],
});
i = end + 1;
continue;
}
}
i += 1;
}
ranges
}
pub fn detect_inline_footnote_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
for caps in INLINE_FOOTNOTE_PATTERN.captures_iter(content) {
let m = caps.get(1).unwrap();
ranges.push(ByteRange {
start: m.start(),
end: m.end(),
});
}
ranges
}
pub fn find_citation_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
let link_label_ranges: Vec<(usize, usize)> = LINK_LABEL_PATTERN
.captures_iter(content)
.filter_map(|c| c.get(1).map(|m| (m.start(), m.end())))
.collect();
let in_link_label = |pos: usize| -> bool { link_label_ranges.iter().any(|&(s, e)| pos >= s && pos < e) };
for mat in BRACKETED_CITATION_PATTERN.find_iter(content) {
if in_link_label(mat.start()) {
continue;
}
ranges.push(ByteRange {
start: mat.start(),
end: mat.end(),
});
}
for cap in INLINE_CITATION_PATTERN.captures_iter(content) {
if let Some(mat) = cap.get(1) {
let start = mat.start();
if in_link_label(start) {
continue;
}
if !ranges.iter().any(|r| start >= r.start && start < r.end) {
ranges.push(ByteRange { start, end: mat.end() });
}
}
}
ranges.sort_by_key(|r| r.start);
ranges
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_div_open_detection() {
assert!(is_div_open("::: {.callout-note}"));
assert!(is_div_open("::: {.callout-warning}"));
assert!(is_div_open("::: {#myid .class}"));
assert!(is_div_open("::: bordered"));
assert!(is_div_open(" ::: {.note}")); assert!(is_div_open("::: {.callout-tip title=\"My Title\"}"));
assert!(!is_div_open(":::")); assert!(!is_div_open("::: ")); assert!(!is_div_open("Regular text"));
assert!(!is_div_open("# Heading"));
assert!(!is_div_open("```python")); }
#[test]
fn test_div_close_detection() {
assert!(is_div_close(":::"));
assert!(is_div_close("::: "));
assert!(is_div_close(" :::"));
assert!(is_div_close(" ::: "));
assert!(!is_div_close("::: {.note}"));
assert!(!is_div_close("::: class"));
assert!(!is_div_close(":::note"));
}
#[test]
fn test_callout_detection() {
assert!(is_callout_open("::: {.callout-note}"));
assert!(is_callout_open("::: {.callout-warning}"));
assert!(is_callout_open("::: {.callout-tip}"));
assert!(is_callout_open("::: {.callout-important}"));
assert!(is_callout_open("::: {.callout-caution}"));
assert!(is_callout_open("::: {#myid .callout-note}"));
assert!(is_callout_open("::: {.callout-note title=\"Title\"}"));
assert!(!is_callout_open("::: {.note}")); assert!(!is_callout_open("::: {.bordered}")); assert!(!is_callout_open("::: callout-note")); }
#[test]
fn test_div_tracker() {
let mut tracker = DivTracker::new();
assert!(tracker.process_line("::: {.callout-note}"));
assert!(tracker.is_inside_div());
assert!(tracker.process_line("This is content."));
assert!(tracker.is_inside_div());
assert!(!tracker.process_line(":::"));
assert!(!tracker.is_inside_div());
}
#[test]
fn test_nested_divs() {
let mut tracker = DivTracker::new();
assert!(tracker.process_line("::: {.outer}"));
assert!(tracker.is_inside_div());
assert!(tracker.process_line(" ::: {.inner}"));
assert!(tracker.is_inside_div());
assert!(tracker.process_line(" Content"));
assert!(tracker.is_inside_div());
assert!(tracker.process_line(" :::"));
assert!(tracker.is_inside_div());
assert!(!tracker.process_line(":::"));
assert!(!tracker.is_inside_div());
}
#[test]
fn test_detect_div_block_ranges() {
let content = r#"# Heading
::: {.callout-note}
This is a note.
:::
Regular text.
::: {.bordered}
Content here.
:::
"#;
let ranges = detect_div_block_ranges(content);
assert_eq!(ranges.len(), 2);
let first_div_content = &content[ranges[0].start..ranges[0].end];
assert!(first_div_content.contains("callout-note"));
assert!(first_div_content.contains("This is a note"));
let second_div_content = &content[ranges[1].start..ranges[1].end];
assert!(second_div_content.contains("bordered"));
assert!(second_div_content.contains("Content here"));
}
#[test]
fn test_pandoc_attributes() {
assert!(has_pandoc_attributes("# Heading {#custom-id}"));
assert!(has_pandoc_attributes("# Heading {.unnumbered}"));
assert!(has_pandoc_attributes("{#fig-1 width=\"50%\"}"));
assert!(has_pandoc_attributes("{#id .class key=\"value\"}"));
assert!(!has_pandoc_attributes("# Heading"));
assert!(!has_pandoc_attributes("Regular text"));
assert!(!has_pandoc_attributes("{}"));
}
#[test]
fn test_div_with_title_attribute() {
let content = r#"::: {.callout-note title="Important Note"}
This is the content of the note.
It can span multiple lines.
:::
"#;
let ranges = detect_div_block_ranges(content);
assert_eq!(ranges.len(), 1);
assert!(is_callout_open("::: {.callout-note title=\"Important Note\"}"));
}
#[test]
fn test_unclosed_div() {
let content = r#"::: {.callout-note}
This note is never closed.
"#;
let ranges = detect_div_block_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_heading_inside_callout() {
let content = r#"::: {.callout-warning}
## Warning Title
Warning content here.
:::
"#;
let ranges = detect_div_block_ranges(content);
assert_eq!(ranges.len(), 1);
let div_content = &content[ranges[0].start..ranges[0].end];
assert!(div_content.contains("## Warning Title"));
}
#[test]
fn test_has_citations() {
assert!(has_citations("See @smith2020 for details."));
assert!(has_citations("[@smith2020]"));
assert!(has_citations("Multiple [@a; @b] citations"));
assert!(!has_citations("No citations here"));
assert!(has_citations("Email: user@example.com"));
}
#[test]
fn test_bracketed_citation_detection() {
let content = "See [@smith2020] for more info.";
let ranges = find_citation_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020]");
}
#[test]
fn test_inline_citation_detection() {
let content = "As @smith2020 argues, this is true.";
let ranges = find_citation_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
}
#[test]
fn test_multiple_citations_in_brackets() {
let content = "See [@smith2020; @jones2021] for details.";
let ranges = find_citation_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020; @jones2021]");
}
#[test]
fn test_citation_with_prefix() {
let content = "[see @smith2020, p. 10]";
let ranges = find_citation_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "[see @smith2020, p. 10]");
}
#[test]
fn test_suppress_author_citation() {
let content = "The theory [-@smith2020] states that...";
let ranges = find_citation_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "[-@smith2020]");
}
#[test]
fn test_mixed_citations() {
let content = "@smith2020 argues that [@jones2021] is wrong.";
let ranges = find_citation_ranges(content);
assert_eq!(ranges.len(), 2);
assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
assert_eq!(&content[ranges[1].start..ranges[1].end], "[@jones2021]");
}
#[test]
fn test_email_not_confused_with_citation() {
let content = "Contact user@example.com for help.";
let ranges = find_citation_ranges(content);
assert!(
ranges.is_empty()
|| !ranges.iter().any(|r| {
let s = &content[r.start..r.end];
s.contains("example.com")
})
);
}
#[test]
fn test_bracketed_link_text_with_email_not_citation() {
let content = "[contact user@example.com](#missing)";
let ranges = find_citation_ranges(content);
assert!(
ranges.is_empty(),
"Bracketed link text with embedded email must not be detected as a Pandoc citation: {ranges:?}"
);
}
#[test]
fn test_bracketed_link_text_with_email_empty_href_not_citation() {
let content = "[contact user@example.com]()";
let ranges = find_citation_ranges(content);
assert!(
ranges.is_empty(),
"Bracketed link text with embedded email and empty href must not be a Pandoc citation: {ranges:?}"
);
}
#[test]
fn test_bracketed_text_followed_by_inline_link_not_citation() {
let content = "[see @smith2020](#missing)";
let ranges = find_citation_ranges(content);
assert!(
ranges.is_empty(),
"Bracketed text followed by `(...)` is a link, not a citation: {ranges:?}"
);
}
#[test]
fn test_bracketed_text_followed_by_empty_inline_link_not_citation() {
let content = "[see @smith2020]()";
let ranges = find_citation_ranges(content);
assert!(
ranges.is_empty(),
"Bracketed text followed by `()` is a link with empty href, not a citation: {ranges:?}"
);
}
#[test]
fn test_bracketed_text_followed_by_reference_link_not_citation() {
let content = "[see @smith2020][ref]";
let ranges = find_citation_ranges(content);
assert!(
ranges.is_empty(),
"Bracketed text followed by `[ref]` is a reference link, not a citation: {ranges:?}"
);
}
#[test]
fn test_standalone_bracketed_citation_still_detected() {
let content = "See [see @smith2020] for details.";
let ranges = find_citation_ranges(content);
assert!(
ranges.iter().any(|r| &content[r.start..r.end] == "[see @smith2020]"),
"Standalone bracketed citation must still be detected: {ranges:?}"
);
}
#[test]
fn test_bracketed_citation_followed_by_punctuation_still_detected() {
let content = "Note [@smith2020].";
let ranges = find_citation_ranges(content);
assert!(
ranges.iter().any(|r| &content[r.start..r.end] == "[@smith2020]"),
"Bracketed citation followed by `.` must still be detected: {ranges:?}"
);
}
#[test]
fn test_detect_inline_footnotes() {
let content = "See ^[a quick note] here.\nAnd ^[another one] too.\n";
let ranges = detect_inline_footnote_ranges(content);
assert_eq!(ranges.len(), 2);
let first_start = content.find("^[").unwrap();
let first_end = content[first_start..].find(']').unwrap() + first_start + 1;
assert_eq!(ranges[0].start, first_start);
assert_eq!(ranges[0].end, first_end);
let second_start = content[first_end..].find("^[").unwrap() + first_end;
let second_end = content[second_start..].find(']').unwrap() + second_start + 1;
assert_eq!(ranges[1].start, second_start);
assert_eq!(ranges[1].end, second_end);
}
#[test]
fn test_inline_footnote_with_brackets_inside() {
let content = "Note ^[ref to [other] thing] here.\n";
let ranges = detect_inline_footnote_ranges(content);
assert_eq!(ranges.len(), 1);
}
#[test]
fn test_inline_footnote_does_not_match_image_or_link() {
let content = "An image  and a link [txt](url).\n";
let ranges = detect_inline_footnote_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_implicit_header_reference_slug() {
assert_eq!(pandoc_header_slug("My Section"), "my-section");
assert_eq!(pandoc_header_slug("API: v2!"), "api-v2");
assert_eq!(pandoc_header_slug(" Trim Me "), "trim-me");
assert_eq!(pandoc_header_slug("Multiple Spaces"), "multiple-spaces");
}
#[test]
fn test_collect_pandoc_header_slugs() {
let content = "# My Section\n\n## Sub-section\n\nbody\n";
let slugs = collect_pandoc_header_slugs(content);
assert!(slugs.contains("my-section"));
assert!(slugs.contains("sub-section"));
}
#[test]
fn test_collect_pandoc_header_slugs_strips_attribute_block() {
let content = "# My Section {#custom-id .red}\n## Plain Section\n";
let slugs = collect_pandoc_header_slugs(content);
assert!(slugs.contains("my-section"));
assert!(slugs.contains("plain-section"));
assert!(!slugs.iter().any(|s| s.contains("custom-id")));
}
#[test]
fn test_collect_pandoc_header_slugs_preserves_body_braces() {
let content = "# Some {curly} word in title\n";
let slugs = collect_pandoc_header_slugs(content);
assert!(slugs.contains("some-curly-word-in-title"));
}
#[test]
fn test_collect_pandoc_header_slugs_disambiguates_duplicates() {
let content = "# A.\n\nbody\n\n# A.\n";
let slugs = collect_pandoc_header_slugs(content);
assert!(slugs.contains("a."), "first occurrence should expose base slug `a.`");
assert!(
slugs.contains("a.-1"),
"second occurrence should expose `a.-1`: got {slugs:?}"
);
}
#[test]
fn test_collect_pandoc_header_slugs_three_duplicates_get_two_suffixes() {
let content = "# Intro\n\n# Intro\n\n# Intro\n";
let slugs = collect_pandoc_header_slugs(content);
assert!(slugs.contains("intro"));
assert!(slugs.contains("intro-1"));
assert!(slugs.contains("intro-2"));
assert!(
!slugs.contains("intro-3"),
"three occurrences must produce only -1 and -2 suffixes, not -3: got {slugs:?}"
);
}
#[test]
fn test_collect_pandoc_header_slugs_unique_headings_get_no_suffix() {
let content = "# Foo\n\n# Bar\n\n# Baz\n";
let slugs = collect_pandoc_header_slugs(content);
assert!(slugs.contains("foo"));
assert!(slugs.contains("bar"));
assert!(slugs.contains("baz"));
assert!(!slugs.contains("foo-1"));
assert!(!slugs.contains("bar-1"));
assert!(!slugs.contains("baz-1"));
}
#[test]
fn test_detect_example_list_markers() {
let content = "(@) First item.\n(@good) Second item.\n(@) Third item.\n";
let ranges = detect_example_list_marker_ranges(content);
assert_eq!(ranges.len(), 3);
assert_eq!(ranges[0].start, 0);
assert_eq!(&content[ranges[0].start..ranges[0].end], "(@)");
let second_start = content.find("(@good)").unwrap();
assert_eq!(ranges[1].start, second_start);
assert_eq!(&content[ranges[1].start..ranges[1].end], "(@good)");
}
#[test]
fn test_detect_example_references() {
let content = "As shown in (@good), this works.\n";
let marker_ranges = detect_example_list_marker_ranges(content);
let ranges = detect_example_reference_ranges(content, &marker_ranges);
assert_eq!(ranges.len(), 1);
}
#[test]
fn test_example_marker_must_be_at_line_start() {
let content = "Inline (@) is not a marker.\n";
let ranges = detect_example_list_marker_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_detect_subscript() {
let content = "H~2~O is water.\n";
let ranges = detect_subscript_superscript_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "~2~");
}
#[test]
fn test_detect_superscript() {
let content = "2^10^ is 1024.\n";
let ranges = detect_subscript_superscript_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "^10^");
}
#[test]
fn test_subscript_does_not_match_strikethrough() {
let content = "This is ~~struck~~.\n";
let ranges = detect_subscript_superscript_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_superscript_with_internal_space_is_not_matched() {
let content = "x^a b^ y\n";
let ranges = detect_subscript_superscript_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_subscript_at_start_of_input() {
let content = "~x~ rest of line\n";
let ranges = detect_subscript_superscript_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "~x~");
}
#[test]
fn test_superscript_at_end_of_input_no_newline() {
let content = "text ^x^";
let ranges = detect_subscript_superscript_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "^x^");
}
#[test]
fn test_detect_inline_code_attribute() {
let content = "Use `print()`{.python} for output.\n";
let ranges = detect_inline_code_attr_ranges(content);
assert_eq!(ranges.len(), 1);
let r = &ranges[0];
assert_eq!(&content[r.start..r.end], "{.python}");
}
#[test]
fn test_inline_code_attribute_only_after_backtick() {
let content = "Use {.example} for the class.\n";
let ranges = detect_inline_code_attr_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_inline_code_attribute_multiple_on_one_line() {
let content = "Use `a`{.x} and `b`{.y} here.\n";
let ranges = detect_inline_code_attr_ranges(content);
assert_eq!(ranges.len(), 2);
assert_eq!(&content[ranges[0].start..ranges[0].end], "{.x}");
assert_eq!(&content[ranges[1].start..ranges[1].end], "{.y}");
}
#[test]
fn test_inline_code_attribute_compound_attributes() {
let content = "Use `code`{.lang #id key=value} here.\n";
let ranges = detect_inline_code_attr_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(&content[ranges[0].start..ranges[0].end], "{.lang #id key=value}");
}
#[test]
fn test_detect_bracketed_span() {
let content = "This is [some text]{.smallcaps} here.\n";
let ranges = detect_bracketed_span_ranges(content);
assert_eq!(ranges.len(), 1);
let r = &ranges[0];
assert_eq!(&content[r.start..r.end], "[some text]{.smallcaps}");
}
#[test]
fn test_bracketed_span_does_not_match_link() {
let content = "A [link](http://example.com) here.\n";
let ranges = detect_bracketed_span_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_bracketed_span_does_not_match_reference_link() {
let content = "A [ref][def] here.\n[def]: http://example.com\n";
let ranges = detect_bracketed_span_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_bracketed_span_multiple_on_one_line() {
let content = "[one]{.a} and [two]{.b} together.\n";
let ranges = detect_bracketed_span_ranges(content);
assert_eq!(ranges.len(), 2);
assert_eq!(&content[ranges[0].start..ranges[0].end], "[one]{.a}");
assert_eq!(&content[ranges[1].start..ranges[1].end], "[two]{.b}");
}
#[test]
fn test_bracketed_span_rejects_empty_content() {
let content = "[]{.x} and [x]{} here.\n";
let ranges = detect_bracketed_span_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_bracketed_span_at_start_of_line() {
let content = "[head]{.intro} starts the line.\n";
let ranges = detect_bracketed_span_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(&content[ranges[0].start..ranges[0].end], "[head]{.intro}");
}
#[test]
fn test_detect_line_block_single() {
let content = "| The Lord of the Rings\n| by J.R.R. Tolkien\n";
let ranges = detect_line_block_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_line_block_no_trailing_newline() {
let content = "| Only line";
let ranges = detect_line_block_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_line_block_indented_pipe_is_not_continuation() {
let content = "| First\n | indented\n";
let ranges = detect_line_block_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].end, "| First\n".len());
}
#[test]
fn test_line_block_continuation_with_indent() {
let content = "| First line\n continuation\n| Second\n";
let ranges = detect_line_block_ranges(content);
assert_eq!(ranges.len(), 1);
}
#[test]
fn test_line_block_separated_by_blank() {
let content = "| Block A\n\n| Block B\n";
let ranges = detect_line_block_ranges(content);
assert_eq!(ranges.len(), 2);
}
#[test]
fn test_line_block_does_not_match_pipe_table() {
let content = "| col1 | col2 |\n|------|------|\n";
let ranges = detect_line_block_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_detect_pipe_table_caption_below() {
let content = "\
| col1 | col2 |
|------|------|
| a | b |
: My caption
";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 1);
let cap = &content[ranges[0].start..ranges[0].end];
assert!(cap.starts_with(": My caption"));
}
#[test]
fn test_detect_pipe_table_caption_above() {
let content = "\
: Caption first
| col1 | col2 |
|------|------|
| a | b |
";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 1);
}
#[test]
fn test_colon_line_without_adjacent_table_is_definition_term() {
let content = "Term\n: definition\n";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_pipe_table_caption_two_blank_lines_does_not_match() {
let content = "\
| a | b |
|---|---|
| 1 | 2 |
: Caption
";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_pipe_table_caption_no_blank_line_does_not_match() {
let content = "\
| a | b |
|---|---|
| 1 | 2 |
: Caption
";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_pipe_table_caption_no_trailing_newline() {
let content = "\
| a | b |
|---|---|
| 1 | 2 |
: Trailing caption";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].end, content.len());
assert_eq!(&content[ranges[0].start..ranges[0].end], ": Trailing caption");
}
#[test]
fn test_pipe_table_caption_handles_crlf() {
let content = "| a | b |\r\n|---|---|\r\n| 1 | 2 |\r\n\r\n: CRLF caption\r\n";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 1);
let cap = &content[ranges[0].start..ranges[0].end];
assert!(cap.starts_with(": CRLF caption"));
}
#[test]
fn test_pipe_table_caption_lone_colon_does_not_match() {
let content = "\
| a | b |
|---|---|
| 1 | 2 |
:
";
let ranges = detect_pipe_table_caption_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_detect_metadata_block_at_start() {
let content = "---\ntitle: Doc\n---\n\nBody.\n";
let ranges = detect_yaml_metadata_block_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
}
#[test]
fn test_detect_metadata_block_mid_document() {
let content = "---\ntitle: Doc\n---\n\n# Heading\n\n---\nauthor: X\n---\n\nBody.\n";
let ranges = detect_yaml_metadata_block_ranges(content);
assert_eq!(ranges.len(), 2);
}
#[test]
fn test_metadata_block_uses_dot_terminator() {
let content = "---\ntitle: Doc\n...\n\nBody.\n";
let ranges = detect_yaml_metadata_block_ranges(content);
assert_eq!(ranges.len(), 1);
}
#[test]
fn test_metadata_block_unterminated_opener_skipped() {
let content = "---\ntitle: Doc\nbody continues forever\n";
let ranges = detect_yaml_metadata_block_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_metadata_block_dashes_after_text_are_not_opener() {
let content = "Some prose paragraph.\n---\nbody: not-metadata\n---\n";
let ranges = detect_yaml_metadata_block_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_metadata_block_no_trailing_newline() {
let content = "---\ntitle: Doc\n---";
let ranges = detect_yaml_metadata_block_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_metadata_block_handles_crlf() {
let content = "---\r\ntitle: Doc\r\n---\r\n\r\nBody.\r\n";
let ranges = detect_yaml_metadata_block_ranges(content);
assert_eq!(ranges.len(), 1);
let block = &content[ranges[0].start..ranges[0].end];
assert!(block.starts_with("---\r\n"));
assert!(block.ends_with("---\r\n"));
}
#[test]
fn test_collect_pandoc_header_slugs_skips_code_blocks() {
let content = "\
# Real Heading
```bash
# This is a bash comment
#!/usr/bin/env bash
```
# Another Heading
";
let slugs = collect_pandoc_header_slugs(content);
assert!(slugs.contains("real-heading"));
assert!(slugs.contains("another-heading"));
assert!(!slugs.contains("this-is-a-bash-comment"));
assert!(!slugs.iter().any(|s| s.contains("usr-bin")));
}
#[test]
fn test_detect_simple_grid_table() {
let content = "\
+---------+---------+
| col1 | col2 |
+=========+=========+
| a | b |
+---------+---------+
";
let ranges = detect_grid_table_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_grid_table_with_surrounding_text() {
let content = "\
Before.
+---+---+
| a | b |
+---+---+
| 1 | 2 |
+---+---+
After.
";
let ranges = detect_grid_table_ranges(content);
assert_eq!(ranges.len(), 1);
let region = &content[ranges[0].start..ranges[0].end];
assert!(region.contains("+---+---+"));
assert!(!region.contains("Before"));
assert!(!region.contains("After"));
}
#[test]
fn test_lone_plus_dash_line_is_not_a_table() {
let content = "Just a +---+ in prose.\n";
let ranges = detect_grid_table_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_grid_table_no_trailing_newline() {
let content = "+---+---+\n| a | b |\n+---+---+\n| 1 | 2 |\n+---+---+";
let ranges = detect_grid_table_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_grid_table_crlf() {
let content = "+---+---+\r\n| a | b |\r\n+---+---+\r\n| 1 | 2 |\r\n+---+---+\r\n";
let ranges = detect_grid_table_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_grid_table_borders_only_no_content_row_rejected() {
let content = "+---+\n+---+\n";
let ranges = detect_grid_table_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_detect_multi_line_table() {
let content = "\
-------------------------------------------------------------
Centered Default Right Left
Header Aligned Aligned Aligned
----------- ------- --------------- -------------------------
First row 12.0 Example of a row that
spans multiple lines.
Second row 5.0 Here's another one. Note
the blank line between
rows.
-------------------------------------------------------------
";
let ranges = detect_multi_line_table_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_simple_dash_header_underline_only_does_not_match() {
let content = "Some text\n--------\nMore text\n";
let ranges = detect_multi_line_table_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_multi_line_table_no_trailing_newline() {
let content = "\
-------------------------------------------------------------
Centered Default Right Left
Header Aligned Aligned Aligned
----------- ------- --------------- -------------------------
First row 12.0 Example of a row that
spans multiple lines.
Second row 5.0 Here's another one. Note
the blank line between
rows.
-------------------------------------------------------------";
let ranges = detect_multi_line_table_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_multi_line_table_crlf() {
let content = "\
-------------------------------------------------------------\r\n\
Centered Default Right Left\r\n\
Header Aligned Aligned Aligned\r\n\
----------- ------- --------------- -------------------------\r\n\
First row 12.0 Example of a row that\r\n\
spans multiple lines.\r\n\
\r\n\
Second row 5.0 Here's another one. Note\r\n\
the blank line between\r\n\
rows.\r\n\
-------------------------------------------------------------\r\n";
let ranges = detect_multi_line_table_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_multi_line_table_unterminated_skipped() {
let content = "\
Centered Default
Header Aligned
----------- -------
First row
Second row
";
let ranges = detect_multi_line_table_ranges(content);
assert_eq!(ranges.len(), 0);
}
#[test]
fn test_multi_line_table_no_top_border() {
let content = "\
Centered Default Right Left
----------- ------- --------------- -------------------------
First row 12.0 Example
Second row 5.0 Another
-------------------------------------------------------------
";
let ranges = detect_multi_line_table_ranges(content);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, content.len());
}
#[test]
fn test_is_pandoc_raw_block_lang() {
assert!(is_pandoc_raw_block_lang("{=html}"));
assert!(is_pandoc_raw_block_lang("{=latex}"));
assert!(is_pandoc_raw_block_lang("{=docx}"));
assert!(is_pandoc_raw_block_lang("{=rst}"));
assert!(is_pandoc_raw_block_lang("{=open-document}"));
assert!(is_pandoc_raw_block_lang("{=my_format}"));
assert!(is_pandoc_raw_block_lang("{=HTML}"));
assert!(!is_pandoc_raw_block_lang("{r}"));
assert!(!is_pandoc_raw_block_lang("{python}"));
assert!(!is_pandoc_raw_block_lang("{=}"));
assert!(!is_pandoc_raw_block_lang("{= }"));
assert!(!is_pandoc_raw_block_lang("=html"));
assert!(!is_pandoc_raw_block_lang("{=html }"));
assert!(!is_pandoc_raw_block_lang("{=ht ml}"));
}
#[test]
fn test_is_pandoc_code_class_attr() {
assert!(is_pandoc_code_class_attr("{.python}"));
assert!(is_pandoc_code_class_attr("{.haskell}"));
assert!(is_pandoc_code_class_attr("{.rust}"));
assert!(is_pandoc_code_class_attr("{.haskell .numberLines}"));
assert!(is_pandoc_code_class_attr("{#myid .python}"));
assert!(is_pandoc_code_class_attr("{.python startFrom=\"10\"}"));
assert!(is_pandoc_code_class_attr("{#snippet .python startFrom=\"10\"}"));
assert!(is_pandoc_code_class_attr("{.objective-c}"));
assert!(is_pandoc_code_class_attr("{.my_lang}"));
assert!(!is_pandoc_code_class_attr("{}"));
assert!(!is_pandoc_code_class_attr("{#myid}"));
assert!(!is_pandoc_code_class_attr("{startFrom=\"10\"}"));
assert!(!is_pandoc_code_class_attr("{=html}"));
assert!(!is_pandoc_code_class_attr("{r}"));
assert!(!is_pandoc_code_class_attr("{python}"));
assert!(!is_pandoc_code_class_attr("{.}"));
assert!(!is_pandoc_code_class_attr(".python"));
assert!(!is_pandoc_code_class_attr("python"));
}
}