phase 4: vimwiki parser + SyntaxPlugin glue
Hand-rolled recursive-descent parser over the token stream. Resilient per SPEC §6.7: never aborts the document — anything the dispatcher can't claim becomes BlockNode::Error and the cursor advances, and unterminated wikilinks / transclusions / delimiters fall back to literal text. Block coverage: every variant from §6.4 — Heading (with inline content between markers), Paragraph (with soft-break across single newlines), HorizontalRule, Blockquote (both `>` and 4-space styles), Preformatted, MathBlock, List (with checkbox + indent-driven sublists), DefinitionList, Table (header separator promotes the preceding row, plus colspan / rowspan cells), Comment (single + multiline), and page-level placeholders threaded into PageMetadata. Inline pairing: left-to-right scan with "find next matching delim", recursing on the slice between. `_*x*_` becomes Italic(Bold(...)), mirroring SPEC §6.7 precedence. URLs inside `[[ ]]` route to ExternalLinkNode; everything else to WikiLinkNode with full LinkTarget classification (Wiki / Interwiki numbered + named / Diary / File / Local / AnchorOnly / directory / root-relative / filesystem-absolute). VimwikiSyntax registers as id="vimwiki", extensions=[".wiki"] and chains lexer + parser inside SyntaxPlugin::parse. SPEC §4 updated to record the hand-rolled choice with rationale — span-bearing tokens fit awkwardly into winnow/nom combinator style; resilience and recovery are explicit in code instead. Tests (41 new parser cases) cover every AST construct, inline precedence including nested bold/italic, every LinkKind, transclusion alt + attrs, resilience on unterminated links, and end-to-end SyntaxRegistry dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
//! End-to-end tests for the vimwiki parser. Each test runs source text
|
||||
//! through the lexer + parser and asserts on the resulting AST.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, CheckboxState, InlineNode, Keyword, LinkKind, ListSymbol};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(text: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(text)
|
||||
}
|
||||
|
||||
// ===== Document scaffolding =====
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_empty_document() {
|
||||
let doc = parse("");
|
||||
assert!(doc.children.is_empty());
|
||||
assert_eq!(doc.metadata.title, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_populate_metadata() {
|
||||
let doc = parse("%title My Page\n%nohtml\n%template fancy\n%date 2026-05-10\n");
|
||||
assert_eq!(doc.metadata.title.as_deref(), Some("My Page"));
|
||||
assert!(doc.metadata.nohtml);
|
||||
assert_eq!(doc.metadata.template.as_deref(), Some("fancy"));
|
||||
assert_eq!(doc.metadata.date.as_deref(), Some("2026-05-10"));
|
||||
// No body blocks.
|
||||
assert!(doc.children.is_empty());
|
||||
}
|
||||
|
||||
// ===== Headings =====
|
||||
|
||||
#[test]
|
||||
fn heading_each_level() {
|
||||
for level in 1..=6 {
|
||||
let line = format!(
|
||||
"{} L{} {}\n",
|
||||
"=".repeat(level as usize),
|
||||
level,
|
||||
"=".repeat(level as usize)
|
||||
);
|
||||
let doc = parse(&line);
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading at level {level}");
|
||||
};
|
||||
assert_eq!(h.level, level);
|
||||
assert!(!h.centered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_flag() {
|
||||
let doc = parse(" = title =\n");
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading");
|
||||
};
|
||||
assert!(h.centered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_inline_bold() {
|
||||
let doc = parse("== Hello *world* ==\n");
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading");
|
||||
};
|
||||
// Children: Text("Hello "), Bold(Text("world"))
|
||||
assert_eq!(h.children.len(), 2);
|
||||
assert!(matches!(&h.children[0], InlineNode::Text(t) if t.content == "Hello "));
|
||||
let InlineNode::Bold(b) = &h.children[1] else {
|
||||
panic!("expected Bold");
|
||||
};
|
||||
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "world"));
|
||||
}
|
||||
|
||||
// ===== Paragraphs =====
|
||||
|
||||
#[test]
|
||||
fn paragraph_simple_text() {
|
||||
let doc = parse("Hello world\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
assert_eq!(p.children.len(), 1);
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_spans_multiple_lines() {
|
||||
let doc = parse("Hello\nworld\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// Contents: Text("Hello"), Text(" ") (from soft newline), Text("world")
|
||||
let text_concat: String = p
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
InlineNode::Text(t) => Some(t.content.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text_concat, "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_breaks_paragraph() {
|
||||
let doc = parse("first\n\nsecond\n");
|
||||
assert_eq!(doc.children.len(), 2);
|
||||
assert!(matches!(doc.children[0], BlockNode::Paragraph(_)));
|
||||
assert!(matches!(doc.children[1], BlockNode::Paragraph(_)));
|
||||
}
|
||||
|
||||
// ===== Horizontal rule =====
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule() {
|
||||
let doc = parse("----\n");
|
||||
assert!(matches!(doc.children[0], BlockNode::HorizontalRule(_)));
|
||||
}
|
||||
|
||||
// ===== Lists =====
|
||||
|
||||
#[test]
|
||||
fn flat_unordered_list() {
|
||||
let doc = parse("- one\n- two\n- three\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
assert!(!list.ordered);
|
||||
assert_eq!(list.symbol, ListSymbol::Dash);
|
||||
assert_eq!(list.items.len(), 3);
|
||||
for (item, expected) in list.items.iter().zip(["one", "two", "three"]) {
|
||||
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_list_each_marker_kind() {
|
||||
let cases: &[(&str, ListSymbol, bool)] = &[
|
||||
("- a\n", ListSymbol::Dash, false),
|
||||
("* a\n", ListSymbol::Star, false),
|
||||
("# a\n", ListSymbol::Hash, false),
|
||||
("1. a\n", ListSymbol::Numeric, true),
|
||||
("1) a\n", ListSymbol::NumericParen, true),
|
||||
("a) a\n", ListSymbol::AlphaParen, true),
|
||||
("A) a\n", ListSymbol::AlphaUpperParen, true),
|
||||
("i) a\n", ListSymbol::RomanParen, true),
|
||||
("I) a\n", ListSymbol::RomanUpperParen, true),
|
||||
];
|
||||
for (src, sym, ordered) in cases {
|
||||
let doc = parse(src);
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list for {src:?}");
|
||||
};
|
||||
assert_eq!(list.symbol, *sym);
|
||||
assert_eq!(list.ordered, *ordered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_list_via_indent() {
|
||||
let doc = parse("- top\n - nested\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
let item = &list.items[0];
|
||||
let sublist = item.sublist.as_ref().expect("sublist on nested item");
|
||||
assert_eq!(sublist.items.len(), 1);
|
||||
assert!(matches!(&sublist.items[0].children[0], InlineNode::Text(t) if t.content == "nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_item_with_checkbox() {
|
||||
let doc = parse("- [X] done\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
let item = &list.items[0];
|
||||
assert_eq!(item.checkbox, Some(CheckboxState::Done));
|
||||
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == "done"));
|
||||
}
|
||||
|
||||
// ===== Blockquotes =====
|
||||
|
||||
#[test]
|
||||
fn angle_blockquote_two_lines() {
|
||||
let doc = parse("> first\n> second\n");
|
||||
let BlockNode::Blockquote(bq) = &doc.children[0] else {
|
||||
panic!("expected blockquote");
|
||||
};
|
||||
assert_eq!(bq.children.len(), 2);
|
||||
for (i, expected) in ["first", "second"].iter().enumerate() {
|
||||
let BlockNode::Paragraph(p) = &bq.children[i] else {
|
||||
panic!("blockquote child should be a paragraph");
|
||||
};
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == *expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_blockquote() {
|
||||
let doc = parse(" quoted\n");
|
||||
let BlockNode::Blockquote(bq) = &doc.children[0] else {
|
||||
panic!("expected blockquote");
|
||||
};
|
||||
assert_eq!(bq.children.len(), 1);
|
||||
}
|
||||
|
||||
// ===== Tables =====
|
||||
|
||||
#[test]
|
||||
fn simple_table_row() {
|
||||
let doc = parse("| a | b |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
let row = &t.rows[0];
|
||||
assert_eq!(row.cells.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_with_header_separator() {
|
||||
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
assert!(t.has_header);
|
||||
assert!(t.rows[0].is_header);
|
||||
assert!(!t.rows[1].is_header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_col_and_row_span_cells() {
|
||||
let doc = parse("| a | > | \\/ |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
let cells = &t.rows[0].cells;
|
||||
assert!(!cells[0].col_span && !cells[0].row_span);
|
||||
assert!(cells[1].col_span);
|
||||
assert!(cells[2].row_span);
|
||||
}
|
||||
|
||||
// ===== Definition list =====
|
||||
|
||||
#[test]
|
||||
fn definition_term_with_inline_definition() {
|
||||
let doc = parse("Term:: Definition\n");
|
||||
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
|
||||
panic!("expected definition list");
|
||||
};
|
||||
assert_eq!(dl.items.len(), 1);
|
||||
let item = &dl.items[0];
|
||||
let term = item.term.as_ref().expect("term");
|
||||
assert!(matches!(&term[0], InlineNode::Text(t) if t.content == "Term"));
|
||||
assert_eq!(item.definitions.len(), 1);
|
||||
let def = &item.definitions[0];
|
||||
assert!(matches!(&def[0], InlineNode::Text(t) if t.content == "Definition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_definition_items() {
|
||||
let doc = parse("A:: 1\nB:: 2\n");
|
||||
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
|
||||
panic!("expected definition list");
|
||||
};
|
||||
assert_eq!(dl.items.len(), 2);
|
||||
}
|
||||
|
||||
// ===== Preformatted / Math =====
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_captures_content_and_language() {
|
||||
let doc = parse("{{{rust\nfn main() {}\n}}}\n");
|
||||
let BlockNode::Preformatted(p) = &doc.children[0] else {
|
||||
panic!("expected preformatted");
|
||||
};
|
||||
assert_eq!(p.content, "fn main() {}");
|
||||
assert_eq!(p.language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_block_captures_content_and_environment() {
|
||||
let doc = parse("{{$%align%\nx = 1\n}}$\n");
|
||||
let BlockNode::MathBlock(m) = &doc.children[0] else {
|
||||
panic!("expected math block");
|
||||
};
|
||||
assert_eq!(m.content, "x = 1");
|
||||
assert_eq!(m.environment.as_deref(), Some("align"));
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
|
||||
#[test]
|
||||
fn single_line_comment_block() {
|
||||
let doc = parse("%% hidden\n");
|
||||
let BlockNode::Comment(c) = &doc.children[0] else {
|
||||
panic!("expected comment");
|
||||
};
|
||||
assert_eq!(c.content, " hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_comment_block() {
|
||||
let doc = parse("%%+ a\nb\n+%%\n");
|
||||
let BlockNode::Comment(c) = &doc.children[0] else {
|
||||
panic!("expected comment");
|
||||
};
|
||||
assert_eq!(c.content, " a\nb");
|
||||
}
|
||||
|
||||
// ===== Inline =====
|
||||
|
||||
#[test]
|
||||
fn inline_bold_italic_strike_super_sub_code_math() {
|
||||
let doc = parse("*b* _i_ ~~s~~ ^sup^ ,,sub,, `c` $m$\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let kinds: Vec<&str> = p
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| match n {
|
||||
InlineNode::Bold(_) => "Bold",
|
||||
InlineNode::Italic(_) => "Italic",
|
||||
InlineNode::Strikethrough(_) => "Strike",
|
||||
InlineNode::Superscript(_) => "Sup",
|
||||
InlineNode::Subscript(_) => "Sub",
|
||||
InlineNode::Code(_) => "Code",
|
||||
InlineNode::MathInline(_) => "Math",
|
||||
InlineNode::Text(_) => "Text",
|
||||
_ => "Other",
|
||||
})
|
||||
.collect();
|
||||
assert!(kinds.contains(&"Bold"));
|
||||
assert!(kinds.contains(&"Italic"));
|
||||
assert!(kinds.contains(&"Strike"));
|
||||
assert!(kinds.contains(&"Sup"));
|
||||
assert!(kinds.contains(&"Sub"));
|
||||
assert!(kinds.contains(&"Code"));
|
||||
assert!(kinds.contains(&"Math"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_bold_falls_back_to_text() {
|
||||
let doc = parse("2 * 3 = 6\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// No Bold node should appear; the orphan `*` becomes literal text.
|
||||
assert!(!p.children.iter().any(|n| matches!(n, InlineNode::Bold(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_italic_around_bold() {
|
||||
let doc = parse("_*x*_\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Italic(it) = &p.children[0] else {
|
||||
panic!("expected italic outer");
|
||||
};
|
||||
let InlineNode::Bold(b) = &it.children[0] else {
|
||||
panic!("expected bold inside italic");
|
||||
};
|
||||
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords_become_keyword_nodes() {
|
||||
let cases = [
|
||||
("TODO", Keyword::Todo),
|
||||
("DONE", Keyword::Done),
|
||||
("STARTED", Keyword::Started),
|
||||
("FIXME", Keyword::Fixme),
|
||||
("FIXED", Keyword::Fixed),
|
||||
("XXX", Keyword::Xxx),
|
||||
];
|
||||
for (src, expected) in cases {
|
||||
let doc = parse(&format!("{src} stuff\n"));
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Keyword(k) = &p.children[0] else {
|
||||
panic!("expected keyword for {src}");
|
||||
};
|
||||
assert_eq!(k.keyword, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_inline() {
|
||||
let doc = parse("see https://example.com here\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
assert!(p
|
||||
.children
|
||||
.iter()
|
||||
.any(|n| matches!(n, InlineNode::RawUrl(u) if u.url == "https://example.com")));
|
||||
}
|
||||
|
||||
// ===== Wikilinks / external links / transclusions =====
|
||||
|
||||
#[test]
|
||||
fn bare_wikilink() {
|
||||
let doc = parse("[[Page]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.kind, LinkKind::Wiki);
|
||||
assert_eq!(w.target.path.as_deref(), Some("Page"));
|
||||
assert!(w.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_anchor() {
|
||||
let doc = parse("[[Page#Section]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.path.as_deref(), Some("Page"));
|
||||
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_wikilink() {
|
||||
let doc = parse("[[#Section]]\n");
|
||||
let InlineNode::WikiLink(w) = &if let BlockNode::Paragraph(p) = &doc.children[0] {
|
||||
p
|
||||
} else {
|
||||
panic!()
|
||||
}
|
||||
.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.kind, LinkKind::AnchorOnly);
|
||||
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_relative_and_filesystem_absolute_wikilinks() {
|
||||
let doc = parse("[[/Page]] [[//etc/hosts]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let mut links = p.children.iter().filter_map(|n| match n {
|
||||
InlineNode::WikiLink(w) => Some(w),
|
||||
_ => None,
|
||||
});
|
||||
let root = links.next().expect("root-relative");
|
||||
assert_eq!(root.target.kind, LinkKind::Wiki);
|
||||
assert!(root.target.is_absolute);
|
||||
assert_eq!(root.target.path.as_deref(), Some("Page"));
|
||||
|
||||
let local = links.next().expect("filesystem-absolute");
|
||||
assert_eq!(local.target.kind, LinkKind::Local);
|
||||
assert!(local.target.is_absolute);
|
||||
assert_eq!(local.target.path.as_deref(), Some("etc/hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_wikilink() {
|
||||
let doc = parse("[[dir/]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert!(w.target.is_directory);
|
||||
assert_eq!(w.target.path.as_deref(), Some("dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_links_numbered_and_named() {
|
||||
let doc = parse("[[wiki1:Page]] [[wn.Notes:Page]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let mut links = p.children.iter().filter_map(|n| match n {
|
||||
InlineNode::WikiLink(w) => Some(w),
|
||||
_ => None,
|
||||
});
|
||||
let numbered = links.next().expect("numbered");
|
||||
assert_eq!(numbered.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(numbered.target.wiki_index, Some(1));
|
||||
assert_eq!(numbered.target.path.as_deref(), Some("Page"));
|
||||
|
||||
let named = links.next().expect("named");
|
||||
assert_eq!(named.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(named.target.wiki_name.as_deref(), Some("Notes"));
|
||||
assert_eq!(named.target.path.as_deref(), Some("Page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_and_file_and_local_kinds() {
|
||||
for (src, expected) in [
|
||||
("[[diary:2026-05-10]]", LinkKind::Diary),
|
||||
("[[file:/tmp/note.txt]]", LinkKind::File),
|
||||
("[[local:rel/path]]", LinkKind::Local),
|
||||
] {
|
||||
let doc = parse(&format!("{src}\n"));
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink for {src}");
|
||||
};
|
||||
assert_eq!(w.target.kind, expected, "src {src}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_inside_brackets_is_external_link() {
|
||||
let doc = parse("[[https://example.com|docs]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::ExternalLink(e) = &p.children[0] else {
|
||||
panic!("expected external link");
|
||||
};
|
||||
assert_eq!(e.url, "https://example.com");
|
||||
let desc = e.description.as_ref().expect("description");
|
||||
assert!(matches!(&desc[0], InlineNode::Text(t) if t.content == "docs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt() {
|
||||
let doc = parse("{{img.png|alt text}}\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Transclusion(t) = &p.children[0] else {
|
||||
panic!("expected transclusion");
|
||||
};
|
||||
assert_eq!(t.url, "img.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("alt text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt_and_attrs() {
|
||||
let doc = parse("{{img.png|alt|class=hi|width=200}}\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Transclusion(t) = &p.children[0] else {
|
||||
panic!("expected transclusion");
|
||||
};
|
||||
assert_eq!(t.url, "img.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("alt"));
|
||||
assert_eq!(t.attrs.get("class").map(String::as_str), Some("hi"));
|
||||
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
|
||||
}
|
||||
|
||||
// ===== Resilience =====
|
||||
|
||||
#[test]
|
||||
fn unterminated_wikilink_falls_back_to_text() {
|
||||
let doc = parse("[[Unterminated\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// First child should be literal "[[" text.
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "[["));
|
||||
}
|
||||
|
||||
// ===== End-to-end through the registry =====
|
||||
|
||||
#[test]
|
||||
fn vimwiki_syntax_registers_and_dispatches() {
|
||||
use nuwiki_core::syntax::SyntaxRegistry;
|
||||
|
||||
let mut reg = SyntaxRegistry::new();
|
||||
reg.register(VimwikiSyntax::new());
|
||||
|
||||
let plugin = reg.detect_from_extension(".wiki").expect("plugin by ext");
|
||||
assert_eq!(plugin.id(), "vimwiki");
|
||||
|
||||
let doc = plugin.parse("= Hello =\n");
|
||||
assert!(matches!(doc.children[0], BlockNode::Heading(_)));
|
||||
}
|
||||
Reference in New Issue
Block a user