feat: initial nuwiki Rust workspace (core, lsp, ls)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "nuwiki-core"
|
||||
description = "Core parser, AST, and renderer for nuwiki — editor-independent."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Block-level AST nodes and their supporting types.
|
||||
|
||||
use super::inline::InlineNode;
|
||||
use super::span::Span;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BlockNode {
|
||||
Heading(HeadingNode),
|
||||
Paragraph(ParagraphNode),
|
||||
HorizontalRule(HorizontalRuleNode),
|
||||
Blockquote(BlockquoteNode),
|
||||
Preformatted(PreformattedNode),
|
||||
MathBlock(MathBlockNode),
|
||||
List(ListNode),
|
||||
DefinitionList(DefinitionListNode),
|
||||
Table(TableNode),
|
||||
Comment(CommentNode),
|
||||
/// Vimwiki tag line — `:tag1:tag2:`. The placement
|
||||
/// rule (file / heading / standalone) is captured in `TagScope` so
|
||||
/// LSP handlers and renderers can treat the three differently.
|
||||
Tag(TagNode),
|
||||
Error(ErrorNode),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HeadingNode {
|
||||
pub span: Span,
|
||||
/// 1..=6 — invariant enforced by the parser, not the type.
|
||||
pub level: u8,
|
||||
pub children: Vec<InlineNode>,
|
||||
pub centered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParagraphNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HorizontalRuleNode {
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BlockquoteNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<BlockNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreformattedNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MathBlockNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
pub environment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListNode {
|
||||
pub span: Span,
|
||||
pub ordered: bool,
|
||||
pub symbol: ListSymbol,
|
||||
pub items: Vec<ListItemNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListItemNode {
|
||||
pub span: Span,
|
||||
pub symbol: ListSymbol,
|
||||
pub level: usize,
|
||||
pub checkbox: Option<CheckboxState>,
|
||||
pub children: Vec<InlineNode>,
|
||||
pub sublist: Option<ListNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ListSymbol {
|
||||
Dash,
|
||||
Star,
|
||||
Hash,
|
||||
Numeric,
|
||||
NumericParen,
|
||||
AlphaParen,
|
||||
AlphaUpperParen,
|
||||
RomanParen,
|
||||
RomanUpperParen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CheckboxState {
|
||||
Empty,
|
||||
Quarter,
|
||||
Half,
|
||||
ThreeQuarters,
|
||||
Done,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DefinitionListNode {
|
||||
pub span: Span,
|
||||
pub items: Vec<DefinitionItemNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DefinitionItemNode {
|
||||
pub span: Span,
|
||||
pub term: Option<Vec<InlineNode>>,
|
||||
pub definitions: Vec<Vec<InlineNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TableAlign {
|
||||
Default,
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
}
|
||||
|
||||
impl Default for TableAlign {
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableNode {
|
||||
pub span: Span,
|
||||
pub rows: Vec<TableRowNode>,
|
||||
pub has_header: bool,
|
||||
/// One entry per column, taken from a Markdown-style header
|
||||
/// separator row (`|:--|--:|:--:|`). Empty when no alignment was
|
||||
/// specified; cells past the end inherit `Default`.
|
||||
pub alignments: Vec<TableAlign>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableRowNode {
|
||||
pub span: Span,
|
||||
pub cells: Vec<TableCellNode>,
|
||||
pub is_header: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableCellNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
pub col_span: bool,
|
||||
pub row_span: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommentNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Where a `TagNode` falls on the page, matching vimwiki's placement rules.
|
||||
///
|
||||
/// - `File` — line 0 or 1 of the document; tags the entire page.
|
||||
/// - `Heading(i)` — within two lines below the i-th `HeadingNode` in the
|
||||
/// document's `children`; tags that heading.
|
||||
/// - `Standalone` — anywhere else; the tag is its own anchor on the source
|
||||
/// line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TagScope {
|
||||
File,
|
||||
Heading(usize),
|
||||
Standalone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TagNode {
|
||||
pub span: Span,
|
||||
pub tags: Vec<String>,
|
||||
pub scope: TagScope,
|
||||
}
|
||||
|
||||
/// Resilient parse-failure node — emitted instead of aborting the document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ErrorNode {
|
||||
pub span: Span,
|
||||
pub raw: String,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Inline AST nodes.
|
||||
//!
|
||||
//! The link-related variants (`WikiLink`, `ExternalLink`,
|
||||
//! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`.
|
||||
|
||||
use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode};
|
||||
use super::span::Span;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Keyword {
|
||||
Todo,
|
||||
Done,
|
||||
Started,
|
||||
Fixme,
|
||||
Fixed,
|
||||
Xxx,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InlineNode {
|
||||
Text(TextNode),
|
||||
Bold(BoldNode),
|
||||
Italic(ItalicNode),
|
||||
BoldItalic(BoldItalicNode),
|
||||
Strikethrough(StrikethroughNode),
|
||||
Code(CodeNode),
|
||||
Superscript(SuperscriptNode),
|
||||
Subscript(SubscriptNode),
|
||||
MathInline(MathInlineNode),
|
||||
Keyword(KeywordNode),
|
||||
Color(ColorNode),
|
||||
WikiLink(WikiLinkNode),
|
||||
ExternalLink(ExternalLinkNode),
|
||||
Transclusion(TransclusionNode),
|
||||
RawUrl(RawUrlNode),
|
||||
/// A soft line break joining two content lines within a paragraph or
|
||||
/// list item. Consumers treat it as whitespace; the HTML renderer emits
|
||||
/// a space (vimwiki `*_ignore_newline = 1`, the default) or `<br />`
|
||||
/// (`= 0`).
|
||||
SoftBreak(SoftBreakNode),
|
||||
}
|
||||
|
||||
impl InlineNode {
|
||||
/// The source [`Span`] this node covers. Every variant wraps a node with
|
||||
/// its own `span` field; this is the one place that maps variant → span.
|
||||
pub fn span(&self) -> Span {
|
||||
match self {
|
||||
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,
|
||||
InlineNode::SoftBreak(n) => n.span,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenate the plain-text content of a run of inlines, descending into
|
||||
/// formatting containers. Links/external links use their description, falling
|
||||
/// back to the target path / URL. This is the canonical heading/anchor text —
|
||||
/// the HTML renderer uses it for heading `id`s and the LSP for anchor matching,
|
||||
/// so the two never drift.
|
||||
pub fn inline_text(inlines: &[InlineNode]) -> String {
|
||||
let mut out = String::new();
|
||||
push_inline_text(inlines, &mut out);
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => push_inline_text(&b.children, out),
|
||||
InlineNode::Italic(i) => push_inline_text(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => push_inline_text(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => push_inline_text(&s.children, out),
|
||||
InlineNode::Code(c) => out.push_str(&c.content),
|
||||
InlineNode::Superscript(s) => push_inline_text(&s.children, out),
|
||||
InlineNode::Subscript(s) => push_inline_text(&s.children, out),
|
||||
InlineNode::Color(c) => push_inline_text(&c.children, out),
|
||||
InlineNode::WikiLink(w) => match &w.description {
|
||||
Some(d) => push_inline_text(d, out),
|
||||
None => {
|
||||
if let Some(p) = &w.target.path {
|
||||
out.push_str(p);
|
||||
}
|
||||
}
|
||||
},
|
||||
InlineNode::ExternalLink(e) => match &e.description {
|
||||
Some(d) => push_inline_text(d, out),
|
||||
None => out.push_str(&e.url),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TextNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SoftBreakNode {
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoldNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ItalicNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoldItalicNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StrikethroughNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodeNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SuperscriptNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MathInlineNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KeywordNode {
|
||||
pub span: Span,
|
||||
pub keyword: Keyword,
|
||||
}
|
||||
|
||||
/// A named colour span (`color` is a `color_dic` key, `children` the wrapped
|
||||
/// inline content).
|
||||
///
|
||||
/// This is an **extension point**, not a node the vimwiki parser emits: in
|
||||
/// the editor, colour comes from the `:Colorize` family writing literal
|
||||
/// `<span style="color:…">` markup, which round-trips through export via
|
||||
/// `valid_html_tags`. `ColorNode` exists so an alternate syntax (or a future
|
||||
/// `[color]` markup) can feed the renderer's `color_dic` / `color_tag_template`
|
||||
/// path directly; see `HtmlRenderer::render_color`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColorNode {
|
||||
pub span: Span,
|
||||
pub color: String,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Link-related AST nodes and supporting types.
|
||||
//!
|
||||
//! All nodes here are inline (`InlineNode` variants);
|
||||
//! they live in their own module because the link model has enough surface
|
||||
//! area (kinds, anchors, interwiki indirection) to warrant separation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::inline::InlineNode;
|
||||
use super::span::Span;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum LinkKind {
|
||||
Wiki,
|
||||
Interwiki,
|
||||
Diary,
|
||||
File,
|
||||
Local,
|
||||
Raw,
|
||||
AnchorOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct LinkTarget {
|
||||
pub kind: LinkKind,
|
||||
pub path: Option<String>,
|
||||
pub wiki_index: Option<usize>,
|
||||
pub wiki_name: Option<String>,
|
||||
pub anchor: Option<String>,
|
||||
pub is_absolute: bool,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
impl Default for LinkKind {
|
||||
fn default() -> Self {
|
||||
Self::Wiki
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WikiLinkNode {
|
||||
pub span: Span,
|
||||
pub target: LinkTarget,
|
||||
pub description: Option<Vec<InlineNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalLinkNode {
|
||||
pub span: Span,
|
||||
pub url: String,
|
||||
pub description: Option<Vec<InlineNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TransclusionNode {
|
||||
pub span: Span,
|
||||
pub url: String,
|
||||
pub alt: Option<String>,
|
||||
pub attrs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RawUrlNode {
|
||||
pub span: Span,
|
||||
pub url: String,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! AST types for nuwiki documents.
|
||||
//!
|
||||
//! Types here are syntax-agnostic — both vimwiki and (eventually) markdown
|
||||
//! produce the same node shapes.
|
||||
|
||||
pub mod block;
|
||||
pub mod inline;
|
||||
pub mod link;
|
||||
pub mod span;
|
||||
pub mod visit;
|
||||
|
||||
pub use block::{
|
||||
BlockNode, BlockquoteNode, CheckboxState, CommentNode, DefinitionItemNode, DefinitionListNode,
|
||||
ErrorNode, HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, ListSymbol, MathBlockNode,
|
||||
ParagraphNode, PreformattedNode, TableAlign, TableCellNode, TableNode, TableRowNode, TagNode,
|
||||
TagScope,
|
||||
};
|
||||
pub use inline::{
|
||||
inline_text, BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword,
|
||||
KeywordNode, MathInlineNode, SoftBreakNode, StrikethroughNode, SubscriptNode, SuperscriptNode,
|
||||
TextNode,
|
||||
};
|
||||
pub use link::{
|
||||
ExternalLinkNode, LinkKind, LinkTarget, RawUrlNode, TransclusionNode, WikiLinkNode,
|
||||
};
|
||||
pub use span::{Position, Span};
|
||||
pub use visit::{
|
||||
walk_block, walk_definition_item, walk_document, walk_inline, walk_list, walk_list_item,
|
||||
walk_table, walk_table_row, Visitor,
|
||||
};
|
||||
|
||||
/// Root of an AST.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DocumentNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<BlockNode>,
|
||||
pub metadata: PageMetadata,
|
||||
}
|
||||
|
||||
/// Document-level placeholders parsed from `%title` / `%nohtml` / `%template`
|
||||
/// / `%date` directives, plus the file-level tag accumulator.
|
||||
///
|
||||
/// New fields are added at the bottom; consumers should construct with
|
||||
/// `..Default::default()` to stay forward-compatible.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PageMetadata {
|
||||
pub title: Option<String>,
|
||||
pub nohtml: bool,
|
||||
pub template: Option<String>,
|
||||
pub date: Option<String>,
|
||||
/// File-scope tags parsed by the tag lexer. Convenience
|
||||
/// projection of the `BlockNode::Tag` nodes whose scope is `File`.
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Source positions and spans carried on every AST node.
|
||||
//!
|
||||
//! Positions are 0-indexed; `column` is a byte offset
|
||||
//! within a line; `offset` is a byte offset from the start of the document.
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Position {
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
pub const fn new(line: u32, column: u32, offset: usize) -> Self {
|
||||
Self {
|
||||
line,
|
||||
column,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Span {
|
||||
pub start: Position,
|
||||
pub end: Position,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub const fn new(start: Position, end: Position) -> Self {
|
||||
Self { start, end }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Open-recursion AST visitor.
|
||||
//!
|
||||
//! Each `visit_*` method has a default body that calls the corresponding
|
||||
//! `walk_*` free function, which in turn dispatches back into the trait.
|
||||
//! Override any method to short-circuit or augment traversal — call the
|
||||
//! matching `walk_*` from your override to keep descending.
|
||||
//!
|
||||
//! Pattern follows `rustc`'s and `syn`'s visitors.
|
||||
//!
|
||||
//! Read-only for now. A `VisitorMut` flavor can be added when transformations
|
||||
//! become relevant.
|
||||
|
||||
use super::block::{
|
||||
BlockNode, BlockquoteNode, CommentNode, DefinitionItemNode, DefinitionListNode, ErrorNode,
|
||||
HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, MathBlockNode, ParagraphNode,
|
||||
PreformattedNode, TableCellNode, TableNode, TableRowNode, TagNode,
|
||||
};
|
||||
use super::inline::{
|
||||
BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, KeywordNode,
|
||||
MathInlineNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TextNode,
|
||||
};
|
||||
use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode};
|
||||
use super::DocumentNode;
|
||||
|
||||
pub trait Visitor {
|
||||
// Top level
|
||||
fn visit_document(&mut self, node: &DocumentNode) {
|
||||
walk_document(self, node);
|
||||
}
|
||||
|
||||
// Block dispatch
|
||||
fn visit_block(&mut self, node: &BlockNode) {
|
||||
walk_block(self, node);
|
||||
}
|
||||
|
||||
// Inline dispatch
|
||||
fn visit_inline(&mut self, node: &InlineNode) {
|
||||
walk_inline(self, node);
|
||||
}
|
||||
|
||||
// Block leaves and recursive block nodes
|
||||
fn visit_heading(&mut self, node: &HeadingNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_paragraph(&mut self, node: &ParagraphNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_horizontal_rule(&mut self, _node: &HorizontalRuleNode) {}
|
||||
fn visit_blockquote(&mut self, node: &BlockquoteNode) {
|
||||
for child in &node.children {
|
||||
self.visit_block(child);
|
||||
}
|
||||
}
|
||||
fn visit_preformatted(&mut self, _node: &PreformattedNode) {}
|
||||
fn visit_math_block(&mut self, _node: &MathBlockNode) {}
|
||||
fn visit_list(&mut self, node: &ListNode) {
|
||||
walk_list(self, node);
|
||||
}
|
||||
fn visit_list_item(&mut self, node: &ListItemNode) {
|
||||
walk_list_item(self, node);
|
||||
}
|
||||
fn visit_definition_list(&mut self, node: &DefinitionListNode) {
|
||||
for item in &node.items {
|
||||
self.visit_definition_item(item);
|
||||
}
|
||||
}
|
||||
fn visit_definition_item(&mut self, node: &DefinitionItemNode) {
|
||||
walk_definition_item(self, node);
|
||||
}
|
||||
fn visit_table(&mut self, node: &TableNode) {
|
||||
walk_table(self, node);
|
||||
}
|
||||
fn visit_table_row(&mut self, node: &TableRowNode) {
|
||||
walk_table_row(self, node);
|
||||
}
|
||||
fn visit_table_cell(&mut self, node: &TableCellNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_comment(&mut self, _node: &CommentNode) {}
|
||||
fn visit_tag(&mut self, _node: &TagNode) {}
|
||||
fn visit_error(&mut self, _node: &ErrorNode) {}
|
||||
|
||||
// Inline leaves and recursive inline nodes
|
||||
fn visit_text(&mut self, _node: &TextNode) {}
|
||||
fn visit_bold(&mut self, node: &BoldNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_italic(&mut self, node: &ItalicNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_bold_italic(&mut self, node: &BoldItalicNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_strikethrough(&mut self, node: &StrikethroughNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_code(&mut self, _node: &CodeNode) {}
|
||||
fn visit_superscript(&mut self, node: &SuperscriptNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_subscript(&mut self, node: &SubscriptNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_math_inline(&mut self, _node: &MathInlineNode) {}
|
||||
fn visit_keyword(&mut self, _node: &KeywordNode) {}
|
||||
fn visit_color(&mut self, node: &ColorNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_wiki_link(&mut self, node: &WikiLinkNode) {
|
||||
if let Some(desc) = &node.description {
|
||||
for child in desc {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn visit_external_link(&mut self, node: &ExternalLinkNode) {
|
||||
if let Some(desc) = &node.description {
|
||||
for child in desc {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn visit_transclusion(&mut self, _node: &TransclusionNode) {}
|
||||
fn visit_raw_url(&mut self, _node: &RawUrlNode) {}
|
||||
}
|
||||
|
||||
pub fn walk_document<V: Visitor + ?Sized>(v: &mut V, node: &DocumentNode) {
|
||||
for child in &node.children {
|
||||
v.visit_block(child);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_block<V: Visitor + ?Sized>(v: &mut V, node: &BlockNode) {
|
||||
match node {
|
||||
BlockNode::Heading(n) => v.visit_heading(n),
|
||||
BlockNode::Paragraph(n) => v.visit_paragraph(n),
|
||||
BlockNode::HorizontalRule(n) => v.visit_horizontal_rule(n),
|
||||
BlockNode::Blockquote(n) => v.visit_blockquote(n),
|
||||
BlockNode::Preformatted(n) => v.visit_preformatted(n),
|
||||
BlockNode::MathBlock(n) => v.visit_math_block(n),
|
||||
BlockNode::List(n) => v.visit_list(n),
|
||||
BlockNode::DefinitionList(n) => v.visit_definition_list(n),
|
||||
BlockNode::Table(n) => v.visit_table(n),
|
||||
BlockNode::Comment(n) => v.visit_comment(n),
|
||||
BlockNode::Tag(n) => v.visit_tag(n),
|
||||
BlockNode::Error(n) => v.visit_error(n),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_inline<V: Visitor + ?Sized>(v: &mut V, node: &InlineNode) {
|
||||
match node {
|
||||
InlineNode::Text(n) => v.visit_text(n),
|
||||
InlineNode::Bold(n) => v.visit_bold(n),
|
||||
InlineNode::Italic(n) => v.visit_italic(n),
|
||||
InlineNode::BoldItalic(n) => v.visit_bold_italic(n),
|
||||
InlineNode::Strikethrough(n) => v.visit_strikethrough(n),
|
||||
InlineNode::Code(n) => v.visit_code(n),
|
||||
InlineNode::Superscript(n) => v.visit_superscript(n),
|
||||
InlineNode::Subscript(n) => v.visit_subscript(n),
|
||||
InlineNode::MathInline(n) => v.visit_math_inline(n),
|
||||
InlineNode::Keyword(n) => v.visit_keyword(n),
|
||||
InlineNode::Color(n) => v.visit_color(n),
|
||||
InlineNode::WikiLink(n) => v.visit_wiki_link(n),
|
||||
InlineNode::ExternalLink(n) => v.visit_external_link(n),
|
||||
InlineNode::Transclusion(n) => v.visit_transclusion(n),
|
||||
InlineNode::RawUrl(n) => v.visit_raw_url(n),
|
||||
// A soft break carries no content/children — treated as whitespace.
|
||||
InlineNode::SoftBreak(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_list<V: Visitor + ?Sized>(v: &mut V, node: &ListNode) {
|
||||
for item in &node.items {
|
||||
v.visit_list_item(item);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_list_item<V: Visitor + ?Sized>(v: &mut V, node: &ListItemNode) {
|
||||
for child in &node.children {
|
||||
v.visit_inline(child);
|
||||
}
|
||||
if let Some(sublist) = &node.sublist {
|
||||
v.visit_list(sublist);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_definition_item<V: Visitor + ?Sized>(v: &mut V, node: &DefinitionItemNode) {
|
||||
if let Some(term) = &node.term {
|
||||
for child in term {
|
||||
v.visit_inline(child);
|
||||
}
|
||||
}
|
||||
for definition in &node.definitions {
|
||||
for child in definition {
|
||||
v.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_table<V: Visitor + ?Sized>(v: &mut V, node: &TableNode) {
|
||||
for row in &node.rows {
|
||||
v.visit_table_row(row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_table_row<V: Visitor + ?Sized>(v: &mut V, node: &TableRowNode) {
|
||||
for cell in &node.cells {
|
||||
v.visit_table_cell(cell);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
//! Date primitives for the diary subsystem.
|
||||
//!
|
||||
//! This crate deliberately doesn't pull in `chrono` or `time`. The diary feature
|
||||
//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which
|
||||
//! is small enough that the dependency cost outweighs the convenience.
|
||||
//!
|
||||
//! Date math uses Howard Hinnant's days-from-civil algorithm
|
||||
//! (<https://howardhinnant.github.io/date_algorithms.html>) — proleptic
|
||||
//! Gregorian, valid for any year representable in `i32`.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A calendar day, decoupled from any time zone or wall-clock context.
|
||||
///
|
||||
/// Validation: `parse` and `from_ymd` reject impossible dates (month 0,
|
||||
/// day 0, day > days-in-month, etc.). The struct fields are public so
|
||||
/// callers can read them, but constructing one with bogus values bypasses
|
||||
/// validation — use the constructors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DiaryDate {
|
||||
pub year: i32,
|
||||
pub month: u8,
|
||||
pub day: u8,
|
||||
}
|
||||
|
||||
impl DiaryDate {
|
||||
/// Build a date from its components, validating the calendar.
|
||||
pub fn from_ymd(year: i32, month: u8, day: u8) -> Option<Self> {
|
||||
if !(1..=12).contains(&month) {
|
||||
return None;
|
||||
}
|
||||
if day < 1 || day > days_in_month(year, month) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { year, month, day })
|
||||
}
|
||||
|
||||
/// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra
|
||||
/// whitespace, missing zero-padding, alternate separators, etc.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let y = parse_digits(&b[0..4])?;
|
||||
let m = parse_digits(&b[5..7])? as u8;
|
||||
let d = parse_digits(&b[8..10])? as u8;
|
||||
Self::from_ymd(y as i32, m, d)
|
||||
}
|
||||
|
||||
/// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render
|
||||
/// numerically; the diary use case never produces those.
|
||||
pub fn format(&self) -> String {
|
||||
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
|
||||
}
|
||||
|
||||
/// UTC "today" computed from `SystemTime::now()`. The diary
|
||||
/// commands intentionally use UTC — local-time semantics depend on a
|
||||
/// timezone DB we don't ship and would surprise users crossing DST
|
||||
/// boundaries.
|
||||
pub fn today_utc() -> Self {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = (secs / 86_400) as i64;
|
||||
from_days(days)
|
||||
}
|
||||
|
||||
pub fn next_day(&self) -> Self {
|
||||
from_days(to_days(self.year, self.month, self.day) + 1)
|
||||
}
|
||||
|
||||
pub fn prev_day(&self) -> Self {
|
||||
from_days(to_days(self.year, self.month, self.day) - 1)
|
||||
}
|
||||
|
||||
/// Internal — exposed for tests that want to verify ordering against
|
||||
/// the days-from-epoch representation.
|
||||
pub fn to_days_epoch(&self) -> i64 {
|
||||
to_days(self.year, self.month, self.day)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for DiaryDate {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for DiaryDate {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.to_days_epoch().cmp(&other.to_days_epoch())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DiaryDate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.format())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_digits(b: &[u8]) -> Option<u32> {
|
||||
if b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut n: u32 = 0;
|
||||
for ch in b {
|
||||
if !ch.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn is_leap(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||
}
|
||||
|
||||
fn days_in_month(year: i32, month: u8) -> u8 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 => {
|
||||
if is_leap(year) {
|
||||
29
|
||||
} else {
|
||||
28
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0.
|
||||
fn to_days(year: i32, month: u8, day: u8) -> i64 {
|
||||
let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y.rem_euclid(400);
|
||||
let m = month as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Diary frequency / period
|
||||
// ============================================================
|
||||
//
|
||||
// `DiaryFrequency` is the user-configured cadence (mirrors vimwiki's
|
||||
// `diary_frequency` setting). `DiaryPeriod` is the *concrete* diary
|
||||
// entry — a specific day, ISO week, calendar month, or year — that
|
||||
// the LSP commands compute and the renderer addresses.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiaryFrequency {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
impl DiaryFrequency {
|
||||
/// Parse the string form used in `WikiConfig.diary_frequency`.
|
||||
/// Falls back to `Daily` for unrecognised values — vimwiki's
|
||||
/// permissive behaviour for stale configs.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"weekly" | "week" => Self::Weekly,
|
||||
"monthly" | "month" => Self::Monthly,
|
||||
"yearly" | "year" => Self::Yearly,
|
||||
_ => Self::Daily,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Daily => "daily",
|
||||
Self::Weekly => "weekly",
|
||||
Self::Monthly => "monthly",
|
||||
Self::Yearly => "yearly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A specific diary entry, identified by its calendar period. The four
|
||||
/// variants share the same `format` / `parse` / `next` / `prev` /
|
||||
/// `today_utc` surface so the LSP dispatcher can stay frequency-agnostic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiaryPeriod {
|
||||
Day(DiaryDate),
|
||||
/// ISO 8601 week — year/week pair, week ∈ 1..=53.
|
||||
Week {
|
||||
iso_year: i32,
|
||||
iso_week: u8,
|
||||
},
|
||||
Month {
|
||||
year: i32,
|
||||
month: u8,
|
||||
},
|
||||
Year {
|
||||
year: i32,
|
||||
},
|
||||
}
|
||||
|
||||
impl DiaryPeriod {
|
||||
/// "Now" for the given frequency, in UTC. See `DiaryDate::today_utc`
|
||||
/// for the timezone rationale.
|
||||
pub fn today_utc(freq: DiaryFrequency) -> Self {
|
||||
let today = DiaryDate::today_utc();
|
||||
match freq {
|
||||
DiaryFrequency::Daily => Self::Day(today),
|
||||
DiaryFrequency::Weekly => Self::week_containing(today),
|
||||
DiaryFrequency::Monthly => Self::Month {
|
||||
year: today.year,
|
||||
month: today.month,
|
||||
},
|
||||
DiaryFrequency::Yearly => Self::Year { year: today.year },
|
||||
}
|
||||
}
|
||||
|
||||
/// File-stem format — also the format vimwiki uses for diary link
|
||||
/// targets (`[[diary:2026-W19]]`, `[[diary:2026-05]]`, etc.).
|
||||
///
|
||||
/// Day(2026-05-12) → `2026-05-12`
|
||||
/// Week(2026, 19) → `2026-W19`
|
||||
/// Month(2026, 5) → `2026-05`
|
||||
/// Year(2026) → `2026`
|
||||
pub fn format(&self) -> String {
|
||||
match self {
|
||||
Self::Day(d) => d.format(),
|
||||
Self::Week { iso_year, iso_week } => format!("{iso_year:04}-W{iso_week:02}"),
|
||||
Self::Month { year, month } => format!("{year:04}-{month:02}"),
|
||||
Self::Year { year } => format!("{year:04}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `format` — recognise any of the four stems. Returns
|
||||
/// `None` for inputs that don't match any flavour.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
// Day: `YYYY-MM-DD`
|
||||
if let Some(d) = DiaryDate::parse(s) {
|
||||
return Some(Self::Day(d));
|
||||
}
|
||||
let b = s.as_bytes();
|
||||
// Week: `YYYY-Www`
|
||||
if b.len() == 8 && b[4] == b'-' && (b[5] == b'W' || b[5] == b'w') {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
let w = parse_digits(&b[6..8])? as u8;
|
||||
if (1..=53).contains(&w) {
|
||||
return Some(Self::Week {
|
||||
iso_year: y,
|
||||
iso_week: w,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Month: `YYYY-MM`
|
||||
if b.len() == 7 && b[4] == b'-' {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
let m = parse_digits(&b[5..7])? as u8;
|
||||
if (1..=12).contains(&m) {
|
||||
return Some(Self::Month { year: y, month: m });
|
||||
}
|
||||
}
|
||||
// Year: `YYYY`
|
||||
if b.len() == 4 {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
return Some(Self::Year { year: y });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Self {
|
||||
match *self {
|
||||
Self::Day(d) => Self::Day(d.next_day()),
|
||||
Self::Week { iso_year, iso_week } => {
|
||||
// Step forward one week: take the Monday of this ISO week,
|
||||
// add 7 days, recompute the (year, week) it falls in.
|
||||
let mon = monday_of_iso_week(iso_year, iso_week);
|
||||
let next_mon = from_days(to_days(mon.year, mon.month, mon.day) + 7);
|
||||
let (iso_year, iso_week) = iso_year_week(&next_mon);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
Self::Month { year, month } => {
|
||||
if month == 12 {
|
||||
Self::Month {
|
||||
year: year + 1,
|
||||
month: 1,
|
||||
}
|
||||
} else {
|
||||
Self::Month {
|
||||
year,
|
||||
month: month + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Year { year } => Self::Year { year: year + 1 },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&self) -> Self {
|
||||
match *self {
|
||||
Self::Day(d) => Self::Day(d.prev_day()),
|
||||
Self::Week { iso_year, iso_week } => {
|
||||
let mon = monday_of_iso_week(iso_year, iso_week);
|
||||
let prev_mon = from_days(to_days(mon.year, mon.month, mon.day) - 7);
|
||||
let (iso_year, iso_week) = iso_year_week(&prev_mon);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
Self::Month { year, month } => {
|
||||
if month == 1 {
|
||||
Self::Month {
|
||||
year: year - 1,
|
||||
month: 12,
|
||||
}
|
||||
} else {
|
||||
Self::Month {
|
||||
year,
|
||||
month: month - 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Year { year } => Self::Year { year: year - 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the ISO week containing the given calendar date.
|
||||
pub fn week_containing(d: DiaryDate) -> Self {
|
||||
let (iso_year, iso_week) = iso_year_week(&d);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
|
||||
/// First calendar day of this period — useful for chronological
|
||||
/// sorting across mixed-frequency entries.
|
||||
///
|
||||
/// Day(d) → d
|
||||
/// Week(y,w) → Monday of that ISO week
|
||||
/// Month(y,m) → 1st of that month
|
||||
/// Year(y) → Jan 1st of that year
|
||||
pub fn first_day(&self) -> DiaryDate {
|
||||
match *self {
|
||||
Self::Day(d) => d,
|
||||
Self::Week { iso_year, iso_week } => monday_of_iso_week(iso_year, iso_week),
|
||||
Self::Month { year, month } => {
|
||||
DiaryDate::from_ymd(year, month, 1).expect("validated month")
|
||||
}
|
||||
Self::Year { year } => DiaryDate::from_ymd(year, 1, 1).expect("Jan 1st always valid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DiaryPeriod {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.format())
|
||||
}
|
||||
}
|
||||
|
||||
/// How a *weekly* diary entry is named.
|
||||
///
|
||||
/// `Iso` — ISO-week label `YYYY-Www` (nuwiki's original scheme; the week
|
||||
/// always begins on Monday, `week_start` is ignored).
|
||||
/// `Date` — the week-start day's calendar date `YYYY-MM-DD`, matching
|
||||
/// upstream vimwiki; honours `week_start`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WeeklyStyle {
|
||||
Iso,
|
||||
Date,
|
||||
}
|
||||
|
||||
impl WeeklyStyle {
|
||||
/// Parse the config string. `date`/`vimwiki` → `Date`; anything else
|
||||
/// (including `iso`/`week`/empty) → `Iso`.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"date" | "vimwiki" | "day" => Self::Date,
|
||||
_ => Self::Iso,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Day a week begins on, in ISO numbering (Monday = 1 … Sunday = 7).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WeekStart(u8);
|
||||
|
||||
impl WeekStart {
|
||||
/// Parse a weekday name (`monday`..`sunday`); unknown → Monday.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
let n = match s.trim().to_ascii_lowercase().as_str() {
|
||||
"tuesday" => 2,
|
||||
"wednesday" => 3,
|
||||
"thursday" => 4,
|
||||
"friday" => 5,
|
||||
"saturday" => 6,
|
||||
"sunday" => 7,
|
||||
_ => 1, // monday (default)
|
||||
};
|
||||
Self(n)
|
||||
}
|
||||
|
||||
pub fn monday() -> Self {
|
||||
Self(1)
|
||||
}
|
||||
|
||||
fn iso(self) -> i64 {
|
||||
self.0 as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// Diary navigation policy: frequency plus, for weekly diaries, the naming
|
||||
/// scheme and week-start day. Built from a wiki's `diary_*` config so the
|
||||
/// LSP commands can compute today / next / prev without re-deriving the
|
||||
/// rules. `Daily`/`Monthly`/`Yearly` ignore the weekly fields.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DiaryCalendar {
|
||||
pub freq: DiaryFrequency,
|
||||
pub weekly_style: WeeklyStyle,
|
||||
pub week_start: WeekStart,
|
||||
}
|
||||
|
||||
impl DiaryCalendar {
|
||||
pub fn new(freq: DiaryFrequency, weekly_style: WeeklyStyle, week_start: WeekStart) -> Self {
|
||||
Self {
|
||||
freq,
|
||||
weekly_style,
|
||||
week_start,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_weekly_date(&self) -> bool {
|
||||
self.freq == DiaryFrequency::Weekly && self.weekly_style == WeeklyStyle::Date
|
||||
}
|
||||
|
||||
/// "Now" as a diary period for this calendar. Date-mode weekly snaps
|
||||
/// today back to the most recent week-start day (a plain `Day`, so the
|
||||
/// file is `YYYY-MM-DD` like upstream); everything else defers to
|
||||
/// [`DiaryPeriod::today_utc`].
|
||||
pub fn today(&self) -> DiaryPeriod {
|
||||
if self.is_weekly_date() {
|
||||
DiaryPeriod::Day(self.week_start_of(DiaryDate::today_utc()))
|
||||
} else {
|
||||
DiaryPeriod::today_utc(self.freq)
|
||||
}
|
||||
}
|
||||
|
||||
/// The period one cadence-step after `p`.
|
||||
pub fn next(&self, p: DiaryPeriod) -> DiaryPeriod {
|
||||
self.step(p, 1)
|
||||
}
|
||||
|
||||
/// The period one cadence-step before `p`.
|
||||
pub fn prev(&self, p: DiaryPeriod) -> DiaryPeriod {
|
||||
self.step(p, -1)
|
||||
}
|
||||
|
||||
fn step(&self, p: DiaryPeriod, dir: i64) -> DiaryPeriod {
|
||||
// Date-mode weekly: the period is a `Day` on a week-start; a step is
|
||||
// ±7 days (re-snapped so an off-week-start pivot still lands cleanly).
|
||||
if self.is_weekly_date() {
|
||||
if let DiaryPeriod::Day(d) = p {
|
||||
let start = self.week_start_of(d);
|
||||
return DiaryPeriod::Day(add_days(start, 7 * dir));
|
||||
}
|
||||
}
|
||||
if dir >= 0 {
|
||||
p.next()
|
||||
} else {
|
||||
p.prev()
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent `week_start` weekday on or before `d`.
|
||||
fn week_start_of(&self, d: DiaryDate) -> DiaryDate {
|
||||
let wd = iso_weekday(&d) as i64; // 1..=7
|
||||
let back = (wd - self.week_start.iso()).rem_euclid(7);
|
||||
add_days(d, -back)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift a calendar date by `delta` days (may be negative).
|
||||
fn add_days(d: DiaryDate, delta: i64) -> DiaryDate {
|
||||
from_days(to_days(d.year, d.month, d.day) + delta)
|
||||
}
|
||||
|
||||
/// Day-of-week with Monday=1 … Sunday=7 (ISO 8601).
|
||||
fn iso_weekday(d: &DiaryDate) -> u8 {
|
||||
// Zeller-style via days-from-epoch: 1970-01-01 was a Thursday.
|
||||
let days = to_days(d.year, d.month, d.day);
|
||||
// (days + 3) mod 7 puts Monday at 0 … Sunday at 6; shift to 1..=7.
|
||||
let r = (days + 3).rem_euclid(7);
|
||||
(r as u8) + 1
|
||||
}
|
||||
|
||||
/// ISO 8601 `(year, week)` for a calendar date. The ISO year may differ
|
||||
/// from the calendar year for early January / late December dates.
|
||||
fn iso_year_week(d: &DiaryDate) -> (i32, u8) {
|
||||
// Standard algorithm: shift to the Thursday of the same ISO week,
|
||||
// then ISO year = thursday.year, ISO week = ordinal_day(thursday) / 7 + 1.
|
||||
let weekday = iso_weekday(d) as i64; // 1..=7
|
||||
let days = to_days(d.year, d.month, d.day);
|
||||
let thursday = from_days(days + 4 - weekday);
|
||||
let jan1 = to_days(thursday.year, 1, 1);
|
||||
let ordinal = to_days(thursday.year, thursday.month, thursday.day) - jan1 + 1;
|
||||
let week = ((ordinal - 1) / 7 + 1) as u8;
|
||||
(thursday.year, week)
|
||||
}
|
||||
|
||||
/// Date of the Monday that opens the given ISO `(year, week)`.
|
||||
fn monday_of_iso_week(iso_year: i32, iso_week: u8) -> DiaryDate {
|
||||
// Find Jan 4th of iso_year — always in ISO week 1 by definition.
|
||||
let jan4 = DiaryDate {
|
||||
year: iso_year,
|
||||
month: 1,
|
||||
day: 4,
|
||||
};
|
||||
let jan4_weekday = iso_weekday(&jan4) as i64; // 1..=7
|
||||
let week1_monday_days = to_days(jan4.year, jan4.month, jan4.day) - (jan4_weekday - 1);
|
||||
let target = week1_monday_days + (iso_week as i64 - 1) * 7;
|
||||
from_days(target)
|
||||
}
|
||||
|
||||
/// Inverse of [`to_days`].
|
||||
fn from_days(days: i64) -> DiaryDate {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u8;
|
||||
let m_int = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = y + if m_int <= 2 { 1 } else { 0 };
|
||||
DiaryDate {
|
||||
year: year as i32,
|
||||
month: m_int as u8,
|
||||
day: d,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Core parser, AST, and renderer for nuwiki.
|
||||
//!
|
||||
//! This crate is editor-independent and must never depend on `nuwiki-lsp` or
|
||||
//! `nuwiki-ls`.
|
||||
|
||||
pub mod ast;
|
||||
pub mod date;
|
||||
pub mod listsyms;
|
||||
pub mod render;
|
||||
pub mod syntax;
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Configurable checkbox progress palette (vimwiki's `g:vimwiki_listsyms`).
|
||||
//!
|
||||
//! A palette is a progression of glyphs from "empty" (index 0) to "done"
|
||||
//! (the last index). The default `" .oOX"` is the stock vimwiki palette.
|
||||
//! A separate `rejected` glyph (`-`) is fixed by convention.
|
||||
//!
|
||||
//! The palette maps glyphs to a normalised five-bucket [`CheckboxState`] for
|
||||
//! the AST (so the HTML renderer keeps its fixed `done0..done4` classes) while
|
||||
//! still letting the LSP cycle/toggle commands operate on the real glyphs.
|
||||
|
||||
use crate::ast::CheckboxState;
|
||||
|
||||
/// Default rejected/cancelled marker (vimwiki's `listsym_rejected`).
|
||||
const REJECTED: char = '-';
|
||||
|
||||
/// An ordered checkbox progress palette. Always holds at least two glyphs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListSyms {
|
||||
progression: Vec<char>,
|
||||
rejected: char,
|
||||
}
|
||||
|
||||
impl Default for ListSyms {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
progression: " .oOX".chars().collect(),
|
||||
rejected: REJECTED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ListSyms {
|
||||
/// Build a palette from a glyph string, with the default rejected marker.
|
||||
/// Falls back to the default when fewer than two glyphs are supplied (a
|
||||
/// one-symbol palette can't express both "empty" and "done").
|
||||
pub fn new(s: &str) -> Self {
|
||||
Self::new_with_rejected(s, REJECTED)
|
||||
}
|
||||
|
||||
/// Like [`new`](Self::new) but with a custom rejected/cancelled glyph
|
||||
/// (vimwiki's `listsym_rejected`).
|
||||
pub fn new_with_rejected(s: &str, rejected: char) -> Self {
|
||||
let progression: Vec<char> = s.chars().collect();
|
||||
if progression.len() < 2 {
|
||||
Self {
|
||||
rejected,
|
||||
..Self::default()
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
progression,
|
||||
rejected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.progression.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.progression.is_empty()
|
||||
}
|
||||
|
||||
pub fn empty_glyph(&self) -> char {
|
||||
self.progression[0]
|
||||
}
|
||||
|
||||
pub fn done_glyph(&self) -> char {
|
||||
*self.progression.last().unwrap()
|
||||
}
|
||||
|
||||
pub fn rejected_glyph(&self) -> char {
|
||||
self.rejected
|
||||
}
|
||||
|
||||
pub fn glyph_at(&self, index: usize) -> char {
|
||||
self.progression[index.min(self.progression.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn index_of(&self, c: char) -> Option<usize> {
|
||||
self.progression.iter().position(|&g| g == c)
|
||||
}
|
||||
|
||||
/// Progress rate (0..=100) for a glyph index.
|
||||
pub fn rate(&self, index: usize) -> i32 {
|
||||
let last = (self.progression.len() - 1) as i32;
|
||||
(index as i32) * 100 / last
|
||||
}
|
||||
|
||||
/// Normalised five-bucket [`CheckboxState`] for a glyph, or `None` when the
|
||||
/// glyph is not part of this palette (and is not the rejected marker).
|
||||
pub fn state_of(&self, c: char) -> Option<CheckboxState> {
|
||||
if c == self.rejected {
|
||||
return Some(CheckboxState::Rejected);
|
||||
}
|
||||
let index = self.index_of(c)?;
|
||||
let rate = self.rate(index);
|
||||
// Round the rate to the nearest 25% bucket.
|
||||
Some(match (rate + 12) / 25 {
|
||||
0 => CheckboxState::Empty,
|
||||
1 => CheckboxState::Quarter,
|
||||
2 => CheckboxState::Half,
|
||||
3 => CheckboxState::ThreeQuarters,
|
||||
_ => CheckboxState::Done,
|
||||
})
|
||||
}
|
||||
|
||||
/// Snap a rate (`-1` = rejected, else 0..=100) to a glyph. Intermediate
|
||||
/// rates distribute over the `n - 2` non-terminal glyphs using vimwiki's
|
||||
/// `ceil` rule, matching the stock five-symbol behaviour.
|
||||
pub fn glyph_for_rate(&self, rate: i32) -> char {
|
||||
if rate < 0 {
|
||||
return self.rejected;
|
||||
}
|
||||
if rate <= 0 {
|
||||
return self.empty_glyph();
|
||||
}
|
||||
if rate >= 100 {
|
||||
return self.done_glyph();
|
||||
}
|
||||
let mids = (self.progression.len().saturating_sub(2)).max(1) as f64;
|
||||
let index = ((rate as f64) / 100.0 * mids).ceil() as usize;
|
||||
self.glyph_at(index)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_palette_matches_stock_vimwiki() {
|
||||
let s = ListSyms::default();
|
||||
assert_eq!(s.len(), 5);
|
||||
assert_eq!(s.empty_glyph(), ' ');
|
||||
assert_eq!(s.done_glyph(), 'X');
|
||||
assert_eq!(s.rejected_glyph(), '-');
|
||||
assert_eq!(s.rate(0), 0);
|
||||
assert_eq!(s.rate(1), 25);
|
||||
assert_eq!(s.rate(2), 50);
|
||||
assert_eq!(s.rate(3), 75);
|
||||
assert_eq!(s.rate(4), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_state_of_round_trips_each_glyph() {
|
||||
let s = ListSyms::default();
|
||||
assert_eq!(s.state_of(' '), Some(CheckboxState::Empty));
|
||||
assert_eq!(s.state_of('.'), Some(CheckboxState::Quarter));
|
||||
assert_eq!(s.state_of('o'), Some(CheckboxState::Half));
|
||||
assert_eq!(s.state_of('O'), Some(CheckboxState::ThreeQuarters));
|
||||
assert_eq!(s.state_of('X'), Some(CheckboxState::Done));
|
||||
assert_eq!(s.state_of('-'), Some(CheckboxState::Rejected));
|
||||
assert_eq!(s.state_of('?'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_glyph_for_rate_matches_legacy_buckets() {
|
||||
let s = ListSyms::default();
|
||||
assert_eq!(s.glyph_for_rate(-1), '-');
|
||||
assert_eq!(s.glyph_for_rate(0), ' ');
|
||||
assert_eq!(s.glyph_for_rate(25), '.');
|
||||
assert_eq!(s.glyph_for_rate(50), 'o');
|
||||
assert_eq!(s.glyph_for_rate(75), 'O');
|
||||
assert_eq!(s.glyph_for_rate(100), 'X');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_input_falls_back_to_default() {
|
||||
assert_eq!(ListSyms::new(""), ListSyms::default());
|
||||
assert_eq!(ListSyms::new("x"), ListSyms::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_rejected_glyph_is_honoured() {
|
||||
// `listsym_rejected = '✗'`: that glyph lexes/snaps as Rejected and the
|
||||
// default `-` no longer does.
|
||||
let s = ListSyms::new_with_rejected(" .oOX", '✗');
|
||||
assert_eq!(s.rejected_glyph(), '✗');
|
||||
assert_eq!(s.state_of('✗'), Some(CheckboxState::Rejected));
|
||||
assert_eq!(s.glyph_for_rate(-1), '✗');
|
||||
assert_eq!(s.state_of('-'), None); // no longer the rejected marker
|
||||
// A short palette still keeps the custom rejected glyph.
|
||||
assert_eq!(ListSyms::new_with_rejected("x", '✗').rejected_glyph(), '✗');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_palette_buckets_to_nearest_state() {
|
||||
// 3 glyphs → rates 0 / 50 / 100.
|
||||
let s = ListSyms::new(" x✓");
|
||||
assert_eq!(s.state_of(' '), Some(CheckboxState::Empty));
|
||||
assert_eq!(s.state_of('x'), Some(CheckboxState::Half));
|
||||
assert_eq!(s.state_of('✓'), Some(CheckboxState::Done));
|
||||
assert_eq!(s.glyph_for_rate(50), 'x');
|
||||
assert_eq!(s.glyph_for_rate(100), '✓');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
//! Document renderers.
|
||||
//!
|
||||
//! A `Renderer` is writer-based, takes a `DocumentNode`, and
|
||||
//! emits its representation into any `Write`. Errors propagate through
|
||||
//! `io::Result` — there's no separate per-renderer error type so the trait
|
||||
//! stays object-safe (`Box<dyn Renderer>` is useful for the LSP later).
|
||||
|
||||
pub mod html;
|
||||
|
||||
pub use html::HtmlRenderer;
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crate::ast::DocumentNode;
|
||||
|
||||
pub trait Renderer {
|
||||
fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()>;
|
||||
|
||||
/// Convenience: render into a `String`. Renderers that emit non-UTF-8
|
||||
/// bytes should override or skip; the default assumes UTF-8 output.
|
||||
fn render_to_string(&self, doc: &DocumentNode) -> io::Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
self.render(doc, &mut buf)?;
|
||||
String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Syntax plugin interface.
|
||||
//!
|
||||
//! Each syntax (vimwiki today, markdown later) implements the same shape:
|
||||
//! a `Lexer` produces a `TokenStream`, a `Parser` consumes it and produces a
|
||||
//! `DocumentNode`. A `SyntaxPlugin` is the type-erased facade that the LSP
|
||||
//! layer holds in a `SyntaxRegistry`.
|
||||
|
||||
pub mod registry;
|
||||
pub mod vimwiki;
|
||||
|
||||
pub use registry::SyntaxRegistry;
|
||||
|
||||
use crate::ast::DocumentNode;
|
||||
|
||||
/// Eager token buffer produced by a `Lexer`. The newtype keeps the door
|
||||
/// open to a lazy/streaming variant later without breaking
|
||||
/// callers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct TokenStream<T> {
|
||||
tokens: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> TokenStream<T> {
|
||||
pub fn new() -> Self {
|
||||
Self { tokens: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn from_vec(tokens: Vec<T>) -> Self {
|
||||
Self { tokens }
|
||||
}
|
||||
|
||||
pub fn into_vec(self) -> Vec<T> {
|
||||
self.tokens
|
||||
}
|
||||
|
||||
pub fn as_slice(&self) -> &[T] {
|
||||
&self.tokens
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.tokens.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tokens.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> std::slice::Iter<'_, T> {
|
||||
self.tokens.iter()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, token: T) {
|
||||
self.tokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Vec<T>> for TokenStream<T> {
|
||||
fn from(tokens: Vec<T>) -> Self {
|
||||
Self::from_vec(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoIterator for TokenStream<T> {
|
||||
type Item = T;
|
||||
type IntoIter = std::vec::IntoIter<T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.tokens.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a TokenStream<T> {
|
||||
type Item = &'a T;
|
||||
type IntoIter = std::slice::Iter<'a, T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.tokens.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lexer half of a syntax plugin. Operates on raw source text and emits a
|
||||
/// `TokenStream` whose token type is syntax-specific (e.g. `VimwikiToken`).
|
||||
pub trait Lexer {
|
||||
type Token;
|
||||
|
||||
fn lex(&self, text: &str) -> TokenStream<Self::Token>;
|
||||
}
|
||||
|
||||
/// Parser half of a syntax plugin. Consumes a `TokenStream` and produces a
|
||||
/// `DocumentNode`. Parsing is resilient — malformed input
|
||||
/// becomes `BlockNode::Error(ErrorNode)`, never a hard failure.
|
||||
pub trait Parser {
|
||||
type Token;
|
||||
|
||||
fn parse(&self, tokens: TokenStream<Self::Token>) -> DocumentNode;
|
||||
}
|
||||
|
||||
/// Type-erased syntax plugin held by the `SyntaxRegistry`.
|
||||
///
|
||||
/// Implementations typically own a `Lexer` and a `Parser` and chain them
|
||||
/// inside `parse`. The token type is deliberately hidden so the registry can
|
||||
/// hold heterogeneous plugins behind a single trait object.
|
||||
///
|
||||
/// `Send + Sync` is required — the LSP server stores plugins behind an `Arc`
|
||||
/// and shares them across request handler tasks.
|
||||
pub trait SyntaxPlugin: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn display_name(&self) -> &str;
|
||||
|
||||
/// File extensions associated with this syntax. Each entry includes the
|
||||
/// leading dot, e.g. `[".wiki"]`.
|
||||
fn file_extensions(&self) -> &[&str];
|
||||
|
||||
fn parse(&self, text: &str) -> DocumentNode;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Registry of syntax plugins.
|
||||
//!
|
||||
//! Plugins are stored behind `Arc<dyn SyntaxPlugin>` so the LSP layer can
|
||||
//! hand a cheap clone to per-document tasks without re-locking the registry.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::SyntaxPlugin;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct SyntaxRegistry {
|
||||
plugins: Vec<Arc<dyn SyntaxPlugin>>,
|
||||
}
|
||||
|
||||
impl SyntaxRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a plugin owned directly. The most common shape — implementer
|
||||
/// hands over a value, registry takes ownership.
|
||||
pub fn register<P>(&mut self, plugin: P)
|
||||
where
|
||||
P: SyntaxPlugin + 'static,
|
||||
{
|
||||
self.plugins.push(Arc::new(plugin));
|
||||
}
|
||||
|
||||
/// Look up a plugin by its `id`.
|
||||
pub fn get(&self, id: &str) -> Option<&dyn SyntaxPlugin> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.find(|p| p.id() == id)
|
||||
.map(|p| p.as_ref())
|
||||
}
|
||||
|
||||
/// Look up a plugin by file extension. `ext` should include the leading
|
||||
/// dot (e.g. `".wiki"`) — matches the plugin's `file_extensions()`.
|
||||
pub fn detect_from_extension(&self, ext: &str) -> Option<&dyn SyntaxPlugin> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.find(|p| p.file_extensions().iter().any(|e| *e == ext))
|
||||
.map(|p| p.as_ref())
|
||||
}
|
||||
|
||||
/// Iterate over registered plugins in registration order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &dyn SyntaxPlugin> + '_ {
|
||||
self.plugins.iter().map(|p| p.as_ref())
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.plugins.is_empty()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
//! Vimwiki syntax plugin.
|
||||
|
||||
pub mod lexer;
|
||||
pub mod parser;
|
||||
|
||||
pub use lexer::{VimwikiLexer, VimwikiToken, VimwikiTokenKind};
|
||||
pub use parser::VimwikiParser;
|
||||
|
||||
use crate::ast::DocumentNode;
|
||||
use crate::listsyms::ListSyms;
|
||||
use crate::syntax::{Lexer, Parser, SyntaxPlugin};
|
||||
|
||||
/// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the
|
||||
/// registry can hand out a single trait object.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct VimwikiSyntax;
|
||||
|
||||
impl VimwikiSyntax {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Parse with a custom checkbox palette (vimwiki's `g:vimwiki_listsyms`).
|
||||
/// The trait-level [`SyntaxPlugin::parse`] uses [`ListSyms::default`].
|
||||
pub fn parse_with_listsyms(&self, text: &str, listsyms: &ListSyms) -> DocumentNode {
|
||||
let tokens = VimwikiLexer::new()
|
||||
.with_listsyms(listsyms.clone())
|
||||
.lex(text);
|
||||
VimwikiParser::new().parse(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
//! Cluster 4 — diary frequency / period primitives.
|
||||
//! Tests the date math for ISO weeks, monthly, yearly periods.
|
||||
|
||||
use nuwiki_core::date::{
|
||||
DiaryCalendar, DiaryDate, DiaryFrequency, DiaryPeriod, WeekStart, WeeklyStyle,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn frequency_parses_canonical_strings() {
|
||||
assert_eq!(DiaryFrequency::parse("daily"), DiaryFrequency::Daily);
|
||||
assert_eq!(DiaryFrequency::parse("weekly"), DiaryFrequency::Weekly);
|
||||
assert_eq!(DiaryFrequency::parse("monthly"), DiaryFrequency::Monthly);
|
||||
assert_eq!(DiaryFrequency::parse("yearly"), DiaryFrequency::Yearly);
|
||||
assert_eq!(DiaryFrequency::parse("WeEkLy"), DiaryFrequency::Weekly);
|
||||
// Unknown falls back to Daily (mirrors vimwiki's permissive behaviour).
|
||||
assert_eq!(DiaryFrequency::parse("hourly"), DiaryFrequency::Daily);
|
||||
assert_eq!(DiaryFrequency::parse(""), DiaryFrequency::Daily);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_format_round_trips() {
|
||||
let cases = [
|
||||
(
|
||||
"2026-05-12",
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
|
||||
),
|
||||
(
|
||||
"2026-W19",
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
},
|
||||
),
|
||||
(
|
||||
"2026-05",
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
},
|
||||
),
|
||||
("2026", DiaryPeriod::Year { year: 2026 }),
|
||||
];
|
||||
for (s, want) in cases {
|
||||
let parsed = DiaryPeriod::parse(s).unwrap_or_else(|| panic!("parse {s}"));
|
||||
assert_eq!(parsed, want, "parse({s})");
|
||||
assert_eq!(parsed.format(), s, "format({s})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_parse_rejects_garbage() {
|
||||
assert!(DiaryPeriod::parse("garbage").is_none());
|
||||
assert!(DiaryPeriod::parse("2026-W00").is_none());
|
||||
assert!(DiaryPeriod::parse("2026-13").is_none()); // month 13
|
||||
assert!(DiaryPeriod::parse("2026-").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_jan_1_can_belong_to_prior_year() {
|
||||
// 2021-01-01 was a Friday → ISO week 53 of 2020.
|
||||
let d = DiaryDate::from_ymd(2021, 1, 1).unwrap();
|
||||
let p = DiaryPeriod::week_containing(d);
|
||||
assert_eq!(
|
||||
p,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2020,
|
||||
iso_week: 53
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_dec_31_can_belong_to_next_year() {
|
||||
// 2024-12-30 (Monday) is in ISO week 1 of 2025.
|
||||
let d = DiaryDate::from_ymd(2024, 12, 30).unwrap();
|
||||
let p = DiaryPeriod::week_containing(d);
|
||||
assert_eq!(
|
||||
p,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2025,
|
||||
iso_week: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_mid_year_is_consistent() {
|
||||
// 2026-05-12 is a Tuesday in ISO week 20.
|
||||
let d = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
let p = DiaryPeriod::week_containing(d);
|
||||
assert_eq!(
|
||||
p,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 20
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn week_next_and_prev_step_by_seven_days() {
|
||||
let p = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 20,
|
||||
};
|
||||
assert_eq!(
|
||||
p.next(),
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 21
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
p.prev(),
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn week_next_crosses_year_boundary() {
|
||||
// Week 53 of 2020 → week 53 ends; next should be week 1 of 2021.
|
||||
let p = DiaryPeriod::Week {
|
||||
iso_year: 2020,
|
||||
iso_week: 53,
|
||||
};
|
||||
assert_eq!(
|
||||
p.next(),
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2021,
|
||||
iso_week: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_next_wraps_to_january() {
|
||||
let dec = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 12,
|
||||
};
|
||||
assert_eq!(
|
||||
dec.next(),
|
||||
DiaryPeriod::Month {
|
||||
year: 2027,
|
||||
month: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_prev_wraps_to_december() {
|
||||
let jan = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
jan.prev(),
|
||||
DiaryPeriod::Month {
|
||||
year: 2025,
|
||||
month: 12
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_next_and_prev_increment_by_one() {
|
||||
let y = DiaryPeriod::Year { year: 2026 };
|
||||
assert_eq!(y.next(), DiaryPeriod::Year { year: 2027 });
|
||||
assert_eq!(y.prev(), DiaryPeriod::Year { year: 2025 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_next_and_prev_still_work() {
|
||||
let d = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
|
||||
assert_eq!(
|
||||
d.next(),
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 13).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
d.prev(),
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 11).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
// ===== DiaryCalendar — weekly naming styles + week-start =====
|
||||
// 2026-05-25 is a Monday (so 2026-05-27 is a Wednesday, 2026-05-24 a Sunday).
|
||||
|
||||
fn day(y: i32, m: u8, d: u8) -> DiaryPeriod {
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(y, m, d).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_style_and_week_start_parse() {
|
||||
assert_eq!(WeeklyStyle::parse("date"), WeeklyStyle::Date);
|
||||
assert_eq!(WeeklyStyle::parse("vimwiki"), WeeklyStyle::Date);
|
||||
assert_eq!(WeeklyStyle::parse("iso"), WeeklyStyle::Iso);
|
||||
assert_eq!(WeeklyStyle::parse(""), WeeklyStyle::Iso); // default
|
||||
assert_eq!(WeeklyStyle::parse("WEEK"), WeeklyStyle::Iso);
|
||||
// WeekStart::parse is exercised via the calendar tests below; unknown
|
||||
// names fall back to Monday.
|
||||
assert_eq!(WeekStart::parse("sunday"), WeekStart::parse("SUNDAY"));
|
||||
assert_eq!(WeekStart::parse("nonsense"), WeekStart::monday());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_mode_weekly_today_is_a_day() {
|
||||
let cal = DiaryCalendar::new(
|
||||
DiaryFrequency::Weekly,
|
||||
WeeklyStyle::Date,
|
||||
WeekStart::monday(),
|
||||
);
|
||||
assert!(matches!(cal.today(), DiaryPeriod::Day(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_mode_weekly_today_is_a_week() {
|
||||
let cal = DiaryCalendar::new(
|
||||
DiaryFrequency::Weekly,
|
||||
WeeklyStyle::Iso,
|
||||
WeekStart::monday(),
|
||||
);
|
||||
assert!(matches!(cal.today(), DiaryPeriod::Week { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_mode_weekly_steps_seven_days_from_the_week_start_monday() {
|
||||
let cal = DiaryCalendar::new(
|
||||
DiaryFrequency::Weekly,
|
||||
WeeklyStyle::Date,
|
||||
WeekStart::monday(),
|
||||
);
|
||||
// From a Wednesday: snap back to Mon 05-25, then ±7.
|
||||
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 6, 1));
|
||||
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 18));
|
||||
// From the Monday itself: no snap, just ±7.
|
||||
assert_eq!(cal.next(day(2026, 5, 25)), day(2026, 6, 1));
|
||||
assert_eq!(cal.prev(day(2026, 5, 25)), day(2026, 5, 18));
|
||||
// Stem is the plain date, like upstream.
|
||||
assert_eq!(cal.next(day(2026, 5, 27)).format(), "2026-06-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_mode_weekly_honours_a_sunday_week_start() {
|
||||
let cal = DiaryCalendar::new(
|
||||
DiaryFrequency::Weekly,
|
||||
WeeklyStyle::Date,
|
||||
WeekStart::parse("sunday"),
|
||||
);
|
||||
// Wed 05-27 snaps back to Sun 05-24, +7 = Sun 05-31.
|
||||
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 31));
|
||||
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_mode_weekly_delegates_to_iso_week_stepping() {
|
||||
let cal = DiaryCalendar::new(
|
||||
DiaryFrequency::Weekly,
|
||||
WeeklyStyle::Iso,
|
||||
WeekStart::monday(),
|
||||
);
|
||||
let wk = DiaryPeriod::week_containing(DiaryDate::from_ymd(2026, 5, 27).unwrap());
|
||||
assert_eq!(cal.next(wk), wk.next());
|
||||
assert_eq!(cal.prev(wk), wk.prev());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_calendar_steps_one_day_regardless_of_weekly_fields() {
|
||||
let cal = DiaryCalendar::new(
|
||||
DiaryFrequency::Daily,
|
||||
WeeklyStyle::Date,
|
||||
WeekStart::parse("sunday"),
|
||||
);
|
||||
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 28));
|
||||
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 26));
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
//! End-to-end tests for the HTML renderer.
|
||||
//!
|
||||
//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, DocumentNode, InlineNode, LinkTarget, Span, TableCellNode, TableNode, TableRowNode,
|
||||
TextNode,
|
||||
};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn render(src: &str) -> String {
|
||||
let doc = VimwikiSyntax::new().parse(src);
|
||||
HtmlRenderer::new().render_to_string(&doc).unwrap()
|
||||
}
|
||||
|
||||
fn span_cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode {
|
||||
let s = Span::default();
|
||||
TableCellNode {
|
||||
span: s,
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: s,
|
||||
content: text.into(),
|
||||
})],
|
||||
col_span,
|
||||
row_span,
|
||||
}
|
||||
}
|
||||
|
||||
fn span_table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
|
||||
let span = Span::default();
|
||||
let rows: Vec<TableRowNode> = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, cells)| TableRowNode {
|
||||
span,
|
||||
cells,
|
||||
is_header: has_header && i == 0,
|
||||
})
|
||||
.collect();
|
||||
DocumentNode {
|
||||
span,
|
||||
metadata: Default::default(),
|
||||
children: vec![BlockNode::Table(TableNode {
|
||||
span,
|
||||
rows,
|
||||
has_header,
|
||||
alignments: Vec::new(),
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Block elements =====
|
||||
|
||||
#[test]
|
||||
fn empty_document_renders_to_empty_string() {
|
||||
assert_eq!(render(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_levels() {
|
||||
for level in 1..=6 {
|
||||
let bars = "=".repeat(level);
|
||||
let out = render(&format!("{bars} Title {bars}\n"));
|
||||
assert!(out.starts_with(&format!("<h{level} id=\"Title\">Title</h{level}>")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_gets_centered_class() {
|
||||
let out = render(" = T =\n");
|
||||
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_wraps_inline_content() {
|
||||
let out = render("Hello world\n");
|
||||
assert_eq!(out.trim(), "<p>Hello world</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule() {
|
||||
let out = render("----\n");
|
||||
assert!(out.contains("<hr>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockquote_lines_become_paragraphs() {
|
||||
let out = render("> first\n> second\n");
|
||||
assert!(out.contains("<blockquote>"));
|
||||
assert!(out.contains("<p>first</p>"));
|
||||
assert!(out.contains("<p>second</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_emits_pre_code_with_language_class() {
|
||||
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
||||
assert!(out.contains("<pre><code class=\"language-rust\">"));
|
||||
assert!(out.contains("fn main() {}"));
|
||||
assert!(out.contains("</code></pre>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_block_emits_div() {
|
||||
let out = render("{{$\nx=1\n}}$\n");
|
||||
assert!(out.contains("<div class=\"math-block\">"));
|
||||
assert!(out.contains("x=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unordered_list_with_checkbox() {
|
||||
let out = render("- [X] done\n- not done\n");
|
||||
assert!(out.contains("<ul>"));
|
||||
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
|
||||
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
|
||||
assert!(out.contains("<li>not done</li>"), "{out}");
|
||||
assert!(!out.contains("<input"), "no input element: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_states_use_vimwiki_done_classes() {
|
||||
let out = render("- [ ] a\n- [.] b\n- [o] c\n- [O] d\n- [X] e\n- [-] f\n");
|
||||
assert!(out.contains("<li class=\"done0\">a</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done1\">b</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done2\">c</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done3\">d</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done4\">e</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"rejected\">f</li>"), "{out}");
|
||||
assert!(!out.contains("task-"), "no legacy task-* classes: {out}");
|
||||
assert!(!out.contains("<input"), "no input elements: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_list() {
|
||||
let out = render("1. first\n");
|
||||
assert!(out.contains("<ol>"));
|
||||
assert!(out.contains("<li>first</li>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_list_renders_recursively() {
|
||||
let out = render("- top\n - nested\n");
|
||||
assert!(out.contains("<ul>"));
|
||||
let inner_idx = out
|
||||
.find("<ul>\n<li>top")
|
||||
.map(|i| i + "<ul>\n<li>top".len())
|
||||
.unwrap_or(0);
|
||||
assert!(out[inner_idx..].contains("<ul>"));
|
||||
assert!(out.contains("nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_list() {
|
||||
let out = render("Term:: Defn\n");
|
||||
assert!(out.contains("<dl>"));
|
||||
assert!(out.contains("<dt>Term</dt>"));
|
||||
assert!(out.contains("<dd>Defn</dd>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_with_header_separator() {
|
||||
let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
assert!(out.contains("<table>"));
|
||||
assert!(out.contains("<thead>"));
|
||||
assert!(out.contains("<th> a </th>"));
|
||||
assert!(out.contains("<tbody>"));
|
||||
assert!(out.contains("<td> 1 </td>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_comment_becomes_html_comment() {
|
||||
let out = render("%% hidden\n");
|
||||
assert!(out.contains("<!--"));
|
||||
assert!(out.contains("hidden"));
|
||||
assert!(out.contains("-->"));
|
||||
}
|
||||
|
||||
// ===== Inline =====
|
||||
|
||||
#[test]
|
||||
fn bold_italic_strike_super_sub() {
|
||||
let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n");
|
||||
assert!(out.contains("<strong>b</strong>"));
|
||||
assert!(out.contains("<em>i</em>"));
|
||||
assert!(out.contains("<del>s</del>"));
|
||||
assert!(out.contains("<sup>sup</sup>"));
|
||||
assert!(out.contains("<sub>sub</sub>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_code_is_escaped() {
|
||||
let out = render("Use `Vec<u32>` here\n");
|
||||
assert!(out.contains("<code>Vec<u32></code>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_spans() {
|
||||
// One class per keyword so a stylesheet can colour groups differently.
|
||||
// TODO keeps the vimwiki `todo` class.
|
||||
let cases = [
|
||||
("TODO x\n", "<span class=\"todo\">TODO</span>"),
|
||||
("DONE x\n", "<span class=\"done\">DONE</span>"),
|
||||
("STARTED x\n", "<span class=\"started\">STARTED</span>"),
|
||||
("FIXME x\n", "<span class=\"fixme\">FIXME</span>"),
|
||||
("FIXED x\n", "<span class=\"fixed\">FIXED</span>"),
|
||||
("XXX x\n", "<span class=\"xxx\">XXX</span>"),
|
||||
("STOPPED x\n", "<span class=\"stopped\">STOPPED</span>"),
|
||||
];
|
||||
for (src, expected) in cases {
|
||||
let out = render(src);
|
||||
assert!(out.contains(expected), "src {src:?} -> {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_link() {
|
||||
let out = render("See https://example.com here\n");
|
||||
assert!(out.contains("<a href=\"https://example.com\">https://example.com</a>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_default_resolver() {
|
||||
let out = render("[[Page]]\n");
|
||||
assert!(out.contains("<a href=\"Page.html\">Page</a>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_anchor_default_resolver() {
|
||||
let out = render("[[Page#Section]]\n");
|
||||
assert!(out.contains("<a href=\"Page.html#Section\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_link() {
|
||||
let out = render("[[#Section]]\n");
|
||||
assert!(out.contains("<a href=\"#Section\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_id_matches_anchor_link_href() {
|
||||
// The heading carries an `id` equal to its plain text, so a `#anchor`
|
||||
// link (TOC or cross-reference) actually lands on it. Regression guard
|
||||
// for "exported HTML anchor links don't work".
|
||||
let out = render("= My Section =\n\n[[#My Section]]\n");
|
||||
assert!(
|
||||
out.contains("<h1 id=\"My Section\">My Section</h1>"),
|
||||
"heading id: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("<a href=\"#My Section\">"),
|
||||
"anchor href: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_header_heading_wrapped_in_div_toc() {
|
||||
// The TOC section heading gets a `<div class="toc">` wrapper (vimwiki
|
||||
// scheme) so `.toc` stylesheet rules apply. The class is on the div, not
|
||||
// the heading.
|
||||
let doc = VimwikiSyntax::new().parse("== Contents ==\n");
|
||||
let out = HtmlRenderer::new()
|
||||
.with_toc_header("Contents")
|
||||
.render_to_string(&doc)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.contains("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_toc_heading_not_wrapped() {
|
||||
let doc = VimwikiSyntax::new().parse("== Intro ==\n");
|
||||
let out = HtmlRenderer::new()
|
||||
.with_toc_header("Contents")
|
||||
.render_to_string(&doc)
|
||||
.unwrap();
|
||||
assert!(out.contains("<h2 id=\"Intro\">Intro</h2>"), "got: {out}");
|
||||
assert!(!out.contains("class=\"toc\""), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_numbered_link() {
|
||||
let out = render("[[wiki1:Page]]\n");
|
||||
assert!(out.contains("<a href=\"../wiki1/Page.html\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_named_link() {
|
||||
let out = render("[[wn.Notes:Page]]\n");
|
||||
assert!(out.contains("<a href=\"../wn-Notes/Page.html\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_link() {
|
||||
let out = render("[[diary:2026-05-10]]\n");
|
||||
assert!(out.contains("<a href=\"diary/2026-05-10.html\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_link_routes_to_external_link_node() {
|
||||
let out = render("[[https://example.com|docs]]\n");
|
||||
assert!(out.contains("<a href=\"https://example.com\">docs</a>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_becomes_img() {
|
||||
let out = render("{{img.png|cat photo|class=hi}}\n");
|
||||
assert!(out.contains("<img src=\"img.png\""));
|
||||
assert!(out.contains("alt=\"cat photo\""));
|
||||
assert!(out.contains("class=\"hi\""));
|
||||
}
|
||||
|
||||
// ===== HTML escaping =====
|
||||
|
||||
#[test]
|
||||
fn html_special_chars_in_text_are_escaped() {
|
||||
let out = render("a < b > c & d \"e\"\n");
|
||||
assert!(out.contains("a < b > c & d "e""));
|
||||
}
|
||||
|
||||
// ===== Custom link resolver =====
|
||||
|
||||
#[test]
|
||||
fn custom_link_resolver_overrides_href() {
|
||||
let doc = VimwikiSyntax::new().parse("[[Page]]\n");
|
||||
let renderer = HtmlRenderer::new()
|
||||
.with_link_resolver(|t: &LinkTarget| format!("/wiki/{}", t.path.as_deref().unwrap_or("")));
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<a href=\"/wiki/Page\">"));
|
||||
}
|
||||
|
||||
// ===== Template =====
|
||||
|
||||
#[test]
|
||||
fn template_substitutes_content_and_title() {
|
||||
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
|
||||
let renderer = HtmlRenderer::new().with_template(
|
||||
"<!doctype html><html><head><title>{{title}}</title></head>\
|
||||
<body>{{content}}</body></html>",
|
||||
);
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title>My Page</title>"));
|
||||
assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_with_missing_title_substitutes_empty_string() {
|
||||
let doc = VimwikiSyntax::new().parse("= Heading =\n");
|
||||
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title></title>"));
|
||||
assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_substitutes_vimwiki_percent_placeholders() {
|
||||
// Stock vimwiki templates use `%title%` / `%content%` / `%root_path%`
|
||||
// rather than nuwiki's `{{…}}`. Both must resolve.
|
||||
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
|
||||
let renderer = HtmlRenderer::new()
|
||||
.with_template(
|
||||
"<title>%title%</title><link href=\"%root_path%style.css\"><body>%content%</body>",
|
||||
)
|
||||
.with_var("root_path", "../");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
||||
assert!(
|
||||
out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
|
||||
"content: {out}"
|
||||
);
|
||||
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
|
||||
assert!(!out.contains('%'), "no placeholder left: {out}");
|
||||
}
|
||||
|
||||
// ===== Colour spans =====
|
||||
|
||||
// `ColorNode` is an extension point the vimwiki parser never emits, so build
|
||||
// a document around one by hand and render it directly.
|
||||
fn doc_with_color(color: &str) -> DocumentNode {
|
||||
use nuwiki_core::ast::{ColorNode, ParagraphNode};
|
||||
let color_node = InlineNode::Color(ColorNode {
|
||||
span: Span::default(),
|
||||
color: color.to_string(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "hi".to_string(),
|
||||
})],
|
||||
});
|
||||
DocumentNode {
|
||||
children: vec![BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![color_node],
|
||||
})],
|
||||
..DocumentNode::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_dic_name_uses_inline_style_via_default_template() {
|
||||
let doc = doc_with_color("red");
|
||||
let renderer =
|
||||
HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into());
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains("<span style=\"color:crimson\">hi</span>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_tag_template_override_is_honored() {
|
||||
let doc = doc_with_color("red");
|
||||
let renderer = HtmlRenderer::new()
|
||||
.with_colors([("red".to_string(), "crimson".to_string())].into())
|
||||
.with_color_template("<em data-style=\"__STYLE__\">__CONTENT__</em>");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains("<em data-style=\"color:crimson\">hi</em>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_color_name_falls_back_to_class() {
|
||||
let doc = doc_with_color("plaid");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains("<span class=\"color-plaid\">hi</span>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== End-to-end smoke =====
|
||||
|
||||
#[test]
|
||||
fn full_document_round_trip() {
|
||||
let src = "\
|
||||
%title Smoke
|
||||
= Heading =
|
||||
|
||||
Paragraph with *bold* and _italic_ and `code`.
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
> quoted
|
||||
|
||||
----
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
{{{rust
|
||||
fn x() {}
|
||||
}}}
|
||||
";
|
||||
let doc = VimwikiSyntax::new().parse(src);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
// A few sanity checks; the full output is exercised piece-by-piece above.
|
||||
for needle in [
|
||||
"<h1 id=\"Heading\">Heading</h1>",
|
||||
"<strong>bold</strong>",
|
||||
"<em>italic</em>",
|
||||
"<code>code</code>",
|
||||
"<ul>",
|
||||
"<blockquote>",
|
||||
"<hr>",
|
||||
"<table>",
|
||||
"<pre><code class=\"language-rust\">",
|
||||
] {
|
||||
assert!(
|
||||
out.contains(needle),
|
||||
"expected {needle:?} in output:\n{out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Table colspan / rowspan =====
|
||||
|
||||
#[test]
|
||||
fn colspan_marker_folds_into_lead_cell() {
|
||||
// | a | b | c |
|
||||
// | d | > | e | → second row has b spanning 2 cols (cell index 1 is >)
|
||||
let doc = span_table(
|
||||
vec![
|
||||
vec![
|
||||
span_cell("a", false, false),
|
||||
span_cell("b", false, false),
|
||||
span_cell("c", false, false),
|
||||
],
|
||||
vec![
|
||||
span_cell("d", false, false),
|
||||
span_cell(">", true, false),
|
||||
span_cell("e", false, false),
|
||||
],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<td colspan="2">d</td>"#), "got: {out}");
|
||||
assert!(!out.contains(r#"class="col-span""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rowspan_marker_folds_into_lead_cell_above() {
|
||||
let doc = span_table(
|
||||
vec![
|
||||
vec![span_cell("a", false, false), span_cell("b", false, false)],
|
||||
vec![span_cell("c", false, false), span_cell("\\/", false, true)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<td rowspan="2">b</td>"#), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_spans_emits_no_table_attrs() {
|
||||
let doc = span_table(
|
||||
vec![
|
||||
vec![span_cell("a", false, false), span_cell("b", false, false)],
|
||||
vec![span_cell("c", false, false), span_cell("d", false, false)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(!out.contains("colspan="));
|
||||
assert!(!out.contains("rowspan="));
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
//! End-to-end tests for the vimwiki lexer.
|
||||
//!
|
||||
//! Each test feeds a small fragment to `VimwikiLexer::lex` and asserts the
|
||||
//! exact token-kind sequence. A separate `spans_are_byte_accurate` test
|
||||
//! pins down position semantics.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nuwiki_core::ast::{CheckboxState, Keyword, ListSymbol, Position};
|
||||
use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiTokenKind};
|
||||
use nuwiki_core::syntax::Lexer;
|
||||
|
||||
use VimwikiTokenKind::*;
|
||||
|
||||
fn lex(text: &str) -> Vec<VimwikiTokenKind> {
|
||||
VimwikiLexer::new()
|
||||
.lex(text)
|
||||
.into_iter()
|
||||
.map(|t| t.kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn text(s: &str) -> VimwikiTokenKind {
|
||||
Text(s.into())
|
||||
}
|
||||
|
||||
// ===== Empty / whitespace =====
|
||||
|
||||
#[test]
|
||||
fn empty_input_produces_no_tokens() {
|
||||
assert!(lex("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_newline_is_a_blank_line() {
|
||||
assert_eq!(lex("\n"), vec![BlankLine]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_line_is_a_blank_line() {
|
||||
assert_eq!(lex(" \n"), vec![BlankLine]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_separate_paragraphs() {
|
||||
assert_eq!(
|
||||
lex("hello\n\nworld\n"),
|
||||
vec![text("hello"), Newline, BlankLine, text("world"), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Headings =====
|
||||
|
||||
#[test]
|
||||
fn headings_at_all_six_levels() {
|
||||
let mut tokens = Vec::new();
|
||||
for level in 1..=6 {
|
||||
let line = format!(
|
||||
"{} L{} {}\n",
|
||||
"=".repeat(level as usize),
|
||||
level,
|
||||
"=".repeat(level as usize)
|
||||
);
|
||||
let lexed = lex(&line);
|
||||
assert!(matches!(
|
||||
lexed[0],
|
||||
HeadingOpen { level: l, centered: false } if l == level
|
||||
));
|
||||
tokens.extend(lexed);
|
||||
}
|
||||
assert!(tokens.iter().any(|t| matches!(t, HeadingClose)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_is_marked_centered() {
|
||||
let lexed = lex(" = title =\n");
|
||||
assert!(matches!(
|
||||
lexed[0],
|
||||
HeadingOpen {
|
||||
level: 1,
|
||||
centered: true
|
||||
}
|
||||
));
|
||||
assert_eq!(lexed[1], text("title"));
|
||||
assert!(matches!(lexed[2], HeadingClose));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_with_inline_bold() {
|
||||
let lexed = lex("== Hello *world* ==\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
HeadingOpen {
|
||||
level: 2,
|
||||
centered: false
|
||||
},
|
||||
text("Hello "),
|
||||
BoldDelim,
|
||||
text("world"),
|
||||
BoldDelim,
|
||||
HeadingClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Horizontal rule =====
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule_requires_four_or_more_dashes() {
|
||||
assert_eq!(lex("----\n"), vec![HorizontalRule, Newline]);
|
||||
assert_eq!(lex("--------\n"), vec![HorizontalRule, Newline]);
|
||||
// Three dashes: not a rule, parsed as text.
|
||||
assert_eq!(lex("---\n"), vec![text("---"), Newline]);
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
|
||||
#[test]
|
||||
fn single_line_comment() {
|
||||
assert_eq!(
|
||||
lex("%% hello\n"),
|
||||
vec![CommentLine(" hello".into()), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_comment_spans_multiple_lines() {
|
||||
assert_eq!(
|
||||
lex("%%+ a\nb\n+%%\n"),
|
||||
vec![
|
||||
CommentMultiOpen,
|
||||
CommentMultiLine(" a".into()),
|
||||
Newline,
|
||||
CommentMultiLine("b".into()),
|
||||
Newline,
|
||||
CommentMultiClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_comment_one_liner() {
|
||||
assert_eq!(
|
||||
lex("%%+ inline +%%\n"),
|
||||
vec![
|
||||
CommentMultiOpen,
|
||||
CommentMultiLine(" inline ".into()),
|
||||
CommentMultiClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Preformatted =====
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_captures_lines_verbatim() {
|
||||
let lexed = lex("{{{\nfn main() {}\n}}}\n");
|
||||
assert!(matches!(lexed[0], PreformattedOpen { .. }));
|
||||
assert_eq!(lexed[1], Newline);
|
||||
assert_eq!(lexed[2], PreformattedLine("fn main() {}".into()));
|
||||
assert_eq!(lexed[3], Newline);
|
||||
assert_eq!(lexed[4], PreformattedClose);
|
||||
assert_eq!(lexed[5], Newline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_parses_language() {
|
||||
let lexed = lex("{{{rust\nx\n}}}\n");
|
||||
let PreformattedOpen { language, .. } = &lexed[0] else {
|
||||
panic!("expected PreformattedOpen");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_parses_attrs() {
|
||||
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
|
||||
let PreformattedOpen { language, attrs } = &lexed[0] else {
|
||||
panic!("expected PreformattedOpen");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("rust"));
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert("class".into(), "hl".into());
|
||||
expected.insert("key".into(), "val".into());
|
||||
assert_eq!(attrs, &expected);
|
||||
}
|
||||
|
||||
// ===== Math block =====
|
||||
|
||||
#[test]
|
||||
fn math_block_captures_environment() {
|
||||
let lexed = lex("{{$%align%\nx = 1\n}}$\n");
|
||||
assert_eq!(
|
||||
lexed[0],
|
||||
MathBlockOpen {
|
||||
environment: Some("align".into())
|
||||
}
|
||||
);
|
||||
assert_eq!(lexed[2], MathBlockLine("x = 1".into()));
|
||||
assert_eq!(lexed[4], MathBlockClose);
|
||||
}
|
||||
|
||||
// ===== Placeholders =====
|
||||
|
||||
#[test]
|
||||
fn all_four_placeholders() {
|
||||
assert_eq!(
|
||||
lex("%title My Page\n"),
|
||||
vec![PlaceholderTitle(Some("My Page".into())), Newline]
|
||||
);
|
||||
assert_eq!(lex("%nohtml\n"), vec![PlaceholderNohtml, Newline]);
|
||||
assert_eq!(
|
||||
lex("%template fancy\n"),
|
||||
vec![PlaceholderTemplate(Some("fancy".into())), Newline]
|
||||
);
|
||||
assert_eq!(
|
||||
lex("%date 2026-05-10\n"),
|
||||
vec![PlaceholderDate(Some("2026-05-10".into())), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Lists =====
|
||||
|
||||
#[test]
|
||||
fn list_marker_dash() {
|
||||
let lexed = lex("- item\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
ListMarker {
|
||||
symbol: ListSymbol::Dash,
|
||||
indent: 0
|
||||
},
|
||||
text("item"),
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_star_with_space_is_a_list_not_bold() {
|
||||
let lexed = lex("* item\n");
|
||||
assert_eq!(
|
||||
lexed[0],
|
||||
ListMarker {
|
||||
symbol: ListSymbol::Star,
|
||||
indent: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(lexed[1], text("item"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_kinds() {
|
||||
let cases: &[(&str, ListSymbol)] = &[
|
||||
("- a\n", ListSymbol::Dash),
|
||||
("* a\n", ListSymbol::Star),
|
||||
("# a\n", ListSymbol::Hash),
|
||||
("1. a\n", ListSymbol::Numeric),
|
||||
("1) a\n", ListSymbol::NumericParen),
|
||||
("a) a\n", ListSymbol::AlphaParen),
|
||||
("A) a\n", ListSymbol::AlphaUpperParen),
|
||||
("i) a\n", ListSymbol::RomanParen),
|
||||
("I) a\n", ListSymbol::RomanUpperParen),
|
||||
];
|
||||
for (line, expected) in cases {
|
||||
let lexed = lex(line);
|
||||
assert!(
|
||||
matches!(lexed[0], ListMarker { symbol, .. } if symbol == *expected),
|
||||
"input {line:?} expected {expected:?}, got {:?}",
|
||||
lexed[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_at_indent() {
|
||||
let lexed = lex(" - nested\n");
|
||||
assert_eq!(
|
||||
lexed[0],
|
||||
ListMarker {
|
||||
symbol: ListSymbol::Dash,
|
||||
indent: 4
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_states() {
|
||||
let cases: &[(&str, CheckboxState)] = &[
|
||||
("- [ ] a\n", CheckboxState::Empty),
|
||||
("- [.] a\n", CheckboxState::Quarter),
|
||||
("- [o] a\n", CheckboxState::Half),
|
||||
("- [O] a\n", CheckboxState::ThreeQuarters),
|
||||
("- [X] a\n", CheckboxState::Done),
|
||||
("- [-] a\n", CheckboxState::Rejected),
|
||||
];
|
||||
for (line, expected) in cases {
|
||||
let lexed = lex(line);
|
||||
assert!(
|
||||
matches!(lexed[1], Checkbox(s) if s == *expected),
|
||||
"input {line:?} expected {expected:?}, got {:?}",
|
||||
lexed[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_states_honor_custom_palette() {
|
||||
use nuwiki_core::listsyms::ListSyms;
|
||||
|
||||
let lex_with = |text: &str, syms: ListSyms| -> Vec<VimwikiTokenKind> {
|
||||
VimwikiLexer::new()
|
||||
.with_listsyms(syms)
|
||||
.lex(text)
|
||||
.into_iter()
|
||||
.map(|t| t.kind)
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 3-glyph palette ` x✓`: empty / half / done, plus the fixed rejected `-`.
|
||||
let syms = ListSyms::new(" x✓");
|
||||
let cases: &[(&str, CheckboxState)] = &[
|
||||
("- [ ] a\n", CheckboxState::Empty),
|
||||
("- [x] a\n", CheckboxState::Half),
|
||||
("- [✓] a\n", CheckboxState::Done),
|
||||
("- [-] a\n", CheckboxState::Rejected),
|
||||
];
|
||||
for (line, expected) in cases {
|
||||
let lexed = lex_with(line, syms.clone());
|
||||
assert!(
|
||||
matches!(lexed[1], Checkbox(s) if s == *expected),
|
||||
"input {line:?} expected {expected:?}, got {:?}",
|
||||
lexed[1]
|
||||
);
|
||||
}
|
||||
|
||||
// Default-palette glyphs are not checkboxes under this palette: `[o]`
|
||||
// is plain inline text, so no Checkbox token is emitted.
|
||||
let lexed = lex_with("- [o] a\n", syms);
|
||||
assert!(
|
||||
!lexed.iter().any(|k| matches!(k, Checkbox(_))),
|
||||
"expected no checkbox token, got {lexed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Blockquotes =====
|
||||
|
||||
#[test]
|
||||
fn angle_blockquote() {
|
||||
assert_eq!(
|
||||
lex("> quoted\n"),
|
||||
vec![BlockquoteMarker, text("quoted"), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_blockquote() {
|
||||
assert_eq!(
|
||||
lex(" quoted\n"),
|
||||
vec![BlockquoteIndent, text("quoted"), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_with_list_marker_is_a_list_not_blockquote() {
|
||||
let lexed = lex(" - item\n");
|
||||
assert!(matches!(lexed[0], ListMarker { indent: 4, .. }));
|
||||
}
|
||||
|
||||
// ===== Tables =====
|
||||
|
||||
#[test]
|
||||
fn simple_table_row() {
|
||||
assert_eq!(
|
||||
lex("| a | b |\n"),
|
||||
vec![
|
||||
TableSep,
|
||||
text(" a "),
|
||||
TableSep,
|
||||
text(" b "),
|
||||
TableSep,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_header_separator_row() {
|
||||
use nuwiki_core::ast::TableAlign;
|
||||
assert_eq!(
|
||||
lex("|---|---|\n"),
|
||||
vec![
|
||||
TableHeaderRow(vec![TableAlign::Default, TableAlign::Default]),
|
||||
Newline
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_col_and_row_span_cells() {
|
||||
let lexed = lex("| a | > | \\/ |\n");
|
||||
// sep, " a ", sep, ColSpan, sep, RowSpan, sep, newline
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
TableSep,
|
||||
text(" a "),
|
||||
TableSep,
|
||||
TableColSpan,
|
||||
TableSep,
|
||||
TableRowSpan,
|
||||
TableSep,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Definition list =====
|
||||
|
||||
#[test]
|
||||
fn definition_term_with_inline_definition() {
|
||||
let lexed = lex("Term:: Definition\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
text("Term"),
|
||||
DefinitionTermMarker,
|
||||
text("Definition"),
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_term_alone() {
|
||||
let lexed = lex("Term::\n");
|
||||
assert_eq!(lexed, vec![text("Term"), DefinitionTermMarker, Newline]);
|
||||
}
|
||||
|
||||
// ===== Inline typefaces =====
|
||||
|
||||
#[test]
|
||||
fn inline_bold_italic_strike_super_sub() {
|
||||
assert_eq!(
|
||||
lex("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n"),
|
||||
vec![
|
||||
BoldDelim,
|
||||
text("b"),
|
||||
BoldDelim,
|
||||
text(" "),
|
||||
ItalicDelim,
|
||||
text("i"),
|
||||
ItalicDelim,
|
||||
text(" "),
|
||||
StrikethroughDelim,
|
||||
text("s"),
|
||||
StrikethroughDelim,
|
||||
text(" "),
|
||||
SuperscriptDelim,
|
||||
text("sup"),
|
||||
SuperscriptDelim,
|
||||
text(" "),
|
||||
SubscriptDelim,
|
||||
text("sub"),
|
||||
SubscriptDelim,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_code_captures_content() {
|
||||
assert_eq!(lex("`x = 1`\n"), vec![Code("x = 1".into()), Newline]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_math_captures_content() {
|
||||
assert_eq!(
|
||||
lex("$E = mc^2$\n"),
|
||||
vec![MathInline("E = mc^2".into()), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Keywords =====
|
||||
|
||||
#[test]
|
||||
fn all_keywords() {
|
||||
let cases: &[(&str, Keyword)] = &[
|
||||
("TODO", Keyword::Todo),
|
||||
("DONE", Keyword::Done),
|
||||
("STARTED", Keyword::Started),
|
||||
("FIXME", Keyword::Fixme),
|
||||
("FIXED", Keyword::Fixed),
|
||||
("XXX", Keyword::Xxx),
|
||||
("STOPPED", Keyword::Stopped),
|
||||
];
|
||||
for (s, expected) in cases {
|
||||
let line = format!("{s} stuff\n");
|
||||
let lexed = lex(&line);
|
||||
assert!(
|
||||
matches!(lexed[0], Keyword(k) if k == *expected),
|
||||
"input {s:?} expected {expected:?}, got {:?}",
|
||||
lexed[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_only_at_word_boundary() {
|
||||
// Embedded TODO inside a longer word should NOT match as a keyword.
|
||||
let lexed = lex("aTODOb\n");
|
||||
assert_eq!(lexed, vec![text("aTODOb"), Newline]);
|
||||
}
|
||||
|
||||
// ===== Wikilinks / transclusions / raw URLs =====
|
||||
|
||||
#[test]
|
||||
fn bare_wikilink() {
|
||||
assert_eq!(
|
||||
lex("[[Page]]\n"),
|
||||
vec![WikiLinkOpen, text("Page"), WikiLinkClose, Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn described_wikilink() {
|
||||
assert_eq!(
|
||||
lex("[[Page|Description]]\n"),
|
||||
vec![
|
||||
WikiLinkOpen,
|
||||
text("Page"),
|
||||
WikiLinkSep,
|
||||
text("Description"),
|
||||
WikiLinkClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_anchor_is_lexed_as_text() {
|
||||
// The lexer doesn't carve out anchors; the parser does.
|
||||
let lexed = lex("[[Page#Anchor]]\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![WikiLinkOpen, text("Page#Anchor"), WikiLinkClose, Newline,]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt_and_attrs() {
|
||||
assert_eq!(
|
||||
lex("{{img.png|alt|class=hi}}\n"),
|
||||
vec![
|
||||
TransclusionOpen,
|
||||
text("img.png"),
|
||||
TransclusionSep,
|
||||
text("alt"),
|
||||
TransclusionSep,
|
||||
text("class=hi"),
|
||||
TransclusionClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_urls_for_each_supported_scheme() {
|
||||
for scheme in &[
|
||||
"https://example.com",
|
||||
"http://example.com/path?q=1",
|
||||
"ftp://archive.example.com/file.tar.gz",
|
||||
"mailto:hi@example.com",
|
||||
"file:///tmp/file.txt",
|
||||
] {
|
||||
let line = format!("see {scheme} now\n");
|
||||
let lexed = lex(&line);
|
||||
assert!(
|
||||
lexed.iter().any(|t| matches!(t, RawUrl(u) if u == scheme)),
|
||||
"scheme {scheme:?} not lexed; got {lexed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_drops_trailing_sentence_punctuation() {
|
||||
let lexed = lex("Visit https://example.com.\n");
|
||||
assert!(
|
||||
lexed
|
||||
.iter()
|
||||
.any(|t| matches!(t, RawUrl(u) if u == "https://example.com")),
|
||||
"got {lexed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Span correctness =====
|
||||
|
||||
#[test]
|
||||
fn spans_are_byte_accurate() {
|
||||
// Input layout:
|
||||
// Line 0: "= H ="
|
||||
// Line 1: "" (blank line)
|
||||
// Line 2: "*bold*"
|
||||
let src = "= H =\n\n*bold*\n";
|
||||
let tokens = VimwikiLexer::new().lex(src).into_vec();
|
||||
|
||||
// First token: HeadingOpen at line 0, col 0..1
|
||||
assert_eq!(
|
||||
tokens[0].span.start,
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
tokens[0].span.end,
|
||||
Position {
|
||||
line: 0,
|
||||
column: 1,
|
||||
offset: 1
|
||||
}
|
||||
);
|
||||
|
||||
// Find the BoldDelim opening on line 2.
|
||||
let bold_open = tokens
|
||||
.iter()
|
||||
.find(|t| matches!(t.kind, BoldDelim))
|
||||
.expect("bold delim");
|
||||
assert_eq!(bold_open.span.start.line, 2);
|
||||
assert_eq!(bold_open.span.start.column, 0);
|
||||
// "= H =\n" = 6 bytes, "\n" = 1, so line 2 starts at offset 7.
|
||||
assert_eq!(bold_open.span.start.offset, 7);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Cluster 8b — multi-line list-item continuation.
|
||||
//!
|
||||
//! Lines indented strictly past the item's marker attach to the item
|
||||
//! rather than becoming a sibling paragraph. Mirrors upstream vimwiki
|
||||
//! + Markdown.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, InlineNode};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn single_list(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::ListNode {
|
||||
match &doc.children[0] {
|
||||
BlockNode::List(l) => l,
|
||||
other => panic!("expected list, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(inlines: &[InlineNode]) -> String {
|
||||
let mut out = String::new();
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => out.push_str(&text_of(&b.children)),
|
||||
InlineNode::Italic(i) => out.push_str(&text_of(&i.children)),
|
||||
// Soft breaks join continuation lines (formerly a Text(" ")).
|
||||
InlineNode::SoftBreak(_) => out.push(' '),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indented_line_after_marker_continues_the_item() {
|
||||
let doc = parse("- first item\n continuing here\n- second\n");
|
||||
assert_eq!(doc.children.len(), 1, "should be a single list block");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 2);
|
||||
let first = &list.items[0];
|
||||
let joined = text_of(&first.children);
|
||||
assert_eq!(joined, "first item continuing here");
|
||||
let second = &list.items[1];
|
||||
assert_eq!(text_of(&second.children), "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_continuation_lines_both_attach() {
|
||||
let doc = parse("- top\n middle\n bottom\n");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 1);
|
||||
let joined = text_of(&list.items[0].children);
|
||||
assert_eq!(joined, "top middle bottom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unindented_line_breaks_the_list() {
|
||||
let doc = parse("- item\nflush against margin\n");
|
||||
// Two blocks: a list (just `- item`) and a paragraph.
|
||||
assert!(
|
||||
doc.children.len() >= 2,
|
||||
"want list + paragraph, got {}",
|
||||
doc.children.len()
|
||||
);
|
||||
match &doc.children[0] {
|
||||
BlockNode::List(l) => {
|
||||
assert_eq!(text_of(&l.items[0].children), "item");
|
||||
}
|
||||
other => panic!("expected list first, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_breaks_the_list_item() {
|
||||
// A blank line ends the current list (paragraph semantics).
|
||||
let doc = parse("- item\n\n orphaned\n");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 1);
|
||||
assert_eq!(text_of(&list.items[0].children), "item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_works_for_nested_lists_too() {
|
||||
let doc = parse("- parent\n - child\n continued child\n- sibling\n");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 2);
|
||||
let parent = &list.items[0];
|
||||
let sublist = parent.sublist.as_ref().expect("nested list");
|
||||
assert_eq!(sublist.items.len(), 1);
|
||||
let child = &sublist.items[0];
|
||||
assert_eq!(text_of(&child.children), "child continued child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_renderer_emits_a_single_li_per_item() {
|
||||
let doc = parse("- first item\n continuing here\n- second\n");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
// Single <ul>, two <li> entries — the bug we're fixing produced
|
||||
// two adjacent <ul> elements with an interleaved <p>.
|
||||
assert_eq!(out.matches("<ul>").count(), 1, "got: {out}");
|
||||
assert_eq!(out.matches("<li>").count(), 2, "got: {out}");
|
||||
assert!(!out.contains("<p>"), "no orphan paragraph; got: {out}");
|
||||
assert!(out.contains("first item continuing here"), "got: {out}");
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
//! End-to-end tests for the vimwiki parser. Each test runs source text
|
||||
//! through the lexer + parser and asserts on the resulting AST.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, CheckboxState, InlineNode, Keyword, LinkKind, ListSymbol};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(text: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(text)
|
||||
}
|
||||
|
||||
// ===== Document scaffolding =====
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_empty_document() {
|
||||
let doc = parse("");
|
||||
assert!(doc.children.is_empty());
|
||||
assert_eq!(doc.metadata.title, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_populate_metadata() {
|
||||
let doc = parse("%title My Page\n%nohtml\n%template fancy\n%date 2026-05-10\n");
|
||||
assert_eq!(doc.metadata.title.as_deref(), Some("My Page"));
|
||||
assert!(doc.metadata.nohtml);
|
||||
assert_eq!(doc.metadata.template.as_deref(), Some("fancy"));
|
||||
assert_eq!(doc.metadata.date.as_deref(), Some("2026-05-10"));
|
||||
// No body blocks.
|
||||
assert!(doc.children.is_empty());
|
||||
}
|
||||
|
||||
// ===== Headings =====
|
||||
|
||||
#[test]
|
||||
fn heading_each_level() {
|
||||
for level in 1..=6 {
|
||||
let line = format!(
|
||||
"{} L{} {}\n",
|
||||
"=".repeat(level as usize),
|
||||
level,
|
||||
"=".repeat(level as usize)
|
||||
);
|
||||
let doc = parse(&line);
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading at level {level}");
|
||||
};
|
||||
assert_eq!(h.level, level);
|
||||
assert!(!h.centered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_flag() {
|
||||
let doc = parse(" = title =\n");
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading");
|
||||
};
|
||||
assert!(h.centered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_inline_bold() {
|
||||
let doc = parse("== Hello *world* ==\n");
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading");
|
||||
};
|
||||
// Children: Text("Hello "), Bold(Text("world"))
|
||||
assert_eq!(h.children.len(), 2);
|
||||
assert!(matches!(&h.children[0], InlineNode::Text(t) if t.content == "Hello "));
|
||||
let InlineNode::Bold(b) = &h.children[1] else {
|
||||
panic!("expected Bold");
|
||||
};
|
||||
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "world"));
|
||||
}
|
||||
|
||||
// ===== Paragraphs =====
|
||||
|
||||
#[test]
|
||||
fn paragraph_simple_text() {
|
||||
let doc = parse("Hello world\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
assert_eq!(p.children.len(), 1);
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_spans_multiple_lines() {
|
||||
let doc = parse("Hello\nworld\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// Contents: Text("Hello"), SoftBreak (from soft newline), Text("world").
|
||||
let text_concat: String = p
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
InlineNode::Text(t) => Some(t.content.clone()),
|
||||
InlineNode::SoftBreak(_) => Some(" ".to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text_concat, "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_breaks_paragraph() {
|
||||
let doc = parse("first\n\nsecond\n");
|
||||
assert_eq!(doc.children.len(), 2);
|
||||
assert!(matches!(doc.children[0], BlockNode::Paragraph(_)));
|
||||
assert!(matches!(doc.children[1], BlockNode::Paragraph(_)));
|
||||
}
|
||||
|
||||
// ===== Horizontal rule =====
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule() {
|
||||
let doc = parse("----\n");
|
||||
assert!(matches!(doc.children[0], BlockNode::HorizontalRule(_)));
|
||||
}
|
||||
|
||||
// ===== Lists =====
|
||||
|
||||
#[test]
|
||||
fn flat_unordered_list() {
|
||||
let doc = parse("- one\n- two\n- three\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
assert!(!list.ordered);
|
||||
assert_eq!(list.symbol, ListSymbol::Dash);
|
||||
assert_eq!(list.items.len(), 3);
|
||||
for (item, expected) in list.items.iter().zip(["one", "two", "three"]) {
|
||||
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_list_each_marker_kind() {
|
||||
let cases: &[(&str, ListSymbol, bool)] = &[
|
||||
("- a\n", ListSymbol::Dash, false),
|
||||
("* a\n", ListSymbol::Star, false),
|
||||
("# a\n", ListSymbol::Hash, false),
|
||||
("1. a\n", ListSymbol::Numeric, true),
|
||||
("1) a\n", ListSymbol::NumericParen, true),
|
||||
("a) a\n", ListSymbol::AlphaParen, true),
|
||||
("A) a\n", ListSymbol::AlphaUpperParen, true),
|
||||
("i) a\n", ListSymbol::RomanParen, true),
|
||||
("I) a\n", ListSymbol::RomanUpperParen, true),
|
||||
];
|
||||
for (src, sym, ordered) in cases {
|
||||
let doc = parse(src);
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list for {src:?}");
|
||||
};
|
||||
assert_eq!(list.symbol, *sym);
|
||||
assert_eq!(list.ordered, *ordered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_list_via_indent() {
|
||||
let doc = parse("- top\n - nested\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
let item = &list.items[0];
|
||||
let sublist = item.sublist.as_ref().expect("sublist on nested item");
|
||||
assert_eq!(sublist.items.len(), 1);
|
||||
assert!(matches!(&sublist.items[0].children[0], InlineNode::Text(t) if t.content == "nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_item_with_checkbox() {
|
||||
let doc = parse("- [X] done\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
let item = &list.items[0];
|
||||
assert_eq!(item.checkbox, Some(CheckboxState::Done));
|
||||
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == "done"));
|
||||
}
|
||||
|
||||
// ===== Blockquotes =====
|
||||
|
||||
#[test]
|
||||
fn angle_blockquote_two_lines() {
|
||||
let doc = parse("> first\n> second\n");
|
||||
let BlockNode::Blockquote(bq) = &doc.children[0] else {
|
||||
panic!("expected blockquote");
|
||||
};
|
||||
assert_eq!(bq.children.len(), 2);
|
||||
for (i, expected) in ["first", "second"].iter().enumerate() {
|
||||
let BlockNode::Paragraph(p) = &bq.children[i] else {
|
||||
panic!("blockquote child should be a paragraph");
|
||||
};
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == *expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_blockquote() {
|
||||
let doc = parse(" quoted\n");
|
||||
let BlockNode::Blockquote(bq) = &doc.children[0] else {
|
||||
panic!("expected blockquote");
|
||||
};
|
||||
assert_eq!(bq.children.len(), 1);
|
||||
}
|
||||
|
||||
// ===== Tables =====
|
||||
|
||||
#[test]
|
||||
fn simple_table_row() {
|
||||
let doc = parse("| a | b |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
let row = &t.rows[0];
|
||||
assert_eq!(row.cells.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_with_header_separator() {
|
||||
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
assert!(t.has_header);
|
||||
assert!(t.rows[0].is_header);
|
||||
assert!(!t.rows[1].is_header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_col_and_row_span_cells() {
|
||||
let doc = parse("| a | > | \\/ |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
let cells = &t.rows[0].cells;
|
||||
assert!(!cells[0].col_span && !cells[0].row_span);
|
||||
assert!(cells[1].col_span);
|
||||
assert!(cells[2].row_span);
|
||||
}
|
||||
|
||||
// ===== Definition list =====
|
||||
|
||||
#[test]
|
||||
fn definition_term_with_inline_definition() {
|
||||
let doc = parse("Term:: Definition\n");
|
||||
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
|
||||
panic!("expected definition list");
|
||||
};
|
||||
assert_eq!(dl.items.len(), 1);
|
||||
let item = &dl.items[0];
|
||||
let term = item.term.as_ref().expect("term");
|
||||
assert!(matches!(&term[0], InlineNode::Text(t) if t.content == "Term"));
|
||||
assert_eq!(item.definitions.len(), 1);
|
||||
let def = &item.definitions[0];
|
||||
assert!(matches!(&def[0], InlineNode::Text(t) if t.content == "Definition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_definition_items() {
|
||||
let doc = parse("A:: 1\nB:: 2\n");
|
||||
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
|
||||
panic!("expected definition list");
|
||||
};
|
||||
assert_eq!(dl.items.len(), 2);
|
||||
}
|
||||
|
||||
// ===== Preformatted / Math =====
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_captures_content_and_language() {
|
||||
let doc = parse("{{{rust\nfn main() {}\n}}}\n");
|
||||
let BlockNode::Preformatted(p) = &doc.children[0] else {
|
||||
panic!("expected preformatted");
|
||||
};
|
||||
assert_eq!(p.content, "fn main() {}");
|
||||
assert_eq!(p.language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_block_captures_content_and_environment() {
|
||||
let doc = parse("{{$%align%\nx = 1\n}}$\n");
|
||||
let BlockNode::MathBlock(m) = &doc.children[0] else {
|
||||
panic!("expected math block");
|
||||
};
|
||||
assert_eq!(m.content, "x = 1");
|
||||
assert_eq!(m.environment.as_deref(), Some("align"));
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
|
||||
#[test]
|
||||
fn single_line_comment_block() {
|
||||
let doc = parse("%% hidden\n");
|
||||
let BlockNode::Comment(c) = &doc.children[0] else {
|
||||
panic!("expected comment");
|
||||
};
|
||||
assert_eq!(c.content, " hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_comment_block() {
|
||||
let doc = parse("%%+ a\nb\n+%%\n");
|
||||
let BlockNode::Comment(c) = &doc.children[0] else {
|
||||
panic!("expected comment");
|
||||
};
|
||||
assert_eq!(c.content, " a\nb");
|
||||
}
|
||||
|
||||
// ===== Inline =====
|
||||
|
||||
#[test]
|
||||
fn inline_bold_italic_strike_super_sub_code_math() {
|
||||
let doc = parse("*b* _i_ ~~s~~ ^sup^ ,,sub,, `c` $m$\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let kinds: Vec<&str> = p
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| match n {
|
||||
InlineNode::Bold(_) => "Bold",
|
||||
InlineNode::Italic(_) => "Italic",
|
||||
InlineNode::Strikethrough(_) => "Strike",
|
||||
InlineNode::Superscript(_) => "Sup",
|
||||
InlineNode::Subscript(_) => "Sub",
|
||||
InlineNode::Code(_) => "Code",
|
||||
InlineNode::MathInline(_) => "Math",
|
||||
InlineNode::Text(_) => "Text",
|
||||
_ => "Other",
|
||||
})
|
||||
.collect();
|
||||
assert!(kinds.contains(&"Bold"));
|
||||
assert!(kinds.contains(&"Italic"));
|
||||
assert!(kinds.contains(&"Strike"));
|
||||
assert!(kinds.contains(&"Sup"));
|
||||
assert!(kinds.contains(&"Sub"));
|
||||
assert!(kinds.contains(&"Code"));
|
||||
assert!(kinds.contains(&"Math"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_bold_falls_back_to_text() {
|
||||
let doc = parse("2 * 3 = 6\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// No Bold node should appear; the orphan `*` becomes literal text.
|
||||
assert!(!p.children.iter().any(|n| matches!(n, InlineNode::Bold(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_italic_around_bold() {
|
||||
let doc = parse("_*x*_\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Italic(it) = &p.children[0] else {
|
||||
panic!("expected italic outer");
|
||||
};
|
||||
let InlineNode::Bold(b) = &it.children[0] else {
|
||||
panic!("expected bold inside italic");
|
||||
};
|
||||
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords_become_keyword_nodes() {
|
||||
let cases = [
|
||||
("TODO", Keyword::Todo),
|
||||
("DONE", Keyword::Done),
|
||||
("STARTED", Keyword::Started),
|
||||
("FIXME", Keyword::Fixme),
|
||||
("FIXED", Keyword::Fixed),
|
||||
("XXX", Keyword::Xxx),
|
||||
("STOPPED", Keyword::Stopped),
|
||||
];
|
||||
for (src, expected) in cases {
|
||||
let doc = parse(&format!("{src} stuff\n"));
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Keyword(k) = &p.children[0] else {
|
||||
panic!("expected keyword for {src}");
|
||||
};
|
||||
assert_eq!(k.keyword, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_inline() {
|
||||
let doc = parse("see https://example.com here\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
assert!(p
|
||||
.children
|
||||
.iter()
|
||||
.any(|n| matches!(n, InlineNode::RawUrl(u) if u.url == "https://example.com")));
|
||||
}
|
||||
|
||||
// ===== Wikilinks / external links / transclusions =====
|
||||
|
||||
#[test]
|
||||
fn bare_wikilink() {
|
||||
let doc = parse("[[Page]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.kind, LinkKind::Wiki);
|
||||
assert_eq!(w.target.path.as_deref(), Some("Page"));
|
||||
assert!(w.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_anchor() {
|
||||
let doc = parse("[[Page#Section]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.path.as_deref(), Some("Page"));
|
||||
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_wikilink() {
|
||||
let doc = parse("[[#Section]]\n");
|
||||
let InlineNode::WikiLink(w) = &if let BlockNode::Paragraph(p) = &doc.children[0] {
|
||||
p
|
||||
} else {
|
||||
panic!()
|
||||
}
|
||||
.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.kind, LinkKind::AnchorOnly);
|
||||
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_relative_and_filesystem_absolute_wikilinks() {
|
||||
let doc = parse("[[/Page]] [[//etc/hosts]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let mut links = p.children.iter().filter_map(|n| match n {
|
||||
InlineNode::WikiLink(w) => Some(w),
|
||||
_ => None,
|
||||
});
|
||||
let root = links.next().expect("root-relative");
|
||||
assert_eq!(root.target.kind, LinkKind::Wiki);
|
||||
assert!(root.target.is_absolute);
|
||||
assert_eq!(root.target.path.as_deref(), Some("Page"));
|
||||
|
||||
let local = links.next().expect("filesystem-absolute");
|
||||
assert_eq!(local.target.kind, LinkKind::Local);
|
||||
assert!(local.target.is_absolute);
|
||||
assert_eq!(local.target.path.as_deref(), Some("etc/hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_wikilink() {
|
||||
let doc = parse("[[dir/]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert!(w.target.is_directory);
|
||||
assert_eq!(w.target.path.as_deref(), Some("dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_links_numbered_and_named() {
|
||||
let doc = parse("[[wiki1:Page]] [[wn.Notes:Page]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let mut links = p.children.iter().filter_map(|n| match n {
|
||||
InlineNode::WikiLink(w) => Some(w),
|
||||
_ => None,
|
||||
});
|
||||
let numbered = links.next().expect("numbered");
|
||||
assert_eq!(numbered.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(numbered.target.wiki_index, Some(1));
|
||||
assert_eq!(numbered.target.path.as_deref(), Some("Page"));
|
||||
|
||||
let named = links.next().expect("named");
|
||||
assert_eq!(named.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(named.target.wiki_name.as_deref(), Some("Notes"));
|
||||
assert_eq!(named.target.path.as_deref(), Some("Page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_and_file_and_local_kinds() {
|
||||
for (src, expected) in [
|
||||
("[[diary:2026-05-10]]", LinkKind::Diary),
|
||||
("[[file:/tmp/note.txt]]", LinkKind::File),
|
||||
("[[local:rel/path]]", LinkKind::Local),
|
||||
] {
|
||||
let doc = parse(&format!("{src}\n"));
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink for {src}");
|
||||
};
|
||||
assert_eq!(w.target.kind, expected, "src {src}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_inside_brackets_is_external_link() {
|
||||
let doc = parse("[[https://example.com|docs]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::ExternalLink(e) = &p.children[0] else {
|
||||
panic!("expected external link");
|
||||
};
|
||||
assert_eq!(e.url, "https://example.com");
|
||||
let desc = e.description.as_ref().expect("description");
|
||||
assert!(matches!(&desc[0], InlineNode::Text(t) if t.content == "docs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt() {
|
||||
let doc = parse("{{img.png|alt text}}\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Transclusion(t) = &p.children[0] else {
|
||||
panic!("expected transclusion");
|
||||
};
|
||||
assert_eq!(t.url, "img.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("alt text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt_and_attrs() {
|
||||
let doc = parse("{{img.png|alt|class=hi|width=200}}\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Transclusion(t) = &p.children[0] else {
|
||||
panic!("expected transclusion");
|
||||
};
|
||||
assert_eq!(t.url, "img.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("alt"));
|
||||
assert_eq!(t.attrs.get("class").map(String::as_str), Some("hi"));
|
||||
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
|
||||
}
|
||||
|
||||
// ===== Resilience =====
|
||||
|
||||
#[test]
|
||||
fn unterminated_wikilink_falls_back_to_text() {
|
||||
let doc = parse("[[Unterminated\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// First child should be literal "[[" text.
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "[["));
|
||||
}
|
||||
|
||||
// ===== End-to-end through the registry =====
|
||||
|
||||
#[test]
|
||||
fn vimwiki_syntax_registers_and_dispatches() {
|
||||
use nuwiki_core::syntax::SyntaxRegistry;
|
||||
|
||||
let mut reg = SyntaxRegistry::new();
|
||||
reg.register(VimwikiSyntax::new());
|
||||
|
||||
let plugin = reg.detect_from_extension(".wiki").expect("plugin by ext");
|
||||
assert_eq!(plugin.id(), "vimwiki");
|
||||
|
||||
let doc = plugin.parse("= Hello =\n");
|
||||
assert!(matches!(doc.children[0], BlockNode::Heading(_)));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Integration tests for the syntax plugin interface.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, DocumentNode, ParagraphNode, Span, TextNode,
|
||||
};
|
||||
use nuwiki_core::syntax::{Lexer, Parser, SyntaxPlugin, SyntaxRegistry, TokenStream};
|
||||
|
||||
// --- A minimal mock plugin: tokenises the input on whitespace, then wraps
|
||||
// the joined text in a single Paragraph. Just enough surface to exercise
|
||||
// every trait + the registry.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MockToken(String);
|
||||
|
||||
struct MockLexer;
|
||||
impl Lexer for MockLexer {
|
||||
type Token = MockToken;
|
||||
|
||||
fn lex(&self, text: &str) -> TokenStream<MockToken> {
|
||||
TokenStream::from_vec(
|
||||
text.split_whitespace()
|
||||
.map(|w| MockToken(w.to_owned()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct MockParser;
|
||||
impl Parser for MockParser {
|
||||
type Token = MockToken;
|
||||
|
||||
fn parse(&self, tokens: TokenStream<MockToken>) -> DocumentNode {
|
||||
let joined = tokens
|
||||
.iter()
|
||||
.map(|t| t.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let paragraph = BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: joined,
|
||||
})],
|
||||
});
|
||||
DocumentNode {
|
||||
span: Span::default(),
|
||||
children: vec![paragraph],
|
||||
metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MockPlugin;
|
||||
impl SyntaxPlugin for MockPlugin {
|
||||
fn id(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
fn display_name(&self) -> &str {
|
||||
"Mock Syntax"
|
||||
}
|
||||
fn file_extensions(&self) -> &[&str] {
|
||||
&[".mock", ".mk"]
|
||||
}
|
||||
fn parse(&self, text: &str) -> DocumentNode {
|
||||
MockParser.parse(MockLexer.lex(text))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_stream_roundtrip() {
|
||||
let mut s = TokenStream::<u32>::new();
|
||||
assert!(s.is_empty());
|
||||
s.push(1);
|
||||
s.push(2);
|
||||
s.push(3);
|
||||
assert_eq!(s.len(), 3);
|
||||
assert_eq!(s.as_slice(), &[1, 2, 3]);
|
||||
assert_eq!(s.iter().sum::<u32>(), 6);
|
||||
assert_eq!((&s).into_iter().sum::<u32>(), 6);
|
||||
assert_eq!(s.into_vec(), vec![1, 2, 3]);
|
||||
|
||||
let from_vec: TokenStream<&str> = vec!["a", "b"].into();
|
||||
assert_eq!(from_vec.len(), 2);
|
||||
let collected: Vec<&str> = from_vec.into_iter().collect();
|
||||
assert_eq!(collected, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_and_parser_chain_through_plugin() {
|
||||
let plugin = MockPlugin;
|
||||
let doc = plugin.parse(" hello world ");
|
||||
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected a paragraph, got {:?}", doc.children[0]);
|
||||
};
|
||||
let InlineNode::Text(t) = &p.children[0] else {
|
||||
panic!("expected a text node");
|
||||
};
|
||||
assert_eq!(t.content, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lookup_by_id_and_extension() {
|
||||
let mut registry = SyntaxRegistry::new();
|
||||
assert!(registry.is_empty());
|
||||
|
||||
registry.register(MockPlugin);
|
||||
assert_eq!(registry.len(), 1);
|
||||
|
||||
let by_id = registry
|
||||
.get("mock")
|
||||
.expect("plugin should be findable by id");
|
||||
assert_eq!(by_id.display_name(), "Mock Syntax");
|
||||
|
||||
let by_ext = registry
|
||||
.detect_from_extension(".mk")
|
||||
.expect("plugin should be findable by secondary extension");
|
||||
assert_eq!(by_ext.id(), "mock");
|
||||
|
||||
assert!(registry.get("nonexistent").is_none());
|
||||
assert!(registry.detect_from_extension(".unknown").is_none());
|
||||
|
||||
// Iteration order matches registration order.
|
||||
let ids: Vec<&str> = registry.iter().map(|p| p.id()).collect();
|
||||
assert_eq!(ids, vec!["mock"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_can_parse_through_trait_object() {
|
||||
let mut registry = SyntaxRegistry::new();
|
||||
registry.register(MockPlugin);
|
||||
|
||||
let plugin = registry.detect_from_extension(".mock").unwrap();
|
||||
let doc = plugin.parse("a b c");
|
||||
assert_eq!(doc.children.len(), 1);
|
||||
}
|
||||
|
||||
/// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server
|
||||
/// can share it across handler tasks.
|
||||
#[test]
|
||||
fn registry_is_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<SyntaxRegistry>();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Cluster 7 — Markdown-style alignment markers on the header
|
||||
//! separator row (`|:--|--:|:--:|`) propagate to the AST and HTML.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, TableAlign};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separator_row_with_no_anchors_yields_defaults() {
|
||||
let doc = parse("| h1 | h2 |\n|----|----|\n| a | b |\n");
|
||||
let table = match &doc.children[0] {
|
||||
BlockNode::Table(t) => t,
|
||||
_ => panic!("expected table"),
|
||||
};
|
||||
assert_eq!(
|
||||
table.alignments,
|
||||
vec![TableAlign::Default, TableAlign::Default]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_right_center_anchors_parsed() {
|
||||
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
|
||||
let table = match &doc.children[0] {
|
||||
BlockNode::Table(t) => t,
|
||||
_ => panic!("expected table"),
|
||||
};
|
||||
assert_eq!(
|
||||
table.alignments,
|
||||
vec![TableAlign::Left, TableAlign::Right, TableAlign::Center]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_renderer_emits_text_align_style() {
|
||||
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains(r#"<th style="text-align: left;"> a </th>"#),
|
||||
"got: {out}"
|
||||
);
|
||||
assert!(out.contains(r#"<th style="text-align: right;"> b </th>"#));
|
||||
assert!(out.contains(r#"<th style="text-align: center;"> c </th>"#));
|
||||
assert!(out.contains(r#"<td style="text-align: left;"> 1 </td>"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_without_alignment_omits_style_attr() {
|
||||
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(!out.contains("text-align"));
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Tag tests: lexer + parser + renderer behaviour on
|
||||
//! `:tag:` lines, plus the scope-detection rules.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, TagScope};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiSyntax, VimwikiTokenKind};
|
||||
use nuwiki_core::syntax::Lexer;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn lex(src: &str) -> Vec<VimwikiTokenKind> {
|
||||
VimwikiLexer::new()
|
||||
.lex(src)
|
||||
.into_iter()
|
||||
.map(|t| t.kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn render(src: &str) -> String {
|
||||
let doc = parse(src);
|
||||
HtmlRenderer::new().render_to_string(&doc).unwrap()
|
||||
}
|
||||
|
||||
// ===== Lexer =====
|
||||
|
||||
#[test]
|
||||
fn lexer_emits_tag_for_single_tag_line() {
|
||||
let toks = lex(":todo:\n");
|
||||
assert!(toks
|
||||
.iter()
|
||||
.any(|t| matches!(t, VimwikiTokenKind::Tag(v) if v == &vec!["todo".to_string()])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_emits_tag_for_multiple_chained_tags() {
|
||||
let toks = lex(":one:two:three:\n");
|
||||
let names = toks
|
||||
.iter()
|
||||
.find_map(|t| match t {
|
||||
VimwikiTokenKind::Tag(v) => Some(v.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag token");
|
||||
assert_eq!(names, vec!["one", "two", "three"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_rejects_empty_segment() {
|
||||
// `::tag::` — empty leading segment between the doubled colons.
|
||||
let toks = lex("::tag::\n");
|
||||
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_rejects_tag_with_whitespace() {
|
||||
let toks = lex(":one tag:other:\n");
|
||||
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_does_not_collide_with_definition_term() {
|
||||
// `Term::` is a definition-term marker (text *before* the `::`); a
|
||||
// tag line has nothing before its leading `:`. The two patterns must
|
||||
// not steal each other's lines.
|
||||
let toks = lex("Term:: Definition\n:foo:\n");
|
||||
assert!(toks
|
||||
.iter()
|
||||
.any(|t| matches!(t, VimwikiTokenKind::DefinitionTermMarker)));
|
||||
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_accepts_indented_tag_line() {
|
||||
let toks = lex(" :indented:\n");
|
||||
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
// ===== Parser scope rules =====
|
||||
|
||||
#[test]
|
||||
fn scope_is_file_when_tag_is_on_line_0_or_1() {
|
||||
let doc = parse(":todo:other:\n= Heading =\n");
|
||||
let BlockNode::Tag(t) = &doc.children[0] else {
|
||||
panic!(
|
||||
"expected first block to be a Tag, got {:?}",
|
||||
doc.children[0]
|
||||
);
|
||||
};
|
||||
assert_eq!(t.scope, TagScope::File);
|
||||
assert_eq!(t.tags, vec!["todo", "other"]);
|
||||
assert_eq!(doc.metadata.tags, vec!["todo", "other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_file_for_tag_on_line_1() {
|
||||
let doc = parse("a paragraph\n:todo:\n");
|
||||
// Tag is on line 1, so still file-level.
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
assert_eq!(tag.scope, TagScope::File);
|
||||
assert!(doc.metadata.tags.contains(&"todo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_heading_when_tag_is_one_or_two_lines_below_heading() {
|
||||
// Heading at line 0; tag at line 1 (one below) → Heading(0).
|
||||
let doc = parse("= H1 =\n:hot:\n");
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
// Tag at line 1 is *also* line ≤ 1 → file rule wins (matches vimwiki:
|
||||
// file rule applies to lines 0-1 regardless of preceding heading).
|
||||
assert_eq!(tag.scope, TagScope::File);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_heading_for_tag_two_lines_below_a_deeper_heading() {
|
||||
// Heading at line 3; tag at line 4 (one below).
|
||||
let src = "first\n\n\n= Section =\n:scoped:\n";
|
||||
let doc = parse(src);
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
// Heading is the only one seen; index 0.
|
||||
assert_eq!(tag.scope, TagScope::Heading(0));
|
||||
// Heading-scope tags do NOT contribute to file-level metadata.
|
||||
assert!(doc.metadata.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_standalone_for_distant_tag() {
|
||||
// Heading at line 3; tag at line 6 → 3 lines below → out of the
|
||||
// heading window (>2), so standalone.
|
||||
let src = "x\n\n\n= H =\n\n\n:wandering:\n";
|
||||
let doc = parse(src);
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
assert_eq!(tag.scope, TagScope::Standalone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_standalone_when_no_heading_precedes() {
|
||||
// No heading at all and not on lines 0-1.
|
||||
let doc = parse("a\nb\nc\n:lonely:\n");
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
assert_eq!(tag.scope, TagScope::Standalone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tags_are_deduped_in_metadata() {
|
||||
let doc = parse(":a:b:\n:b:c:\n");
|
||||
assert_eq!(doc.metadata.tags, vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
// ===== Renderer =====
|
||||
|
||||
#[test]
|
||||
fn renderer_emits_div_with_tag_spans() {
|
||||
let html = render(":todo:done:\n");
|
||||
assert!(html.contains("<div class=\"tags\">"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_escapes_html_inside_tag_names() {
|
||||
// Tags shouldn't contain `<`/`>` per the lexer rule (whitespace
|
||||
// forbidden, but `<` isn't), so this checks the renderer's escaping
|
||||
// fires anyway as defence-in-depth.
|
||||
let html = render(":a<b:\n");
|
||||
assert!(html.contains("a<b"));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Cluster 8 — transclusion attribute parsing. The basic
|
||||
//! `{{url|alt|key=val}}` shape was already wired; this test pins
|
||||
//! down quote-stripping on quoted values (`key="quoted value"`)
|
||||
//! so an HTML renderer doesn't double-emit `"` artefacts.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, InlineNode};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn first_transclusion(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::TransclusionNode {
|
||||
let para = match &doc.children[0] {
|
||||
BlockNode::Paragraph(p) => p,
|
||||
_ => panic!("expected paragraph"),
|
||||
};
|
||||
for child in ¶.children {
|
||||
if let InlineNode::Transclusion(t) = child {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
panic!("no transclusion in paragraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_attrs_strip_double_quotes() {
|
||||
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
|
||||
let t = first_transclusion(&doc);
|
||||
assert_eq!(t.url, "cat.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("cat"));
|
||||
assert_eq!(
|
||||
t.attrs.get("style").map(String::as_str),
|
||||
Some("border: 1px")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_attrs_strip_single_quotes() {
|
||||
let doc = parse("{{cat.png|cat|class='thumb'}}");
|
||||
let t = first_transclusion(&doc);
|
||||
assert_eq!(t.attrs.get("class").map(String::as_str), Some("thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_attrs_leave_unquoted_values_alone() {
|
||||
let doc = parse("{{cat.png|cat|width=200}}");
|
||||
let t = first_transclusion(&doc);
|
||||
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_renders_clean_html_with_quoted_attr() {
|
||||
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"style="border: 1px""#), "got: {out}");
|
||||
assert!(!out.contains("""));
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Smoke test for the AST visitor: build a small document by hand and
|
||||
//! confirm a Visitor descends into every nested node exactly once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BoldNode, DocumentNode, ExternalLinkNode, HeadingNode, InlineNode, ListItemNode,
|
||||
ListNode, ListSymbol, ParagraphNode, Span, TableCellNode, TableNode, TableRowNode, TextNode,
|
||||
TransclusionNode, Visitor,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counter {
|
||||
headings: usize,
|
||||
paragraphs: usize,
|
||||
text: usize,
|
||||
bold: usize,
|
||||
list_items: usize,
|
||||
table_cells: usize,
|
||||
external_links: usize,
|
||||
transclusions: usize,
|
||||
blocks: usize,
|
||||
inlines: usize,
|
||||
}
|
||||
|
||||
impl Visitor for Counter {
|
||||
fn visit_block(&mut self, node: &BlockNode) {
|
||||
self.blocks += 1;
|
||||
nuwiki_core::ast::walk_block(self, node);
|
||||
}
|
||||
fn visit_inline(&mut self, node: &InlineNode) {
|
||||
self.inlines += 1;
|
||||
nuwiki_core::ast::walk_inline(self, node);
|
||||
}
|
||||
fn visit_heading(&mut self, node: &HeadingNode) {
|
||||
self.headings += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_paragraph(&mut self, node: &ParagraphNode) {
|
||||
self.paragraphs += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_text(&mut self, _: &TextNode) {
|
||||
self.text += 1;
|
||||
}
|
||||
fn visit_bold(&mut self, node: &BoldNode) {
|
||||
self.bold += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_list_item(&mut self, node: &ListItemNode) {
|
||||
self.list_items += 1;
|
||||
nuwiki_core::ast::walk_list_item(self, node);
|
||||
}
|
||||
fn visit_table_cell(&mut self, node: &TableCellNode) {
|
||||
self.table_cells += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_external_link(&mut self, node: &ExternalLinkNode) {
|
||||
self.external_links += 1;
|
||||
if let Some(desc) = &node.description {
|
||||
for child in desc {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn visit_transclusion(&mut self, _: &TransclusionNode) {
|
||||
self.transclusions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn text(s: &str) -> InlineNode {
|
||||
InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: s.into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visitor_descends_into_every_node() {
|
||||
// = Title with *bold* word =
|
||||
let heading = BlockNode::Heading(HeadingNode {
|
||||
span: Span::default(),
|
||||
level: 1,
|
||||
centered: false,
|
||||
children: vec![
|
||||
text("Title with "),
|
||||
InlineNode::Bold(BoldNode {
|
||||
span: Span::default(),
|
||||
children: vec![text("bold")],
|
||||
}),
|
||||
text(" word"),
|
||||
],
|
||||
});
|
||||
|
||||
// A paragraph containing an external link with two text children in its
|
||||
// description, and a transclusion sibling.
|
||||
let paragraph = BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![
|
||||
InlineNode::ExternalLink(ExternalLinkNode {
|
||||
span: Span::default(),
|
||||
url: "https://example.com".into(),
|
||||
description: Some(vec![text("see "), text("docs")]),
|
||||
}),
|
||||
InlineNode::Transclusion(TransclusionNode {
|
||||
span: Span::default(),
|
||||
url: "image.png".into(),
|
||||
alt: None,
|
||||
attrs: HashMap::new(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// - one
|
||||
// - two
|
||||
let list = BlockNode::List(ListNode {
|
||||
span: Span::default(),
|
||||
ordered: false,
|
||||
symbol: ListSymbol::Dash,
|
||||
items: vec![
|
||||
ListItemNode {
|
||||
span: Span::default(),
|
||||
symbol: ListSymbol::Dash,
|
||||
level: 0,
|
||||
checkbox: None,
|
||||
children: vec![text("one")],
|
||||
sublist: None,
|
||||
},
|
||||
ListItemNode {
|
||||
span: Span::default(),
|
||||
symbol: ListSymbol::Dash,
|
||||
level: 0,
|
||||
checkbox: None,
|
||||
children: vec![text("two")],
|
||||
sublist: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// | a | b |
|
||||
let table = BlockNode::Table(TableNode {
|
||||
span: Span::default(),
|
||||
has_header: false,
|
||||
alignments: Vec::new(),
|
||||
rows: vec![TableRowNode {
|
||||
span: Span::default(),
|
||||
is_header: false,
|
||||
cells: vec![
|
||||
TableCellNode {
|
||||
span: Span::default(),
|
||||
children: vec![text("a")],
|
||||
col_span: false,
|
||||
row_span: false,
|
||||
},
|
||||
TableCellNode {
|
||||
span: Span::default(),
|
||||
children: vec![text("b")],
|
||||
col_span: false,
|
||||
row_span: false,
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![heading, paragraph, list, table],
|
||||
};
|
||||
|
||||
let mut counter = Counter::default();
|
||||
counter.visit_document(&doc);
|
||||
|
||||
// Block-level: 4 top-level + 2 list items aren't blocks
|
||||
assert_eq!(counter.blocks, 4, "top-level blocks");
|
||||
assert_eq!(counter.headings, 1);
|
||||
assert_eq!(counter.paragraphs, 1);
|
||||
assert_eq!(counter.list_items, 2);
|
||||
// 1 cell row with 2 cells
|
||||
assert_eq!(counter.table_cells, 2);
|
||||
|
||||
// Inlines:
|
||||
// heading: 3 ("Title with ", Bold, " word") + 1 (bold child) = 4
|
||||
// paragraph: 2 (ExternalLink, Transclusion) + 2 (link desc) = 4
|
||||
// list items: 2 (one each)
|
||||
// table cells: 2 (one each)
|
||||
// total: 12
|
||||
assert_eq!(counter.inlines, 12);
|
||||
// Text leaves: heading 3, link description 2, list items 2, table cells 2 = 9
|
||||
assert_eq!(counter.text, 9, "Text leaves");
|
||||
assert_eq!(counter.bold, 1);
|
||||
assert_eq!(counter.external_links, 1);
|
||||
assert_eq!(counter.transclusions, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_default_is_empty() {
|
||||
let doc = DocumentNode::default();
|
||||
assert!(doc.children.is_empty());
|
||||
assert_eq!(doc.metadata.title, None);
|
||||
assert!(!doc.metadata.nohtml);
|
||||
|
||||
let mut counter = Counter::default();
|
||||
counter.visit_document(&doc);
|
||||
assert_eq!(counter.blocks, 0);
|
||||
assert_eq!(counter.inlines, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user