//! 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, TagNode, TagScope, 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, /// Phase 12: count of `HeadingNode`s already pushed to `children`. /// `headings_seen - 1` is the most recent heading's index. headings_seen: usize, /// Phase 12: source line of the most recently-emitted heading. /// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag. last_heading_line: Option, } impl<'a> ParseState<'a> { fn new(toks: &'a [VimwikiToken]) -> Self { Self { toks, pos: 0, headings_seen: 0, last_heading_line: None, } } 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; let block = self.parse_block(); // Phase 12: file-scope tags get accumulated into metadata so // callers can read page-level tag membership without walking // the AST. The TagNode itself stays in `children` so renderers // see it too. if let BlockNode::Tag(t) = &block { if t.scope == TagScope::File { for name in &t.tags { if !metadata.tags.iter().any(|t| t == name) { metadata.tags.push(name.clone()); } } } } children.push(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(), K::Tag(_) => self.parse_tag(), _ => { if self.is_definition_start() { self.parse_definition_list() } else { self.parse_paragraph() } } } } // ===== Tag ===== fn parse_tag(&mut self) -> BlockNode { let t = self.advance().unwrap(); let names = match &t.kind { K::Tag(v) => v.clone(), _ => unreachable!(), }; let span = t.span; let scope = self.scope_for_tag(span.start.line); self.eat_newline(); BlockNode::Tag(TagNode { span, tags: names, scope, }) } /// Decide the placement-determined scope of a tag at the given source /// line, matching vimwiki's rules: /// - lines 0 or 1 → `File` /// - within two lines after the most recent heading → `Heading(idx)` /// - otherwise → `Standalone` fn scope_for_tag(&self, tag_line: u32) -> TagScope { if tag_line <= 1 { return TagScope::File; } if let Some(h_line) = self.last_heading_line { let delta = tag_line.saturating_sub(h_line); if (1..=2).contains(&delta) && self.headings_seen > 0 { return TagScope::Heading(self.headings_seen - 1); } } TagScope::Standalone } // ===== 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(); // Record for tag-scope resolution (Phase 12). self.headings_seen += 1; self.last_heading_line = Some(span_end.line); 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(_) | K::Tag(_) ) } // ===== 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, } }