From 294963b5173fdee0424a44ef411b528f2923118c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Sun, 10 May 2026 17:58:41 +0000 Subject: [PATCH] phase 4: vimwiki parser + SyntaxPlugin glue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 8 +- SPEC.md | 4 +- crates/nuwiki-core/src/syntax/vimwiki/mod.rs | 38 +- .../nuwiki-core/src/syntax/vimwiki/parser.rs | 1294 +++++++++++++++++ crates/nuwiki-core/tests/vimwiki_parser.rs | 590 ++++++++ 5 files changed, 1925 insertions(+), 9 deletions(-) create mode 100644 crates/nuwiki-core/src/syntax/vimwiki/parser.rs create mode 100644 crates/nuwiki-core/tests/vimwiki_parser.rs diff --git a/README.md b/README.md index 4b14088..0a5da30 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a Rust-based language server (LSP). -> **Status:** Phase 3 (vimwiki lexer) complete. Parser, renderer, and editor -> glue still pending — not yet functional end-to-end. +> **Status:** Phase 4 (vimwiki parser) complete. End-to-end source → AST works +> via `VimwikiSyntax`. Renderer and editor glue still pending. See [`SPEC.md`](./SPEC.md) for the full project specification. @@ -16,8 +16,8 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 1 | Core AST | ✅ done | | 2 | Syntax Plugin Interface | ✅ done | | 3 | Vimwiki Lexer | ✅ done | -| 4 | Vimwiki Parser | ⏳ next | -| 5 | Renderer | ⏳ | +| 4 | Vimwiki Parser | ✅ done | +| 5 | Renderer | ⏳ next | | 6 | LSP Foundation | ⏳ | | 7 | Semantic Tokens | ⏳ | | 8 | Navigation | ⏳ | diff --git a/SPEC.md b/SPEC.md index d421f04..69f71a3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,7 +1,7 @@ # nuwiki — Project Specification > Last updated: 2026-05-10 -> Status: Phase 3 (Vimwiki Lexer) complete — moving to Phase 4 (Vimwiki Parser) +> Status: Phase 4 (Vimwiki Parser) complete — moving to Phase 5 (Renderer) --- @@ -61,7 +61,7 @@ nuwiki is a Vim/Neovim plugin that provides full vimwiki syntax support, impleme | Implementation language | Rust | Performance, type safety, single binary distribution, ideal for AST modelling | | Editor integration | LSP over stdio | Works in both Vim and Neovim without a shared scripting language | | LSP server library | `tower-lsp` | Async, tokio-based, higher-level than `lsp-server`; easier to start with | -| Parser library | `winnow` | Successor to `nom`, designed for resilient/partial parsing; strong error recovery | +| Parser library | None — hand-rolled recursive descent | Tokens are span-bearing structs that fit awkwardly into `winnow`/`nom` combinator style; resilience and error recovery are explicit in the parser code. Originally specified as `winnow`; revisited in Phase 4. | | Cargo edition | 2021 | Current standard; required for resolver v2 | | Cargo resolver | v2 | Required for edition 2021 workspaces | | Vim glue layer | VimL (`plugin/nuwiki.vim`, `autoload/`) | Universal entry point for all plugin managers | diff --git a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs index 5dee08b..0667564 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs @@ -1,8 +1,40 @@ //! Vimwiki syntax plugin. -//! -//! Phase 3 lands the lexer; the parser arrives in Phase 4 and the full -//! `SyntaxPlugin` impl follows. pub mod lexer; +pub mod parser; pub use lexer::{VimwikiLexer, VimwikiToken, VimwikiTokenKind}; +pub use parser::VimwikiParser; + +use crate::ast::DocumentNode; +use crate::syntax::{Lexer, Parser, SyntaxPlugin}; + +/// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the +/// registry can hand out a single trait object per SPEC.md §6.3. +#[derive(Debug, Default, Clone)] +pub struct VimwikiSyntax; + +impl VimwikiSyntax { + pub fn new() -> Self { + Self + } +} + +impl SyntaxPlugin for VimwikiSyntax { + fn id(&self) -> &str { + "vimwiki" + } + + fn display_name(&self) -> &str { + "Vimwiki" + } + + fn file_extensions(&self) -> &[&str] { + &[".wiki"] + } + + fn parse(&self, text: &str) -> DocumentNode { + let tokens = VimwikiLexer::new().lex(text); + VimwikiParser::new().parse(tokens) + } +} diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs new file mode 100644 index 0000000..6ffff8c --- /dev/null +++ b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs @@ -0,0 +1,1294 @@ +//! Vimwiki parser. +//! +//! Hand-rolled recursive-descent over `Vec` (SPEC.md §6.7). +//! Resilient: never aborts the document. Anything the dispatcher can't +//! claim becomes a `BlockNode::Error(ErrorNode)` so progress is +//! guaranteed. +//! +//! ## Inline marker precedence +//! +//! Within a text run, the lexer emits delimiter tokens +//! (`BoldDelim` / `ItalicDelim` / `StrikethroughDelim` / +//! `SuperscriptDelim` / `SubscriptDelim`) one at a time. The parser pairs +//! them by scanning forward for the *next* token of the same kind, then +//! recursing on the slice between them: +//! +//! - `*foo*` → Bold +//! - `_foo_` → Italic +//! - `~~foo~~` → Strikethrough +//! - `^foo^` → Superscript +//! - `,,foo,,` → Subscript +//! - `_*foo*_` → Italic(Bold(...)) — semantically bold-italic +//! - `*_foo_*` → Bold(Italic(...)) — semantically bold-italic +//! +//! Unmatched delimiters fall back to literal `Text` nodes so input like +//! `2 * 3 = 6` survives without a closing pair. +//! +//! ## Link target classification +//! +//! `[[ ]]` payloads are inspected to choose between `WikiLinkNode` +//! (the default) and `ExternalLinkNode` (when the target is a URL). The +//! `LinkTarget` enum follows SPEC §9: leading `/` is root-relative, +//! `//` is filesystem-absolute, `wiki:` / `wn.:` are interwiki, +//! `diary:` / `file:` / `local:` are their own kinds, and a trailing `/` +//! marks a directory link. + +use crate::ast::{ + BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode, + DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode, + InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol, + MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode, Span, + StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, TableNode, TableRowNode, + TextNode, TransclusionNode, WikiLinkNode, +}; +use crate::syntax::{Parser, TokenStream}; + +use super::lexer::{VimwikiToken, VimwikiTokenKind as K}; + +#[derive(Debug, Default, Clone)] +pub struct VimwikiParser; + +impl VimwikiParser { + pub fn new() -> Self { + Self + } +} + +impl Parser for VimwikiParser { + type Token = VimwikiToken; + + fn parse(&self, tokens: TokenStream) -> DocumentNode { + let v = tokens.into_vec(); + let mut state = ParseState::new(&v); + state.parse_document() + } +} + +// ===== Cursor ===== + +struct ParseState<'a> { + toks: &'a [VimwikiToken], + pos: usize, +} + +impl<'a> ParseState<'a> { + fn new(toks: &'a [VimwikiToken]) -> Self { + Self { toks, pos: 0 } + } + + fn at_eof(&self) -> bool { + self.pos >= self.toks.len() + } + + fn peek(&self) -> Option<&'a VimwikiToken> { + self.toks.get(self.pos) + } + + fn peek_kind(&self) -> Option<&'a K> { + self.peek().map(|t| &t.kind) + } + + fn advance(&mut self) -> Option<&'a VimwikiToken> { + let t = self.toks.get(self.pos); + if t.is_some() { + self.pos += 1; + } + t + } + + fn skip_blanks(&mut self) { + while matches!(self.peek_kind(), Some(K::Newline) | Some(K::BlankLine)) { + self.pos += 1; + } + } + + fn eat_newline(&mut self) -> bool { + if matches!(self.peek_kind(), Some(K::Newline)) { + self.pos += 1; + true + } else { + false + } + } + + // ===== Document ===== + + fn parse_document(&mut self) -> DocumentNode { + let start_pos = self.toks.first().map(|t| t.span.start).unwrap_or_default(); + let end_pos = self.toks.last().map(|t| t.span.end).unwrap_or(start_pos); + + let mut metadata = PageMetadata::default(); + let mut children = Vec::new(); + + loop { + self.skip_blanks(); + if self.at_eof() { + break; + } + if self.consume_placeholder(&mut metadata) { + continue; + } + let before = self.pos; + children.push(self.parse_block()); + // Resilience: guarantee forward progress. + if self.pos == before { + let span = self.toks.get(self.pos).map(|t| t.span).unwrap_or_default(); + children.push(BlockNode::Error(ErrorNode { + span, + raw: format!("{:?}", self.toks.get(self.pos).map(|t| &t.kind)), + message: "parser made no progress".to_owned(), + })); + self.pos += 1; + } + } + + DocumentNode { + span: Span::new(start_pos, end_pos), + children, + metadata, + } + } + + fn consume_placeholder(&mut self, metadata: &mut PageMetadata) -> bool { + let updated = match self.peek_kind() { + Some(K::PlaceholderTitle(v)) => { + metadata.title = v.clone(); + true + } + Some(K::PlaceholderNohtml) => { + metadata.nohtml = true; + true + } + Some(K::PlaceholderTemplate(v)) => { + metadata.template = v.clone(); + true + } + Some(K::PlaceholderDate(v)) => { + metadata.date = v.clone(); + true + } + _ => false, + }; + if updated { + self.advance(); + self.eat_newline(); + } + updated + } + + // ===== Block dispatch ===== + + fn parse_block(&mut self) -> BlockNode { + let kind = self.peek_kind().expect("parse_block called at EOF"); + match kind { + K::HeadingOpen { .. } => self.parse_heading(), + K::HorizontalRule => self.parse_horizontal_rule(), + K::ListMarker { .. } => self.parse_list(), + K::BlockquoteMarker | K::BlockquoteIndent => self.parse_blockquote(), + K::TableSep | K::TableHeaderRow => self.parse_table(), + K::PreformattedOpen { .. } => self.parse_preformatted(), + K::MathBlockOpen { .. } => self.parse_math_block(), + K::CommentLine(_) => self.parse_single_comment(), + K::CommentMultiOpen => self.parse_multi_comment(), + _ => { + if self.is_definition_start() { + self.parse_definition_list() + } else { + self.parse_paragraph() + } + } + } + } + + // ===== Heading ===== + + fn parse_heading(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let (level, centered) = match &open.kind { + K::HeadingOpen { level, centered } => (*level, *centered), + _ => unreachable!(), + }; + let inline_start = self.pos; + let mut span_end = open.span.end; + while let Some(t) = self.peek() { + match &t.kind { + K::HeadingClose => { + span_end = t.span.end; + let inline = parse_inline_seq(&self.toks[inline_start..self.pos]); + self.advance(); + self.eat_newline(); + return BlockNode::Heading(HeadingNode { + span: Span::new(span_start, span_end), + level, + centered, + children: inline, + }); + } + K::Newline | K::BlankLine => break, + _ => { + self.advance(); + } + } + } + // No closing `=` seen: emit a paragraph from what we collected. + let inline = parse_inline_seq(&self.toks[inline_start..self.pos]); + self.eat_newline(); + BlockNode::Paragraph(ParagraphNode { + span: Span::new(span_start, span_end), + children: inline, + }) + } + + // ===== Horizontal rule ===== + + fn parse_horizontal_rule(&mut self) -> BlockNode { + let t = self.advance().unwrap(); + let span = t.span; + self.eat_newline(); + BlockNode::HorizontalRule(HorizontalRuleNode { span }) + } + + // ===== Comments ===== + + fn parse_single_comment(&mut self) -> BlockNode { + let t = self.advance().unwrap(); + let content = match &t.kind { + K::CommentLine(s) => s.clone(), + _ => unreachable!(), + }; + let span = t.span; + self.eat_newline(); + BlockNode::Comment(CommentNode { span, content }) + } + + fn parse_multi_comment(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let mut span_end = open.span.end; + let mut content = String::new(); + let mut first = true; + while let Some(t) = self.peek() { + match &t.kind { + K::CommentMultiClose => { + span_end = t.span.end; + self.advance(); + self.eat_newline(); + return BlockNode::Comment(CommentNode { + span: Span::new(span_start, span_end), + content, + }); + } + K::CommentMultiLine(s) => { + if !first { + content.push('\n'); + } + content.push_str(s); + first = false; + span_end = t.span.end; + self.advance(); + } + K::Newline => { + self.advance(); + } + _ => { + self.advance(); + } + } + } + // Unterminated: keep what we have. + BlockNode::Comment(CommentNode { + span: Span::new(span_start, span_end), + content, + }) + } + + // ===== Preformatted ===== + + fn parse_preformatted(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let mut span_end = open.span.end; + let language = match &open.kind { + K::PreformattedOpen { language, .. } => language.clone(), + _ => unreachable!(), + }; + let mut content = String::new(); + let mut first = true; + while let Some(t) = self.peek() { + match &t.kind { + K::PreformattedClose => { + span_end = t.span.end; + self.advance(); + self.eat_newline(); + return BlockNode::Preformatted(PreformattedNode { + span: Span::new(span_start, span_end), + content, + language, + }); + } + K::PreformattedLine(s) => { + if !first { + content.push('\n'); + } + content.push_str(s); + first = false; + span_end = t.span.end; + self.advance(); + } + K::Newline => { + self.advance(); + } + _ => { + self.advance(); + } + } + } + BlockNode::Preformatted(PreformattedNode { + span: Span::new(span_start, span_end), + content, + language, + }) + } + + // ===== Math block ===== + + fn parse_math_block(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let mut span_end = open.span.end; + let environment = match &open.kind { + K::MathBlockOpen { environment } => environment.clone(), + _ => unreachable!(), + }; + let mut content = String::new(); + let mut first = true; + while let Some(t) = self.peek() { + match &t.kind { + K::MathBlockClose => { + span_end = t.span.end; + self.advance(); + self.eat_newline(); + return BlockNode::MathBlock(MathBlockNode { + span: Span::new(span_start, span_end), + content, + environment, + }); + } + K::MathBlockLine(s) => { + if !first { + content.push('\n'); + } + content.push_str(s); + first = false; + span_end = t.span.end; + self.advance(); + } + K::Newline => { + self.advance(); + } + _ => { + self.advance(); + } + } + } + BlockNode::MathBlock(MathBlockNode { + span: Span::new(span_start, span_end), + content, + environment, + }) + } + + // ===== List ===== + + fn parse_list(&mut self) -> BlockNode { + let first = self.peek().expect("parse_list at EOF"); + let span_start = first.span.start; + let (base_symbol, base_indent) = match &first.kind { + K::ListMarker { symbol, indent } => (*symbol, *indent), + _ => unreachable!(), + }; + let mut items = Vec::new(); + let mut span_end = span_start; + while let Some(t) = self.peek() { + match &t.kind { + K::ListMarker { indent, .. } if *indent == base_indent => { + let item = self.parse_list_item(); + span_end = item.span.end; + items.push(item); + } + _ => break, + } + } + BlockNode::List(ListNode { + span: Span::new(span_start, span_end), + ordered: matches!( + base_symbol, + ListSymbol::Numeric + | ListSymbol::NumericParen + | ListSymbol::AlphaParen + | ListSymbol::AlphaUpperParen + | ListSymbol::RomanParen + | ListSymbol::RomanUpperParen + ), + symbol: base_symbol, + items, + }) + } + + fn parse_list_item(&mut self) -> ListItemNode { + let marker = self.advance().expect("list item without marker"); + let span_start = marker.span.start; + let mut span_end = marker.span.end; + let (symbol, level) = match &marker.kind { + K::ListMarker { symbol, indent } => (*symbol, *indent as usize), + _ => unreachable!(), + }; + + // Optional Checkbox + let checkbox = if let Some(K::Checkbox(state)) = self.peek_kind() { + let state = *state; + let t = self.advance().unwrap(); + span_end = t.span.end; + Some(state) + } else { + None + }; + + // Inline content until end of line. + let inline_start = self.pos; + while let Some(t) = self.peek() { + match &t.kind { + K::Newline | K::BlankLine => break, + _ => { + span_end = t.span.end; + self.advance(); + } + } + } + let inline_end = self.pos; + let children = parse_inline_seq(&self.toks[inline_start..inline_end]); + self.eat_newline(); + + // Sublist: subsequent ListMarker tokens with deeper indent. + let sublist = if let Some(K::ListMarker { indent, .. }) = self.peek_kind() { + if (*indent as usize) > level { + let block = self.parse_list(); + if let BlockNode::List(node) = block { + span_end = node.span.end; + Some(node) + } else { + None + } + } else { + None + } + } else { + None + }; + + ListItemNode { + span: Span::new(span_start, span_end), + symbol, + level, + checkbox, + children, + sublist, + } + } + + // ===== Blockquote ===== + + fn parse_blockquote(&mut self) -> BlockNode { + let first = self.peek().unwrap(); + let span_start = first.span.start; + let mut span_end = span_start; + let mut lines: Vec> = Vec::new(); + while let Some(t) = self.peek() { + match &t.kind { + K::BlockquoteMarker | K::BlockquoteIndent => { + self.advance(); + let inline_start = self.pos; + while let Some(t2) = self.peek() { + match &t2.kind { + K::Newline | K::BlankLine => break, + _ => { + span_end = t2.span.end; + self.advance(); + } + } + } + let inline_end = self.pos; + let inline = parse_inline_seq(&self.toks[inline_start..inline_end]); + if !inline.is_empty() { + lines.push(inline); + } + self.eat_newline(); + } + _ => break, + } + } + let mut children: Vec = Vec::with_capacity(lines.len()); + for line in lines { + let line_start = line + .first() + .and_then(span_start_of_inline) + .unwrap_or(span_start); + let line_end = line.last().and_then(span_end_of_inline).unwrap_or(span_end); + children.push(BlockNode::Paragraph(ParagraphNode { + span: Span::new(line_start, line_end), + children: line, + })); + } + BlockNode::Blockquote(BlockquoteNode { + span: Span::new(span_start, span_end), + children, + }) + } + + // ===== Table ===== + + fn parse_table(&mut self) -> BlockNode { + let first = self.peek().unwrap(); + let span_start = first.span.start; + let mut span_end = span_start; + let mut rows: Vec = Vec::new(); + let mut next_is_header = false; + let mut has_header = false; + + while let Some(t) = self.peek() { + match &t.kind { + K::TableHeaderRow => { + has_header = true; + if let Some(last) = rows.last_mut() { + last.is_header = true; + } + next_is_header = true; + span_end = t.span.end; + self.advance(); + self.eat_newline(); + } + K::TableSep => { + let row = self.parse_table_row(); + span_end = row.span.end; + rows.push(row); + if next_is_header { + // Header sep applies to the *previous* row, not this one. + next_is_header = false; + } + } + _ => break, + } + } + BlockNode::Table(TableNode { + span: Span::new(span_start, span_end), + rows, + has_header, + }) + } + + fn parse_table_row(&mut self) -> TableRowNode { + // Consume leading TableSep + let first_sep = self.advance().expect("table row without leading |"); + let span_start = first_sep.span.start; + let mut span_end = first_sep.span.end; + let mut cells: Vec = Vec::new(); + + while let Some(t) = self.peek() { + match &t.kind { + K::Newline | K::BlankLine => break, + K::TableSep => { + span_end = t.span.end; + self.advance(); + } + K::TableColSpan => { + let span = t.span; + span_end = span.end; + self.advance(); + cells.push(TableCellNode { + span, + children: Vec::new(), + col_span: true, + row_span: false, + }); + } + K::TableRowSpan => { + let span = t.span; + span_end = span.end; + self.advance(); + cells.push(TableCellNode { + span, + children: Vec::new(), + col_span: false, + row_span: true, + }); + } + _ => { + let cell_start_idx = self.pos; + let cell_start_pos = t.span.start; + let mut cell_end_pos = t.span.end; + while let Some(tt) = self.peek() { + match &tt.kind { + K::TableSep | K::Newline | K::BlankLine => break, + _ => { + cell_end_pos = tt.span.end; + self.advance(); + } + } + } + let inline = parse_inline_seq(&self.toks[cell_start_idx..self.pos]); + span_end = cell_end_pos; + cells.push(TableCellNode { + span: Span::new(cell_start_pos, cell_end_pos), + children: inline, + col_span: false, + row_span: false, + }); + } + } + } + self.eat_newline(); + TableRowNode { + span: Span::new(span_start, span_end), + cells, + is_header: false, + } + } + + // ===== Definition list ===== + + fn is_definition_start(&self) -> bool { + // Walk forward on the current line. If we find a DefinitionTermMarker + // before a Newline/BlankLine, treat as a definition. + let mut i = self.pos; + while let Some(t) = self.toks.get(i) { + match &t.kind { + K::Newline | K::BlankLine => return false, + K::DefinitionTermMarker => return true, + _ => i += 1, + } + } + false + } + + fn parse_definition_list(&mut self) -> BlockNode { + let first = self.peek().unwrap(); + let span_start = first.span.start; + let mut span_end = span_start; + let mut items: Vec = Vec::new(); + while self.is_definition_start() { + let item = self.parse_definition_item(); + span_end = item.span.end; + items.push(item); + } + BlockNode::DefinitionList(DefinitionListNode { + span: Span::new(span_start, span_end), + items, + }) + } + + fn parse_definition_item(&mut self) -> DefinitionItemNode { + let item_start_idx = self.pos; + let item_span_start = self.toks[item_start_idx].span.start; + + // Term: tokens until DefinitionTermMarker + let term_start = self.pos; + while let Some(t) = self.peek() { + match &t.kind { + K::DefinitionTermMarker | K::Newline | K::BlankLine => break, + _ => { + self.advance(); + } + } + } + let term_end = self.pos; + let term_inline = if term_end > term_start { + let v = parse_inline_seq(&self.toks[term_start..term_end]); + if v.is_empty() { + None + } else { + Some(v) + } + } else { + None + }; + + let mut span_end = self + .toks + .get(term_end.saturating_sub(1)) + .map(|t| t.span.end) + .unwrap_or(item_span_start); + + // DefinitionTermMarker + if matches!(self.peek_kind(), Some(K::DefinitionTermMarker)) { + span_end = self.peek().unwrap().span.end; + self.advance(); + } + + // Definition tokens until end of line + let def_start = self.pos; + while let Some(t) = self.peek() { + match &t.kind { + K::Newline | K::BlankLine => break, + _ => { + span_end = t.span.end; + self.advance(); + } + } + } + let def_end = self.pos; + let mut definitions = Vec::new(); + if def_end > def_start { + let v = parse_inline_seq(&self.toks[def_start..def_end]); + if !v.is_empty() { + definitions.push(v); + } + } + self.eat_newline(); + + DefinitionItemNode { + span: Span::new(item_span_start, span_end), + term: term_inline, + definitions, + } + } + + // ===== Paragraph ===== + + fn parse_paragraph(&mut self) -> BlockNode { + let span_start = self.peek().unwrap().span.start; + let inline_start = self.pos; + // `content_end` excludes a trailing newline that just terminates + // the paragraph (instead of joining two content lines), so it + // doesn't leak into `parse_inline_seq` as a stray soft-break " ". + let mut content_end = self.pos; + let mut span_end = span_start; + + loop { + match self.peek_kind() { + None => break, + Some(K::Newline) => { + span_end = self.peek().unwrap().span.end; + self.advance(); + match self.peek_kind() { + None => break, + Some(k) if starts_new_block(k) => break, + // Soft-break: the newline joins two content lines. + // Pull it into the inline range as a space. + _ => content_end = self.pos, + } + } + Some(k) if starts_new_block(k) => break, + Some(_) => { + span_end = self.peek().unwrap().span.end; + self.advance(); + content_end = self.pos; + } + } + } + + let children = parse_inline_seq(&self.toks[inline_start..content_end]); + BlockNode::Paragraph(ParagraphNode { + span: Span::new(span_start, span_end), + children, + }) + } +} + +fn starts_new_block(kind: &K) -> bool { + matches!( + kind, + K::BlankLine + | K::HeadingOpen { .. } + | K::HorizontalRule + | K::ListMarker { .. } + | K::BlockquoteMarker + | K::BlockquoteIndent + | K::TableSep + | K::TableHeaderRow + | K::PreformattedOpen { .. } + | K::MathBlockOpen { .. } + | K::CommentLine(_) + | K::CommentMultiOpen + | K::PlaceholderTitle(_) + | K::PlaceholderNohtml + | K::PlaceholderTemplate(_) + | K::PlaceholderDate(_) + ) +} + +// ===== Inline pairing ===== + +fn parse_inline_seq(toks: &[VimwikiToken]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < toks.len() { + let token = &toks[i]; + match &token.kind { + K::Text(s) => { + out.push(InlineNode::Text(TextNode { + span: token.span, + content: s.clone(), + })); + i += 1; + } + K::Code(s) => { + out.push(InlineNode::Code(CodeNode { + span: token.span, + content: s.clone(), + })); + i += 1; + } + K::MathInline(s) => { + out.push(InlineNode::MathInline(MathInlineNode { + span: token.span, + content: s.clone(), + })); + i += 1; + } + K::Keyword(k) => { + out.push(InlineNode::Keyword(KeywordNode { + span: token.span, + keyword: *k, + })); + i += 1; + } + K::RawUrl(u) => { + out.push(InlineNode::RawUrl(RawUrlNode { + span: token.span, + url: u.clone(), + })); + i += 1; + } + K::Newline => { + // Soft break inside a multi-line block: render as space. + out.push(InlineNode::Text(TextNode { + span: token.span, + content: " ".into(), + })); + i += 1; + } + K::BoldDelim => { + i = pair_or_text(toks, i, "*", &mut out, K::BoldDelim, |span, kids| { + InlineNode::Bold(BoldNode { + span, + children: kids, + }) + }) + } + K::ItalicDelim => { + i = pair_or_text(toks, i, "_", &mut out, K::ItalicDelim, |span, kids| { + InlineNode::Italic(ItalicNode { + span, + children: kids, + }) + }) + } + K::StrikethroughDelim => { + i = pair_or_text( + toks, + i, + "~~", + &mut out, + K::StrikethroughDelim, + |span, kids| { + InlineNode::Strikethrough(StrikethroughNode { + span, + children: kids, + }) + }, + ) + } + K::SuperscriptDelim => { + i = pair_or_text(toks, i, "^", &mut out, K::SuperscriptDelim, |span, kids| { + InlineNode::Superscript(SuperscriptNode { + span, + children: kids, + }) + }) + } + K::SubscriptDelim => { + i = pair_or_text(toks, i, ",,", &mut out, K::SubscriptDelim, |span, kids| { + InlineNode::Subscript(SubscriptNode { + span, + children: kids, + }) + }) + } + K::WikiLinkOpen => { + let (node, next) = parse_wikilink(toks, i); + out.push(node); + i = next; + } + K::TransclusionOpen => { + let (node, next) = parse_transclusion(toks, i); + out.push(node); + i = next; + } + // Tokens that shouldn't appear in inline context (e.g. dangling + // close delimiters, separators outside their owners): emit as + // literal text so nothing is silently dropped. + K::WikiLinkClose => push_literal(token, "]]", &mut out, &mut i), + K::WikiLinkSep => push_literal(token, "|", &mut out, &mut i), + K::TransclusionClose => push_literal(token, "}}", &mut out, &mut i), + K::TransclusionSep => push_literal(token, "|", &mut out, &mut i), + K::HeadingClose => push_literal(token, "=", &mut out, &mut i), + // Other tokens (block markers, etc.) should not appear here; skip. + _ => { + i += 1; + } + } + } + out +} + +fn push_literal(token: &VimwikiToken, lit: &str, out: &mut Vec, i: &mut usize) { + out.push(InlineNode::Text(TextNode { + span: token.span, + content: lit.into(), + })); + *i += 1; +} + +fn pair_or_text( + toks: &[VimwikiToken], + open_idx: usize, + literal: &str, + out: &mut Vec, + target: K, + build: F, +) -> usize +where + F: FnOnce(Span, Vec) -> InlineNode, +{ + if let Some(close_idx) = find_close(toks, open_idx, &target) { + let inner = parse_inline_seq(&toks[open_idx + 1..close_idx]); + let span = Span::new(toks[open_idx].span.start, toks[close_idx].span.end); + out.push(build(span, inner)); + close_idx + 1 + } else { + out.push(InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: literal.into(), + })); + open_idx + 1 + } +} + +fn find_close(toks: &[VimwikiToken], start: usize, target: &K) -> Option { + for (offset, t) in toks.iter().enumerate().skip(start + 1) { + if &t.kind == target { + return Some(offset); + } + } + None +} + +// ===== Wikilink + transclusion ===== + +fn parse_wikilink(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { + let span_start = toks[open_idx].span.start; + let mut i = open_idx + 1; + let target_start = i; + let mut target_end = i; + let mut sep_idx: Option = None; + let mut close_idx: Option = None; + while i < toks.len() { + match &toks[i].kind { + K::WikiLinkClose => { + close_idx = Some(i); + break; + } + K::WikiLinkSep if sep_idx.is_none() => { + sep_idx = Some(i); + target_end = i; + } + _ => {} + } + i += 1; + } + let close_idx = match close_idx { + Some(c) => c, + None => { + // Unterminated link: emit literal `[[`. + return ( + InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: "[[".into(), + }), + open_idx + 1, + ); + } + }; + + if sep_idx.is_none() { + target_end = close_idx; + } + + let target_text = collect_text(&toks[target_start..target_end]); + let span = Span::new(span_start, toks[close_idx].span.end); + + let description: Option> = sep_idx.map(|sep| { + let inner = parse_inline_seq(&toks[sep + 1..close_idx]); + if inner.is_empty() { + vec![] + } else { + inner + } + }); + + if is_url(&target_text) { + ( + InlineNode::ExternalLink(ExternalLinkNode { + span, + url: target_text, + description, + }), + close_idx + 1, + ) + } else { + let target = parse_link_target(&target_text); + ( + InlineNode::WikiLink(WikiLinkNode { + span, + target, + description, + }), + close_idx + 1, + ) + } +} + +fn parse_transclusion(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { + let span_start = toks[open_idx].span.start; + let mut i = open_idx + 1; + let url_start = i; + let mut url_end = i; + let mut seps: Vec = Vec::new(); + let mut close_idx: Option = None; + while i < toks.len() { + match &toks[i].kind { + K::TransclusionClose => { + close_idx = Some(i); + break; + } + K::TransclusionSep => { + if seps.is_empty() { + url_end = i; + } + seps.push(i); + } + _ => {} + } + i += 1; + } + let close_idx = match close_idx { + Some(c) => c, + None => { + return ( + InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: "{{".into(), + }), + open_idx + 1, + ); + } + }; + if seps.is_empty() { + url_end = close_idx; + } + let url = collect_text(&toks[url_start..url_end]); + let span = Span::new(span_start, toks[close_idx].span.end); + + // After URL: optional alt, then key=val attrs. + let mut alt = None; + let mut attrs = std::collections::HashMap::new(); + if !seps.is_empty() { + let alt_start = seps[0] + 1; + let alt_end = if seps.len() >= 2 { seps[1] } else { close_idx }; + let alt_text = collect_text(&toks[alt_start..alt_end]); + if !alt_text.is_empty() { + alt = Some(alt_text); + } + for window in seps.windows(2) { + let s = window[0]; + let e = window[1]; + // attr segment is at [s+1..e]; format key=val + let segment = collect_text(&toks[s + 1..e]); + if let Some(eq) = segment.find('=') { + let k = segment[..eq].trim().to_owned(); + let v = segment[eq + 1..].trim().to_owned(); + if !k.is_empty() { + attrs.insert(k, v); + } + } + } + // Last attribute segment between final sep and close. + if let Some(&last) = seps.last() { + if last + 1 < close_idx && seps.len() >= 2 { + let segment = collect_text(&toks[last + 1..close_idx]); + if let Some(eq) = segment.find('=') { + let k = segment[..eq].trim().to_owned(); + let v = segment[eq + 1..].trim().to_owned(); + if !k.is_empty() { + attrs.insert(k, v); + } + } + } + } + } + + ( + InlineNode::Transclusion(TransclusionNode { + span, + url, + alt, + attrs, + }), + close_idx + 1, + ) +} + +fn collect_text(toks: &[VimwikiToken]) -> String { + let mut out = String::new(); + for t in toks { + if let K::Text(s) = &t.kind { + out.push_str(s); + } + } + out +} + +fn is_url(s: &str) -> bool { + s.starts_with("http://") + || s.starts_with("https://") + || s.starts_with("ftp://") + || s.starts_with("mailto:") + || s.starts_with("file://") +} + +// ===== LinkTarget classification ===== + +fn parse_link_target(text: &str) -> LinkTarget { + let mut target = LinkTarget { + kind: LinkKind::Wiki, + path: None, + wiki_index: None, + wiki_name: None, + anchor: None, + is_absolute: false, + is_directory: false, + }; + + // Split off anchor first. + let (path_part, anchor) = match text.split_once('#') { + Some((p, a)) => (p, Some(a.to_owned())), + None => (text, None), + }; + target.anchor = anchor; + + if path_part.is_empty() { + target.kind = LinkKind::AnchorOnly; + return target; + } + + // `//path` — filesystem-absolute. + if let Some(rest) = path_part.strip_prefix("//") { + target.kind = LinkKind::Local; + target.is_absolute = true; + let (path, is_dir) = strip_directory(rest); + target.is_directory = is_dir; + target.path = Some(path); + return target; + } + + // `/Page` — root-relative wiki link. + if let Some(rest) = path_part.strip_prefix('/') { + target.kind = LinkKind::Wiki; + target.is_absolute = true; + let (path, is_dir) = strip_directory(rest); + target.is_directory = is_dir; + target.path = Some(path); + return target; + } + + // Scheme prefixes. + if let Some(rest) = path_part.strip_prefix("file:") { + target.kind = LinkKind::File; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("local:") { + target.kind = LinkKind::Local; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("diary:") { + target.kind = LinkKind::Diary; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("wn.") { + if let Some((name, page)) = rest.split_once(':') { + target.kind = LinkKind::Interwiki; + target.wiki_name = Some(name.to_owned()); + target.path = Some(page.to_owned()); + return target; + } + } + if let Some((scheme, rest)) = path_part.split_once(':') { + if let Some(num) = scheme.strip_prefix("wiki") { + if let Ok(idx) = num.parse::() { + target.kind = LinkKind::Interwiki; + target.wiki_index = Some(idx); + target.path = Some(rest.to_owned()); + return target; + } + } + } + + // Default: plain wiki link, possibly directory. + let (path, is_dir) = strip_directory(path_part); + target.kind = LinkKind::Wiki; + target.is_directory = is_dir; + target.path = Some(path); + target +} + +fn strip_directory(s: &str) -> (String, bool) { + if let Some(stripped) = s.strip_suffix('/') { + (stripped.to_owned(), true) + } else { + (s.to_owned(), false) + } +} + +// ===== Span helpers ===== + +fn span_start_of_inline(node: &InlineNode) -> Option { + Some(span_of_inline(node).start) +} + +fn span_end_of_inline(node: &InlineNode) -> Option { + Some(span_of_inline(node).end) +} + +fn span_of_inline(node: &InlineNode) -> Span { + match node { + InlineNode::Text(n) => n.span, + InlineNode::Bold(n) => n.span, + InlineNode::Italic(n) => n.span, + InlineNode::BoldItalic(n) => n.span, + InlineNode::Strikethrough(n) => n.span, + InlineNode::Code(n) => n.span, + InlineNode::Superscript(n) => n.span, + InlineNode::Subscript(n) => n.span, + InlineNode::MathInline(n) => n.span, + InlineNode::Keyword(n) => n.span, + InlineNode::Color(n) => n.span, + InlineNode::WikiLink(n) => n.span, + InlineNode::ExternalLink(n) => n.span, + InlineNode::Transclusion(n) => n.span, + InlineNode::RawUrl(n) => n.span, + } +} diff --git a/crates/nuwiki-core/tests/vimwiki_parser.rs b/crates/nuwiki-core/tests/vimwiki_parser.rs new file mode 100644 index 0000000..b1080ff --- /dev/null +++ b/crates/nuwiki-core/tests/vimwiki_parser.rs @@ -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(_))); +}