use tftio_org::ast::{
Block, Checkbox, Document, Inline, ListItem, ListType, LogEntry, PlanningEntry, TableCell, Tag,
Timestamp, Title,
};
#[derive(Debug, Clone)]
pub struct ParseError {
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for ParseError {}
pub fn parse_document(input: &str) -> Result<Document, ParseError> {
parse_document_with_residue(input).map(|(doc, _residue)| doc)
}
pub fn parse_document_with_residue(input: &str) -> Result<(Document, Vec<String>), ParseError> {
let lines: Vec<&str> = input.lines().collect();
let mut residue = Vec::new();
let (blocks, _) = parse_blocks(&lines, 0, &mut residue)?;
Ok((Document { blocks }, residue))
}
type ParseResult<T> = Result<(T, usize), ParseError>;
#[allow(
clippy::unnecessary_wraps,
reason = "mirrors the fallible `ParseResult` shape of the sibling `try_parse_*` combinators for uniform composition"
)]
fn parse_blocks(lines: &[&str], pos: usize, residue: &mut Vec<String>) -> ParseResult<Vec<Block>> {
let mut blocks = Vec::new();
let mut i = pos;
while i < lines.len() {
let Some(&line) = lines.get(i) else { break };
if line.is_empty() {
blocks.push(Block::BlankLine);
i += 1;
continue;
}
if let Some(Ok((block, next))) = try_parse_heading(lines, i, residue)
.or_else(|| try_parse_property_drawer(lines, i))
.or_else(|| try_parse_logbook_drawer(lines, i))
.or_else(|| try_parse_src_block(lines, i))
.or_else(|| try_parse_example_block(lines, i))
.or_else(|| try_parse_quote_block(lines, i))
.or_else(|| try_parse_list(lines, i))
.or_else(|| try_parse_table(lines, i))
.or_else(|| try_parse_planning(i, line))
.or_else(|| try_parse_comment(i, line))
.or_else(|| try_parse_keyword(i, line))
.or_else(|| try_parse_horizontal_rule(i, line))
.or_else(|| try_parse_paragraph(lines, i))
{
blocks.push(block);
i = next;
} else {
if !line.is_empty() {
residue.push(line.to_string());
}
i += 1;
}
}
Ok((blocks, i))
}
fn try_parse_heading(
lines: &[&str],
pos: usize,
residue: &mut Vec<String>,
) -> Option<ParseResult<Block>> {
let line = *lines.get(pos)?;
if !line.starts_with('*') {
return None;
}
let raw_level = line.chars().take_while(|c| *c == '*').count();
if raw_level >= line.len() || !line[raw_level..].starts_with(' ') {
return None;
}
let level: u8 = u8::try_from(raw_level.min(255)).unwrap_or(u8::MAX);
let rest = line[raw_level..].trim();
let (title_str, tags) = rest.rfind(" :").map_or((rest, vec![]), |tag_start| {
let tag_part = &rest[tag_start + 1..];
if tag_part.starts_with(':') && tag_part.ends_with(':') && tag_part.len() > 2 {
let title = rest[..tag_start].trim();
let tags: Vec<Tag> = tag_part[1..tag_part.len() - 1]
.split(':')
.filter(|t| !t.is_empty())
.map(|t| Tag(t.to_string()))
.collect();
(title, tags)
} else {
(rest, vec![])
}
});
let title = Title(title_str.to_string());
let mut children = Vec::new();
let mut next = pos + 1;
while next < lines.len() && !lines.get(next).is_some_and(|l| l.starts_with('*')) {
let Some(&line) = lines.get(next) else { break };
if line.is_empty() {
children.push(Block::BlankLine);
next += 1;
continue;
}
let mut consumed = false;
for child_parser in &[
try_parse_property_drawer,
try_parse_logbook_drawer,
try_parse_src_block,
try_parse_example_block,
try_parse_quote_block,
try_parse_list,
try_parse_table,
] {
if let Some(Ok((child_block, new_pos))) = child_parser(lines, next) {
children.push(child_block);
next = new_pos;
consumed = true;
break;
}
}
if !consumed {
if let Some(Ok((para, new_pos))) = try_parse_paragraph(lines, next) {
children.push(para);
next = new_pos;
} else {
if let Some(&child) = lines.get(next)
&& !child.is_empty()
{
residue.push(child.to_string());
}
next += 1;
}
}
}
Some(Ok((
Block::Heading {
level,
title,
tags,
children,
},
next,
)))
}
fn try_parse_property_drawer(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
if lines.get(pos)?.trim() != ":PROPERTIES:" {
return None;
}
let mut entries = Vec::new();
let mut i = pos + 1;
while i < lines.len() {
let Some(line) = lines.get(i).map(|l| l.trim()) else {
break;
};
if line == ":END:" {
return Some(Ok((Block::PropertyDrawer { entries }, i + 1)));
}
if let Some(stripped) = line.strip_prefix(':')
&& let Some(colon_pos) = stripped.find(':')
{
let key = &stripped[..colon_pos];
let value = &stripped[colon_pos + 1..];
entries.push((key.to_string(), value.to_string()));
}
i += 1;
}
Some(Ok((Block::PropertyDrawer { entries }, i)))
}
fn try_parse_logbook_drawer(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
if lines.get(pos)?.trim() != ":LOGBOOK:" {
return None;
}
let mut entries = Vec::new();
let mut i = pos + 1;
while i < lines.len() {
let Some(line) = lines.get(i).map(|l| l.trim()) else {
break;
};
if line == ":END:" {
return Some(Ok((Block::LogbookDrawer { entries }, i + 1)));
}
if let Some(rest) = line.strip_prefix("- ")
&& rest.starts_with('<')
&& let Some(close) = rest.find('>')
{
let ts = &rest[..=close];
let note = rest[close + 1..].trim();
entries.push(LogEntry {
timestamp: Timestamp(ts.to_string()),
note: note.to_string(),
});
}
i += 1;
}
Some(Ok((Block::LogbookDrawer { entries }, i)))
}
fn try_parse_src_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
let line = lines.get(pos)?.trim();
if !line.starts_with("#+begin_src") {
return None;
}
let language = line.strip_prefix("#+begin_src")?.trim().to_string();
let mut i = pos + 1;
let mut content = String::new();
while i < lines.len() {
let Some(&cur) = lines.get(i) else { break };
if cur.trim() == "#+end_src" {
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
return Some(Ok((Block::SrcBlock { language, content }, i + 1)));
}
if !content.is_empty() {
content.push('\n');
}
content.push_str(cur);
i += 1;
}
Some(Ok((Block::SrcBlock { language, content }, i)))
}
fn try_parse_example_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
if lines.get(pos)?.trim() != "#+begin_example" {
return None;
}
let mut i = pos + 1;
let mut content = String::new();
while i < lines.len() {
let Some(&cur) = lines.get(i) else { break };
if cur.trim() == "#+end_example" {
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
return Some(Ok((Block::ExampleBlock { content }, i + 1)));
}
if !content.is_empty() {
content.push('\n');
}
content.push_str(cur);
i += 1;
}
Some(Ok((Block::ExampleBlock { content }, i)))
}
fn try_parse_quote_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
if lines.get(pos)?.trim() != "#+begin_quote" {
return None;
}
let mut i = pos + 1;
let mut child_lines = Vec::new();
while i < lines.len() {
let Some(&cur) = lines.get(i) else { break };
if cur.trim() == "#+end_quote" {
let (children, _) = parse_blocks(&child_lines, 0, &mut Vec::new()).ok()?;
return Some(Ok((Block::QuoteBlock { children }, i + 1)));
}
child_lines.push(cur);
i += 1;
}
None
}
fn try_parse_list(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
let (list_type, first_checkbox, first_rest) = match_bullet(lines.get(pos)?)?;
let mut items: Vec<ListItem> = Vec::new();
let mut cur_checkbox = first_checkbox;
let mut cur_inlines = parse_inlines(first_rest);
let mut i = pos + 1;
while i < lines.len() {
let Some(&line) = lines.get(i) else { break };
if line.is_empty() {
break;
}
if let Some((_lt, checkbox, rest)) = match_bullet(line) {
items.push(ListItem {
content: vec![Block::Paragraph {
inlines: std::mem::take(&mut cur_inlines),
}],
checkbox: cur_checkbox,
});
cur_checkbox = checkbox;
cur_inlines = parse_inlines(rest);
i += 1;
} else if line.starts_with(' ') || line.starts_with('\t') {
cur_inlines.push(Inline::LineBreak);
cur_inlines.extend(parse_inlines(line));
i += 1;
} else {
break;
}
}
items.push(ListItem {
content: vec![Block::Paragraph {
inlines: cur_inlines,
}],
checkbox: cur_checkbox,
});
Some(Ok((Block::List { list_type, items }, i)))
}
fn match_bullet(line: &str) -> Option<(ListType, Checkbox, &str)> {
if let Some(rest) = line.strip_prefix("- ") {
let (checkbox, rest) = strip_checkbox(rest);
return Some((ListType::Unordered, checkbox, rest));
}
let digits = line.chars().take_while(char::is_ascii_digit).count();
if digits > 0
&& let Some(rest) = line[digits..].strip_prefix(". ")
{
let ordinal: u64 = line[..digits].parse().unwrap_or(1);
let (checkbox, rest) = strip_checkbox(rest);
return Some((ListType::Ordered(ordinal), checkbox, rest));
}
None
}
fn strip_checkbox(s: &str) -> (Checkbox, &str) {
s.strip_prefix("[X] ").map_or_else(
|| {
s.strip_prefix("[ ] ")
.map_or((Checkbox::NoCheckbox, s), |r| (Checkbox::Unchecked, r))
},
|r| (Checkbox::Checked, r),
)
}
fn try_parse_table(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
let first = lines.get(pos)?.trim();
if !first.starts_with('|') || !first.ends_with('|') {
return None;
}
let mut rows = Vec::new();
let mut i = pos;
while i < lines.len() {
let Some(line) = lines.get(i).map(|l| l.trim()) else {
break;
};
if line.is_empty() {
break;
}
if line.len() < 2 || !line.starts_with('|') || !line.ends_with('|') {
break;
}
let cells: Vec<TableCell> = line[1..line.len() - 1]
.split('|')
.map(|c| TableCell {
inlines: parse_inlines(c),
})
.collect();
rows.push(cells);
i += 1;
}
if rows.is_empty() {
return None;
}
Some(Ok((Block::Table { rows }, i)))
}
fn try_parse_planning(pos: usize, line: &str) -> Option<ParseResult<Block>> {
let trimmed = line.trim();
if !trimmed.starts_with("SCHEDULED: ")
&& !trimmed.starts_with("DEADLINE: ")
&& !trimmed.starts_with("CLOSED: ")
{
return None;
}
let mut entries = Vec::new();
let mut remaining = trimmed;
while !remaining.is_empty() {
if let Some(rest) = remaining.strip_prefix("SCHEDULED: ")
&& let Some((ts, after)) = extract_timestamp(rest)
{
entries.push(PlanningEntry::Scheduled(Timestamp(ts)));
remaining = after;
continue;
}
if let Some(rest) = remaining.strip_prefix("DEADLINE: ")
&& let Some((ts, after)) = extract_timestamp(rest)
{
entries.push(PlanningEntry::Deadline(Timestamp(ts)));
remaining = after;
continue;
}
if let Some(rest) = remaining.strip_prefix("CLOSED: ")
&& let Some((ts, after)) = extract_timestamp(rest)
{
entries.push(PlanningEntry::Closed(Timestamp(ts)));
remaining = after;
continue;
}
break;
}
if entries.is_empty() {
return None;
}
Some(Ok((Block::Planning { entries }, pos + 1)))
}
fn extract_timestamp(s: &str) -> Option<(String, &str)> {
let s = s.trim();
if !s.starts_with('<') {
return None;
}
let close = s.find('>')?;
let ts = s[..=close].to_string();
Some((ts, s[close + 1..].trim()))
}
fn try_parse_comment(pos: usize, line: &str) -> Option<ParseResult<Block>> {
let trimmed = line.trim();
trimmed.strip_prefix("# ").map(|text| {
Ok((
Block::Comment {
text: text.to_string(),
},
pos + 1,
))
})
}
fn try_parse_keyword(pos: usize, line: &str) -> Option<ParseResult<Block>> {
let rest = line.strip_prefix("#+")?;
let name_len = rest
.find(|c: char| c == ':' || c.is_whitespace())
.unwrap_or(rest.len());
if name_len == 0 || rest.as_bytes().get(name_len) != Some(&b':') {
return None;
}
let name = rest[..name_len].to_string();
let value = rest[name_len + 1..].to_string();
Some(Ok((Block::Keyword { name, value }, pos + 1)))
}
fn try_parse_horizontal_rule(pos: usize, line: &str) -> Option<ParseResult<Block>> {
let trimmed = line.trim();
if trimmed == "-----" {
Some(Ok((Block::HorizontalRule, pos + 1)))
} else {
None
}
}
fn try_parse_paragraph(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
if !is_paragraph_line(lines.get(pos)?) {
return None;
}
let mut inlines = Vec::new();
let mut i = pos;
while let Some(&line) = lines.get(i).filter(|l| is_paragraph_line(l)) {
if i > pos {
inlines.push(Inline::LineBreak);
}
inlines.extend(parse_inlines(line));
i += 1;
}
Some(Ok((Block::Paragraph { inlines }, i)))
}
fn is_paragraph_line(line: &str) -> bool {
if line.is_empty() {
return false;
}
let trimmed = line.trim();
let stars = trimmed.chars().take_while(|c| *c == '*').count();
if stars > 0 && trimmed[stars..].starts_with(' ') {
return false;
}
if trimmed.starts_with("# ")
|| trimmed.starts_with(":PROPERTIES:")
|| trimmed.starts_with(":LOGBOOK:")
|| trimmed.starts_with("#+begin_")
|| trimmed.starts_with("SCHEDULED:")
|| trimmed.starts_with("DEADLINE:")
|| trimmed.starts_with("CLOSED:")
|| trimmed == "-----"
|| (trimmed.starts_with('|') && trimmed.ends_with('|'))
{
return false;
}
if match_bullet(line).is_some() {
return false;
}
if let Some(rest) = trimmed.strip_prefix("#+") {
let name_len = rest
.find(|c: char| c == ':' || c.is_whitespace())
.unwrap_or(rest.len());
if name_len > 0 && rest.as_bytes().get(name_len) == Some(&b':') {
return false;
}
}
true
}
fn parse_inlines(input: &str) -> Vec<Inline> {
let mut inlines = Vec::new();
let mut pos = 0;
let chars: Vec<char> = input.chars().collect();
let slice =
|a: usize, b: usize| -> String { chars.get(a..b).unwrap_or_default().iter().collect() };
let slice_from = |a: usize| -> String { chars.get(a..).unwrap_or_default().iter().collect() };
while pos < chars.len() {
let Some(&c) = chars.get(pos) else { break };
match c {
'*' => {
if let Some(end) = find_closing(&chars, pos + 1, '*') {
let inner = slice(pos + 1, end);
inlines.push(Inline::Bold(parse_inlines(&inner)));
pos = end + 1;
} else {
if let Some(end) = next_marker_or_end(&chars, pos) {
inlines.push(Inline::Plain(slice(pos, end)));
pos = end;
} else {
inlines.push(Inline::Plain(slice_from(pos)));
pos = chars.len();
}
}
}
'/' => {
if let Some(end) = find_closing(&chars, pos + 1, '/') {
let inner = slice(pos + 1, end);
inlines.push(Inline::Italic(parse_inlines(&inner)));
pos = end + 1;
} else if let Some(end) = next_marker_or_end(&chars, pos) {
inlines.push(Inline::Plain(slice(pos, end)));
pos = end;
} else {
inlines.push(Inline::Plain(slice_from(pos)));
pos = chars.len();
}
}
'+' => {
if let Some(end) = find_closing(&chars, pos + 1, '+') {
let inner = slice(pos + 1, end);
inlines.push(Inline::Strikethrough(parse_inlines(&inner)));
pos = end + 1;
} else if let Some(end) = next_marker_or_end(&chars, pos) {
inlines.push(Inline::Plain(slice(pos, end)));
pos = end;
} else {
inlines.push(Inline::Plain(slice_from(pos)));
pos = chars.len();
}
}
'=' => {
if let Some(end) = find_closing(&chars, pos + 1, '=') {
let code = slice(pos + 1, end);
inlines.push(Inline::InlineCode(code));
pos = end + 1;
} else if let Some(end) = next_marker_or_end(&chars, pos) {
inlines.push(Inline::Plain(slice(pos, end)));
pos = end;
} else {
inlines.push(Inline::Plain(slice_from(pos)));
pos = chars.len();
}
}
'~' => {
if let Some(end) = find_closing(&chars, pos + 1, '~') {
let verb = slice(pos + 1, end);
inlines.push(Inline::Verbatim(verb));
pos = end + 1;
} else if let Some(end) = next_marker_or_end(&chars, pos) {
inlines.push(Inline::Plain(slice(pos, end)));
pos = end;
} else {
inlines.push(Inline::Plain(slice_from(pos)));
pos = chars.len();
}
}
'[' => {
let (inline, next) = consume_bracket(&chars, pos);
inlines.push(inline);
pos = next;
}
_ => {
if let Some(end) = next_marker_or_end(&chars, pos) {
inlines.push(Inline::Plain(slice(pos, end)));
pos = end;
} else {
inlines.push(Inline::Plain(slice_from(pos)));
pos = chars.len();
}
}
}
}
merge_adjacent_plain(&mut inlines);
inlines
}
fn consume_bracket(chars: &[char], pos: usize) -> (Inline, usize) {
let collect =
|a: usize, b: usize| -> String { chars.get(a..b).unwrap_or_default().iter().collect() };
let collect_from = |a: usize| -> String { chars.get(a..).unwrap_or_default().iter().collect() };
let plain_to = |end: usize| Inline::Plain(collect(pos, end));
let plain_rest = || Inline::Plain(collect_from(pos));
let literal = || {
next_marker_or_end(chars, pos)
.map_or_else(|| (plain_rest(), chars.len()), |end| (plain_to(end), end))
};
if pos + 1 >= chars.len() || chars.get(pos + 1) != Some(&'[') {
return literal();
}
let start = pos + 2;
let Some(bracket_end) = chars
.get(start..)
.and_then(|rest| rest.iter().position(|&c| c == ']'))
.map(|p| start + p)
else {
return (plain_rest(), chars.len());
};
if bracket_end + 1 < chars.len() && chars.get(bracket_end + 1) == Some(&']') {
let target = collect(start, bracket_end);
return (
Inline::Link {
target,
description: None,
},
bracket_end + 2,
);
}
if bracket_end + 1 >= chars.len() || chars.get(bracket_end + 1) != Some(&'[') {
return literal();
}
let target: String = collect(start, bracket_end);
let desc_start = bracket_end + 2;
match chars
.get(desc_start..)
.and_then(|rest| rest.iter().position(|&c| c == ']'))
.map(|p| desc_start + p)
{
Some(desc_end) if desc_end + 1 < chars.len() && chars.get(desc_end + 1) == Some(&']') => {
let description = collect(desc_start, desc_end);
(
Inline::Link {
target,
description: Some(description),
},
desc_end + 2,
)
}
Some(desc_end) => (plain_to(desc_end + 1), desc_end + 1),
None => (plain_rest(), chars.len()),
}
}
fn find_closing(chars: &[char], start: usize, marker: char) -> Option<usize> {
for i in start..chars.len() {
let Some(&c) = chars.get(i) else { break };
if c == marker && (i + 1 == chars.len() || chars.get(i + 1) != Some(&marker)) {
return Some(i);
}
}
None
}
fn next_marker_or_end(chars: &[char], pos: usize) -> Option<usize> {
for i in pos..chars.len() {
let Some(&c) = chars.get(i) else { break };
if c == '*' || c == '/' || c == '+' || c == '=' || c == '~' || c == '[' {
if i == pos {
continue;
}
return Some(i);
}
if c == '[' && i + 1 < chars.len() && chars.get(i + 1) == Some(&'[') {
if i == pos {
continue;
}
return Some(i);
}
}
None
}
fn merge_adjacent_plain(inlines: &mut Vec<Inline>) {
let mut i = 0;
while i + 1 < inlines.len() {
if let (Some(Inline::Plain(a)), Some(Inline::Plain(b))) =
(inlines.get(i), inlines.get(i + 1))
{
let merged = format!("{a}{b}");
if let Some(slot) = inlines.get_mut(i) {
*slot = Inline::Plain(merged);
}
inlines.remove(i + 1);
} else {
i += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_heading_level_1() {
let doc = parse_document("* Hello\n").unwrap();
assert_eq!(
doc.blocks[0],
Block::Heading {
level: 1,
title: Title("Hello".into()),
tags: vec![],
children: vec![]
}
);
}
#[test]
fn parse_heading_with_tags() {
let doc = parse_document("** Task :rust:kb:\n").unwrap();
if let Block::Heading {
level,
title,
tags,
children: _,
} = &doc.blocks[0]
{
assert_eq!(*level, 2);
assert_eq!(title.0, "Task");
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].0, "rust");
assert_eq!(tags[1].0, "kb");
} else {
panic!("expected heading");
}
}
#[test]
fn parse_paragraph() {
let doc = parse_document("some text\n").unwrap();
assert_eq!(
doc.blocks[0],
Block::Paragraph {
inlines: vec![Inline::Plain("some text".into())]
}
);
}
#[test]
fn parse_bold() {
let doc = parse_document("*bold*\n").unwrap();
if let Block::Paragraph { inlines } = &doc.blocks[0] {
assert_eq!(inlines.len(), 1);
assert_eq!(inlines[0], Inline::Bold(vec![Inline::Plain("bold".into())]));
} else {
panic!("expected paragraph");
}
}
#[test]
fn parse_italic() {
let doc = parse_document("/italic/\n").unwrap();
if let Block::Paragraph { inlines } = &doc.blocks[0] {
assert_eq!(
inlines[0],
Inline::Italic(vec![Inline::Plain("italic".into())])
);
} else {
panic!("expected paragraph");
}
}
#[test]
fn parse_strikethrough() {
let doc = parse_document("+struck+\n").unwrap();
if let Block::Paragraph { inlines } = &doc.blocks[0] {
assert_eq!(
inlines[0],
Inline::Strikethrough(vec![Inline::Plain("struck".into())])
);
} else {
panic!("expected paragraph");
}
}
#[test]
fn parse_link_no_description() {
let doc = parse_document("[[https://example.com]]\n").unwrap();
if let Block::Paragraph { inlines } = &doc.blocks[0] {
assert_eq!(
inlines[0],
Inline::Link {
target: "https://example.com".into(),
description: None,
}
);
} else {
panic!("expected paragraph");
}
}
#[test]
fn parse_link_with_description() {
let doc = parse_document("[[https://example.com][example]]\n").unwrap();
if let Block::Paragraph { inlines } = &doc.blocks[0] {
assert_eq!(
inlines[0],
Inline::Link {
target: "https://example.com".into(),
description: Some("example".into()),
}
);
} else {
panic!("expected paragraph");
}
}
#[test]
fn parse_src_block() {
let input = "#+begin_src rust\nfn main() {}\n#+end_src\n";
let doc = parse_document(input).unwrap();
if let Block::SrcBlock { language, content } = &doc.blocks[0] {
assert_eq!(language, "rust");
assert_eq!(content, "fn main() {}\n");
} else {
panic!("expected src block");
}
}
#[test]
fn parse_example_block() {
let input = "#+begin_example\n$ ls\nfoo\n#+end_example\n";
let doc = parse_document(input).unwrap();
assert_eq!(
doc.blocks[0],
Block::ExampleBlock {
content: "$ ls\nfoo\n".into(),
}
);
}
#[test]
fn parse_property_drawer() {
let input = ":PROPERTIES:\n:ID: abc-123\n:END:\n";
let doc = parse_document(input).unwrap();
if let Block::PropertyDrawer { entries } = &doc.blocks[0] {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, "ID");
assert_eq!(entries[0].1, " abc-123");
} else {
panic!("expected property drawer");
}
}
#[test]
fn parse_list_unordered() {
let input = "- one\n- two\n";
let doc = parse_document(input).unwrap();
if let Block::List { list_type, items } = &doc.blocks[0] {
assert_eq!(*list_type, ListType::Unordered);
assert_eq!(items.len(), 2);
} else {
panic!("expected list");
}
}
#[test]
fn parse_comment() {
let doc = parse_document("# a comment\n").unwrap();
assert_eq!(
doc.blocks[0],
Block::Comment {
text: "a comment".into()
}
);
}
#[test]
fn parse_horizontal_rule() {
let doc = parse_document("-----\n").unwrap();
assert_eq!(doc.blocks[0], Block::HorizontalRule);
}
#[test]
fn parse_single_bracket_run_keeps_trailing_text() {
let doc = parse_document("see [his] notes here\n").unwrap();
assert_eq!(
doc.blocks[0],
Block::Paragraph {
inlines: vec![Inline::Plain("see [his] notes here".into())],
}
);
}
#[test]
fn residue_empty_when_every_line_claimed() {
let input = "* Heading\n\nA paragraph.\n";
let (_, residue) = parse_document_with_residue(input).unwrap();
assert!(
residue.is_empty(),
"no line should be unclaimed: {residue:?}"
);
}
#[test]
fn residue_records_unparsable_block_marker() {
let (_, residue) = parse_document_with_residue("#+begin_verse\n").unwrap();
assert_eq!(residue, vec!["#+begin_verse".to_string()]);
}
#[test]
fn residue_records_unclaimed_line_under_heading() {
let input = "* Heading\n#+begin_verse\n";
let (_, residue) = parse_document_with_residue(input).unwrap();
assert_eq!(residue, vec!["#+begin_verse".to_string()]);
}
#[test]
fn residue_excludes_blank_lines() {
let (_, residue) = parse_document_with_residue("para\n\n\n").unwrap();
assert!(
residue.is_empty(),
"blank lines are not residue: {residue:?}"
);
}
}