Files
nuwiki-rs/crates/parser.rs
T
gffranco 93312d4a23
CI / cargo clippy (push) Successful in 40s
CI / cargo fmt --check (push) Successful in 53s
CI / cargo test (push) Successful in 55s
sync: bring Rust crates in line with nuwiki main (v0.4.0+release bumps)
2026-06-24 00:35:51 +00:00

1437 lines
47 KiB
Rust

//! Vimwiki parser.
//!
//! Hand-rolled recursive-descent over `Vec<VimwikiToken>`.
//! 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 the vimwiki conventions: leading `/` is root-relative,
//! `//` is filesystem-absolute, `wiki<N>:` / `wn.<Name>:` 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,
SoftBreakNode, 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<VimwikiToken>) -> DocumentNode {
let v = tokens.into_vec();
let mut state = ParseState::new(&v);
state.parse_document()
}
}
// ===== Cursor =====
struct ParseState<'a> {
toks: &'a [VimwikiToken],
pos: usize,
/// Count of `HeadingNode`s already pushed to `children`.
/// `headings_seen - 1` is the most recent heading's index.
headings_seen: usize,
/// Source line of the most recently-emitted heading.
/// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag.
last_heading_line: Option<u32>,
}
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();
// 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.
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, attrs) = match &open.kind {
K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.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,
attrs,
});
}
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,
attrs,
})
}
// ===== 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 mut children = parse_inline_seq(&self.toks[inline_start..inline_end]);
self.eat_newline();
// Continuation lines (vimwiki parity): lines indented strictly past
// the marker's column attach to this item rather than becoming a
// sibling paragraph. The lexer doesn't model list state, so we
// detect this here by inspecting whatever the next line opens
// with: either a `Text(s)` whose leading whitespace exceeds the
// marker's column, or a `BlockquoteIndent` token (the lexer emits
// it for 4+ leading spaces, swallowing them from the following
// Text). We accept either as a continuation when the implied
// indent is `> level`.
let continuation_threshold = level + 1;
loop {
let Some(first) = self.peek() else { break };
let mut consume_first = false;
let is_continuation = match &first.kind {
K::Text(s) => leading_ws_count(s) >= continuation_threshold,
K::BlockquoteIndent => {
// `BlockquoteIndent`'s span covers the leading-whitespace
// run on the current line. Width ≥ threshold means this
// is a continuation, not a blockquote.
let width = (first.span.end.column - first.span.start.column) as usize;
if width >= continuation_threshold {
consume_first = true;
true
} else {
false
}
}
_ => false,
};
if !is_continuation {
break;
}
// Eat the synthetic BlockquoteIndent if present — the lexer
// already stripped the whitespace from the following Text so
// we don't want to also emit a leading space ourselves.
if consume_first {
self.advance();
}
let cont_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 cont_end = self.pos;
// Drop the leading whitespace on the first text token (only
// present when we came via the Text branch — the
// BlockquoteIndent path already stripped it).
let mut buf: Vec<VimwikiToken> = self.toks[cont_start..cont_end].to_vec();
if !consume_first {
if let Some(t0) = buf.first_mut() {
if let K::Text(text) = &t0.kind {
let trimmed = text.trim_start().to_owned();
t0.kind = K::Text(trimmed);
}
}
}
// Insert a soft break between the previous content and the
// continuation so the rendered text flows naturally (and honours
// `*_ignore_newline`).
if !children.is_empty() {
children.push(InlineNode::SoftBreak(SoftBreakNode { span: first.span }));
}
let cont = parse_inline_seq(&buf);
children.extend(cont);
if !self.eat_newline() {
break;
}
}
// 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<InlineNode>> = 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<BlockNode> = 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<TableRowNode> = Vec::new();
let mut next_is_header = false;
let mut has_header = false;
let mut alignments: Vec<crate::ast::TableAlign> = Vec::new();
while let Some(t) = self.peek() {
match &t.kind {
K::TableHeaderRow(aligns) => {
has_header = true;
if alignments.is_empty() {
alignments = aligns.clone();
}
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 {
next_is_header = false;
}
}
_ => break,
}
}
BlockNode::Table(TableNode {
span: Span::new(span_start, span_end),
rows,
has_header,
alignments,
})
}
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<TableCellNode> = 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<DefinitionItemNode> = 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<InlineNode> {
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. A dedicated node lets
// the renderer honour `*_ignore_newline` (space vs `<br />`);
// other consumers treat it as whitespace.
out.push(InlineNode::SoftBreak(SoftBreakNode { span: token.span }));
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<InlineNode>, i: &mut usize) {
out.push(InlineNode::Text(TextNode {
span: token.span,
content: lit.into(),
}));
*i += 1;
}
fn pair_or_text<F>(
toks: &[VimwikiToken],
open_idx: usize,
literal: &str,
out: &mut Vec<InlineNode>,
target: K,
build: F,
) -> usize
where
F: FnOnce(Span, Vec<InlineNode>) -> 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<usize> {
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<usize> = None;
let mut close_idx: Option<usize> = 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<Vec<InlineNode>> = 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<usize> = Vec::new();
let mut close_idx: Option<usize> = 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];
let segment = collect_text(&toks[s + 1..e]);
insert_attr(&segment, &mut attrs);
}
// 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]);
insert_attr(&segment, &mut attrs);
}
}
}
(
InlineNode::Transclusion(TransclusionNode {
span,
url,
alt,
attrs,
}),
close_idx + 1,
)
}
/// Count leading space / tab characters at the start of `s`. Used by
/// `parse_list_item` to detect continuation lines.
fn leading_ws_count(s: &str) -> usize {
s.chars().take_while(|c| *c == ' ' || *c == '\t').count()
}
/// Split a `key=value` attribute segment, strip surrounding quotes from
/// the value, and insert into `attrs`. Empty keys are skipped.
fn insert_attr(segment: &str, attrs: &mut std::collections::HashMap<String, String>) {
let Some(eq) = segment.find('=') else { return };
let k = segment[..eq].trim().to_owned();
if k.is_empty() {
return;
}
let raw = segment[eq + 1..].trim();
let v = strip_quotes(raw).to_owned();
attrs.insert(k, v);
}
fn strip_quotes(s: &str) -> &str {
let b = s.as_bytes();
if b.len() >= 2
&& ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
{
&s[1..s.len() - 1]
} else {
s
}
}
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::<usize>() {
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<crate::ast::Position> {
Some(node.span().start)
}
fn span_end_of_inline(node: &InlineNode) -> Option<crate::ast::Position> {
Some(node.span().end)
}