//! 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"), SoftBreak (from soft newline), Text("world"). let text_concat: String = p .children .iter() .filter_map(|n| match n { InlineNode::Text(t) => Some(t.content.clone()), InlineNode::SoftBreak(_) => Some(" ".to_string()), _ => 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), ("STOPPED", Keyword::Stopped), ]; 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(_))); }