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);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "nuwiki-ls"
|
||||
description = "nuwiki language server binary — speaks LSP over stdio."
|
||||
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
|
||||
|
||||
[[bin]]
|
||||
name = "nuwiki-ls"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.3.0" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
|
||||
@@ -0,0 +1,6 @@
|
||||
//! `nuwiki-ls` — language-server binary. Speaks LSP over stdio.
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
nuwiki_lsp::run_stdio().await
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "nuwiki-lsp"
|
||||
description = "LSP protocol bridge for nuwiki, built on top of nuwiki-core."
|
||||
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]
|
||||
nuwiki-core = { path = "../nuwiki-core", version = "0.3.0" }
|
||||
tower-lsp = "0.20"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] }
|
||||
dashmap = "6"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
//! Diagnostic sources beyond parse-time `ErrorNode`s.
|
||||
//!
|
||||
//! The `nuwiki.link` source emits broken-link warnings. Walks
|
||||
//! every `WikiLinkNode` in the AST and emits one diagnostic per:
|
||||
//! - `Wiki` target that doesn't resolve to a page in the workspace index,
|
||||
//! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a
|
||||
//! heading nor a `:tag:` on the target page,
|
||||
//! - `File`/`Local` target whose resolved path doesn't exist on disk.
|
||||
//!
|
||||
//! Interwiki, Diary, Raw, and external links are not diagnosed —
|
||||
//! interwiki resolution and diary handling live elsewhere, and we
|
||||
//! don't fetch URLs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url};
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, ListItemNode, ListNode,
|
||||
TableNode, WikiLinkNode,
|
||||
};
|
||||
|
||||
use crate::config::LinkSeverity;
|
||||
use crate::index::{OutgoingLink, WorkspaceIndex};
|
||||
use crate::to_lsp_range;
|
||||
|
||||
pub const LINK_SOURCE: &str = "nuwiki.link";
|
||||
|
||||
/// Convert a [`LinkSeverity`] to its LSP counterpart. `Off` returns `None`
|
||||
/// so callers can short-circuit before walking the document.
|
||||
pub fn severity_to_lsp(s: LinkSeverity) -> Option<DiagnosticSeverity> {
|
||||
match s {
|
||||
LinkSeverity::Off => None,
|
||||
LinkSeverity::Hint => Some(DiagnosticSeverity::HINT),
|
||||
LinkSeverity::Warning => Some(DiagnosticSeverity::WARNING),
|
||||
LinkSeverity::Error => Some(DiagnosticSeverity::ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the AST and collect every `WikiLinkNode` in source order.
|
||||
pub fn collect_wiki_links(ast: &DocumentNode) -> Vec<&WikiLinkNode> {
|
||||
let mut out = Vec::new();
|
||||
for block in &ast.children {
|
||||
walk_block(block, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_block<'a>(block: &'a BlockNode, out: &mut Vec<&'a WikiLinkNode>) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => walk_inlines(&h.children, out),
|
||||
BlockNode::Paragraph(p) => walk_inlines(&p.children, out),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
walk_block(c, out);
|
||||
}
|
||||
}
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
walk_list_item(item, out);
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(t) = &item.term {
|
||||
walk_inlines(t, out);
|
||||
}
|
||||
for d in &item.definitions {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
walk_inlines(&cell.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_list_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a WikiLinkNode>) {
|
||||
walk_inlines(&item.children, out);
|
||||
if let Some(sub) = &item.sublist {
|
||||
for sub_it in &sub.items {
|
||||
walk_list_item(sub_it, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_inlines<'a>(inlines: &'a [InlineNode], out: &mut Vec<&'a WikiLinkNode>) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::WikiLink(w) => {
|
||||
out.push(w);
|
||||
if let Some(d) = &w.description {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
InlineNode::Bold(b) => walk_inlines(&b.children, out),
|
||||
InlineNode::Italic(i) => walk_inlines(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => walk_inlines(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Superscript(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Subscript(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Color(c) => walk_inlines(&c.children, out),
|
||||
InlineNode::ExternalLink(e) => {
|
||||
if let Some(d) = &e.description {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of why a single wikilink is broken. Returned by
|
||||
/// [`classify_link`] so callers (diagnostics + `nuwiki.workspace.checkLinks`)
|
||||
/// share the same logic.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BrokenLinkKind {
|
||||
/// Target page doesn't exist in the workspace index.
|
||||
MissingPage(String),
|
||||
/// Page exists but the `#anchor` doesn't match any heading or `:tag:`.
|
||||
MissingAnchor { page: String, anchor: String },
|
||||
/// `file:` / `local:` target whose resolved path isn't on disk.
|
||||
MissingFile(PathBuf),
|
||||
}
|
||||
|
||||
impl BrokenLinkKind {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
BrokenLinkKind::MissingPage(p) => format!("broken wikilink: page '{p}' not in wiki"),
|
||||
BrokenLinkKind::MissingAnchor { page, anchor } => {
|
||||
format!("broken anchor: '#{anchor}' not found on page '{page}'")
|
||||
}
|
||||
BrokenLinkKind::MissingFile(p) => {
|
||||
format!("broken file link: '{}' not found", p.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Short machine-readable kind, used in the `checkLinks` JSON output so
|
||||
/// editors can colour-code or filter.
|
||||
pub fn tag(&self) -> &'static str {
|
||||
match self {
|
||||
BrokenLinkKind::MissingPage(_) => "missing-page",
|
||||
BrokenLinkKind::MissingAnchor { .. } => "missing-anchor",
|
||||
BrokenLinkKind::MissingFile(_) => "missing-file",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect one wikilink and return `Some(_)` when it's broken. The `uri`
|
||||
/// is the source document's URI — required for `file:` / `local:`
|
||||
/// resolution. `source_page` is the source's name in the index (used by
|
||||
/// `AnchorOnly` links to look up the current page's headings/tags).
|
||||
pub fn classify_link(
|
||||
link: &WikiLinkNode,
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
) -> Option<BrokenLinkKind> {
|
||||
match link.target.kind {
|
||||
LinkKind::Wiki => {
|
||||
let path = link.target.path.as_deref()?;
|
||||
match index.page_for_wiki_target(path, source_page, link.target.is_absolute) {
|
||||
None => Some(BrokenLinkKind::MissingPage(path.to_string())),
|
||||
Some(page) => {
|
||||
if let Some(anchor) = &link.target.anchor {
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: path.to_string(),
|
||||
anchor: anchor.clone(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LinkKind::AnchorOnly => {
|
||||
let anchor = link.target.anchor.as_deref()?;
|
||||
let page = index.page_by_name(source_page)?;
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: source_page.to_string(),
|
||||
anchor: anchor.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
LinkKind::File | LinkKind::Local => {
|
||||
let path = link.target.path.as_deref()?;
|
||||
let resolved = resolve_file_path(uri, index, path, link.target.is_absolute)?;
|
||||
if resolved.exists() {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingFile(resolved))
|
||||
}
|
||||
}
|
||||
LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_resolves_on(page: &crate::index::IndexedPage, anchor: &str) -> bool {
|
||||
page.find_heading_by_anchor(anchor).is_some() || page.tags.iter().any(|t| t.name == anchor)
|
||||
}
|
||||
|
||||
/// Same classification as [`classify_link`] but driven from the cached
|
||||
/// [`OutgoingLink`] data so workspace-wide checks don't have to re-parse
|
||||
/// every page. The on-disk `File`/`Local` check is performed when the
|
||||
/// source URI is available.
|
||||
pub fn classify_outgoing(
|
||||
link: &OutgoingLink,
|
||||
source_uri: &Url,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
) -> Option<BrokenLinkKind> {
|
||||
match link.kind {
|
||||
LinkKind::Wiki => {
|
||||
let path = link.target_page.as_deref()?;
|
||||
match index.page_for_wiki_target(path, source_page, link.is_absolute) {
|
||||
None => Some(BrokenLinkKind::MissingPage(path.to_string())),
|
||||
Some(page) => {
|
||||
if let Some(anchor) = &link.anchor {
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: path.to_string(),
|
||||
anchor: anchor.clone(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LinkKind::AnchorOnly => {
|
||||
let anchor = link.anchor.as_deref()?;
|
||||
let page = index.page_by_name(source_page)?;
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: source_page.to_string(),
|
||||
anchor: anchor.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
LinkKind::File | LinkKind::Local => {
|
||||
let path = link.target_page.as_deref()?;
|
||||
let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?;
|
||||
if resolved.exists() {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingFile(resolved))
|
||||
}
|
||||
}
|
||||
LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten heading inlines down to a plain string. Used by `commands::ops`
|
||||
/// to match heading text against the configured TOC/Links section name, and
|
||||
/// (via `nuwiki_core::ast::inline_text`) the same text the HTML renderer uses
|
||||
/// for heading `id`s — so anchors validated here match anchors emitted there.
|
||||
pub fn heading_text(inlines: &[InlineNode]) -> String {
|
||||
nuwiki_core::ast::inline_text(inlines)
|
||||
}
|
||||
|
||||
/// Resolve a `file:` / `local:` link target to an absolute path.
|
||||
///
|
||||
/// - Absolute (parsed `//path` or `is_absolute`): used as-is.
|
||||
/// - Otherwise: joined with the source file's parent directory.
|
||||
/// - If we don't have a URI to anchor relative paths against, falls back
|
||||
/// to the wiki root from the index.
|
||||
fn resolve_file_path(
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
path: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<PathBuf> {
|
||||
let candidate = PathBuf::from(path);
|
||||
if is_absolute || candidate.is_absolute() {
|
||||
return Some(candidate);
|
||||
}
|
||||
if let Some(uri) = uri {
|
||||
if let Ok(base) = uri.to_file_path() {
|
||||
if let Some(parent) = base.parent() {
|
||||
return Some(parent.join(&candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
index.root.as_ref().map(|r| r.join(&candidate))
|
||||
}
|
||||
|
||||
/// `nuwiki.link` diagnostics for a single document.
|
||||
pub fn link_health(
|
||||
ast: &DocumentNode,
|
||||
text: &str,
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
severity: LinkSeverity,
|
||||
utf8: bool,
|
||||
) -> Vec<Diagnostic> {
|
||||
let Some(sev) = severity_to_lsp(severity) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for link in collect_wiki_links(ast) {
|
||||
if let Some(kind) = classify_link(link, uri, index, source_page) {
|
||||
out.push(Diagnostic {
|
||||
range: to_lsp_range(&link.span, text, utf8),
|
||||
severity: Some(sev),
|
||||
source: Some(LINK_SOURCE.into()),
|
||||
message: kind.message(),
|
||||
code: Some(tower_lsp::lsp_types::NumberOrString::String(
|
||||
kind.tag().to_string(),
|
||||
)),
|
||||
..Diagnostic::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Diary subsystem — pure helpers used by the
|
||||
//! `nuwiki.diary.*` `executeCommand` handlers.
|
||||
//!
|
||||
//! All path/URI math lives here so the command dispatcher stays a thin
|
||||
//! wrapper. The diary navigation model is intentionally lightweight:
|
||||
//! entries are identified by file stem (`YYYY-MM-DD`) living under
|
||||
//! `<wiki_root>/<diary_rel_path>/`. The `WorkspaceIndex` tags each
|
||||
//! `IndexedPage` with a `diary_date` so prev/next/list operations stay
|
||||
//! O(n) over the indexed pages.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
|
||||
|
||||
use crate::config::WikiConfig;
|
||||
use crate::index::WorkspaceIndex;
|
||||
|
||||
/// Resolve a `DiaryDate` to its `file://` URI under the given wiki.
|
||||
pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_path_for(date)).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `DiaryPeriod` to its `file://` URI under the given wiki.
|
||||
/// Picks the right stem for the period's flavour (day / week / month / year).
|
||||
pub fn uri_for_period(cfg: &WikiConfig, period: &DiaryPeriod) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_path_for_period(period)).ok()
|
||||
}
|
||||
|
||||
/// `file://` URI of the diary index page.
|
||||
pub fn index_uri(cfg: &WikiConfig) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_index_path()).ok()
|
||||
}
|
||||
|
||||
/// All diary entries currently indexed for `wiki`, sorted ascending by
|
||||
/// date. The wiki's index must already have its `diary_rel_path` set
|
||||
/// (handled by `Wiki::new`).
|
||||
pub fn list_entries(index: &WorkspaceIndex) -> Vec<DiaryEntry> {
|
||||
let mut out: Vec<DiaryEntry> = index
|
||||
.pages_by_uri
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
p.diary_date.map(|d| DiaryEntry {
|
||||
date: d,
|
||||
uri: p.uri.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.date.cmp(&b.date));
|
||||
out
|
||||
}
|
||||
|
||||
/// Entries for a given (year, month). `month = None` returns the whole
|
||||
/// year; `year = None` matches every year.
|
||||
pub fn list_entries_filtered(
|
||||
index: &WorkspaceIndex,
|
||||
year: Option<i32>,
|
||||
month: Option<u8>,
|
||||
) -> Vec<DiaryEntry> {
|
||||
list_entries(index)
|
||||
.into_iter()
|
||||
.filter(|e| year.is_none_or(|y| e.date.year == y))
|
||||
.filter(|e| month.is_none_or(|m| e.date.month == m))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The diary entry chronologically after `from`. `from` doesn't need to
|
||||
/// be indexed itself — we look for the smallest indexed date strictly
|
||||
/// greater than `from`.
|
||||
pub fn next_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
||||
list_entries(index).into_iter().find(|e| e.date > *from)
|
||||
}
|
||||
|
||||
/// The diary entry chronologically before `from`.
|
||||
pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
||||
list_entries(index)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|e| e.date < *from)
|
||||
}
|
||||
|
||||
/// All indexed diary periods of a *specific* flavour (daily / weekly /
|
||||
/// monthly / yearly), sorted ascending. Used by next/prev navigation so
|
||||
/// `<C-Down>` on a weekly page jumps to the next weekly entry, not the
|
||||
/// next daily one.
|
||||
pub fn list_periods_of_kind(index: &WorkspaceIndex, kind: PeriodKind) -> Vec<PeriodEntry> {
|
||||
let mut out: Vec<PeriodEntry> = index
|
||||
.pages_by_uri
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
let period = p.diary_period?;
|
||||
if PeriodKind::of(&period) != kind {
|
||||
return None;
|
||||
}
|
||||
Some(PeriodEntry {
|
||||
period,
|
||||
uri: p.uri.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|e| period_first_day(&e.period));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn next_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
|
||||
let kind = PeriodKind::of(from);
|
||||
let pivot = period_first_day(from);
|
||||
list_periods_of_kind(index, kind)
|
||||
.into_iter()
|
||||
.find(|e| period_first_day(&e.period) > pivot)
|
||||
}
|
||||
|
||||
pub fn prev_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
|
||||
let kind = PeriodKind::of(from);
|
||||
let pivot = period_first_day(from);
|
||||
list_periods_of_kind(index, kind)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|e| period_first_day(&e.period) < pivot)
|
||||
}
|
||||
|
||||
/// Cluster 4: `(period, uri)` pair for non-daily diary navigation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeriodEntry {
|
||||
pub period: DiaryPeriod,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
impl serde::Serialize for PeriodEntry {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
// For back-compat with daily-only clients, emit `date` AND
|
||||
// `period` — the former is just the first-day string of the
|
||||
// period, the latter is the canonical stem.
|
||||
let mut s = serializer.serialize_struct("PeriodEntry", 3)?;
|
||||
s.serialize_field("date", &self.period.first_day().format())?;
|
||||
s.serialize_field("period", &self.period.format())?;
|
||||
s.serialize_field("uri", &self.uri)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which flavour of `DiaryPeriod` we're looking at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PeriodKind {
|
||||
Day,
|
||||
Week,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl PeriodKind {
|
||||
pub fn of(p: &DiaryPeriod) -> Self {
|
||||
match p {
|
||||
DiaryPeriod::Day(_) => Self::Day,
|
||||
DiaryPeriod::Week { .. } => Self::Week,
|
||||
DiaryPeriod::Month { .. } => Self::Month,
|
||||
DiaryPeriod::Year { .. } => Self::Year,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn period_first_day(p: &DiaryPeriod) -> DiaryDate {
|
||||
p.first_day()
|
||||
}
|
||||
|
||||
/// Ordering of entries in the diary index page.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiarySort {
|
||||
/// Newest first (vimwiki default).
|
||||
Desc,
|
||||
/// Oldest first.
|
||||
Asc,
|
||||
}
|
||||
|
||||
impl DiarySort {
|
||||
/// Parse a wiki's `diary_sort` field. Anything other than `asc`
|
||||
/// (case-insensitive) is treated as the default `desc`.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
if s.trim().eq_ignore_ascii_case("asc") {
|
||||
Self::Asc
|
||||
} else {
|
||||
Self::Desc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a heading line at `level` (1 = `= … =`). Clamped to vimwiki's
|
||||
/// 1..=6 range so a stray config value can't emit a 200-equals heading.
|
||||
fn heading(level: u8, text: &str) -> String {
|
||||
let n = level.clamp(1, 6) as usize;
|
||||
let eq = "=".repeat(n);
|
||||
format!("{eq} {text} {eq}")
|
||||
}
|
||||
|
||||
/// Generate the body of the diary index page — a flat list of
|
||||
/// `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
|
||||
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
|
||||
///
|
||||
/// `caption_level` sets the level of the top caption; the year and month
|
||||
/// subheadings nest one and two levels below it. A `caption_level < 0`
|
||||
/// clamps to `0` (nuwiki builds the tree from dates, so upstream's `-1`
|
||||
/// "no per-page captions" mode doesn't apply). `months` supplies the
|
||||
/// month-number → display name table; an empty slice (or a short list)
|
||||
/// falls back to the English `month_name` for any missing slot. `sort`
|
||||
/// controls whether the flat list runs newest- or oldest-first. `margin`
|
||||
/// is the number of leading spaces before each entry bullet (vimwiki's
|
||||
/// `list_margin`).
|
||||
pub fn build_index_body(
|
||||
entries: &[DiaryEntry],
|
||||
diary_rel_path: &str,
|
||||
index_heading: &str,
|
||||
sort: DiarySort,
|
||||
caption_level: i8,
|
||||
months: &[String],
|
||||
margin: usize,
|
||||
) -> String {
|
||||
let base_level = caption_level.max(0) as u8;
|
||||
let pad = " ".repeat(margin);
|
||||
let mut out = String::new();
|
||||
out.push_str(&heading(base_level, index_heading));
|
||||
out.push('\n');
|
||||
|
||||
if entries.is_empty() {
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
|
||||
sorted.sort_by(|a, b| match sort {
|
||||
DiarySort::Desc => b.date.cmp(&a.date),
|
||||
DiarySort::Asc => a.date.cmp(&b.date),
|
||||
});
|
||||
|
||||
let year_level = base_level.saturating_add(1);
|
||||
let month_level = base_level.saturating_add(2);
|
||||
let mut current_year: Option<i32> = None;
|
||||
let mut current_month: Option<u8> = None;
|
||||
for e in sorted {
|
||||
if current_year != Some(e.date.year) {
|
||||
out.push('\n');
|
||||
out.push_str(&heading(year_level, &e.date.year.to_string()));
|
||||
out.push('\n');
|
||||
current_year = Some(e.date.year);
|
||||
current_month = None;
|
||||
}
|
||||
if current_month != Some(e.date.month) {
|
||||
let label = months
|
||||
.get((e.date.month.saturating_sub(1)) as usize)
|
||||
.map(String::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| month_name(e.date.month));
|
||||
out.push_str(&heading(month_level, label));
|
||||
out.push('\n');
|
||||
current_month = Some(e.date.month);
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"{pad}- [[{}/{}]]\n",
|
||||
diary_rel_path.trim_end_matches('/'),
|
||||
e.date.format()
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the diary-index body for the given wiki's currently-indexed
|
||||
/// entries. Convenience wrapper around [`build_index_body`] that honours
|
||||
/// the wiki's `diary_header`, `diary_sort`, `diary_caption_level`, and
|
||||
/// `diary_months`.
|
||||
pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String {
|
||||
build_index_body(
|
||||
&list_entries(index),
|
||||
&cfg.diary_rel_path,
|
||||
&cfg.diary_header,
|
||||
DiarySort::parse(&cfg.diary_sort),
|
||||
cfg.diary_caption_level,
|
||||
&cfg.diary_months,
|
||||
cfg.list_margin.max(0) as usize,
|
||||
)
|
||||
}
|
||||
|
||||
/// Cheap "is this path under the diary subdir of this wiki" check —
|
||||
/// surface-level, no parse needed.
|
||||
pub fn is_in_diary(cfg: &WikiConfig, path: &Path) -> bool {
|
||||
path.starts_with(cfg.diary_dir())
|
||||
}
|
||||
|
||||
/// One diary entry, returned by listing/navigation queries. The custom
|
||||
/// `Serialize` emits `{ "date": "YYYY-MM-DD", "uri": "file://..." }` so
|
||||
/// the client doesn't have to know about [`DiaryDate`]'s internal layout.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiaryEntry {
|
||||
pub date: DiaryDate,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
impl serde::Serialize for DiaryEntry {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut s = serializer.serialize_struct("DiaryEntry", 2)?;
|
||||
s.serialize_field("date", &self.date.format())?;
|
||||
s.serialize_field("uri", &self.uri)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
fn month_name(m: u8) -> &'static str {
|
||||
match m {
|
||||
1 => "January",
|
||||
2 => "February",
|
||||
3 => "March",
|
||||
4 => "April",
|
||||
5 => "May",
|
||||
6 => "June",
|
||||
7 => "July",
|
||||
8 => "August",
|
||||
9 => "September",
|
||||
10 => "October",
|
||||
11 => "November",
|
||||
12 => "December",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Helpers for building `WorkspaceEdit`s.
|
||||
//!
|
||||
//! Rename, list/table edits, and link/TOC generation all return
|
||||
//! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position
|
||||
//! conversion (UTF-8 or UTF-16, matching whatever was negotiated in
|
||||
//! `initialize`) so each call site doesn't have to redo it.
|
||||
//!
|
||||
//! The builder emits `documentChanges` (with `Operation` for file ops)
|
||||
//! whenever any file op is queued, and falls back to the simpler
|
||||
//! `changes` field for pure text-edit batches — the latter is what older
|
||||
//! LSP 3.15- clients understand.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tower_lsp::lsp_types::{
|
||||
CreateFile, DeleteFile, DocumentChangeOperation, DocumentChanges, OneOf,
|
||||
OptionalVersionedTextDocumentIdentifier, Position, Range, RenameFile, ResourceOp,
|
||||
TextDocumentEdit, TextEdit, Url, WorkspaceEdit,
|
||||
};
|
||||
|
||||
use nuwiki_core::ast::Span;
|
||||
|
||||
use crate::semantic_tokens::LineIndex;
|
||||
|
||||
/// Replace the source covered by `span` with `replacement`. `text` and
|
||||
/// `utf8` are needed to translate the byte-offset span into the LSP
|
||||
/// `Range` the client expects.
|
||||
pub fn text_edit_replace(span: Span, replacement: String, text: &str, utf8: bool) -> TextEdit {
|
||||
TextEdit {
|
||||
range: span_to_range(span, text, utf8),
|
||||
new_text: replacement,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `content` at `pos`. The caller has already converted to LSP
|
||||
/// coordinates — no further conversion is performed here.
|
||||
pub fn text_edit_insert(pos: Position, content: String) -> TextEdit {
|
||||
TextEdit {
|
||||
range: Range {
|
||||
start: pos,
|
||||
end: pos,
|
||||
},
|
||||
new_text: content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the source covered by `span`.
|
||||
pub fn text_edit_delete(span: Span, text: &str, utf8: bool) -> TextEdit {
|
||||
text_edit_replace(span, String::new(), text, utf8)
|
||||
}
|
||||
|
||||
pub fn op_rename(old: Url, new: Url) -> DocumentChangeOperation {
|
||||
DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile {
|
||||
old_uri: old,
|
||||
new_uri: new,
|
||||
options: None,
|
||||
annotation_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn op_delete(uri: Url) -> DocumentChangeOperation {
|
||||
// lsp-types 0.94.x ships `DeleteFile` without `annotation_id`; later
|
||||
// versions added it. Stick with the 0.94 shape until we move tower-lsp.
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(DeleteFile { uri, options: None }))
|
||||
}
|
||||
|
||||
pub fn op_create(uri: Url) -> DocumentChangeOperation {
|
||||
DocumentChangeOperation::Op(ResourceOp::Create(CreateFile {
|
||||
uri,
|
||||
options: None,
|
||||
annotation_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WorkspaceEditBuilder {
|
||||
changes: HashMap<Url, Vec<TextEdit>>,
|
||||
ops: Vec<DocumentChangeOperation>,
|
||||
}
|
||||
|
||||
impl WorkspaceEditBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn edit(&mut self, uri: Url, edit: TextEdit) -> &mut Self {
|
||||
self.changes.entry(uri).or_default().push(edit);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty() && self.ops.is_empty()
|
||||
}
|
||||
|
||||
pub fn build(self) -> WorkspaceEdit {
|
||||
// documentChanges is required whenever there are file ops (LSP
|
||||
// doesn't represent renames/deletes/creates in the flat `changes`
|
||||
// map). For pure text edits the older `changes` form keeps
|
||||
// compatibility with LSP 3.15-clients.
|
||||
if self.ops.is_empty() {
|
||||
return WorkspaceEdit {
|
||||
changes: Some(self.changes),
|
||||
document_changes: None,
|
||||
change_annotations: None,
|
||||
};
|
||||
}
|
||||
|
||||
let mut document_changes: Vec<DocumentChangeOperation> = self.ops;
|
||||
// Edits land after file ops so the file exists when the text edit
|
||||
// applies (e.g. create + initial content).
|
||||
for (uri, edits) in self.changes {
|
||||
document_changes.push(DocumentChangeOperation::Edit(TextDocumentEdit {
|
||||
text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None },
|
||||
edits: edits.into_iter().map(OneOf::Left).collect(),
|
||||
}));
|
||||
}
|
||||
WorkspaceEdit {
|
||||
changes: None,
|
||||
document_changes: Some(DocumentChanges::Operations(document_changes)),
|
||||
change_annotations: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn span_to_range(span: Span, text: &str, utf8: bool) -> Range {
|
||||
let idx = LineIndex::new(text);
|
||||
Range {
|
||||
start: Position {
|
||||
line: span.start.line,
|
||||
character: char_at(span.start.line, span.start.column, text, &idx, utf8),
|
||||
},
|
||||
end: Position {
|
||||
line: span.end.line,
|
||||
character: char_at(span.end.line, span.end.column, text, &idx, utf8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn char_at(line: u32, byte_col: u32, text: &str, idx: &LineIndex, utf8: bool) -> u32 {
|
||||
if utf8 {
|
||||
return byte_col;
|
||||
}
|
||||
let line_str = idx.line_str(line, text);
|
||||
let upto = (byte_col as usize).min(line_str.len());
|
||||
line_str[..upto].chars().map(char::len_utf16).sum::<usize>() as u32
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! HTML export — pure helpers shared by the `nuwiki.export.*`
|
||||
//! command handlers. Side-effecting work (reading the template from
|
||||
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
|
||||
//! since the dispatcher is the one with a `&Backend` and async context.
|
||||
//!
|
||||
//! Design split:
|
||||
//! - [`output_path_for`] / [`output_url_for`] — name → on-disk path.
|
||||
//! - [`template_for`] — locate the right template file on disk.
|
||||
//! - [`fallback_template`] — minimal stand-in when nothing is found.
|
||||
//! - [`format_date`] — strftime subset (`%Y`/`%m`/`%d`).
|
||||
//! - [`build_toc_html`] — TOC rendered into the `{{toc}}` variable.
|
||||
//! - [`compute_root_path`] — relative path from a page's output to
|
||||
//! `html_path` for stylesheet hrefs in nested pages.
|
||||
//! - [`is_excluded`] — glob match against `exclude_files`.
|
||||
//! - [`render_page_html`] — drives [`HtmlRenderer`] with all of the
|
||||
//! above wired up; returns the final HTML string.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, LinkKind};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
|
||||
use crate::config::HtmlConfig;
|
||||
|
||||
/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML
|
||||
/// output path under `html_path`. Honours
|
||||
/// [`HtmlConfig::html_filename_parameterization`] for URL-safe slugs
|
||||
/// — when enabled, only the final path segment is slugified (so the
|
||||
/// `diary/` subdir survives).
|
||||
pub fn output_path_for(cfg: &HtmlConfig, name: &str) -> PathBuf {
|
||||
let segments: Vec<String> = name
|
||||
.split('/')
|
||||
.map(|s| {
|
||||
if cfg.html_filename_parameterization {
|
||||
slugify(s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut p = cfg.html_path.clone();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
if i + 1 == segments.len() {
|
||||
p.push(format!("{seg}.html"));
|
||||
} else {
|
||||
p.push(seg);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Locate the template body for a document. Resolution order:
|
||||
/// 1. `<template_path>/<doc.metadata.template>.<template_ext>` if set.
|
||||
/// 2. `<template_path>/<template_default>.<template_ext>`.
|
||||
/// 3. [`fallback_template`].
|
||||
///
|
||||
/// The on-disk read is performed by the caller (`commands.rs`) via the
|
||||
/// [`TemplateSource`] returned here. Keeps this fn pure for tests.
|
||||
pub fn template_for(cfg: &HtmlConfig, doc: &DocumentNode) -> TemplateSource {
|
||||
let primary = doc.metadata.template.as_deref().map(|name| {
|
||||
cfg.template_path
|
||||
.join(format!("{name}{}", cfg.template_ext))
|
||||
});
|
||||
let fallback = cfg
|
||||
.template_path
|
||||
.join(format!("{}{}", cfg.template_default, cfg.template_ext));
|
||||
TemplateSource { primary, fallback }
|
||||
}
|
||||
|
||||
/// Two-tier lookup result. Caller tries `primary` first, then `fallback`,
|
||||
/// then falls back to [`fallback_template`] when neither file exists.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TemplateSource {
|
||||
pub primary: Option<PathBuf>,
|
||||
pub fallback: PathBuf,
|
||||
}
|
||||
|
||||
/// Minimal valid HTML page used when no template file is found on disk.
|
||||
/// Includes the same `{{title}}` / `{{content}}` placeholders as a real
|
||||
/// template so the renderer's substitution still works.
|
||||
pub fn fallback_template(css_name: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{{{title}}}}</title>
|
||||
<link rel="stylesheet" href="{{{{root_path}}}}{css_name}">
|
||||
</head>
|
||||
<body>
|
||||
{{{{content}}}}
|
||||
</body>
|
||||
</html>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Tiny strftime: supports `%Y` (4-digit year), `%m` (2-digit month),
|
||||
/// `%d` (2-digit day), `%%` (literal percent). Anything else passes
|
||||
/// through. The diary use case never produces time-of-day output, so
|
||||
/// `%H`/`%M`/`%S` are intentionally omitted.
|
||||
pub fn format_date(date: &DiaryDate, fmt: &str) -> String {
|
||||
let mut out = String::with_capacity(fmt.len() + 4);
|
||||
let bytes = fmt.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 1 < bytes.len() {
|
||||
match bytes[i + 1] {
|
||||
b'Y' => out.push_str(&format!("{:04}", date.year)),
|
||||
b'm' => out.push_str(&format!("{:02}", date.month)),
|
||||
b'd' => out.push_str(&format!("{:02}", date.day)),
|
||||
b'%' => out.push('%'),
|
||||
other => {
|
||||
out.push('%');
|
||||
out.push(other as char);
|
||||
}
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the page's headings as a nested `<ul>` for the `{{toc}}`
|
||||
/// template variable. Returns an empty string when the document has
|
||||
/// no headings.
|
||||
pub fn build_toc_html(doc: &DocumentNode) -> String {
|
||||
let headings = collect_headings(doc);
|
||||
if headings.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1);
|
||||
let mut out = String::from("<ul class=\"toc\">");
|
||||
let mut depth = 0usize;
|
||||
for h in &headings {
|
||||
let target = h.level.saturating_sub(min_level) as usize;
|
||||
while depth < target {
|
||||
out.push_str("<ul>");
|
||||
depth += 1;
|
||||
}
|
||||
while depth > target {
|
||||
out.push_str("</ul>");
|
||||
depth -= 1;
|
||||
}
|
||||
// Anchor is the raw heading text (vimwiki scheme), HTML-escaped — must
|
||||
// match the `id` the renderer puts on the heading element.
|
||||
let anchor = escape_html(&h.title);
|
||||
out.push_str(&format!("<li><a href=\"#{anchor}\">{anchor}</a></li>"));
|
||||
}
|
||||
while depth > 0 {
|
||||
out.push_str("</ul>");
|
||||
depth -= 1;
|
||||
}
|
||||
out.push_str("</ul>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the relative path back to `html_path` from the directory
|
||||
/// holding the output file for `name`. Used as the `{{root_path}}`
|
||||
/// prefix so a nested page's `<link>`/`<script>` hrefs still find
|
||||
/// the shared CSS at the export root.
|
||||
pub fn compute_root_path(name: &str) -> String {
|
||||
let depth = name.matches('/').count();
|
||||
if depth == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
let mut s = String::with_capacity(depth * 3);
|
||||
for _ in 0..depth {
|
||||
s.push_str("../");
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `name` matches any of the `exclude_files` glob patterns.
|
||||
/// Supported wildcards: `*` (any segment chars except `/`), `**` (any
|
||||
/// segments including `/`), `?` (one char). Anything else is literal.
|
||||
pub fn is_excluded(patterns: &[String], name: &str) -> bool {
|
||||
patterns.iter().any(|p| glob_match(p, name))
|
||||
}
|
||||
|
||||
/// Pure HTML render pass. The caller has already loaded the template
|
||||
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
||||
/// plus arbitrary extras passed via `extra_vars`.
|
||||
///
|
||||
/// `wiki_extension` is the wiki's source file extension (e.g. `".wiki"`).
|
||||
/// When set, it is stripped from wiki/interwiki link targets so a link
|
||||
/// written with the extension (`[[todo.wiki]]`) exports to `todo.html`,
|
||||
/// matching how the LSP resolves the same link for in-editor navigation.
|
||||
// Every parameter is a distinct, cohesive render input; bundling them into a
|
||||
// struct would add an abstraction without simplifying the single caller.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_page_html(
|
||||
doc: &DocumentNode,
|
||||
template: Option<String>,
|
||||
name: &str,
|
||||
today: DiaryDate,
|
||||
cfg: &HtmlConfig,
|
||||
wiki_extension: Option<&str>,
|
||||
extra_vars: HashMap<String, String>,
|
||||
) -> std::io::Result<String> {
|
||||
let date_str = match doc.metadata.date.as_deref() {
|
||||
Some(s) => match DiaryDate::parse(s) {
|
||||
Some(d) => format_date(&d, &cfg.template_date_format),
|
||||
None => s.to_string(),
|
||||
},
|
||||
None => format_date(&today, &cfg.template_date_format),
|
||||
};
|
||||
let root_path = compute_root_path(name);
|
||||
let toc = build_toc_html(doc);
|
||||
|
||||
let mut vars: HashMap<String, String> = HashMap::new();
|
||||
vars.insert("date".into(), date_str);
|
||||
vars.insert("root_path".into(), root_path);
|
||||
vars.insert("toc".into(), toc);
|
||||
vars.insert("css".into(), cfg.css_name.clone());
|
||||
// vimwiki names the stylesheet placeholder `%wiki_css%`; alias it so stock
|
||||
// templates resolve. nuwiki's own templates use `{{css}}`.
|
||||
vars.insert("wiki_css".into(), cfg.css_name.clone());
|
||||
for (k, v) in extra_vars {
|
||||
vars.insert(k, v);
|
||||
}
|
||||
|
||||
let mut r = HtmlRenderer::new();
|
||||
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
|
||||
// Strip the wiki extension from page links before the default resolver
|
||||
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
|
||||
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
|
||||
// file:/local: paths keep their literal extension.
|
||||
let ext = ext.to_string();
|
||||
r = r.with_link_resolver(move |target| {
|
||||
let mut t = target.clone();
|
||||
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
|
||||
if let Some(p) = t.path.take() {
|
||||
t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
|
||||
}
|
||||
}
|
||||
nuwiki_core::render::html::default_link_resolver(&t)
|
||||
});
|
||||
}
|
||||
if let Some(t) = template {
|
||||
r = r.with_template(t);
|
||||
}
|
||||
r = r.with_vars(vars);
|
||||
if !cfg.color_dic.is_empty() {
|
||||
r = r.with_colors(cfg.color_dic.clone());
|
||||
}
|
||||
if !cfg.color_tag_template.is_empty() {
|
||||
r = r.with_color_template(cfg.color_tag_template.clone());
|
||||
}
|
||||
if cfg.html_header_numbering > 0 {
|
||||
r = r.with_header_numbering(
|
||||
cfg.html_header_numbering,
|
||||
cfg.html_header_numbering_sym.clone(),
|
||||
);
|
||||
}
|
||||
r = r.with_toc_header(&cfg.toc_header);
|
||||
if !cfg.valid_html_tags.is_empty() {
|
||||
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
|
||||
}
|
||||
r = r.with_emoji(cfg.emoji_enable);
|
||||
r = r.with_newline_handling(cfg.text_ignore_newline, cfg.list_ignore_newline);
|
||||
r.render_to_string(doc)
|
||||
}
|
||||
|
||||
// ===== Internals =====
|
||||
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
for c in ch.to_lowercase() {
|
||||
out.push(c);
|
||||
}
|
||||
prev_dash = false;
|
||||
} else if !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
while out.ends_with('-') {
|
||||
out.pop();
|
||||
}
|
||||
if out.is_empty() {
|
||||
"untitled".into()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
|
||||
let mut out = Vec::new();
|
||||
for b in &doc.children {
|
||||
if let BlockNode::Heading(h) = b {
|
||||
out.push(HeadingProj {
|
||||
level: h.level,
|
||||
title: nuwiki_core::ast::inline_text(&h.children),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct HeadingProj {
|
||||
level: u8,
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
||||
/// literal characters. No character classes — those aren't needed for
|
||||
/// "exclude foo/*.tmp" style patterns.
|
||||
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
glob_impl(pattern.as_bytes(), text.as_bytes())
|
||||
}
|
||||
|
||||
fn glob_impl(p: &[u8], t: &[u8]) -> bool {
|
||||
let mut pi = 0;
|
||||
let mut ti = 0;
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
let mut globstar = false;
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && pi + 1 < p.len() && p[pi] == b'*' && p[pi + 1] == b'*' {
|
||||
globstar = true;
|
||||
star = Some((pi, ti));
|
||||
pi += 2;
|
||||
// Allow optional `/` after globstar.
|
||||
if pi < p.len() && p[pi] == b'/' {
|
||||
pi += 1;
|
||||
}
|
||||
continue;
|
||||
} else if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some((pi, ti));
|
||||
pi += 1;
|
||||
globstar = false;
|
||||
continue;
|
||||
} else if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
continue;
|
||||
} else if let Some((s_pi, s_ti)) = star {
|
||||
if !globstar && t[s_ti] == b'/' && t[ti] != b'/' {
|
||||
// single-star can't cross a `/`
|
||||
return false;
|
||||
}
|
||||
pi = if globstar { s_pi + 2 } else { s_pi + 1 };
|
||||
ti = s_ti + 1;
|
||||
star = Some((s_pi, ti));
|
||||
continue;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && (p[pi] == b'*' || (p[pi] == b'/' && globstar)) {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
/// `<html_path>/<css_name>` — the absolute path of the CSS file the
|
||||
/// renderer references via `{{root_path}}{{css}}`.
|
||||
pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
|
||||
cfg.html_path.join(&cfg.css_name)
|
||||
}
|
||||
|
||||
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
|
||||
/// file exists yet at [`css_path`]. Deliberately tiny — users are
|
||||
/// expected to replace it with their own.
|
||||
pub const DEFAULT_CSS: &str =
|
||||
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
|
||||
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
|
||||
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
|
||||
code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
|
||||
table{border-collapse:collapse}\
|
||||
table td,table th{border:1px solid #ccc;padding:.25em .5em}\
|
||||
ul.toc{padding-left:1.5em}\n";
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `textDocument/foldingRange` provider.
|
||||
//!
|
||||
//! Strategy: one fold per heading (line → line before the next heading
|
||||
//! of same-or-higher level) plus one fold per top-level list block.
|
||||
//! Folding is deliberately conservative — we never produce nested folds
|
||||
//! beyond what the AST already groups, since vimwiki users mostly want
|
||||
//! "collapse this section" behaviour rather than fine-grained code-fold
|
||||
//! semantics.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode};
|
||||
use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind};
|
||||
|
||||
/// Compute folding ranges for the whole document. `total_lines` is the
|
||||
/// number of lines in the source (`text.lines().count() as u32`) — used
|
||||
/// to extend the last heading's fold to end-of-document.
|
||||
pub fn folding_ranges(ast: &DocumentNode, total_lines: u32) -> Vec<FoldingRange> {
|
||||
let mut out = Vec::new();
|
||||
let last_line = total_lines.saturating_sub(1);
|
||||
|
||||
// Collect heading spans + levels in source order. We walk the
|
||||
// top-level children only — nested headings inside blockquotes are
|
||||
// rare and folding them adds complexity for little gain.
|
||||
let mut headings: Vec<(u32, u8)> = Vec::new();
|
||||
for block in &ast.children {
|
||||
if let BlockNode::Heading(h) = block {
|
||||
headings.push((h.span.start.line, h.level));
|
||||
}
|
||||
}
|
||||
// For each heading, end-line is one before the next heading whose
|
||||
// level is <= current level (or last_line otherwise).
|
||||
for (i, &(line, level)) in headings.iter().enumerate() {
|
||||
let end = headings[i + 1..]
|
||||
.iter()
|
||||
.find(|(_, l)| *l <= level)
|
||||
.map(|(next_line, _)| next_line.saturating_sub(1))
|
||||
.unwrap_or(last_line);
|
||||
if end > line {
|
||||
out.push(FoldingRange {
|
||||
start_line: line,
|
||||
end_line: end,
|
||||
start_character: None,
|
||||
end_character: None,
|
||||
kind: Some(FoldingRangeKind::Region),
|
||||
collapsed_text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fold each top-level list. Sublists fold implicitly because their
|
||||
// parent item's source span already covers them.
|
||||
for block in &ast.children {
|
||||
if let BlockNode::List(l) = block {
|
||||
let start = l.span.start.line;
|
||||
let end = l.span.end.line;
|
||||
if end > start {
|
||||
out.push(FoldingRange {
|
||||
start_line: start,
|
||||
end_line: end,
|
||||
start_character: None,
|
||||
end_character: None,
|
||||
kind: Some(FoldingRangeKind::Region),
|
||||
collapsed_text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Count lines in `text` (always ≥ 1 for non-empty input).
|
||||
pub fn line_count(text: &str) -> u32 {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
let mut n = text.bytes().filter(|b| *b == b'\n').count() as u32;
|
||||
if !text.ends_with('\n') {
|
||||
n += 1;
|
||||
} else {
|
||||
// trailing newline still bounds a virtual line; LSP positions
|
||||
// index 0-based, so the file ends at line `n` (the empty one
|
||||
// after the final newline). We treat "lines" as the count
|
||||
// *including* that trailing empty line for fold-end clamping.
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Workspace index — the in-memory model of every `.wiki` page nuwiki
|
||||
//! has seen, used to power nav features.
|
||||
//!
|
||||
//! The initial scan runs as a background tokio task so
|
||||
//! the server stays responsive on startup. Per-document updates land here
|
||||
//! synchronously via `upsert` whenever the LSP backend re-parses on
|
||||
//! `didOpen` / `didChange`.
|
||||
//!
|
||||
//! The index keeps only the projections nav needs (headings + outgoing
|
||||
//! links) — the full AST already lives in the backend's `documents`
|
||||
//! `DashMap`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
||||
TableNode, TagScope,
|
||||
};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexedPage {
|
||||
pub uri: Url,
|
||||
/// Page name relative to the workspace root, without the `.wiki`
|
||||
/// extension. Falls back to the URL's last path segment if no root.
|
||||
pub name: String,
|
||||
pub title: Option<String>,
|
||||
pub headings: Vec<HeadingInfo>,
|
||||
pub outgoing: Vec<OutgoingLink>,
|
||||
/// Every `TagNode` on the page. `tags_by_name` on the
|
||||
/// containing `WorkspaceIndex` is the reverse map.
|
||||
pub tags: Vec<TagInfo>,
|
||||
/// `Some(date)` when this page is a *daily* diary entry —
|
||||
/// stem parses as `YYYY-MM-DD`. Equivalent to
|
||||
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
|
||||
/// Kept as its own field for back-compat with prev/next/list callers
|
||||
/// that pre-date the multi-frequency upgrade.
|
||||
pub diary_date: Option<DiaryDate>,
|
||||
/// Cluster 4 (vimwiki parity): `Some(period)` for any diary entry
|
||||
/// (daily, weekly, monthly, yearly). The stem must match the
|
||||
/// canonical format for one of the four frequencies.
|
||||
pub diary_period: Option<nuwiki_core::date::DiaryPeriod>,
|
||||
}
|
||||
|
||||
/// One tag occurrence on a page. `name` is the bare tag string (no
|
||||
/// surrounding colons). Multiple tags on the same source line each get
|
||||
/// their own `TagInfo` so anchor lookup can return a precise location.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagInfo {
|
||||
pub name: String,
|
||||
pub scope: TagScope,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeadingInfo {
|
||||
pub title: String,
|
||||
pub level: u8,
|
||||
pub anchor: String,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl IndexedPage {
|
||||
/// Find a heading whose anchor matches `request`. The on-disk wikilink
|
||||
/// syntax lets users write either the slug (`[[Page#some-heading]]`) or
|
||||
/// the raw heading text (`[[Page#Some Heading]]`). `slugify` is
|
||||
/// idempotent on valid slug input, so applying it to the request
|
||||
/// before comparison handles both forms in a single pass.
|
||||
pub fn find_heading_by_anchor(&self, request: &str) -> Option<&HeadingInfo> {
|
||||
let needle = slugify(request);
|
||||
self.headings.iter().find(|h| h.anchor == needle)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutgoingLink {
|
||||
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
||||
/// that don't address another page in the workspace (`AnchorOnly`,
|
||||
/// `Raw`, `File`, `Local`, `Diary`, …).
|
||||
pub target_page: Option<String>,
|
||||
pub anchor: Option<String>,
|
||||
pub kind: LinkKind,
|
||||
pub span: Span,
|
||||
/// Mirrors `LinkTarget::is_absolute` so `classify_outgoing` and the
|
||||
/// `links` query can distinguish `[[Page]]` (source-relative) from
|
||||
/// `[[/Page]]` (root-relative) without re-parsing.
|
||||
pub is_absolute: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backlink {
|
||||
pub source_uri: Url,
|
||||
pub source_span: Span,
|
||||
pub target_anchor: Option<String>,
|
||||
}
|
||||
|
||||
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
|
||||
/// `[[Page#tag]]` resolution and `nuwiki.tags.search`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagOccurrence {
|
||||
pub uri: Url,
|
||||
pub span: Span,
|
||||
pub scope: TagScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkspaceIndex {
|
||||
pub root: Option<PathBuf>,
|
||||
/// Subdir relative to `root` that holds diary entries.
|
||||
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
||||
/// classify each indexed URI without a back-reference to the config.
|
||||
pub diary_rel_path: Option<String>,
|
||||
/// File extension for this wiki's pages (e.g. `".wiki"` or `"wiki"`).
|
||||
/// Used to normalise link targets that include the extension so that
|
||||
/// `[[page.wiki]]` resolves the same way as `[[page]]`.
|
||||
pub file_extension: Option<String>,
|
||||
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
||||
pub pages_by_name: HashMap<String, Url>,
|
||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
||||
/// Tag name → every occurrence across the workspace. Used by
|
||||
/// the nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
||||
/// inclusion) and the `nuwiki.tags.search` command.
|
||||
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
|
||||
/// For each source URI, the backlink-bucket keys and tag-bucket names it
|
||||
/// contributed. Lets `remove` touch only the affected buckets instead of
|
||||
/// scanning every bucket in the workspace (O(touched), not O(total)).
|
||||
contributions: HashMap<Url, SourceContributions>,
|
||||
}
|
||||
|
||||
/// The bucket keys a single source page wrote into, so they can be undone
|
||||
/// in `remove` without a full scan. See [`WorkspaceIndex::contributions`].
|
||||
#[derive(Debug, Default)]
|
||||
struct SourceContributions {
|
||||
backlink_keys: Vec<String>,
|
||||
tag_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceIndex {
|
||||
pub fn new(root: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
root,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_diary_rel_path(mut self, rel: Option<String>) -> Self {
|
||||
self.diary_rel_path = rel;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_file_extension(mut self, ext: Option<String>) -> Self {
|
||||
self.file_extension = ext;
|
||||
self
|
||||
}
|
||||
|
||||
/// Strip the wiki's configured file extension from a link target path.
|
||||
/// Handles both `"page.wiki"` → `"page"` and `"subdir/page.wiki"` →
|
||||
/// `"subdir/page"`. Returns `path` unchanged when the extension doesn't
|
||||
/// match or no extension is configured.
|
||||
pub fn strip_link_extension<'a>(&self, path: &'a str) -> &'a str {
|
||||
strip_wiki_extension(path, self.file_extension.as_deref())
|
||||
}
|
||||
|
||||
/// Insert or update an indexed page. Removes any prior indexing for
|
||||
/// the same URI first (handles renames and re-parses without leaking
|
||||
/// stale backlinks).
|
||||
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
||||
self.remove(&uri);
|
||||
let name = page_name_from_uri(&uri, self.root.as_deref());
|
||||
let diary_period =
|
||||
diary_period_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
|
||||
let diary_date = diary_period.and_then(|p| match p {
|
||||
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
||||
_ => None,
|
||||
});
|
||||
let mut page = IndexedPage {
|
||||
uri: uri.clone(),
|
||||
name: name.clone(),
|
||||
title: ast.metadata.title.clone(),
|
||||
headings: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
diary_date,
|
||||
diary_period,
|
||||
};
|
||||
index_blocks(&ast.children, &mut page);
|
||||
|
||||
let mut contrib = SourceContributions::default();
|
||||
let ext = self.file_extension.as_deref();
|
||||
for link in &page.outgoing {
|
||||
if let Some(tgt) = &link.target_page {
|
||||
let key = canonical_link_name(tgt, &name, link.is_absolute, ext);
|
||||
self.backlinks
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.push(Backlink {
|
||||
source_uri: uri.clone(),
|
||||
source_span: link.span,
|
||||
target_anchor: link.anchor.clone(),
|
||||
});
|
||||
contrib.backlink_keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain the reverse tag map alongside the per-page list.
|
||||
for tag in &page.tags {
|
||||
self.tags_by_name
|
||||
.entry(tag.name.clone())
|
||||
.or_default()
|
||||
.push(TagOccurrence {
|
||||
uri: uri.clone(),
|
||||
span: tag.span,
|
||||
scope: tag.scope,
|
||||
});
|
||||
contrib.tag_names.push(tag.name.clone());
|
||||
}
|
||||
self.contributions.insert(uri.clone(), contrib);
|
||||
|
||||
self.pages_by_name.insert(name, uri.clone());
|
||||
self.pages_by_uri.insert(uri, page);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, uri: &Url) {
|
||||
if let Some(page) = self.pages_by_uri.remove(uri) {
|
||||
self.pages_by_name.remove(&page.name);
|
||||
}
|
||||
// Only revisit the buckets this source actually wrote into, instead
|
||||
// of scanning every backlink/tag bucket in the workspace.
|
||||
if let Some(contrib) = self.contributions.remove(uri) {
|
||||
for key in contrib.backlink_keys {
|
||||
if let Some(entries) = self.backlinks.get_mut(&key) {
|
||||
entries.retain(|b| &b.source_uri != uri);
|
||||
if entries.is_empty() {
|
||||
self.backlinks.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in contrib.tag_names {
|
||||
if let Some(entries) = self.tags_by_name.get_mut(&name) {
|
||||
entries.retain(|t| &t.uri != uri);
|
||||
if entries.is_empty() {
|
||||
self.tags_by_name.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All occurrences of a tag across the workspace. Empty slice when the
|
||||
/// tag isn't indexed.
|
||||
pub fn tags_for(&self, name: &str) -> &[TagOccurrence] {
|
||||
self.tags_by_name
|
||||
.get(name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Resolve a `LinkTarget` to a page URI in this workspace.
|
||||
/// `source_page` is the name of the page the link originates from —
|
||||
/// needed for `AnchorOnly` resolution and source-relative `Wiki` links.
|
||||
pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
|
||||
match target.kind {
|
||||
LinkKind::Wiki => target
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| self.resolve_wiki_path(p, source_page, target.is_absolute)),
|
||||
LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
|
||||
// Interwiki / Diary / File / Local / Raw aren't resolved
|
||||
// against the workspace index here. The handlers fall back to
|
||||
// best-effort resolution (e.g. building file:// URLs).
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a wiki link target by name. Tries source-relative first
|
||||
/// (vimwiki's default — `[[Page]]` in `posts/index.wiki` opens
|
||||
/// `posts/Page.wiki`) then falls back to root-relative. Skips the
|
||||
/// source-relative attempt for explicitly absolute (`[[/Page]]`) and
|
||||
/// for source pages already at the wiki root (`source_page` with no
|
||||
/// `/`). Strips the configured `.wiki` extension and collapses any
|
||||
/// `.`/`..` segments before lookup.
|
||||
pub fn resolve_wiki_path(
|
||||
&self,
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<&Url> {
|
||||
let normalized = self.strip_link_extension(path);
|
||||
if !is_absolute {
|
||||
if let Some(slash) = source_page.rfind('/') {
|
||||
let candidate = collapse_dots(&format!("{}/{}", &source_page[..slash], normalized));
|
||||
if let Some(uri) = self.pages_by_name.get(&candidate) {
|
||||
return Some(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
let root_candidate = collapse_dots(normalized);
|
||||
self.pages_by_name.get(&root_candidate)
|
||||
}
|
||||
|
||||
pub fn page(&self, uri: &Url) -> Option<&IndexedPage> {
|
||||
self.pages_by_uri.get(uri)
|
||||
}
|
||||
|
||||
pub fn page_by_name(&self, name: &str) -> Option<&IndexedPage> {
|
||||
self.pages_by_name
|
||||
.get(name)
|
||||
.and_then(|u| self.pages_by_uri.get(u))
|
||||
}
|
||||
|
||||
/// Resolve a wiki link target to the page it points at. Source-relative
|
||||
/// first, then root-relative — mirrors [`Self::resolve_wiki_path`] but
|
||||
/// returns the `IndexedPage` so anchor checks have everything they need.
|
||||
pub fn page_for_wiki_target(
|
||||
&self,
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<&IndexedPage> {
|
||||
self.resolve_wiki_path(path, source_page, is_absolute)
|
||||
.and_then(|u| self.pages_by_uri.get(u))
|
||||
}
|
||||
|
||||
pub fn page_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn backlinks_for(&self, page_name: &str) -> &[Backlink] {
|
||||
self.backlinks
|
||||
.get(page_name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
|
||||
/// the four supported frequencies. Recognises:
|
||||
///
|
||||
/// `YYYY-MM-DD` → Day
|
||||
/// `YYYY-Www` → Week (ISO)
|
||||
/// `YYYY-MM` → Month
|
||||
/// `YYYY` → Year
|
||||
///
|
||||
/// The path must sit under `<root>/<diary_rel_path>/` to be accepted;
|
||||
/// without a `root` we accept any stem (so ad-hoc test setups still work).
|
||||
pub fn diary_period_for_uri(
|
||||
uri: &Url,
|
||||
root: Option<&Path>,
|
||||
diary_rel_path: Option<&str>,
|
||||
) -> Option<nuwiki_core::date::DiaryPeriod> {
|
||||
let path = uri.to_file_path().ok()?;
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let parsed = nuwiki_core::date::DiaryPeriod::parse(stem)?;
|
||||
let Some(root) = root else {
|
||||
return Some(parsed);
|
||||
};
|
||||
let parent = path.parent()?;
|
||||
let expected = root.join(diary_rel_path.unwrap_or("diary"));
|
||||
if parent.starts_with(&expected) {
|
||||
Some(parsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a page name from a `file://` URL. With a workspace root the name
|
||||
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
||||
/// just the filename stem.
|
||||
pub fn page_name_from_uri(uri: &Url, root: Option<&Path>) -> String {
|
||||
if let (Some(root), Ok(p)) = (root, uri.to_file_path()) {
|
||||
if let Ok(rel) = p.strip_prefix(root) {
|
||||
let stripped = rel.with_extension("");
|
||||
return stripped
|
||||
.to_string_lossy()
|
||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
||||
}
|
||||
}
|
||||
// Fallback: last path segment, strip .wiki if present.
|
||||
if let Ok(p) = uri.to_file_path() {
|
||||
return p
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
uri.path_segments()
|
||||
.and_then(|mut s| s.next_back())
|
||||
.map(|s| {
|
||||
s.strip_suffix(".wiki")
|
||||
.unwrap_or(s)
|
||||
.trim_start_matches('/')
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Normalise heading text into a URL-safe anchor slug.
|
||||
pub fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
for c in ch.to_lowercase() {
|
||||
out.push(c);
|
||||
}
|
||||
prev_dash = false;
|
||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
// other punctuation: drop
|
||||
}
|
||||
while out.ends_with('-') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ===== Indexing helpers =====
|
||||
|
||||
fn index_blocks(blocks: &[BlockNode], page: &mut IndexedPage) {
|
||||
for block in blocks {
|
||||
index_block(block, page);
|
||||
}
|
||||
}
|
||||
|
||||
fn index_block(block: &BlockNode, page: &mut IndexedPage) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => {
|
||||
let title = inline_to_text(&h.children);
|
||||
page.headings.push(HeadingInfo {
|
||||
title: title.clone(),
|
||||
level: h.level,
|
||||
anchor: slugify(&title),
|
||||
span: h.span,
|
||||
});
|
||||
index_inlines(&h.children, page);
|
||||
}
|
||||
BlockNode::Paragraph(p) => index_inlines(&p.children, page),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
index_block(c, page);
|
||||
}
|
||||
}
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
index_inlines(&item.children, page);
|
||||
if let Some(sub) = &item.sublist {
|
||||
index_block(&BlockNode::List(sub.clone()), page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(term) = &item.term {
|
||||
index_inlines(term, page);
|
||||
}
|
||||
for def in &item.definitions {
|
||||
index_inlines(def, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
index_inlines(&cell.children, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Tag(t) => {
|
||||
for name in &t.tags {
|
||||
page.tags.push(TagInfo {
|
||||
name: name.clone(),
|
||||
scope: t.scope,
|
||||
span: t.span,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::WikiLink(w) => {
|
||||
page.outgoing.push(OutgoingLink {
|
||||
target_page: w.target.path.clone(),
|
||||
anchor: w.target.anchor.clone(),
|
||||
kind: w.target.kind,
|
||||
span: w.span,
|
||||
is_absolute: w.target.is_absolute,
|
||||
});
|
||||
if let Some(desc) = &w.description {
|
||||
index_inlines(desc, page);
|
||||
}
|
||||
}
|
||||
InlineNode::ExternalLink(e) => {
|
||||
if let Some(desc) = &e.description {
|
||||
index_inlines(desc, page);
|
||||
}
|
||||
}
|
||||
InlineNode::Bold(b) => index_inlines(&b.children, page),
|
||||
InlineNode::Italic(i) => index_inlines(&i.children, page),
|
||||
InlineNode::BoldItalic(bi) => index_inlines(&bi.children, page),
|
||||
InlineNode::Strikethrough(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Superscript(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Subscript(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Color(c) => index_inlines(&c.children, page),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_to_text(inlines: &[InlineNode]) -> String {
|
||||
let mut out = String::new();
|
||||
inline_to_text_into(inlines, &mut out);
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => inline_to_text_into(&b.children, out),
|
||||
InlineNode::Italic(i) => inline_to_text_into(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::Code(c) => out.push_str(&c.content),
|
||||
InlineNode::Superscript(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::Subscript(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::MathInline(m) => out.push_str(&m.content),
|
||||
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
||||
InlineNode::WikiLink(w) => {
|
||||
if let Some(d) = &w.description {
|
||||
inline_to_text_into(d, out);
|
||||
} else if let Some(p) = &w.target.path {
|
||||
out.push_str(p);
|
||||
}
|
||||
}
|
||||
InlineNode::ExternalLink(e) => match &e.description {
|
||||
Some(d) => inline_to_text_into(d, out),
|
||||
None => out.push_str(&e.url),
|
||||
},
|
||||
InlineNode::Keyword(_) => {}
|
||||
InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
||||
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
||||
InlineNode::SoftBreak(_) => out.push(' '),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Filesystem walking =====
|
||||
|
||||
/// Canonicalise a wikilink target into the page name the workspace index
|
||||
/// would key it by. Mirrors [`WorkspaceIndex::resolve_wiki_path`]'s preference
|
||||
/// (source-relative when not explicitly absolute, root-relative otherwise) but
|
||||
/// without requiring the target to already be indexed — useful for backlinks
|
||||
/// where the target may be added to the index after the source.
|
||||
///
|
||||
/// Collapses `.` and `..` segments so `posts/../foo` → `foo`. `..` at the
|
||||
/// root of the wiki is treated as a no-op (the wiki has no parent in this
|
||||
/// model — interwiki crossings use the dedicated `[[wiki:Page]]` syntax).
|
||||
pub fn canonical_link_name(
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
file_extension: Option<&str>,
|
||||
) -> String {
|
||||
let normalized = strip_wiki_extension(path, file_extension);
|
||||
let joined = if !is_absolute {
|
||||
if let Some(slash) = source_page.rfind('/') {
|
||||
format!("{}/{}", &source_page[..slash], normalized)
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
} else {
|
||||
normalized.to_string()
|
||||
};
|
||||
collapse_dots(&joined)
|
||||
}
|
||||
|
||||
/// Collapse `.` and `..` segments in a `/`-separated path string. Doesn't
|
||||
/// touch URI escapes — these are workspace-relative page names, not URLs.
|
||||
pub fn collapse_dots(s: &str) -> String {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for seg in s.split('/') {
|
||||
match seg {
|
||||
"" | "." => continue,
|
||||
".." => {
|
||||
parts.pop();
|
||||
}
|
||||
_ => parts.push(seg),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// Strip a wiki file extension from a link target path without allocating.
|
||||
///
|
||||
/// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without).
|
||||
/// Returns `path` unchanged when no extension is configured, the extension is
|
||||
/// empty, or the path doesn't end with `.{ext}`.
|
||||
pub fn strip_wiki_extension<'a>(path: &'a str, file_extension: Option<&str>) -> &'a str {
|
||||
let ext = match file_extension {
|
||||
Some(e) => e.trim_start_matches('.'),
|
||||
None => return path,
|
||||
};
|
||||
if ext.is_empty() {
|
||||
return path;
|
||||
}
|
||||
let dot_ext_len = ext.len() + 1; // +1 for the '.'
|
||||
if path.len() > dot_ext_len
|
||||
&& path.ends_with(ext)
|
||||
&& path.as_bytes()[path.len() - dot_ext_len] == b'.'
|
||||
{
|
||||
&path[..path.len() - dot_ext_len]
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively collect every `.wiki` file under `root`, skipping dotfiles
|
||||
/// and dot-directories.
|
||||
pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name() else {
|
||||
continue;
|
||||
};
|
||||
if name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wiki") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
//! Helpers shared by the navigation handlers (definition,
|
||||
//! references, hover, completion).
|
||||
//!
|
||||
//! Two responsibilities:
|
||||
//!
|
||||
//! 1. **Position conversion** — clients send LSP `Position`s in either
|
||||
//! UTF-8 or UTF-16 encoding; nuwiki AST spans are byte offsets.
|
||||
//! `lsp_to_byte_pos` translates the LSP position into the same shape
|
||||
//! so it can be compared against AST spans.
|
||||
//! 2. **Position lookup** — given a byte position, find the most specific
|
||||
//! `InlineNode` whose span covers it. Used to identify the link under
|
||||
//! the cursor.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, InlineNode, ListItemNode, Span};
|
||||
use tower_lsp::lsp_types::Position as LspPosition;
|
||||
|
||||
use crate::semantic_tokens::LineIndex;
|
||||
|
||||
/// Translate an LSP `Position` (whose `character` is a UTF-16 code-unit
|
||||
/// offset by default, or a UTF-8 byte offset when negotiated) into our
|
||||
/// internal `(line, byte_col)` shape.
|
||||
pub fn lsp_to_byte_pos(p: LspPosition, text: &str, utf8: bool) -> (u32, u32) {
|
||||
if utf8 {
|
||||
return (p.line, p.character);
|
||||
}
|
||||
let idx = LineIndex::new(text);
|
||||
let line_str = idx.line_str(p.line, text);
|
||||
let byte_col = utf16_to_byte_col(line_str, p.character);
|
||||
(p.line, byte_col)
|
||||
}
|
||||
|
||||
fn utf16_to_byte_col(line: &str, target_utf16: u32) -> u32 {
|
||||
let mut byte = 0u32;
|
||||
let mut units = 0u32;
|
||||
for ch in line.chars() {
|
||||
if units >= target_utf16 {
|
||||
break;
|
||||
}
|
||||
units += ch.len_utf16() as u32;
|
||||
byte += ch.len_utf8() as u32;
|
||||
}
|
||||
byte
|
||||
}
|
||||
|
||||
/// Find the most specific inline node whose span covers the given byte
|
||||
/// position. Walks block structure first, then descends into inline
|
||||
/// containers (bold, italic, …) until no child claims the position.
|
||||
pub fn find_inline_at(doc: &DocumentNode, line: u32, byte_col: u32) -> Option<&InlineNode> {
|
||||
for block in &doc.children {
|
||||
if let Some(node) = find_in_block(block, line, byte_col) {
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
match block {
|
||||
BlockNode::Heading(h) => find_in_inlines(&h.children, line, col),
|
||||
BlockNode::Paragraph(p) => find_in_inlines(&p.children, line, col),
|
||||
BlockNode::Blockquote(b) => b.children.iter().find_map(|c| find_in_block(c, line, col)),
|
||||
BlockNode::List(l) => l
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| find_in_list_item(it, line, col)),
|
||||
BlockNode::DefinitionList(dl) => dl.items.iter().find_map(|it| {
|
||||
it.term
|
||||
.as_ref()
|
||||
.and_then(|t| find_in_inlines(t, line, col))
|
||||
.or_else(|| {
|
||||
it.definitions
|
||||
.iter()
|
||||
.find_map(|d| find_in_inlines(d, line, col))
|
||||
})
|
||||
}),
|
||||
BlockNode::Table(t) => t.rows.iter().find_map(|r| {
|
||||
r.cells
|
||||
.iter()
|
||||
.find_map(|c| find_in_inlines(&c.children, line, col))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
find_in_inlines(&item.children, line, col).or_else(|| {
|
||||
item.sublist.as_ref().and_then(|sub| {
|
||||
sub.items
|
||||
.iter()
|
||||
.find_map(|i| find_in_list_item(i, line, col))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> {
|
||||
for n in inlines {
|
||||
if !span_contains(n.span(), line, col) {
|
||||
continue;
|
||||
}
|
||||
// Descend into inline containers so we return the *innermost* hit.
|
||||
let deeper = match n {
|
||||
InlineNode::Bold(b) => find_in_inlines(&b.children, line, col),
|
||||
InlineNode::Italic(i) => find_in_inlines(&i.children, line, col),
|
||||
InlineNode::BoldItalic(bi) => find_in_inlines(&bi.children, line, col),
|
||||
InlineNode::Strikethrough(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Color(c) => find_in_inlines(&c.children, line, col),
|
||||
// Links are an atomic unit for navigation: a cursor anywhere in
|
||||
// `[[target|description]]` (or `[desc](url)`) should resolve the
|
||||
// link itself. Don't descend into the description — returning its
|
||||
// inner Text node leaves goto-definition/references/hover with
|
||||
// nothing to act on ("No locations found" when on the title).
|
||||
_ => None,
|
||||
};
|
||||
return Some(deeper.unwrap_or(n));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn span_contains(s: Span, line: u32, col: u32) -> bool {
|
||||
let start = (s.start.line, s.start.column);
|
||||
let end = (s.end.line, s.end.column);
|
||||
let p = (line, col);
|
||||
p >= start && p < end
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! `textDocument/rename` — server-side rename with cross-document link
|
||||
//! rewriting.
|
||||
//!
|
||||
//! Implementation:
|
||||
//! 1. Resolve the rename target — cursor on a wikilink renames the linked
|
||||
//! page; otherwise rename the current file.
|
||||
//! 2. Build the new URI from the wiki root + the user-supplied path.
|
||||
//! 3. For every page in the wiki's index with an outgoing link to the
|
||||
//! old page name, read its source via the closed-doc loader
|
||||
//! so cached spans are validated against current text, then emit a
|
||||
//! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving
|
||||
//! anchors and descriptions.
|
||||
//! 4. Add the `RenameFile` op last so the builder emits it before content
|
||||
//! edits in `documentChanges` ordering.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tower_lsp::lsp_types::{Url, WorkspaceEdit};
|
||||
|
||||
use crate::edits::{op_rename, text_edit_replace, WorkspaceEditBuilder};
|
||||
use crate::index::page_name_from_uri;
|
||||
use crate::Backend;
|
||||
|
||||
/// Compute the `WorkspaceEdit` for renaming `target_uri` to `new_name`.
|
||||
///
|
||||
/// Returns `None` when:
|
||||
/// - the URI doesn't belong to any registered wiki,
|
||||
/// - the new URI couldn't be built from the wiki root + new_name,
|
||||
/// - the new URI is the same as the old one.
|
||||
pub(crate) async fn compute_rename(
|
||||
backend: &Backend,
|
||||
target_uri: Url,
|
||||
new_name: String,
|
||||
utf8: bool,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let wiki = backend.wiki_for_uri(&target_uri)?;
|
||||
let old_name = page_name_from_uri(&target_uri, Some(&wiki.config.root));
|
||||
// Spaces in the link *target* (and the file on disk) are replaced with
|
||||
// the wiki's `links_space_char`; descriptions keep their original text.
|
||||
let new_name = apply_links_space_char(&new_name, &wiki.config.links_space_char);
|
||||
let new_uri = build_new_uri(&wiki.config.root, &new_name, &wiki.config.file_extension)?;
|
||||
if new_uri == target_uri {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut builder = WorkspaceEditBuilder::new();
|
||||
builder.file_op(op_rename(target_uri.clone(), new_uri));
|
||||
|
||||
// Snapshot the backlinks so the index read lock is released before we
|
||||
// await any disk I/O.
|
||||
let backlinks = {
|
||||
let idx = wiki.index.read().ok()?;
|
||||
idx.backlinks_for(&old_name).to_vec()
|
||||
};
|
||||
|
||||
for bl in backlinks {
|
||||
let Ok(snapshot) = backend.read_or_open(&bl.source_uri).await else {
|
||||
continue;
|
||||
};
|
||||
let start = bl.source_span.start.offset.min(snapshot.text.len());
|
||||
let end = bl.source_span.end.offset.min(snapshot.text.len());
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let original = &snapshot.text[start..end];
|
||||
if let Some(new_link) = rewrite_wikilink_target(original, &new_name) {
|
||||
let edit = text_edit_replace(bl.source_span, new_link, &snapshot.text, utf8);
|
||||
builder.edit(bl.source_uri.clone(), edit);
|
||||
}
|
||||
}
|
||||
|
||||
if builder.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace spaces in a link path with the configured `links_space_char`.
|
||||
/// The default `" "` is an identity transform (spaces kept verbatim).
|
||||
pub fn apply_links_space_char(name: &str, space_char: &str) -> String {
|
||||
if space_char == " " {
|
||||
name.to_string()
|
||||
} else {
|
||||
name.replace(' ', space_char)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `<root>/<new_name>(<ext>)` as a `file://` URL.
|
||||
pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option<Url> {
|
||||
let mut path = root.to_path_buf();
|
||||
for part in new_name.split(['/', '\\']) {
|
||||
if !part.is_empty() {
|
||||
path.push(part);
|
||||
}
|
||||
}
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let with_ext = if path_str.ends_with(ext) {
|
||||
path_str
|
||||
} else {
|
||||
format!("{path_str}{ext}")
|
||||
};
|
||||
Url::from_file_path(with_ext).ok()
|
||||
}
|
||||
|
||||
/// Rewrite a `[[...]]` source segment so the path portion becomes
|
||||
/// `new_path`, keeping any `#anchor` and `|description` parts intact.
|
||||
///
|
||||
/// Returns `None` if `segment` doesn't look like a wikilink (starts with
|
||||
/// `[[`, ends with `]]`).
|
||||
pub fn rewrite_wikilink_target(segment: &str, new_path: &str) -> Option<String> {
|
||||
if !segment.starts_with("[[") || !segment.ends_with("]]") || segment.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let inner = &segment[2..segment.len() - 2];
|
||||
let (path_part, description) = match inner.find('|') {
|
||||
Some(i) => (&inner[..i], Some(&inner[i + 1..])),
|
||||
None => (inner, None),
|
||||
};
|
||||
let anchor = path_part.find('#').map(|i| &path_part[i + 1..]);
|
||||
|
||||
let mut out = String::with_capacity(segment.len());
|
||||
out.push_str("[[");
|
||||
out.push_str(new_path);
|
||||
if let Some(a) = anchor {
|
||||
out.push('#');
|
||||
out.push_str(a);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
out.push('|');
|
||||
out.push_str(d);
|
||||
}
|
||||
out.push_str("]]");
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Semantic-token emission for the vimwiki AST.
|
||||
//!
|
||||
//! Token-type scheme: custom `vimwiki*` types plus
|
||||
//! `level1`..`level6` + `centered` modifiers.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//!
|
||||
//! 1. Walk the AST, emitting one or more `RawToken`s per AST node. Block
|
||||
//! structure that "wraps" text (paragraph, list item, table cell)
|
||||
//! recurses into inlines instead of emitting a wrapping token, so we
|
||||
//! end up with a non-overlapping tile of inline tokens. Block nodes
|
||||
//! that are themselves visually distinct (heading, code block, math
|
||||
//! block, comment) emit one token across their whole span.
|
||||
//! 2. Multi-line spans are split into one token per line — the LSP wire
|
||||
//! format can't express a single token that crosses a newline.
|
||||
//! 3. Tokens are sorted by `(line, byte_col)` and encoded as the LSP
|
||||
//! delta-quintuple stream `(deltaLine, deltaStart, length, type, mods)`.
|
||||
//! When the client doesn't support UTF-8 encoding, byte offsets are
|
||||
//! converted to UTF-16 code-unit counts on the fly.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, HeadingNode, InlineNode, ListItemNode, ListNode, Span,
|
||||
TableNode,
|
||||
};
|
||||
use tower_lsp::lsp_types::{Range, SemanticTokenModifier, SemanticTokenType, SemanticTokensLegend};
|
||||
|
||||
// ===== Legend =====
|
||||
|
||||
pub const TOKEN_HEADING: u32 = 0;
|
||||
pub const TOKEN_BOLD: u32 = 1;
|
||||
pub const TOKEN_ITALIC: u32 = 2;
|
||||
pub const TOKEN_BOLD_ITALIC: u32 = 3;
|
||||
pub const TOKEN_STRIKETHROUGH: u32 = 4;
|
||||
pub const TOKEN_SUPERSCRIPT: u32 = 5;
|
||||
pub const TOKEN_SUBSCRIPT: u32 = 6;
|
||||
pub const TOKEN_CODE: u32 = 7;
|
||||
pub const TOKEN_CODE_BLOCK: u32 = 8;
|
||||
pub const TOKEN_MATH: u32 = 9;
|
||||
pub const TOKEN_MATH_BLOCK: u32 = 10;
|
||||
pub const TOKEN_KEYWORD: u32 = 11;
|
||||
pub const TOKEN_COLOR: u32 = 12;
|
||||
pub const TOKEN_WIKILINK: u32 = 13;
|
||||
pub const TOKEN_EXTERNAL_LINK: u32 = 14;
|
||||
pub const TOKEN_TRANSCLUSION: u32 = 15;
|
||||
pub const TOKEN_URL: u32 = 16;
|
||||
pub const TOKEN_COMMENT: u32 = 17;
|
||||
pub const TOKEN_DEFINITION_TERM: u32 = 18;
|
||||
pub const TOKEN_TAG: u32 = 19;
|
||||
|
||||
pub const TOKEN_TYPE_NAMES: &[&str] = &[
|
||||
"vimwikiHeading",
|
||||
"vimwikiBold",
|
||||
"vimwikiItalic",
|
||||
"vimwikiBoldItalic",
|
||||
"vimwikiStrikethrough",
|
||||
"vimwikiSuperscript",
|
||||
"vimwikiSubscript",
|
||||
"vimwikiCode",
|
||||
"vimwikiCodeBlock",
|
||||
"vimwikiMath",
|
||||
"vimwikiMathBlock",
|
||||
"vimwikiKeyword",
|
||||
"vimwikiColor",
|
||||
"vimwikiWikilink",
|
||||
"vimwikiExternalLink",
|
||||
"vimwikiTransclusion",
|
||||
"vimwikiUrl",
|
||||
"vimwikiComment",
|
||||
"vimwikiDefinitionTerm",
|
||||
"vimwikiTag",
|
||||
];
|
||||
|
||||
pub const MOD_LEVEL1: u32 = 1 << 0;
|
||||
pub const MOD_LEVEL2: u32 = 1 << 1;
|
||||
pub const MOD_LEVEL3: u32 = 1 << 2;
|
||||
pub const MOD_LEVEL4: u32 = 1 << 3;
|
||||
pub const MOD_LEVEL5: u32 = 1 << 4;
|
||||
pub const MOD_LEVEL6: u32 = 1 << 5;
|
||||
pub const MOD_CENTERED: u32 = 1 << 6;
|
||||
|
||||
pub const TOKEN_MODIFIER_NAMES: &[&str] = &[
|
||||
"level1", "level2", "level3", "level4", "level5", "level6", "centered",
|
||||
];
|
||||
|
||||
pub fn legend() -> SemanticTokensLegend {
|
||||
SemanticTokensLegend {
|
||||
token_types: TOKEN_TYPE_NAMES
|
||||
.iter()
|
||||
.map(|s| SemanticTokenType::new(s))
|
||||
.collect(),
|
||||
token_modifiers: TOKEN_MODIFIER_NAMES
|
||||
.iter()
|
||||
.map(|s| SemanticTokenModifier::new(s))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Raw tokens =====
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RawToken {
|
||||
pub line: u32,
|
||||
pub byte_col: u32,
|
||||
pub byte_len: u32,
|
||||
pub kind: u32,
|
||||
pub mods: u32,
|
||||
}
|
||||
|
||||
/// Index of byte offsets at the start of each line. Built once per request
|
||||
/// so multi-line span splitting and UTF-16 conversion stay linear in
|
||||
/// document size.
|
||||
pub struct LineIndex {
|
||||
pub starts: Vec<usize>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
impl LineIndex {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut starts = vec![0];
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
if b == b'\n' {
|
||||
starts.push(i + 1);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
starts,
|
||||
total: text.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_byte_range(&self, line: u32) -> (usize, usize) {
|
||||
let start = self
|
||||
.starts
|
||||
.get(line as usize)
|
||||
.copied()
|
||||
.unwrap_or(self.total);
|
||||
let end = self
|
||||
.starts
|
||||
.get(line as usize + 1)
|
||||
.map(|&s| s.saturating_sub(1))
|
||||
.unwrap_or(self.total);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
pub fn line_str<'a>(&self, line: u32, text: &'a str) -> &'a str {
|
||||
let (s, e) = self.line_byte_range(line);
|
||||
&text[s..e]
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Collection =====
|
||||
|
||||
pub fn collect_tokens(doc: &DocumentNode, text: &str) -> Vec<RawToken> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut out = Vec::new();
|
||||
for block in &doc.children {
|
||||
emit_block(block, text, &idx, &mut out);
|
||||
}
|
||||
out.sort_by_key(|t| (t.line, t.byte_col));
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_block(block: &BlockNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => emit_heading(h, text, idx, out),
|
||||
BlockNode::Paragraph(p) => emit_inlines(&p.children, text, idx, out),
|
||||
BlockNode::HorizontalRule(_) => {}
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for child in children {
|
||||
emit_block(child, text, idx, out);
|
||||
}
|
||||
}
|
||||
BlockNode::Preformatted(p) => split_span(p.span, text, idx, TOKEN_CODE_BLOCK, 0, out),
|
||||
BlockNode::MathBlock(m) => split_span(m.span, text, idx, TOKEN_MATH_BLOCK, 0, out),
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
emit_list_item(item, text, idx, out);
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(term) = &item.term {
|
||||
if let (Some(first), Some(last)) = (term.first(), term.last()) {
|
||||
let s = Span::new(first.span().start, last.span().end);
|
||||
split_span(s, text, idx, TOKEN_DEFINITION_TERM, 0, out);
|
||||
}
|
||||
}
|
||||
for def in &item.definitions {
|
||||
emit_inlines(def, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
emit_inlines(&cell.children, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Comment(c) => split_span(c.span, text, idx, TOKEN_COMMENT, 0, out),
|
||||
BlockNode::Tag(t) => split_span(t.span, text, idx, TOKEN_TAG, 0, out),
|
||||
BlockNode::Error(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_heading(h: &HeadingNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
let mut mods = 0u32;
|
||||
mods |= match h.level {
|
||||
1 => MOD_LEVEL1,
|
||||
2 => MOD_LEVEL2,
|
||||
3 => MOD_LEVEL3,
|
||||
4 => MOD_LEVEL4,
|
||||
5 => MOD_LEVEL5,
|
||||
_ => MOD_LEVEL6,
|
||||
};
|
||||
if h.centered {
|
||||
mods |= MOD_CENTERED;
|
||||
}
|
||||
split_span(h.span, text, idx, TOKEN_HEADING, mods, out);
|
||||
}
|
||||
|
||||
fn emit_list_item(item: &ListItemNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
emit_inlines(&item.children, text, idx, out);
|
||||
if let Some(sublist) = &item.sublist {
|
||||
for child in &sublist.items {
|
||||
emit_list_item(child, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_inlines(inlines: &[InlineNode], text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
for n in inlines {
|
||||
let (kind, span) = match n {
|
||||
InlineNode::Text(_) | InlineNode::SoftBreak(_) => continue,
|
||||
InlineNode::Bold(b) => (TOKEN_BOLD, b.span),
|
||||
InlineNode::Italic(i) => (TOKEN_ITALIC, i.span),
|
||||
InlineNode::BoldItalic(bi) => (TOKEN_BOLD_ITALIC, bi.span),
|
||||
InlineNode::Strikethrough(s) => (TOKEN_STRIKETHROUGH, s.span),
|
||||
InlineNode::Code(c) => (TOKEN_CODE, c.span),
|
||||
InlineNode::Superscript(s) => (TOKEN_SUPERSCRIPT, s.span),
|
||||
InlineNode::Subscript(s) => (TOKEN_SUBSCRIPT, s.span),
|
||||
InlineNode::MathInline(m) => (TOKEN_MATH, m.span),
|
||||
InlineNode::Keyword(k) => (TOKEN_KEYWORD, k.span),
|
||||
InlineNode::Color(c) => (TOKEN_COLOR, c.span),
|
||||
InlineNode::WikiLink(w) => (TOKEN_WIKILINK, w.span),
|
||||
InlineNode::ExternalLink(e) => (TOKEN_EXTERNAL_LINK, e.span),
|
||||
InlineNode::Transclusion(t) => (TOKEN_TRANSCLUSION, t.span),
|
||||
InlineNode::RawUrl(u) => (TOKEN_URL, u.span),
|
||||
};
|
||||
split_span(span, text, idx, kind, 0, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `Span` (potentially multi-line) into one or more single-line
|
||||
/// `RawToken`s. The LSP wire format can't express tokens that span newlines,
|
||||
/// so we split on line boundaries.
|
||||
pub fn split_span(
|
||||
span: Span,
|
||||
text: &str,
|
||||
idx: &LineIndex,
|
||||
kind: u32,
|
||||
mods: u32,
|
||||
out: &mut Vec<RawToken>,
|
||||
) {
|
||||
if span.start.line == span.end.line {
|
||||
let len = span.end.column.saturating_sub(span.start.column);
|
||||
if len > 0 {
|
||||
out.push(RawToken {
|
||||
line: span.start.line,
|
||||
byte_col: span.start.column,
|
||||
byte_len: len,
|
||||
kind,
|
||||
mods,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
for line in span.start.line..=span.end.line {
|
||||
let line_str = idx.line_str(line, text);
|
||||
let line_len = line_str.len() as u32;
|
||||
let start_col = if line == span.start.line {
|
||||
span.start.column
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end_col = if line == span.end.line {
|
||||
span.end.column.min(line_len)
|
||||
} else {
|
||||
line_len
|
||||
};
|
||||
if end_col > start_col {
|
||||
out.push(RawToken {
|
||||
line,
|
||||
byte_col: start_col,
|
||||
byte_len: end_col - start_col,
|
||||
kind,
|
||||
mods,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Encoding =====
|
||||
|
||||
/// LSP wire-format encoder. Produces a flat `Vec<u32>` of
|
||||
/// `(deltaLine, deltaStart, length, type, modifiers)` quintuples.
|
||||
///
|
||||
/// `utf8 = true` means the client opted into UTF-8 encoding, so byte offsets
|
||||
/// pass through directly. Otherwise positions and lengths are converted to
|
||||
/// UTF-16 code units per the LSP default.
|
||||
pub fn encode(raws: &[RawToken], text: &str, idx: &LineIndex, utf8: bool) -> Vec<u32> {
|
||||
let mut data = Vec::with_capacity(raws.len() * 5);
|
||||
let mut prev_line = 0u32;
|
||||
let mut prev_col = 0u32;
|
||||
for tok in raws {
|
||||
let (col, len) = if utf8 {
|
||||
(tok.byte_col, tok.byte_len)
|
||||
} else {
|
||||
let line_str = idx.line_str(tok.line, text);
|
||||
let start = utf16_count(line_str, tok.byte_col as usize);
|
||||
let end = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
||||
(start, end - start)
|
||||
};
|
||||
let delta_line = tok.line - prev_line;
|
||||
let delta_col = if delta_line == 0 { col - prev_col } else { col };
|
||||
data.extend_from_slice(&[delta_line, delta_col, len, tok.kind, tok.mods]);
|
||||
prev_line = tok.line;
|
||||
prev_col = col;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn utf16_count(s: &str, byte_offset: usize) -> u32 {
|
||||
let upto = byte_offset.min(s.len());
|
||||
s[..upto].chars().map(char::len_utf16).sum::<usize>() as u32
|
||||
}
|
||||
|
||||
/// Build the encoded data stream for either the full document or a range
|
||||
/// request.
|
||||
pub fn build_data(doc: &DocumentNode, text: &str, utf8: bool, range: Option<Range>) -> Vec<u32> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut tokens = collect_tokens(doc, text);
|
||||
if let Some(r) = range {
|
||||
tokens.retain(|t| token_overlaps_range(t, &r, text, &idx, utf8));
|
||||
}
|
||||
encode(&tokens, text, &idx, utf8)
|
||||
}
|
||||
|
||||
fn token_overlaps_range(
|
||||
tok: &RawToken,
|
||||
range: &Range,
|
||||
text: &str,
|
||||
idx: &LineIndex,
|
||||
utf8: bool,
|
||||
) -> bool {
|
||||
let (start_char, end_char) = if utf8 {
|
||||
(tok.byte_col, tok.byte_col + tok.byte_len)
|
||||
} else {
|
||||
let line_str = idx.line_str(tok.line, text);
|
||||
let s = utf16_count(line_str, tok.byte_col as usize);
|
||||
let e = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
||||
(s, e)
|
||||
};
|
||||
let tok_start = (tok.line, start_char);
|
||||
let tok_end = (tok.line, end_char);
|
||||
let r_start = (range.start.line, range.start.character);
|
||||
let r_end = (range.end.line, range.end.character);
|
||||
tok_end > r_start && tok_start < r_end
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! `Wiki` aggregate — per-wiki state, even when there's only one.
|
||||
//!
|
||||
//! The single `Arc<RwLock<WorkspaceIndex>>` on `Backend` is lifted into a
|
||||
//! `Wiki` aggregate so multi-wiki support only changes config shape, not
|
||||
//! data flow. Handlers always operate on a `Wiki`; in practice the `wikis`
|
||||
//! `Vec` has one entry until multi-wiki support lands.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
use crate::config::WikiConfig;
|
||||
use crate::index::WorkspaceIndex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
|
||||
pub struct WikiId(pub u32);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Wiki {
|
||||
pub id: WikiId,
|
||||
// `Arc` so cloning a `Wiki` (done on every handler entry that picks a
|
||||
// wiki out of the list) is a refcount bump, not a deep copy of the whole
|
||||
// config — color_dic, templates, header strings, and so on.
|
||||
pub config: Arc<WikiConfig>,
|
||||
pub index: Arc<RwLock<WorkspaceIndex>>,
|
||||
}
|
||||
|
||||
impl Wiki {
|
||||
pub fn new(id: WikiId, config: WikiConfig) -> Self {
|
||||
let index = WorkspaceIndex::new(Some(config.root.clone()))
|
||||
.with_diary_rel_path(Some(config.diary_rel_path.clone()))
|
||||
.with_file_extension(Some(config.file_extension.clone()));
|
||||
let index = Arc::new(RwLock::new(index));
|
||||
Self {
|
||||
id,
|
||||
config: Arc::new(config),
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `uri` resolves to a file under this wiki's root.
|
||||
pub fn contains(&self, uri: &Url) -> bool {
|
||||
let Ok(path) = uri.to_file_path() else {
|
||||
return false;
|
||||
};
|
||||
path.starts_with(&self.config.root)
|
||||
}
|
||||
|
||||
/// Length of the wiki's root path (component count) — used to break
|
||||
/// ties in nested-root layouts.
|
||||
pub fn root_depth(&self) -> usize {
|
||||
self.config.root.components().count()
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.config.root
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the wiki whose root is the longest prefix of `uri`. Returns the
|
||||
/// wiki id, not a guard, so callers can release the read lock before
|
||||
/// awaiting.
|
||||
pub fn resolve_uri_to_wiki(wikis: &[Wiki], uri: &Url) -> Option<WikiId> {
|
||||
wikis
|
||||
.iter()
|
||||
.filter(|w| w.contains(uri))
|
||||
.max_by_key(|w| w.root_depth())
|
||||
.map(|w| w.id)
|
||||
}
|
||||
|
||||
/// Build the initial `Vec<Wiki>` from a list of `WikiConfig`s. Always
|
||||
/// assigns ids by position so `WikiId(0)` is the default wiki.
|
||||
pub fn build_wikis(configs: &[WikiConfig]) -> Vec<Wiki> {
|
||||
configs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cfg)| Wiki::new(WikiId(i as u32), cfg.clone()))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! Checkbox state transitions and the `checkbox_edit` rewriter that
|
||||
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
|
||||
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
|
||||
|
||||
use nuwiki_core::listsyms::ListSyms;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::{DocumentChanges, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn syms() -> ListSyms {
|
||||
ListSyms::default()
|
||||
}
|
||||
|
||||
fn dummy_uri() -> Url {
|
||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
||||
}
|
||||
|
||||
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
|
||||
let changes = edit.changes.expect("expected changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
||||
assert_eq!(edits.len(), 1, "expected exactly one text edit");
|
||||
edits.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option<DocumentChanges> {
|
||||
edit.document_changes
|
||||
}
|
||||
|
||||
// ===== state transitions =====
|
||||
|
||||
#[test]
|
||||
fn toggle_state_round_trip() {
|
||||
let s = syms();
|
||||
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[X]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[.]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[?]").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_state_walks_progression() {
|
||||
let s = syms();
|
||||
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[.]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[.]").as_deref(), Some("[o]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), Some("[O]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[O]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[X]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_state_back_is_exact_inverse() {
|
||||
// `glp` walks the progression in reverse — every step must undo the
|
||||
// matching `cycle_state` step.
|
||||
let s = syms();
|
||||
assert_eq!(ops::cycle_state_back(&s, "[ ]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[X]").as_deref(), Some("[O]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[O]").as_deref(), Some("[o]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[o]").as_deref(), Some("[.]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[.]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
// Composing forward then backward returns to the start for every
|
||||
// in-cycle marker.
|
||||
for m in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
||||
let fwd = ops::cycle_state(&s, m).unwrap();
|
||||
assert_eq!(ops::cycle_state_back(&s, &fwd).as_deref(), Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_state_toggles_dash_marker() {
|
||||
let s = syms();
|
||||
assert_eq!(ops::reject_state(&s, "[ ]").as_deref(), Some("[-]"));
|
||||
assert_eq!(ops::reject_state(&s, "[X]").as_deref(), Some("[-]"));
|
||||
assert_eq!(ops::reject_state(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_palette_three_symbols() {
|
||||
// A 3-glyph palette `" x✓"`: empty/half/done. `[x]` is the only
|
||||
// intermediate; cycling walks ` `→`x`→`✓`→` `.
|
||||
let s = ListSyms::new(" x✓");
|
||||
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[x]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[x]").as_deref(), Some("[✓]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[✓]").as_deref(), Some("[ ]"));
|
||||
// Default-palette glyphs are not in this palette → no transition.
|
||||
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), None);
|
||||
// Toggle still maps empty→done, done→empty.
|
||||
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[✓]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[✓]").as_deref(), Some("[ ]"));
|
||||
}
|
||||
|
||||
// ===== checkbox_edit =====
|
||||
|
||||
#[test]
|
||||
fn toggle_empty_checkbox_to_done() {
|
||||
let src = "- [ ] task\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
4,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[X]");
|
||||
assert_eq!(te.range.start.character, 2); // bytes 2..5 = "[ ]"
|
||||
assert_eq!(te.range.end.character, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_advances_partial_states() {
|
||||
let src = "- [.] half\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::cycle_state,
|
||||
true,
|
||||
)
|
||||
.expect("cycle edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[o]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_replaces_empty_with_dash() {
|
||||
let src = "* [ ] thing\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::reject_state,
|
||||
true,
|
||||
)
|
||||
.expect("reject edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[-]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_returns_none_on_plain_list_item() {
|
||||
// No `[…]` at all → no edit.
|
||||
let src = "- plain item\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_returns_none_off_list() {
|
||||
let src = "just a paragraph\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_finds_item_in_sublist() {
|
||||
let src = "- top\n - [ ] nested\n";
|
||||
let doc = parse(src);
|
||||
// Cursor on line 1, the nested item.
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
5,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("edit on nested item");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[X]");
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
}
|
||||
|
||||
// ===== sanity: workspace edit shape =====
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
||||
let src = "- [ ] task\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
4,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(doc_changes_for(edit.clone()).is_none());
|
||||
assert!(edit.changes.is_some());
|
||||
}
|
||||
|
||||
// ===== parent propagation (vimwiki listsyms_propagate) =====
|
||||
|
||||
/// Collect the (line, new_text) pairs for every TextEdit in the result,
|
||||
/// sorted by line. Lets propagation tests assert the parent edit
|
||||
/// without caring about edit ordering.
|
||||
fn edits_by_line(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Vec<(u32, String)> {
|
||||
let changes = edit.changes.expect("expected changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
||||
let mut pairs: Vec<(u32, String)> = edits
|
||||
.into_iter()
|
||||
.map(|e| (e.range.start.line, e.new_text))
|
||||
.collect();
|
||||
pairs.sort_by_key(|(line, _)| *line);
|
||||
pairs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_one_of_four_done_marks_parent_quarter() {
|
||||
// Toggle the first child of a 4-child parent. Parent should land at
|
||||
// [.] (Quarter, 25%).
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[.]".into()), (1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_two_of_four_done_marks_parent_half() {
|
||||
// sub1 is already done; toggling sub2 → done leaves rate=50% → [o].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[o]".into()), (2, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_three_of_four_done_marks_parent_three_quarters() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [X] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
3,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[O]".into()), (3, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_all_done_marks_parent_done() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [X] sub2
|
||||
- [X] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
4,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[X]".into()), (4, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_untoggle_drops_parent_back() {
|
||||
// Untoggling the only completed child should put parent back to [ ].
|
||||
let src = "\
|
||||
- [.] root
|
||||
- [X] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[ ]".into()), (1, "[ ]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_disabled_only_emits_leaf_edit() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
false,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_skips_parent_without_checkbox() {
|
||||
// Parent has no `[…]`, so propagation has nothing to update.
|
||||
let src = "\
|
||||
- root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_walks_multiple_levels() {
|
||||
// Toggle the deepest leaf: grandparent has 1 child (parent), parent
|
||||
// has 2 children. Sub1 done → parent half → grandparent half.
|
||||
let src = "\
|
||||
- [ ] grand
|
||||
- [ ] parent
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
8,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![(0, "[o]".into()), (1, "[o]".into()), (2, "[X]".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_all_rejected_marks_parent_rejected() {
|
||||
// Every counted child is rejected → parent becomes [-].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [-] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
// Reject sub2 so both children are rejected.
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::reject_state,
|
||||
true,
|
||||
)
|
||||
.expect("reject edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[-]".into()), (2, "[-]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_rejected_sibling_counts_as_done_for_average() {
|
||||
// sub1 rejected, sub2 toggled to done → both "complete" → parent [X].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [-] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `color_dic` → `HtmlRenderer` integration.
|
||||
//!
|
||||
//! Drives the renderer directly so we don't have to spin up the full
|
||||
//! export pipeline. The end-to-end path (export command → renderer
|
||||
//! with `cfg.color_dic` plumbed through) is integration-tested via the
|
||||
//! export tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nuwiki_core::ast::{ColorNode, DocumentNode, InlineNode, ParagraphNode, Span, TextNode};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
|
||||
fn doc_with_color(name: &str, text: &str) -> DocumentNode {
|
||||
let span = Span::default();
|
||||
DocumentNode {
|
||||
span,
|
||||
metadata: Default::default(),
|
||||
children: vec![nuwiki_core::ast::BlockNode::Paragraph(ParagraphNode {
|
||||
span,
|
||||
children: vec![InlineNode::Color(ColorNode {
|
||||
span,
|
||||
color: name.into(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span,
|
||||
content: text.into(),
|
||||
})],
|
||||
})],
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_falls_back_to_class_when_dict_empty() {
|
||||
let doc = doc_with_color("red", "hello");
|
||||
let r = HtmlRenderer::new();
|
||||
let out = r.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<span class="color-red">"#), "got: {out}");
|
||||
assert!(out.contains("hello</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_dic_emits_inline_style_when_name_listed() {
|
||||
let mut dict = HashMap::new();
|
||||
dict.insert("red".to_string(), "#ff0000".to_string());
|
||||
let doc = doc_with_color("red", "hello");
|
||||
let r = HtmlRenderer::new().with_colors(dict);
|
||||
let out = r.render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains(r#"<span style="color:#ff0000">"#),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_color_falls_back_even_with_dict_set() {
|
||||
let mut dict = HashMap::new();
|
||||
dict.insert("red".to_string(), "#ff0000".to_string());
|
||||
let doc = doc_with_color("violet", "x");
|
||||
let r = HtmlRenderer::new().with_colors(dict);
|
||||
let out = r.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<span class="color-violet">"#), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_json_accepts_color_dic() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/c", "color_dic": { "red": "red", "blue": "blue" } },
|
||||
],
|
||||
}));
|
||||
let d = &cfg.wikis[0].html.color_dic;
|
||||
assert_eq!(d.get("red").map(String::as_str), Some("red"));
|
||||
assert_eq!(d.get("blue").map(String::as_str), Some("blue"));
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Command-coverage suite: at least one behavioural test for every
|
||||
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
|
||||
//! of `doc/nuwiki.txt`).
|
||||
//!
|
||||
//! The user-facing commands funnel through `workspace/executeCommand`
|
||||
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
|
||||
//! the standard LSP requests. The Backend itself owns a live `Client` and
|
||||
//! can't be built in an integration test, so each case drives the *pure*
|
||||
//! building-block the command relies on (the same functions the dispatcher
|
||||
//! wraps). This file is organised to mirror the doc's command groups so a
|
||||
//! reader can confirm every command has coverage.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use nuwiki_lsp::nav::find_inline_at;
|
||||
use nuwiki_lsp::rename::{apply_links_space_char, build_new_uri, rewrite_wikilink_target};
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
|
||||
use nuwiki_lsp::{diary, export};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
||||
WikiConfig::from_root(PathBuf::from(root))
|
||||
}
|
||||
|
||||
/// Build a workspace index rooted at `root` with the given `name -> source`
|
||||
/// pages, mirroring the helper used by the other command suites.
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse(src));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
||||
let mut idx =
|
||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
||||
for d in dates {
|
||||
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse("= entry =\n"));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 1: Wiki / navigation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiIndex — open wiki N's index page.
|
||||
#[test]
|
||||
fn nuwiki_index_resolves_index_page() {
|
||||
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
|
||||
let page = idx.page_by_name("index").expect("index page indexed");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
|
||||
// The tab-vs-current split is an editor concern; the resolved target is
|
||||
// identical, and both verbs are advertised as executeCommands.
|
||||
#[test]
|
||||
fn nuwiki_tab_index_advertised_alongside_index() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
|
||||
}
|
||||
|
||||
// :NuwikiUISelect — pick a wiki from the configured list.
|
||||
#[test]
|
||||
fn nuwiki_ui_select_lists_configured_wikis() {
|
||||
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
|
||||
let wikis = build_wikis(&cfgs);
|
||||
assert_eq!(wikis.len(), 2);
|
||||
assert_eq!(wikis[0].config.name, "personal");
|
||||
assert_eq!(wikis[1].config.name, "work");
|
||||
|
||||
// Selection by URI picks the wiki that actually owns the file.
|
||||
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
|
||||
}
|
||||
|
||||
// :NuwikiGoto {page} — open `{page}.wiki` by name.
|
||||
#[test]
|
||||
fn nuwiki_goto_opens_page_by_name() {
|
||||
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
|
||||
let page = idx
|
||||
.page_by_name("Recipes")
|
||||
.expect("page resolvable by name");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
|
||||
assert!(idx.page_by_name("Missing").is_none());
|
||||
}
|
||||
|
||||
// :NuwikiFollowLink — follow the link under the cursor.
|
||||
#[test]
|
||||
fn nuwiki_follow_link_resolves_target_under_cursor() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let doc = parse("see [[About]]\n");
|
||||
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
|
||||
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
|
||||
let InlineNode::WikiLink(w) = node else {
|
||||
panic!("expected a wikilink, got {node:?}");
|
||||
};
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
|
||||
}
|
||||
|
||||
// :NuwikiBacklinks — every reference to the current page.
|
||||
#[test]
|
||||
fn nuwiki_backlinks_lists_referrers() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", "[[About]]\n"),
|
||||
("Contact", "see [[About]] too\n"),
|
||||
("About", "= A =\n"),
|
||||
],
|
||||
);
|
||||
let back = idx.backlinks_for("About");
|
||||
assert_eq!(back.len(), 2);
|
||||
assert!(idx.backlinks_for("Home").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
|
||||
// The jump itself is editor-side cursor movement; what the server provides
|
||||
// is the ordered set of links on the page.
|
||||
#[test]
|
||||
fn nuwiki_next_prev_link_walk_links_in_document_order() {
|
||||
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
|
||||
let links = ops::collect_wiki_links(&doc);
|
||||
let targets: Vec<&str> = links
|
||||
.iter()
|
||||
.filter_map(|l| l.target.path.as_deref())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["First", "Second", "Third"]);
|
||||
}
|
||||
|
||||
// :NuwikiBaddLink — add the target of the link under the cursor to the
|
||||
// buffer list. The editor :badds whatever URI the link resolves to.
|
||||
#[test]
|
||||
fn nuwiki_badd_link_resolves_target_uri() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
|
||||
);
|
||||
let target = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Notes/Todo".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
|
||||
}
|
||||
|
||||
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
|
||||
#[test]
|
||||
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
|
||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
|
||||
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
|
||||
|
||||
assert_eq!(
|
||||
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
|
||||
Some("[[New Name|caption]]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// links_space_char rewrites spaces in the link target / on-disk path while
|
||||
// the default of a single space keeps the name verbatim.
|
||||
#[test]
|
||||
fn links_space_char_replaces_spaces_in_target_path() {
|
||||
// Default " " — identity, spaces preserved.
|
||||
assert_eq!(apply_links_space_char("My Page", " "), "My Page");
|
||||
// Custom char — spaces become the configured glyph.
|
||||
assert_eq!(apply_links_space_char("My Page", "_"), "My_Page");
|
||||
// Subdir separators are untouched; only spaces within segments change.
|
||||
assert_eq!(
|
||||
apply_links_space_char("sub dir/My Page", "_"),
|
||||
"sub_dir/My_Page"
|
||||
);
|
||||
|
||||
// The transformed name flows into both the new URI and the rewritten link.
|
||||
let renamed = apply_links_space_char("New Page", "-");
|
||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), &renamed, ".wiki").unwrap();
|
||||
assert!(new_uri.as_str().ends_with("/wiki/New-Page.wiki"));
|
||||
assert_eq!(
|
||||
rewrite_wikilink_target("[[Old Name|caption]]", &renamed),
|
||||
Some("[[New-Page|caption]]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// :NuwikiDeleteFile — delete the current page and its on-disk file.
|
||||
#[test]
|
||||
fn nuwiki_delete_file_emits_delete_workspace_edit() {
|
||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
||||
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
||||
let we = b.build();
|
||||
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected resource operations");
|
||||
};
|
||||
assert!(matches!(
|
||||
&ops[0],
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
|
||||
));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 2: Diary
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiMakeDiaryNote — open today's entry.
|
||||
#[test]
|
||||
fn nuwiki_make_diary_note_targets_todays_file() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
let uri = diary::uri_for_date(&cfg, &today).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
|
||||
// diary back / forward by one period at the daily cadence.
|
||||
#[test]
|
||||
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
||||
assert!(diary::uri_for_date(&cfg, &yesterday)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-11.wiki"));
|
||||
assert!(diary::uri_for_date(&cfg, &tomorrow)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-13.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryIndex — open the diary index page.
|
||||
#[test]
|
||||
fn nuwiki_diary_index_resolves_index_uri() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let uri = diary::index_uri(&cfg).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_generate_links_builds_grouped_body() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(
|
||||
&entries,
|
||||
"diary",
|
||||
"Diary",
|
||||
diary::DiarySort::Desc,
|
||||
1,
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
assert!(body.starts_with("= Diary ="));
|
||||
assert!(body.contains("2026-05-12"));
|
||||
assert!(body.contains("2026-05-10"));
|
||||
assert!(body.contains("2026-04-30"));
|
||||
// Newest entry appears before the older one within the same month.
|
||||
let p12 = body.find("2026-05-12").unwrap();
|
||||
let p10 = body.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10, "expected newest-first ordering");
|
||||
}
|
||||
|
||||
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_next_and_prev_day_walk_entries() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
assert_eq!(
|
||||
diary::next_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-15"
|
||||
);
|
||||
assert_eq!(
|
||||
diary::prev_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-10"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 3: Page generation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiTOC — generate / refresh the table of contents.
|
||||
#[test]
|
||||
fn nuwiki_toc_generates_nested_contents() {
|
||||
let src = "= Top =\n== Sub ==\ntext\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
|
||||
let edit =
|
||||
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced");
|
||||
assert!(edit.changes.is_some() || edit.document_changes.is_some());
|
||||
|
||||
let toc = ops::build_toc_text(
|
||||
&[
|
||||
(1, "Top".into(), "top".into()),
|
||||
(2, "Sub".into(), "sub".into()),
|
||||
],
|
||||
"Contents",
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert!(toc.contains("= Contents ="));
|
||||
assert!(toc.contains("- [[#top|Top]]"));
|
||||
assert!(toc.contains(" - [[#sub|Sub]]"));
|
||||
}
|
||||
|
||||
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
|
||||
#[test]
|
||||
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
|
||||
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
|
||||
let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0, None);
|
||||
assert!(text.contains("= Links ="));
|
||||
assert!(text.contains("- [[About]]"));
|
||||
assert!(text.contains("- [[Notes]]"));
|
||||
assert!(!text.contains("[[Home]]"), "current page excluded");
|
||||
|
||||
let src = "= Existing =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::links_edit(
|
||||
src,
|
||||
&doc,
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
// :NuwikiCheckLinks — surface broken links across the workspace.
|
||||
#[test]
|
||||
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let good = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("About".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let broken = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Ghost".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(idx.resolve(&good, "Home").is_some());
|
||||
assert!(
|
||||
idx.resolve(&broken, "Home").is_none(),
|
||||
"broken link unresolved"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 4: Tags
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
|
||||
#[test]
|
||||
fn nuwiki_search_tags_finds_occurrences() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "alpha");
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert!(hits.iter().all(|h| h.name == "alpha"));
|
||||
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
|
||||
#[test]
|
||||
fn nuwiki_generate_tag_links_builds_section() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", ":alpha:\n"),
|
||||
("Work", ":alpha:\n"),
|
||||
("Misc", ":beta:\n"),
|
||||
],
|
||||
);
|
||||
let by_tag = ops::tag_pages_snapshot(&idx);
|
||||
|
||||
let single = ops::build_tag_links_text(&by_tag, Some("alpha"), "Generated Tags", 1, 0).unwrap();
|
||||
assert!(single.starts_with("= Tag: alpha ="));
|
||||
assert!(single.contains("- [[Home]]"));
|
||||
assert!(single.contains("- [[Work]]"));
|
||||
|
||||
// No-arg variant emits a section per tag.
|
||||
let all = ops::build_tag_links_text(&by_tag, None, "Generated Tags", 1, 0).unwrap();
|
||||
assert!(all.starts_with("= Generated Tags ="));
|
||||
assert!(all.contains("== alpha =="));
|
||||
assert!(all.contains("== beta =="));
|
||||
|
||||
let src = "= Home =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::tag_links_edit(
|
||||
src,
|
||||
&doc,
|
||||
&uri,
|
||||
Some("alpha"),
|
||||
&by_tag,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
|
||||
// reflect the current on-disk tags, dropping stale ones.
|
||||
#[test]
|
||||
fn nuwiki_rebuild_tags_reflects_current_state() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":old:\n"));
|
||||
assert_eq!(idx.tags_for("old").len(), 1);
|
||||
|
||||
// Re-index after the page changed on disk.
|
||||
idx.upsert(uri, &parse(":new:\n"));
|
||||
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
|
||||
assert_eq!(idx.tags_for("new").len(), 1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 5: HTML export
|
||||
// =====================================================================
|
||||
|
||||
// :Nuwiki2HTML — render the current page to HTML.
|
||||
#[test]
|
||||
fn nuwiki_2html_renders_page() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("%title My Page\n= One =\nbody text\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"));
|
||||
assert!(html.contains("body text"));
|
||||
}
|
||||
|
||||
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
|
||||
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
|
||||
#[test]
|
||||
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("= Page =\nhi\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("{{content}}".into()),
|
||||
"Page",
|
||||
DiaryDate::today_utc(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("hi"));
|
||||
}
|
||||
|
||||
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
|
||||
// per-page output paths.
|
||||
#[test]
|
||||
fn nuwiki_all2html_maps_output_paths_and_excludes() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
|
||||
assert!(
|
||||
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
|
||||
);
|
||||
|
||||
let patterns = vec!["_drafts/**".to_string()];
|
||||
assert!(export::is_excluded(&patterns, "_drafts/secret"));
|
||||
assert!(!export::is_excluded(&patterns, "Home"));
|
||||
|
||||
// Both incremental and forced (`!`) variants are advertised.
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
|
||||
}
|
||||
|
||||
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
|
||||
#[test]
|
||||
fn nuwiki_rss_writes_feed_file() {
|
||||
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let cfg = WikiConfig::from_root(root.clone());
|
||||
|
||||
let entries = vec![
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
|
||||
},
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
|
||||
},
|
||||
];
|
||||
|
||||
let path = export_ops::write_rss(&cfg, &entries).unwrap();
|
||||
assert!(path.ends_with("rss.xml"));
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.contains("<rss version=\"2.0\""));
|
||||
// Newest entry listed first.
|
||||
let p12 = xml.find("2026-05-12").unwrap();
|
||||
let p10 = xml.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 6: Other
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
|
||||
// editor-side action (binary download / cargo build); it is deliberately
|
||||
// NOT an LSP executeCommand, so the server must not advertise it.
|
||||
#[test]
|
||||
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
|
||||
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Command-surface cross-check: every documented command that maps to an
|
||||
// executeCommand handler is actually advertised by the server.
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn every_documented_lsp_command_is_advertised() {
|
||||
let expected = [
|
||||
"nuwiki.wiki.openIndex", // :NuwikiIndex
|
||||
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
|
||||
"nuwiki.wiki.listAll", // :NuwikiUISelect
|
||||
"nuwiki.wiki.gotoPage", // :NuwikiGoto
|
||||
"nuwiki.file.delete", // :NuwikiDeleteFile
|
||||
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
|
||||
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
|
||||
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
|
||||
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
|
||||
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
|
||||
"nuwiki.diary.next", // :NuwikiDiaryNextDay
|
||||
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
|
||||
"nuwiki.toc.generate", // :NuwikiTOC
|
||||
"nuwiki.links.generate", // :NuwikiGenerateLinks
|
||||
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
|
||||
"nuwiki.tags.search", // :NuwikiSearchTags
|
||||
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
|
||||
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
|
||||
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
|
||||
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
|
||||
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
|
||||
"nuwiki.export.rss", // :NuwikiRss
|
||||
];
|
||||
for cmd in expected {
|
||||
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Pure-function tests for the rename helper + the file-delete
|
||||
//! command. The async `Backend::rename` end-to-end (with `read_or_open`
|
||||
//! hitting disk) is exercised by integration tests but covered here
|
||||
//! at the building-block level — same logic, no async client.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
// ===== rewrite_wikilink_target =====
|
||||
|
||||
#[test]
|
||||
fn rewrites_plain_wikilink() {
|
||||
let out = rewrite_wikilink_target("[[Old Page]]", "New Page").unwrap();
|
||||
assert_eq!(out, "[[New Page]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_description() {
|
||||
let out = rewrite_wikilink_target("[[Old|some text]]", "New").unwrap();
|
||||
assert_eq!(out, "[[New|some text]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_anchor() {
|
||||
let out = rewrite_wikilink_target("[[Old#section]]", "New").unwrap();
|
||||
assert_eq!(out, "[[New#section]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_anchor_and_description() {
|
||||
let out = rewrite_wikilink_target("[[Old#sec|desc]]", "New").unwrap();
|
||||
assert_eq!(out, "[[New#sec|desc]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_subdirectory_path() {
|
||||
let out = rewrite_wikilink_target("[[old/page]]", "new/subdir/page").unwrap();
|
||||
assert_eq!(out, "[[new/subdir/page]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_non_wikilink_text() {
|
||||
assert_eq!(rewrite_wikilink_target("just text", "X"), None);
|
||||
assert_eq!(rewrite_wikilink_target("[[not closed", "X"), None);
|
||||
assert_eq!(rewrite_wikilink_target("not opened]]", "X"), None);
|
||||
assert_eq!(rewrite_wikilink_target("[]", "X"), None);
|
||||
}
|
||||
|
||||
// ===== build_new_uri =====
|
||||
|
||||
#[test]
|
||||
fn builds_uri_under_root() {
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_uri_with_subdirectory() {
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "dir/Page", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/dir/Page.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_duplicate_extension() {
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page.wiki", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_empty_path_segments() {
|
||||
// `//page` should not collapse into a filesystem-absolute path.
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "//page", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/page.wiki"));
|
||||
}
|
||||
|
||||
// ===== executeCommand: nuwiki.file.delete =====
|
||||
//
|
||||
// We don't need a Backend to drive `commands::execute` for the delete
|
||||
// command — it ignores its first arg. The smoke test verifies the
|
||||
// dispatcher + DeleteFile-op packaging.
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_delete_returns_workspace_edit_with_delete_op() {
|
||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
||||
// Building a real Backend requires a Client. For this unit test we use
|
||||
// `commands::execute` with `Backend` typed as `&_` since the delete
|
||||
// path doesn't touch backend state. Instead we drive the inner logic
|
||||
// by constructing the args inline.
|
||||
//
|
||||
// Easier: shape the test as an integration test over the public
|
||||
// module surface — call `commands::execute` once we expose a thin
|
||||
// builder. We test the JSON-shaped contract using the
|
||||
// edits module directly, since that's what `file_delete` returns.
|
||||
let uri = Url::parse("file:///tmp/note.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
||||
let we = b.build();
|
||||
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected Operations variant");
|
||||
};
|
||||
assert_eq!(ops.len(), 1);
|
||||
match &ops[0] {
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) => {
|
||||
assert_eq!(d.uri, uri);
|
||||
}
|
||||
other => panic!("expected DeleteFile op, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Heading level rewriter — `nuwiki.heading.addLevel` /
|
||||
//! `nuwiki.heading.removeLevel`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn dummy_uri() -> Url {
|
||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
||||
}
|
||||
|
||||
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
|
||||
let changes = edit.changes.expect("expected changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
||||
assert_eq!(edits.len(), 1, "expected exactly one text edit");
|
||||
edits.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_heading_level_promotes_h2_to_h3() {
|
||||
let src = "== Sub ==\n";
|
||||
let doc = parse(src);
|
||||
let edit =
|
||||
ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "=== Sub ===");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_heading_level_caps_at_6() {
|
||||
let src = "====== Deep ======\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
||||
// No-op because level 6 is the cap; clamp matched current level.
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_heading_level_demotes() {
|
||||
let src = "=== Mid ===\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1)
|
||||
.expect("remove_level edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "== Mid ==");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_heading_level_at_h1_is_noop() {
|
||||
let src = "= Top =\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_change_returns_none_when_cursor_not_on_heading() {
|
||||
let src = "paragraph\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() {
|
||||
// The lexer's heading span starts at the first `=`, not the line's
|
||||
// start, so the leading whitespace that marks a "centered" heading
|
||||
// lives outside the edit range — preserved automatically. The
|
||||
// rewriter's output is just the `=...=` portion.
|
||||
let src = " = Title =\n";
|
||||
let doc = parse(src);
|
||||
let h = match &doc.children[0] {
|
||||
nuwiki_core::ast::BlockNode::Heading(h) => h,
|
||||
_ => panic!("expected heading"),
|
||||
};
|
||||
let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap();
|
||||
assert_eq!(new, "== Title ==");
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Link-related LSP commands.
|
||||
//!
|
||||
//! Covers:
|
||||
//! - `nuwiki.link.pasteWikilink` / `nuwiki.link.pasteUrl` — page-name
|
||||
//! synthesis from a URI and the textual shape the handler inserts
|
||||
//! at the cursor.
|
||||
//! - Wikilink resolution, including the regression where `<CR>` on a
|
||||
//! wikilink to a not-yet-created page must still resolve to a URI
|
||||
//! so the editor can open (and on save create) the future page.
|
||||
//! The resolver itself is private; we exercise the synthesised-path
|
||||
//! semantics via the public config helpers + index lookup.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::LinkKind;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::index::{page_name_from_uri, WorkspaceIndex};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
// ===== paste helpers =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_advertises_link_helpers() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.link.pasteWikilink",
|
||||
"nuwiki.link.pasteUrl",
|
||||
"nuwiki.link.catUrl",
|
||||
// Both GenerateLinks forms must be advertised so coc/vim-lsp accept
|
||||
// them (the scoped form was previously sent but unregistered).
|
||||
"nuwiki.links.generate",
|
||||
"nuwiki.links.generateForPath",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_for_root_level_page() {
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
|
||||
let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_for_subdir_page() {
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
|
||||
let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
||||
assert_eq!(
|
||||
page_name_from_uri(&uri, Some(&cfg.root)),
|
||||
"diary/2026-05-11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_url_format_matches_export_output_path_for() {
|
||||
// The URL the `pasteUrl` handler emits is `<name>.html`, where
|
||||
// `<name>` walks path segments. Mirror that derivation and assert
|
||||
// the shape we'd insert at cursor.
|
||||
let name = "diary/2026-05-11";
|
||||
let mut url = String::new();
|
||||
for (i, seg) in name.split('/').enumerate() {
|
||||
if i > 0 {
|
||||
url.push('/');
|
||||
}
|
||||
url.push_str(seg);
|
||||
}
|
||||
url.push_str(".html");
|
||||
assert_eq!(url, "diary/2026-05-11.html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_wikilink_shape_for_root_page() {
|
||||
// `[[<name>]]` is what the handler inserts at the cursor.
|
||||
let name = "Notes";
|
||||
assert_eq!(format!("[[{name}]]"), "[[Notes]]");
|
||||
}
|
||||
|
||||
// ===== resolve follow-link to non-existent page =====
|
||||
|
||||
#[test]
|
||||
fn link_to_indexed_page_resolves_via_index() {
|
||||
let root = "/tmp/follow-link-1";
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap();
|
||||
idx.upsert(target.clone(), &parse("= notes =\n"));
|
||||
|
||||
let resolved = idx.pages_by_name.get("Notes").cloned();
|
||||
assert_eq!(resolved, Some(target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_page_is_not_in_index() {
|
||||
let root = "/tmp/follow-link-2";
|
||||
let idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
// Empty index — "Missing" isn't there, so the fallback synthesise
|
||||
// path is the only way to follow `[[Missing]]`.
|
||||
assert!(!idx.pages_by_name.contains_key("Missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_root_plus_path_plus_ext_is_a_valid_uri() {
|
||||
// Sanity: the synthesise logic constructs <root>/<path>.<ext>.
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3"));
|
||||
assert_eq!(cfg.file_extension, ".wiki");
|
||||
let target_path = cfg.root.join("Missing.wiki");
|
||||
assert!(Url::from_file_path(&target_path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_to_subdir_page_parses_with_slash_in_path() {
|
||||
// Synthesis must walk path segments, not just concatenate.
|
||||
let doc = parse("[[notes/Daily]]\n");
|
||||
let inline = match &doc.children[0] {
|
||||
nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0],
|
||||
_ => panic!("expected paragraph"),
|
||||
};
|
||||
let target = match inline {
|
||||
nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target,
|
||||
_ => panic!("expected wikilink"),
|
||||
};
|
||||
assert_eq!(target.kind, LinkKind::Wiki);
|
||||
assert_eq!(target.path.as_deref(), Some("notes/Daily"));
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Cluster A — list rewriters: `removeDone`, `renumber`,
|
||||
//! `changeSymbol`, `changeLevel`.
|
||||
|
||||
use nuwiki_core::ast::ListSymbol;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::{Position as LspPosition, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn one_edit_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("≥1 uri");
|
||||
edits
|
||||
.into_iter()
|
||||
.map(|e| e.new_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn uri() -> Url {
|
||||
Url::parse("file:///tmp/list.wiki").unwrap()
|
||||
}
|
||||
|
||||
// ===== parse_symbol + render_marker =====
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_accepts_short_and_long_names() {
|
||||
assert_eq!(ops::parse_symbol("-"), Some(ListSymbol::Dash));
|
||||
assert_eq!(ops::parse_symbol("Dash"), Some(ListSymbol::Dash));
|
||||
assert_eq!(ops::parse_symbol("1."), Some(ListSymbol::Numeric));
|
||||
assert_eq!(ops::parse_symbol("Numeric"), Some(ListSymbol::Numeric));
|
||||
assert_eq!(ops::parse_symbol("a)"), Some(ListSymbol::AlphaParen));
|
||||
assert_eq!(ops::parse_symbol("i)"), Some(ListSymbol::RomanParen));
|
||||
assert!(ops::parse_symbol("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_marker_handles_each_symbol() {
|
||||
assert_eq!(ops::render_marker(ListSymbol::Dash, 1), "-");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Star, 1), "*");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Hash, 1), "#");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Numeric, 1), "1.");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Numeric, 4), "4.");
|
||||
assert_eq!(ops::render_marker(ListSymbol::NumericParen, 7), "7)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 1), "a)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 26), "z)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 27), "aa)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaUpperParen, 2), "B)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 4), "iv)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 9), "ix)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::RomanUpperParen, 14), "XIV)");
|
||||
}
|
||||
|
||||
// ===== removeDone =====
|
||||
|
||||
#[test]
|
||||
fn remove_done_strips_done_and_rejected_items() {
|
||||
let src = "- [ ] todo\n- [X] done\n- [-] rejected\n- [ ] also todo\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).expect("edit");
|
||||
let changes = edit.changes.expect("changes");
|
||||
let edits = &changes[&uri()];
|
||||
assert_eq!(edits.len(), 2, "expected 2 deletions");
|
||||
// Each delete edit replaces a span with empty string.
|
||||
for e in edits {
|
||||
assert_eq!(e.new_text, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_leaves_open_items_alone() {
|
||||
let src = "- [ ] todo\n- [.] partial\n- [o] more\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_returns_none_when_no_lists() {
|
||||
let src = "= heading =\nparagraph\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_scoped_by_position_to_current_list() {
|
||||
// With `position` set, the scope is the contiguous list block the
|
||||
// cursor sits in — every done item in that list is removed, not just
|
||||
// the one under the cursor.
|
||||
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 2, "both done items in the current list match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_leaves_other_lists_untouched() {
|
||||
// Two separate list blocks split by a paragraph. Cursor in the first
|
||||
// list removes only that list's done items; the second list's done
|
||||
// item survives.
|
||||
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_cascades_into_sublists() {
|
||||
// The current-list scope includes nested sublists of the block.
|
||||
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "the nested done child is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_ranged_restricts_to_selected_lines() {
|
||||
// The `:'<,'>VimwikiRemoveDone` form: a 0-indexed inclusive line range
|
||||
// sweeps the whole doc but only deletes done/rejected items inside it.
|
||||
// Lines: 0 `[X]`, 1 `[ ]`, 2 `[X]`, 3 `[-]`. Range 0..=1 keeps only the
|
||||
// line-0 done item; the line-2/3 done+rejected items survive.
|
||||
let src = "- [X] a\n- [ ] b\n- [X] c\n- [-] d\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, Some((0, 1)), true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the done item on line 0 is in range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_ranged_returns_none_when_range_has_no_done() {
|
||||
// Range covering only open items → nothing to delete.
|
||||
let src = "- [X] a\n- [ ] b\n- [ ] c\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, Some((1, 2)), true).is_none());
|
||||
}
|
||||
|
||||
// ===== renumber =====
|
||||
|
||||
#[test]
|
||||
fn renumber_resequences_numeric_markers() {
|
||||
let src = "5. first\n9. second\n2. third\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::renumber_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
// We expect 3 marker rewrites: 5.→1., 9.→2., 2.→3.
|
||||
assert_eq!(edits.len(), 3);
|
||||
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
||||
assert!(texts.contains(&"1."));
|
||||
assert!(texts.contains(&"2."));
|
||||
assert!(texts.contains(&"3."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumber_ignores_unordered_lists() {
|
||||
let src = "- a\n- b\n- c\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::renumber_edit(src, &ast, &uri(), 0, false, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumber_whole_file_walks_every_list() {
|
||||
let src = "5. one\n\n2. two\n9. three\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::renumber_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
// Two separate lists — first has 1 numeric item, second has 2 →
|
||||
// 3 marker rewrites total.
|
||||
assert_eq!(edits.len(), 3);
|
||||
}
|
||||
|
||||
// ===== changeSymbol =====
|
||||
|
||||
#[test]
|
||||
fn change_symbol_rewrites_just_the_current_item() {
|
||||
let src = "- a\n- b\n- c\n";
|
||||
let ast = parse(src);
|
||||
let edit =
|
||||
ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Star, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, "*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_symbol_whole_list_rewrites_every_item() {
|
||||
let src = "- a\n- b\n- c\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Numeric, true, true)
|
||||
.expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 3);
|
||||
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
||||
assert!(texts.contains(&"1."));
|
||||
assert!(texts.contains(&"2."));
|
||||
assert!(texts.contains(&"3."));
|
||||
}
|
||||
|
||||
// ===== removeCheckbox =====
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_strips_single_item() {
|
||||
let src = "- [ ] task\n- [X] done\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, "");
|
||||
// Removes `[ ] ` — columns 2..6 on the first line.
|
||||
assert_eq!(edits[0].range.start.line, 0);
|
||||
assert_eq!(edits[0].range.start.character, 2);
|
||||
assert_eq!(edits[0].range.end.character, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_whole_list_strips_every_item() {
|
||||
let src = "- [ ] a\n- [X] b\n- [-] c\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 3);
|
||||
for e in edits {
|
||||
assert_eq!(e.new_text, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_none_without_checkbox() {
|
||||
let src = "- plain item\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).is_none());
|
||||
}
|
||||
|
||||
// ===== changeLevel =====
|
||||
|
||||
#[test]
|
||||
fn change_level_indents_single_item() {
|
||||
let src = "- one\n- two\n";
|
||||
let ast = parse(src);
|
||||
let edit =
|
||||
ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
// delta=1 → +2 spaces
|
||||
assert_eq!(edits[0].new_text, " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_level_dedents_clamps_to_zero() {
|
||||
let src = "- root\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_level_edit(src, &ast, &uri(), 0, -1, false, true, false, &[]);
|
||||
// Already at column 0 — dedent is a no-op.
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_bullets_rotates_glyph_on_indent() {
|
||||
// With cycle_bullets + bullet_types [-,*,#], indenting a `-` item (depth 0
|
||||
// → 1) rotates the glyph to `*` (and dedenting back to `-`).
|
||||
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
|
||||
// Put the list well into the document so depth must come from the item's
|
||||
// indentation, not its absolute byte offset (regression guard).
|
||||
let src = "some intro paragraph text here\n\n- one\n- two\n";
|
||||
let ast = parse(src);
|
||||
let edit =
|
||||
ops::change_level_edit(src, &ast, &uri(), 3, 1, false, true, true, &bullets).expect("edit");
|
||||
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
|
||||
.iter()
|
||||
.map(|e| e.new_text.clone())
|
||||
.collect();
|
||||
assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}");
|
||||
assert!(
|
||||
texts.contains(&"*".to_string()),
|
||||
"glyph rotated to *: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_bullets_off_leaves_glyph() {
|
||||
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
|
||||
let src = "- one\n- two\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &bullets)
|
||||
.expect("edit");
|
||||
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
|
||||
.iter()
|
||||
.map(|e| e.new_text.clone())
|
||||
.collect();
|
||||
// Only the indent edit; no glyph rewrite.
|
||||
assert!(
|
||||
!texts.iter().any(|t| t == "*"),
|
||||
"no glyph change: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_level_whole_subtree_walks_descendants() {
|
||||
let src = "- parent\n - child a\n - child b\n- sibling\n";
|
||||
let ast = parse(src);
|
||||
let edit =
|
||||
ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
// Parent + 2 children get indented. Sibling stays.
|
||||
assert!(edits.len() >= 3, "got {} edits", edits.len());
|
||||
}
|
||||
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_cluster_a() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.list.removeDone",
|
||||
"nuwiki.list.removeCheckbox",
|
||||
"nuwiki.list.renumber",
|
||||
"nuwiki.list.changeSymbol",
|
||||
"nuwiki.list.changeLevel",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// Smoke a one_edit_text helper so it's exercised by at least one test.
|
||||
#[test]
|
||||
fn one_edit_text_round_trip() {
|
||||
let src = "- [X] done\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).unwrap();
|
||||
let _ = one_edit_text(edit);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn uri() -> Url {
|
||||
Url::parse("file:///tmp/tbl.wiki").unwrap()
|
||||
}
|
||||
|
||||
fn one_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
|
||||
let changes = edit.changes.expect("changes");
|
||||
let (_, edits) = changes.into_iter().next().unwrap();
|
||||
edits.into_iter().next().unwrap().new_text
|
||||
}
|
||||
|
||||
// ===== render_blank_table =====
|
||||
|
||||
#[test]
|
||||
fn blank_table_has_header_separator_and_rows() {
|
||||
let out = ops::render_blank_table(3, 2);
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
// Header + separator + 2 data rows = 4.
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert_eq!(lines[0], "| | | |");
|
||||
assert_eq!(lines[1], "|--|--|--|");
|
||||
assert_eq!(lines[2], "| | | |");
|
||||
assert_eq!(lines[3], "| | | |");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_table_handles_single_cell_grid() {
|
||||
let out = ops::render_blank_table(1, 1);
|
||||
assert_eq!(out, "| |\n|--|\n| |\n");
|
||||
}
|
||||
|
||||
// ===== align =====
|
||||
|
||||
#[test]
|
||||
fn align_pads_short_cells_to_widest() {
|
||||
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, false).expect("edit");
|
||||
let body = one_text(edit);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
eprintln!("rendered table:");
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
eprintln!(" {i}: {l:?}");
|
||||
}
|
||||
// Column widths: max(a,1)=1 → 1, max(bbb,2)=3, max(c,three)=5.
|
||||
assert_eq!(lines[0], "| a | bbb | c |");
|
||||
// Separator row is repadded with `-` runs sized `width + 2` per
|
||||
// column (the two padding spaces of content rows).
|
||||
assert!(lines[1].starts_with("|---|"), "got: {:?}", lines[1]);
|
||||
assert_eq!(lines[2], "| 1 | 2 | three |");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_returns_none_when_cursor_off_table() {
|
||||
let src = "paragraph\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::table_align_edit(src, &ast, &uri(), 0, true, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_reduce_last_col_keeps_last_column_minimal() {
|
||||
// With reduce_last_col=true, the last column isn't padded to its content
|
||||
// width — it stays at the minimum (1), so `three` is not surrounded by
|
||||
// alignment padding on the right edge.
|
||||
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
||||
let body = one_text(edit);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
// First two columns still align; the last column stays at width 1 (not 5),
|
||||
// so the header's last cell renders `| c |` not `| c |`.
|
||||
assert_eq!(lines[0], "| a | bbb | c |", "got: {:?}", lines[0]);
|
||||
assert_eq!(lines[2], "| 1 | 2 | three |", "got: {:?}", lines[2]);
|
||||
}
|
||||
|
||||
// ===== moveColumn =====
|
||||
|
||||
#[test]
|
||||
fn move_column_right_swaps_with_neighbour() {
|
||||
let src = "|a|b|c|\n|--|--|--|\n|1|2|3|\n";
|
||||
let ast = parse(src);
|
||||
// Cursor on first column → swap right.
|
||||
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true, false).expect("edit");
|
||||
let body = one_text(edit);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert!(lines[0].contains("b") && lines[0].contains("a"));
|
||||
// b should come before a after swapping col 0 → 1.
|
||||
let line0 = lines[0];
|
||||
let b_pos = line0.find('b').unwrap();
|
||||
let a_pos = line0.find('a').unwrap();
|
||||
assert!(b_pos < a_pos, "expected b before a; got {line0:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_column_left_at_zero_is_noop() {
|
||||
let src = "|a|b|\n|--|--|\n|1|2|\n";
|
||||
let ast = parse(src);
|
||||
// Cursor in column 0; left would be -1 → returns None.
|
||||
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true, false);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_column_returns_none_off_table() {
|
||||
let src = "paragraph\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true, false).is_none());
|
||||
}
|
||||
|
||||
// ===== COMMANDS list =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_cluster_b() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.table.insert",
|
||||
"nuwiki.table.align",
|
||||
"nuwiki.table.moveColumn",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Tag-related LSP surface:
|
||||
//! - `WorkspaceIndex` per-page tag lists + reverse `tags_by_name` map,
|
||||
//! stale-tag replacement on re-upsert, and tag cleanup on `remove`.
|
||||
//! - Tag commands: `nuwiki.tags.search`, `nuwiki.tags.generateLinks`,
|
||||
//! `nuwiki.tags.rebuild` — `tag_hits` / `tag_pages_snapshot` /
|
||||
//! `build_tag_links_text` / `tag_links_edit` driven directly so we
|
||||
//! don't need a live handler.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let path = format!("{root}/{name}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
idx.upsert(uri, &parse(src));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// ===== Workspace index: per-page tags + reverse map =====
|
||||
|
||||
#[test]
|
||||
fn upsert_populates_per_page_tags() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":alpha:beta:\n= Home =\n"));
|
||||
|
||||
let page = idx.page(&uri).expect("indexed");
|
||||
assert_eq!(page.tags.len(), 2);
|
||||
let names: Vec<_> = page.tags.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"alpha"));
|
||||
assert!(names.contains(&"beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_by_name_aggregates_across_pages() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse(":shared:\n"));
|
||||
idx.upsert(about.clone(), &parse(":shared:other:\n"));
|
||||
|
||||
let shared = idx.tags_for("shared");
|
||||
assert_eq!(shared.len(), 2);
|
||||
let uris: Vec<&Url> = shared.iter().map(|o| &o.uri).collect();
|
||||
assert!(uris.contains(&&home));
|
||||
assert!(uris.contains(&&about));
|
||||
|
||||
assert_eq!(idx.tags_for("other").len(), 1);
|
||||
assert!(idx.tags_for("nonexistent").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_upsert_replaces_stale_tags() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":old:\n"));
|
||||
assert_eq!(idx.tags_for("old").len(), 1);
|
||||
|
||||
idx.upsert(uri.clone(), &parse(":new:\n"));
|
||||
assert!(idx.tags_for("old").is_empty());
|
||||
assert_eq!(idx.tags_for("new").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_tags_and_reverse_entries() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":a:b:\n"));
|
||||
idx.remove(&uri);
|
||||
|
||||
assert!(idx.page(&uri).is_none());
|
||||
assert!(idx.tags_for("a").is_empty());
|
||||
assert!(idx.tags_for("b").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_scope_tag_indexed_but_not_in_metadata() {
|
||||
// Tag at the third line below a heading is `Heading`-scoped; the
|
||||
// metadata.tags accumulator only collects file-scope tags.
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let doc = parse("para\n\n\n= H =\n:scoped:\n");
|
||||
idx.upsert(uri.clone(), &doc);
|
||||
|
||||
assert_eq!(idx.tags_for("scoped").len(), 1);
|
||||
assert!(doc.metadata.tags.is_empty());
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.search` — tag_hits =====
|
||||
|
||||
#[test]
|
||||
fn tag_hits_empty_query_returns_every_tag() {
|
||||
let idx = build_index(
|
||||
"/tmp/th1",
|
||||
&[
|
||||
("Home", "= Home =\n:work:personal:\n"),
|
||||
("Notes", "= Notes =\n:work:\n"),
|
||||
],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "");
|
||||
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
|
||||
assert!(names.contains(&"work"));
|
||||
assert!(names.contains(&"personal"));
|
||||
// 2 pages tagged with `work`, 1 with `personal` → 3 hits.
|
||||
assert_eq!(hits.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_hits_substring_filter_case_insensitive() {
|
||||
let idx = build_index(
|
||||
"/tmp/th2",
|
||||
&[("Page", "= Page =\n:release-2026:RoadMap:\n")],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "RELEASE");
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].name, "release-2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_hits_orders_by_name_then_page() {
|
||||
let idx = build_index(
|
||||
"/tmp/th3",
|
||||
&[
|
||||
("Beta", "= Beta =\n:zeta:\n"),
|
||||
("Alpha", "= Alpha =\n:alpha:\n"),
|
||||
("Gamma", "= Gamma =\n:alpha:\n"),
|
||||
],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "");
|
||||
let pairs: Vec<(String, String)> = hits
|
||||
.iter()
|
||||
.map(|h| (h.name.clone(), h.page.clone()))
|
||||
.collect();
|
||||
// alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta.
|
||||
assert_eq!(pairs[0], ("alpha".into(), "Alpha".into()));
|
||||
assert_eq!(pairs[1], ("alpha".into(), "Gamma".into()));
|
||||
assert_eq!(pairs[2], ("zeta".into(), "Beta".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_hits_serializes_to_expected_json_shape() {
|
||||
let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]);
|
||||
let hits = ops::tag_hits(&idx, "");
|
||||
let v = serde_json::to_value(&hits).unwrap();
|
||||
let row = &v[0];
|
||||
assert_eq!(row["name"], "release");
|
||||
assert!(row["uri"].is_string());
|
||||
assert!(row["range"]["start"]["line"].is_number());
|
||||
assert!(row["page"].is_string());
|
||||
// scope is one of the three string variants.
|
||||
let scope = row["scope"].as_str().unwrap();
|
||||
assert!(
|
||||
["file", "heading", "standalone"].contains(&scope),
|
||||
"{scope}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.rebuild` — tag_pages_snapshot =====
|
||||
|
||||
#[test]
|
||||
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
|
||||
// Same page has the tag in both file scope and heading scope —
|
||||
// dedup so the rendered list doesn't show it twice.
|
||||
let idx = build_index(
|
||||
"/tmp/ts1",
|
||||
&[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")],
|
||||
);
|
||||
let snap = ops::tag_pages_snapshot(&idx);
|
||||
assert_eq!(snap.get("release").unwrap().len(), 1);
|
||||
assert_eq!(snap["release"][0], "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_pages_snapshot_sorts_pages_alphabetically() {
|
||||
let idx = build_index(
|
||||
"/tmp/ts2",
|
||||
&[
|
||||
("Charlie", "= Charlie =\n:topic:\n"),
|
||||
("Alpha", "= Alpha =\n:topic:\n"),
|
||||
("Bravo", "= Bravo =\n:topic:\n"),
|
||||
],
|
||||
);
|
||||
let snap = ops::tag_pages_snapshot(&idx);
|
||||
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.generateLinks` — build_tag_links_text =====
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_for_single_tag() {
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]);
|
||||
let out = ops::build_tag_links_text(&snap, Some("release"), "Generated Tags", 1, 0).unwrap();
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "= Tag: release =");
|
||||
assert_eq!(lines[1], "- [[Alpha]]");
|
||||
assert_eq!(lines[2], "- [[Beta]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_for_missing_tag_returns_none() {
|
||||
let snap = BTreeMap::new();
|
||||
assert!(ops::build_tag_links_text(&snap, Some("ghost"), "Generated Tags", 1, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_full_index_groups_by_tag() {
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("a".to_string(), vec!["P1".into()]);
|
||||
snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]);
|
||||
let out = ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).unwrap();
|
||||
assert!(out.starts_with("= Generated Tags =\n"));
|
||||
assert!(out.contains("== a =="));
|
||||
assert!(out.contains("== b =="));
|
||||
assert!(out.contains("- [[P1]]"));
|
||||
assert!(out.contains("- [[P2]]"));
|
||||
assert!(out.contains("- [[P3]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_full_index_returns_none_when_no_tags() {
|
||||
let snap = BTreeMap::new();
|
||||
assert!(ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).is_none());
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) =====
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_inserts_when_section_missing() {
|
||||
let src = "Some content.\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("release".to_string(), vec!["Alpha".into()]);
|
||||
let edit = ops::tag_links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
Some("release"),
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
.expect("got an edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
// Insertion at start.
|
||||
assert_eq!(te.range.start, te.range.end);
|
||||
assert!(te.new_text.contains("= Tag: release ="));
|
||||
assert!(te.new_text.contains("[[Alpha]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_inserts_at_cursor_line() {
|
||||
// A fresh tags section goes at the cursor line, not the EOF.
|
||||
let src = "= Notes =\nbody\nmore\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("release".to_string(), vec!["Alpha".into()]);
|
||||
let edit = ops::tag_links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
Some("release"),
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
Some(1),
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
assert_eq!(te.range.start.character, 0);
|
||||
assert!(
|
||||
te.new_text.starts_with("\n= Tag: release ="),
|
||||
"got: {:?}",
|
||||
te.new_text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_replaces_existing_section() {
|
||||
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("release".to_string(), vec!["Fresh".into()]);
|
||||
let edit = ops::tag_links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
Some("release"),
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
assert!(te.new_text.contains("[[Fresh]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_full_index_replaces_existing_tags_section() {
|
||||
let src = "= Generated Tags =\n- [[old]]\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("alpha".to_string(), vec!["P".into()]);
|
||||
let edit = ops::tag_links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
None,
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("== alpha =="));
|
||||
assert!(!te.new_text.contains("[[old]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_rebuild_edit_only_acts_when_index_present() {
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("a".to_string(), vec!["P".into()]);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let without = "= Home =\nbody\n";
|
||||
assert!(
|
||||
ops::tag_links_rebuild_edit(
|
||||
without,
|
||||
&parse(without),
|
||||
&uri,
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0
|
||||
)
|
||||
.is_none(),
|
||||
"auto_generate_tags must not insert an index into a page that lacks one"
|
||||
);
|
||||
let with = "= Generated Tags =\n- [[old]]\n";
|
||||
assert!(ops::tag_links_rebuild_edit(
|
||||
with,
|
||||
&parse(with),
|
||||
&uri,
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_returns_none_for_unknown_tag() {
|
||||
let src = "hi\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let snap = BTreeMap::new();
|
||||
assert!(ops::tag_links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
Some("ghost"),
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// ===== smoke: COMMANDS list advertises tag commands =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_tag_commands() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.tags.search",
|
||||
"nuwiki.tags.generateLinks",
|
||||
"nuwiki.tags.rebuild",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Task navigation (`nuwiki.list.nextTask`) plus a smoke test that the
|
||||
//! `COMMANDS` registry advertises every list/heading/file handler the
|
||||
//! editor surface relies on.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn dummy_uri() -> Url {
|
||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
||||
}
|
||||
|
||||
// ===== next_task =====
|
||||
|
||||
#[test]
|
||||
fn next_task_returns_first_unfinished() {
|
||||
let src = "- [X] done\n- [ ] todo\n- [ ] later\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task");
|
||||
// Should be line 1 (the first [ ]).
|
||||
assert_eq!(loc.range.start.line, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_task_wraps_around_when_no_task_after_cursor() {
|
||||
// Only task is on line 0; cursor on line 2 → wraps back.
|
||||
let src = "- [ ] todo\n- [X] done\n- [X] also done\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found");
|
||||
assert_eq!(loc.range.start.line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_task_returns_none_when_no_open_tasks() {
|
||||
let src = "- [X] a\n- [X] b\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true);
|
||||
assert!(loc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_task_only_counts_items_with_checkboxes() {
|
||||
// Plain items without a `[…]` are ignored — they're not tasks.
|
||||
let src = "- plain\n- [ ] real task\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found");
|
||||
assert_eq!(loc.range.start.line, 1);
|
||||
}
|
||||
|
||||
// ===== smoke: COMMANDS list matches registered handlers =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_is_complete() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.file.delete",
|
||||
"nuwiki.list.toggleCheckbox",
|
||||
"nuwiki.list.cycleCheckbox",
|
||||
"nuwiki.list.rejectCheckbox",
|
||||
"nuwiki.list.nextTask",
|
||||
"nuwiki.heading.addLevel",
|
||||
"nuwiki.heading.removeLevel",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
//! Diary feature tests:
|
||||
//! - Date primitives, path conventions, nav helpers, IndexedPage diary
|
||||
//! classification.
|
||||
//! - Frequency wiring (daily/weekly/monthly/yearly): per-frequency
|
||||
//! path/URI shape, index recognition, and `next_period` / `prev_period`
|
||||
//! navigation across mixed-frequency entries.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::diary;
|
||||
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
||||
let mut cfg = WikiConfig::from_root(PathBuf::from(root));
|
||||
cfg.diary_rel_path = "diary".into();
|
||||
cfg.diary_index = "diary".into();
|
||||
cfg
|
||||
}
|
||||
|
||||
fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
||||
let mut idx =
|
||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
||||
for d in dates {
|
||||
let path = format!("{root}/diary/{d}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
idx.upsert(uri, &parse("= entry =\n"));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// ===== DiaryDate parser =====
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_canonical_iso8601() {
|
||||
let d = DiaryDate::parse("2026-05-11").unwrap();
|
||||
assert_eq!(d.year, 2026);
|
||||
assert_eq!(d.month, 5);
|
||||
assert_eq!(d.day, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_missing_zero_padding() {
|
||||
assert!(DiaryDate::parse("2026-5-11").is_none());
|
||||
assert!(DiaryDate::parse("2026-05-1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_alternate_separators() {
|
||||
assert!(DiaryDate::parse("2026/05/11").is_none());
|
||||
assert!(DiaryDate::parse("2026.05.11").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_extra_whitespace() {
|
||||
assert!(DiaryDate::parse(" 2026-05-11").is_none());
|
||||
assert!(DiaryDate::parse("2026-05-11 ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_invalid_calendar_dates() {
|
||||
assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13");
|
||||
assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30");
|
||||
assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31");
|
||||
assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0");
|
||||
assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_leap_day_in_leap_year() {
|
||||
assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap");
|
||||
assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap");
|
||||
assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400");
|
||||
assert!(
|
||||
DiaryDate::parse("1900-02-29").is_none(),
|
||||
"div by 100 not 400"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_round_trip() {
|
||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert_eq!(d.format(), "2026-05-11");
|
||||
assert_eq!(DiaryDate::parse(&d.format()), Some(d));
|
||||
}
|
||||
|
||||
// ===== Date arithmetic =====
|
||||
|
||||
#[test]
|
||||
fn next_day_wraps_month_boundary() {
|
||||
let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap();
|
||||
assert_eq!(
|
||||
last_of_jan.next_day(),
|
||||
DiaryDate::from_ymd(2026, 2, 1).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_day_wraps_year_boundary() {
|
||||
let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap();
|
||||
assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prev_day_walks_back_across_month() {
|
||||
let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap();
|
||||
assert_eq!(
|
||||
first_of_mar.prev_day(),
|
||||
DiaryDate::from_ymd(2026, 2, 28).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prev_day_walks_back_across_leap() {
|
||||
let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap();
|
||||
assert_eq!(
|
||||
first_of_mar.prev_day(),
|
||||
DiaryDate::from_ymd(2024, 2, 29).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_is_chronological() {
|
||||
let a = DiaryDate::from_ymd(2026, 1, 1).unwrap();
|
||||
let b = DiaryDate::from_ymd(2026, 1, 2).unwrap();
|
||||
let c = DiaryDate::from_ymd(2026, 2, 1).unwrap();
|
||||
let d = DiaryDate::from_ymd(2027, 1, 1).unwrap();
|
||||
assert!(a < b);
|
||||
assert!(b < c);
|
||||
assert!(c < d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn today_utc_is_a_real_calendar_date() {
|
||||
let t = DiaryDate::today_utc();
|
||||
assert!(t.year >= 2020 && t.year <= 2100);
|
||||
assert!((1..=12).contains(&t.month));
|
||||
assert!((1..=31).contains(&t.day));
|
||||
}
|
||||
|
||||
// ===== WikiConfig diary paths =====
|
||||
|
||||
#[test]
|
||||
fn diary_dir_joins_root_and_rel_path() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_index_path_uses_diary_index_filename_and_extension() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
assert_eq!(
|
||||
cfg.diary_index_path(),
|
||||
PathBuf::from("/tmp/wiki/diary/diary.wiki")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_path_for_uses_iso_date_as_stem() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert_eq!(
|
||||
cfg.diary_path_for(&date),
|
||||
PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_uri_for_date_is_file_url() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let uri = diary::uri_for_date(&cfg, &date).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_index_uri_resolves() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
let uri = diary::index_uri(&cfg).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
||||
}
|
||||
|
||||
// ===== Index integration: diary_date on IndexedPage =====
|
||||
|
||||
#[test]
|
||||
fn upsert_marks_diary_entries() {
|
||||
let root = "/tmp/diary1";
|
||||
let idx = build_index_with_entries(root, &["2026-05-11"]);
|
||||
let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap();
|
||||
let page = idx.page(&uri).expect("page");
|
||||
let date = page.diary_date.expect("diary_date");
|
||||
assert_eq!(date.format(), "2026-05-11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_does_not_mark_non_diary_files() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2")))
|
||||
.with_diary_rel_path(Some("diary".into()));
|
||||
let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse("= Home =\n"));
|
||||
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_does_not_mark_dates_outside_diary_subdir() {
|
||||
// A file at root named like a date but not under diary/ → not a
|
||||
// diary entry.
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3")))
|
||||
.with_diary_rel_path(Some("diary".into()));
|
||||
let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse("= note =\n"));
|
||||
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
||||
}
|
||||
|
||||
// ===== Diary listing + navigation =====
|
||||
|
||||
#[test]
|
||||
fn list_entries_returns_sorted_ascending() {
|
||||
let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let dates: Vec<String> = entries.iter().map(|e| e.date.format()).collect();
|
||||
assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_entries_filtered_by_year_and_month() {
|
||||
let idx = build_index_with_entries(
|
||||
"/tmp/diary5",
|
||||
&["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"],
|
||||
);
|
||||
let may = diary::list_entries_filtered(&idx, Some(2026), Some(5));
|
||||
assert_eq!(may.len(), 2);
|
||||
let y_only = diary::list_entries_filtered(&idx, Some(2026), None);
|
||||
assert_eq!(y_only.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_entry_finds_strictly_greater() {
|
||||
let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let next = diary::next_entry(&idx, &from).unwrap();
|
||||
assert_eq!(next.date.format(), "2026-05-12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_entry_none_when_no_future_entries() {
|
||||
let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert!(diary::next_entry(&idx, &from).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prev_entry_finds_strictly_lesser() {
|
||||
let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
||||
let prev = diary::prev_entry(&idx, &from).unwrap();
|
||||
assert_eq!(prev.date.format(), "2026-05-12");
|
||||
}
|
||||
|
||||
// ===== Index body generation =====
|
||||
|
||||
#[test]
|
||||
fn build_index_body_is_newest_first_grouped_by_year_month() {
|
||||
let idx = build_index_with_entries(
|
||||
"/tmp/diary9",
|
||||
&["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"],
|
||||
);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(
|
||||
&entries,
|
||||
"diary",
|
||||
"Diary",
|
||||
diary::DiarySort::Desc,
|
||||
1,
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert_eq!(lines[0], "= Diary =");
|
||||
// First date in output should be the latest: 2026-05-12
|
||||
let earliest_2026_pos = body.find("2026-05-12").unwrap();
|
||||
let latest_2025_pos = body.find("2025-12-31").unwrap();
|
||||
assert!(earliest_2026_pos < latest_2025_pos, "newer entries first");
|
||||
// Year headings appear
|
||||
assert!(body.contains("== 2026 =="));
|
||||
assert!(body.contains("== 2025 =="));
|
||||
// Month headings appear
|
||||
assert!(body.contains("=== May ==="));
|
||||
assert!(body.contains("=== December ==="));
|
||||
assert!(body.contains("=== April ==="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_empty_when_no_entries() {
|
||||
let body = diary::build_index_body(&[], "diary", "Diary", diary::DiarySort::Desc, 1, &[], 0);
|
||||
assert_eq!(body, "= Diary =\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_ascending_sort_lists_oldest_first() {
|
||||
let idx = build_index_with_entries("/tmp/diarySort", &["2026-05-11", "2026-05-12"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body =
|
||||
diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Asc, 1, &[], 0);
|
||||
let older = body.find("2026-05-11").unwrap();
|
||||
let newer = body.find("2026-05-12").unwrap();
|
||||
assert!(older < newer, "ascending sort lists oldest first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_caption_level_shifts_all_headings() {
|
||||
let idx = build_index_with_entries("/tmp/diaryCap", &["2026-05-11"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(
|
||||
&entries,
|
||||
"diary",
|
||||
"Diary",
|
||||
diary::DiarySort::Desc,
|
||||
2,
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
assert!(body.contains("== Diary =="), "caption at level 2");
|
||||
assert!(
|
||||
body.contains("=== 2026 ==="),
|
||||
"year nests one below caption"
|
||||
);
|
||||
assert!(
|
||||
body.contains("==== May ===="),
|
||||
"month nests two below caption"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_clamps_caption_level_to_six() {
|
||||
let idx = build_index_with_entries("/tmp/diaryClamp", &["2026-05-11"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
// caption_level 6 → year/month would be 7/8 but clamp to 6.
|
||||
let body = diary::build_index_body(
|
||||
&entries,
|
||||
"diary",
|
||||
"Diary",
|
||||
diary::DiarySort::Desc,
|
||||
6,
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
assert!(body.contains("====== Diary ======"));
|
||||
assert!(!body.contains("======="), "no heading exceeds level 6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_uses_custom_diary_months() {
|
||||
let idx = build_index_with_entries("/tmp/diaryMonths", &["2026-05-11", "2026-12-01"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
// A localized month table (Portuguese) replaces the English labels.
|
||||
let months: Vec<String> = [
|
||||
"Janeiro",
|
||||
"Fevereiro",
|
||||
"Março",
|
||||
"Abril",
|
||||
"Maio",
|
||||
"Junho",
|
||||
"Julho",
|
||||
"Agosto",
|
||||
"Setembro",
|
||||
"Outubro",
|
||||
"Novembro",
|
||||
"Dezembro",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let body = diary::build_index_body(
|
||||
&entries,
|
||||
"diary",
|
||||
"Diary",
|
||||
diary::DiarySort::Desc,
|
||||
1,
|
||||
&months,
|
||||
0,
|
||||
);
|
||||
assert!(body.contains("=== Maio ==="), "May → Maio: {body}");
|
||||
assert!(
|
||||
body.contains("=== Dezembro ==="),
|
||||
"December → Dezembro: {body}"
|
||||
);
|
||||
assert!(!body.contains("May"), "English label leaked: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_falls_back_per_missing_month_slot() {
|
||||
let idx = build_index_with_entries("/tmp/diaryShort", &["2026-05-11"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
// A too-short table only covers Jan–Mar; May falls back to English.
|
||||
let months: Vec<String> = ["Jan", "Feb", "Mar"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let body = diary::build_index_body(
|
||||
&entries,
|
||||
"diary",
|
||||
"Diary",
|
||||
diary::DiarySort::Desc,
|
||||
1,
|
||||
&months,
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
body.contains("=== May ==="),
|
||||
"missing slot → English: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_clamps_negative_caption_level_to_zero() {
|
||||
let idx = build_index_with_entries("/tmp/diaryNeg", &["2026-05-11"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
// vimwiki allows diary_caption_level = -1; nuwiki clamps it to 0 (same as
|
||||
// a caption_level of 0: caption h1, year h1, month h2).
|
||||
let body = diary::build_index_body(
|
||||
&entries,
|
||||
"diary",
|
||||
"Diary",
|
||||
diary::DiarySort::Desc,
|
||||
-1,
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
assert!(body.contains("= Diary ="), "caption at level 1: {body}");
|
||||
assert!(body.contains("= 2026 ="), "year at level 1: {body}");
|
||||
assert!(body.contains("== May =="), "month at level 2: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_index_body_round_trip() {
|
||||
let cfg = wiki_cfg("/tmp/diaryA");
|
||||
let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]);
|
||||
let body = diary::render_index_body(&cfg, &idx);
|
||||
assert!(body.contains("= Diary ="));
|
||||
assert!(body.contains("[[diary/2026-05-11]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_index_body_honours_custom_header_sort_and_caption() {
|
||||
let mut cfg = wiki_cfg("/tmp/diaryCustom");
|
||||
cfg.diary_header = "Journal".into();
|
||||
cfg.diary_sort = "asc".into();
|
||||
cfg.diary_caption_level = 2;
|
||||
let idx = build_index_with_entries("/tmp/diaryCustom", &["2026-05-11", "2026-05-12"]);
|
||||
let body = diary::render_index_body(&cfg, &idx);
|
||||
assert!(
|
||||
body.contains("== Journal =="),
|
||||
"custom header at caption level 2"
|
||||
);
|
||||
let older = body.find("2026-05-11").unwrap();
|
||||
let newer = body.find("2026-05-12").unwrap();
|
||||
assert!(older < newer, "asc sort honoured");
|
||||
}
|
||||
|
||||
// ===== is_in_diary =====
|
||||
|
||||
#[test]
|
||||
fn is_in_diary_recognises_subdir_paths() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
assert!(diary::is_in_diary(
|
||||
&cfg,
|
||||
&PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
||||
));
|
||||
assert!(!diary::is_in_diary(
|
||||
&cfg,
|
||||
&PathBuf::from("/tmp/wiki/Home.wiki")
|
||||
));
|
||||
}
|
||||
|
||||
// ===== Serde =====
|
||||
|
||||
#[test]
|
||||
fn diary_entry_serializes_to_date_string_and_uri() {
|
||||
let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
||||
let entry = diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
uri,
|
||||
};
|
||||
let v = serde_json::to_value(&entry).unwrap();
|
||||
assert_eq!(v["date"], "2026-05-11");
|
||||
assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki"));
|
||||
}
|
||||
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_diary_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.diary.openToday",
|
||||
"nuwiki.diary.openYesterday",
|
||||
"nuwiki.diary.openTomorrow",
|
||||
"nuwiki.diary.openIndex",
|
||||
"nuwiki.diary.generateIndex",
|
||||
"nuwiki.diary.next",
|
||||
"nuwiki.diary.prev",
|
||||
"nuwiki.diary.listEntries",
|
||||
"nuwiki.diary.openForDate",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Config wire format =====
|
||||
|
||||
#[test]
|
||||
fn wiki_config_defaults_match_vimwiki() {
|
||||
let cfg = WikiConfig::empty();
|
||||
assert_eq!(cfg.diary_rel_path, "diary");
|
||||
assert_eq!(cfg.diary_index, "diary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_config_from_root_uses_default_diary_paths() {
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x"));
|
||||
assert_eq!(cfg.diary_rel_path, "diary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_json_accepts_custom_diary_paths() {
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis[0].diary_rel_path, "journal");
|
||||
assert_eq!(cfg.wikis[0].diary_index, "index");
|
||||
}
|
||||
|
||||
// ===== Frequency wiring (daily / weekly / monthly / yearly) =====
|
||||
|
||||
fn cfg(root: &str, frequency: &str) -> WikiConfig {
|
||||
let mut c = WikiConfig::from_root(PathBuf::from(root));
|
||||
c.diary_rel_path = "diary".into();
|
||||
c.diary_frequency = frequency.into();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequency_helper_picks_up_config_value() {
|
||||
assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily);
|
||||
assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly);
|
||||
assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly);
|
||||
assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_path_for_period_picks_right_stem_per_frequency() {
|
||||
let c = cfg("/wiki", "daily");
|
||||
let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
|
||||
let wk = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
let mo = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
};
|
||||
let yr = DiaryPeriod::Year { year: 2026 };
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&day),
|
||||
PathBuf::from("/wiki/diary/2026-05-12.wiki")
|
||||
);
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&wk),
|
||||
PathBuf::from("/wiki/diary/2026-W19.wiki")
|
||||
);
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&mo),
|
||||
PathBuf::from("/wiki/diary/2026-05.wiki")
|
||||
);
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&yr),
|
||||
PathBuf::from("/wiki/diary/2026.wiki")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_for_period_produces_file_url_per_frequency() {
|
||||
let c = cfg("/wiki", "weekly");
|
||||
let wk = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
let u = diary::uri_for_period(&c, &wk).unwrap();
|
||||
assert!(
|
||||
u.as_str().ends_with("/wiki/diary/2026-W19.wiki"),
|
||||
"got: {u}"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_index_with(entries: &[&str]) -> WorkspaceIndex {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into()));
|
||||
for stem in entries {
|
||||
let path = format!("/wiki/diary/{stem}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
let ast = VimwikiSyntax::new().parse("= entry =\n");
|
||||
idx.upsert(uri, &ast);
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_recognises_all_four_frequency_stems() {
|
||||
let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]);
|
||||
for (stem, expected) in [
|
||||
(
|
||||
"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 }),
|
||||
] {
|
||||
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
||||
let page = idx.page(&uri).expect(stem);
|
||||
assert_eq!(
|
||||
page.diary_period,
|
||||
Some(expected),
|
||||
"stem {stem} should index as {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_only_sets_diary_date_for_daily_entries() {
|
||||
let idx = make_index_with(&["2026-05-12", "2026-W19"]);
|
||||
let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap();
|
||||
let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap();
|
||||
assert!(idx.page(&daily).unwrap().diary_date.is_some());
|
||||
assert!(idx.page(&weekly).unwrap().diary_date.is_none());
|
||||
assert!(idx.page(&weekly).unwrap().diary_period.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_period_for_uri_rejects_paths_outside_diary_dir() {
|
||||
// Non-diary path with a date-looking stem — should NOT count.
|
||||
let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap();
|
||||
let root = PathBuf::from("/wiki");
|
||||
assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_prev_period_navigate_at_the_same_frequency() {
|
||||
// Mix entries at different frequencies. Navigation should stay
|
||||
// within the same flavour as the pivot.
|
||||
let idx = make_index_with(&[
|
||||
"2026-W18",
|
||||
"2026-W19",
|
||||
"2026-W20", // weekly
|
||||
"2026-04",
|
||||
"2026-05",
|
||||
"2026-06", // monthly
|
||||
"2026-05-10",
|
||||
"2026-05-12", // daily
|
||||
]);
|
||||
let pivot_w = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
let next_w = diary::next_period(&idx, &pivot_w).unwrap();
|
||||
assert_eq!(
|
||||
next_w.period,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 20
|
||||
}
|
||||
);
|
||||
let prev_w = diary::prev_period(&idx, &pivot_w).unwrap();
|
||||
assert_eq!(
|
||||
prev_w.period,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 18
|
||||
}
|
||||
);
|
||||
|
||||
let pivot_m = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
};
|
||||
let next_m = diary::next_period(&idx, &pivot_m).unwrap();
|
||||
assert_eq!(
|
||||
next_m.period,
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 6
|
||||
}
|
||||
);
|
||||
let prev_m = diary::prev_period(&idx, &pivot_m).unwrap();
|
||||
assert_eq!(
|
||||
prev_m.period,
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 4
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_prev_period_returns_none_past_the_edge() {
|
||||
let idx = make_index_with(&["2026-W19"]);
|
||||
let only = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
assert!(diary::next_period(&idx, &only).is_none());
|
||||
assert!(diary::prev_period(&idx, &only).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_period_for_uri_handles_each_frequency_under_diary_dir() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
for (stem, expected) in [
|
||||
(
|
||||
"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 }),
|
||||
] {
|
||||
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
||||
let got = diary_period_for_uri(&uri, Some(&root), Some("diary"));
|
||||
assert_eq!(got, Some(expected), "stem {stem}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! LSP folding-range provider.
|
||||
//!
|
||||
//! Drives `folding::folding_ranges` directly. The handler wiring is
|
||||
//! shallow — it just calls into this function — so we verify the
|
||||
//! shapes here and trust the integration via the LSP test harness in
|
||||
//! `lsp_helpers.rs`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::folding;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_headings_no_lists_emits_no_folds() {
|
||||
let src = "just text\non two lines\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
assert!(folds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_heading_folds_to_end_of_document() {
|
||||
let src = "= Title =\nbody line 1\nbody line 2\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
assert_eq!(folds.len(), 1);
|
||||
assert_eq!(folds[0].start_line, 0);
|
||||
// body line 2 is line 2; trailing newline pushes line_count to 4 so
|
||||
// last_line == 3 (the empty line after the final newline).
|
||||
assert!(folds[0].end_line >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_headings_each_get_their_own_fold() {
|
||||
let src = "= One =\nbody\n= Two =\nbody2\n= Three =\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
let heading_folds: Vec<_> = folds
|
||||
.iter()
|
||||
.filter(|f| f.start_line == 0 || f.start_line == 2 || f.start_line == 4)
|
||||
.collect();
|
||||
assert_eq!(heading_folds.len(), 3, "got: {folds:?}");
|
||||
// One closes before Two
|
||||
assert_eq!(heading_folds[0].end_line, 1);
|
||||
// Two closes before Three
|
||||
assert_eq!(heading_folds[1].end_line, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_heading_folds_inside_parent() {
|
||||
let src = "= Outer =\nintro\n== Inner ==\nbody\n= Other =\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
// Outer fold spans up to the line before `= Other =` (line 4).
|
||||
let outer = folds
|
||||
.iter()
|
||||
.find(|f| f.start_line == 0)
|
||||
.expect("outer fold");
|
||||
assert_eq!(outer.end_line, 3);
|
||||
// Inner fold is bounded by the next h1 too (since the only following
|
||||
// heading is h1, which has level <= 2).
|
||||
let inner = folds
|
||||
.iter()
|
||||
.find(|f| f.start_line == 2)
|
||||
.expect("inner fold");
|
||||
assert_eq!(inner.end_line, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_list_gets_its_own_fold() {
|
||||
let src = "before\n- item one\n- item two\n- item three\nafter\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
let list_fold = folds.iter().find(|f| f.start_line == 1);
|
||||
assert!(list_fold.is_some(), "expected a list fold: {folds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_blocks_are_not_folded() {
|
||||
let src = "= Solo =\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
// A heading on the only line of the document covers nothing
|
||||
// foldable, so it's omitted.
|
||||
let none_for_solo = folds
|
||||
.iter()
|
||||
.all(|f| !(f.start_line == 0 && f.end_line == 0));
|
||||
assert!(none_for_solo, "got: {folds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_kind_is_region() {
|
||||
use tower_lsp::lsp_types::FoldingRangeKind;
|
||||
let src = "= H =\nbody\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
assert!(!folds.is_empty());
|
||||
assert_eq!(folds[0].kind, Some(FoldingRangeKind::Region));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_matches_intuitive_definition() {
|
||||
assert_eq!(folding::line_count(""), 0);
|
||||
assert_eq!(folding::line_count("one"), 1);
|
||||
assert_eq!(folding::line_count("one\n"), 2); // trailing newline → virtual empty line
|
||||
assert_eq!(folding::line_count("one\ntwo"), 2);
|
||||
assert_eq!(folding::line_count("one\ntwo\n"), 3);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Tests for the pure helpers that the LSP backend uses to translate
|
||||
//! between `nuwiki-core` ASTs and the LSP wire format. The async
|
||||
//! request-handling loop is exercised at integration time by the
|
||||
//! navigation tests, so this file stays focused on conversion correctness.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
|
||||
Position, Span, TextNode,
|
||||
};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::{ast_diagnostics, headings_to_symbols, to_lsp_position, to_lsp_range};
|
||||
use tower_lsp::lsp_types::{DiagnosticSeverity, DocumentSymbolResponse, SymbolKind};
|
||||
|
||||
// ===== Position / Range conversion =====
|
||||
|
||||
#[test]
|
||||
fn position_passthrough_in_utf8_mode() {
|
||||
let pos = Position {
|
||||
line: 3,
|
||||
column: 7,
|
||||
offset: 0,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, "ignored", true);
|
||||
assert_eq!(lsp.line, 3);
|
||||
assert_eq!(lsp.character, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_translates_to_utf16_for_multibyte_chars() {
|
||||
// "héllo" in UTF-8: h(1) é(2 bytes) l(1) l(1) o(1) — total 6 bytes.
|
||||
// In UTF-16 each char is one code unit (BMP), so byte col 3 ("after é")
|
||||
// maps to UTF-16 char col 2.
|
||||
let line = "héllo\nworld\n";
|
||||
let pos = Position {
|
||||
line: 0,
|
||||
column: 3,
|
||||
offset: 3,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, line, false);
|
||||
assert_eq!(lsp.line, 0);
|
||||
assert_eq!(lsp.character, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_handles_astral_plane_in_utf16() {
|
||||
// 🦀 is U+1F980, two UTF-16 code units (surrogate pair), four UTF-8 bytes.
|
||||
let text = "🦀x\n";
|
||||
// After the crab + x, byte col is 5, UTF-16 col is 3 (2 surrogates + 1 BMP).
|
||||
let pos = Position {
|
||||
line: 0,
|
||||
column: 5,
|
||||
offset: 5,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, text, false);
|
||||
assert_eq!(lsp.character, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_passthrough_in_utf8_mode() {
|
||||
let span = Span::new(
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 0,
|
||||
column: 5,
|
||||
offset: 5,
|
||||
},
|
||||
);
|
||||
let r = to_lsp_range(&span, "hello\n", true);
|
||||
assert_eq!(r.start.line, 0);
|
||||
assert_eq!(r.start.character, 0);
|
||||
assert_eq!(r.end.character, 5);
|
||||
}
|
||||
|
||||
// ===== Diagnostics =====
|
||||
|
||||
#[test]
|
||||
fn ast_diagnostics_yields_one_per_error_node() {
|
||||
let span_a = Span::new(
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 0,
|
||||
column: 4,
|
||||
offset: 4,
|
||||
},
|
||||
);
|
||||
let span_b = Span::new(
|
||||
Position {
|
||||
line: 2,
|
||||
column: 0,
|
||||
offset: 10,
|
||||
},
|
||||
Position {
|
||||
line: 2,
|
||||
column: 3,
|
||||
offset: 13,
|
||||
},
|
||||
);
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_a,
|
||||
raw: "first".into(),
|
||||
message: "first parse failure".into(),
|
||||
}),
|
||||
BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "ok".into(),
|
||||
})],
|
||||
}),
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_b,
|
||||
raw: "second".into(),
|
||||
message: "second parse failure".into(),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
let diags = ast_diagnostics(&doc, "ignored", true);
|
||||
assert_eq!(diags.len(), 2);
|
||||
assert_eq!(diags[0].message, "first parse failure");
|
||||
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
|
||||
assert_eq!(diags[0].source.as_deref(), Some("nuwiki"));
|
||||
assert_eq!(diags[0].range.start.line, 0);
|
||||
assert_eq!(diags[1].range.start.line, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ast_diagnostics_descends_into_blockquotes() {
|
||||
let span = Span::new(
|
||||
Position {
|
||||
line: 1,
|
||||
column: 2,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 1,
|
||||
column: 6,
|
||||
offset: 4,
|
||||
},
|
||||
);
|
||||
let inner_error = BlockNode::Error(ErrorNode {
|
||||
span,
|
||||
raw: "x".into(),
|
||||
message: "nested".into(),
|
||||
});
|
||||
let bq = BlockNode::Blockquote(BlockquoteNode {
|
||||
span: Span::default(),
|
||||
children: vec![inner_error],
|
||||
});
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![bq],
|
||||
};
|
||||
|
||||
let diags = ast_diagnostics(&doc, "", true);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].message, "nested");
|
||||
}
|
||||
|
||||
// ===== Document symbols =====
|
||||
|
||||
fn parse(src: &str) -> DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_outline_when_all_headings_same_level() {
|
||||
let doc = parse("= A =\n= B =\n= C =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 3);
|
||||
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["A", "B", "C"]);
|
||||
for s in &syms {
|
||||
assert!(s.children.is_none() || s.children.as_ref().unwrap().is_empty());
|
||||
assert_eq!(s.kind, SymbolKind::STRING);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_outline_follows_heading_levels() {
|
||||
let src = "\
|
||||
= Top =
|
||||
== Sub A ==
|
||||
=== Deeper ===
|
||||
== Sub B ==
|
||||
= Other =
|
||||
";
|
||||
let doc = parse(src);
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 2);
|
||||
|
||||
// Top contains Sub A and Sub B; Sub A contains Deeper.
|
||||
let top = &syms[0];
|
||||
assert_eq!(top.name, "Top");
|
||||
let top_kids = top.children.as_ref().expect("Top should have children");
|
||||
assert_eq!(top_kids.len(), 2);
|
||||
assert_eq!(top_kids[0].name, "Sub A");
|
||||
assert_eq!(top_kids[1].name, "Sub B");
|
||||
|
||||
let sub_a_kids = top_kids[0]
|
||||
.children
|
||||
.as_ref()
|
||||
.expect("Sub A should have children");
|
||||
assert_eq!(sub_a_kids.len(), 1);
|
||||
assert_eq!(sub_a_kids[0].name, "Deeper");
|
||||
|
||||
assert_eq!(syms[1].name, "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_title_strips_inline_markers() {
|
||||
let doc = parse("= Hello *world* and `code` =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 1);
|
||||
assert_eq!(syms[0].name, "Hello world and code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_heading_falls_back_to_placeholder_name() {
|
||||
let doc = parse("= =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
if let Some(s) = syms.first() {
|
||||
assert!(!s.name.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== End-to-end through the registry =====
|
||||
|
||||
#[test]
|
||||
fn outline_symbols_match_registry_dispatch() {
|
||||
let src = "= A =\n== B ==\n";
|
||||
let plugin = VimwikiSyntax::new();
|
||||
let doc = plugin.parse(src);
|
||||
let syms = headings_to_symbols(&doc, src, true);
|
||||
assert_eq!(syms.len(), 1);
|
||||
assert_eq!(syms[0].name, "A");
|
||||
let kids = syms[0].children.as_ref().unwrap();
|
||||
assert_eq!(kids[0].name, "B");
|
||||
// Sanity: symbols can be wrapped in DocumentSymbolResponse::Nested.
|
||||
let _resp = DocumentSymbolResponse::Nested(syms);
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! HTML export commands.
|
||||
//!
|
||||
//! Drives the pure `export::*` helpers directly. The side-effecting
|
||||
//! `export_ops::write_page` / `write_rss` paths get exercised via a
|
||||
//! per-test temp dir so we know writes land where they should and
|
||||
//! the rendered HTML contains the substituted vars.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::{HtmlConfig, WikiConfig};
|
||||
use nuwiki_lsp::export;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn cfg(root: &str) -> WikiConfig {
|
||||
WikiConfig::from_root(PathBuf::from(root))
|
||||
}
|
||||
|
||||
fn temp_root(suffix: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("nuwiki-p17-{suffix}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
// ===== HtmlConfig defaults =====
|
||||
|
||||
#[test]
|
||||
fn html_config_defaults_match_vimwiki() {
|
||||
let c = HtmlConfig::default();
|
||||
assert_eq!(c.template_default, "default");
|
||||
assert_eq!(c.template_ext, ".tpl");
|
||||
assert_eq!(c.template_date_format, "%Y-%m-%d");
|
||||
assert_eq!(c.css_name, "style.css");
|
||||
assert!(!c.auto_export);
|
||||
assert!(!c.html_filename_parameterization);
|
||||
assert!(c.exclude_files.is_empty());
|
||||
assert_eq!(c.html_header_numbering, 0); // upstream default: numbering off
|
||||
assert!(c.html_header_numbering_sym.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_config_from_root_uses_underscore_dirs() {
|
||||
let h = HtmlConfig::from_root(Path::new("/tmp/wiki"));
|
||||
assert_eq!(h.html_path, PathBuf::from("/tmp/wiki/_html"));
|
||||
assert_eq!(h.template_path, PathBuf::from("/tmp/wiki/_templates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_config_picks_up_html_paths() {
|
||||
let c = cfg("/tmp/x");
|
||||
assert_eq!(c.html.html_path, PathBuf::from("/tmp/x/_html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_json_honours_explicit_html_keys() {
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
let c = config_from_json(serde_json::json!({
|
||||
"wikis": [{
|
||||
"root": "/tmp/y",
|
||||
"html_path": "/tmp/y/site",
|
||||
"template_default": "post",
|
||||
"template_ext": ".tmpl",
|
||||
"auto_export": true,
|
||||
"html_filename_parameterization": true,
|
||||
"exclude_files": ["_drafts/**"],
|
||||
}],
|
||||
}));
|
||||
let h = &c.wikis[0].html;
|
||||
assert_eq!(h.html_path, PathBuf::from("/tmp/y/site"));
|
||||
assert_eq!(h.template_default, "post");
|
||||
assert_eq!(h.template_ext, ".tmpl");
|
||||
assert!(h.auto_export);
|
||||
assert!(h.html_filename_parameterization);
|
||||
assert_eq!(h.exclude_files, vec!["_drafts/**"]);
|
||||
}
|
||||
|
||||
// ===== format_date =====
|
||||
|
||||
#[test]
|
||||
fn format_date_strftime_subset() {
|
||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert_eq!(export::format_date(&d, "%Y-%m-%d"), "2026-05-11");
|
||||
assert_eq!(export::format_date(&d, "%Y"), "2026");
|
||||
assert_eq!(export::format_date(&d, "%m/%d/%Y"), "05/11/2026");
|
||||
assert_eq!(export::format_date(&d, "literal text"), "literal text");
|
||||
assert_eq!(
|
||||
export::format_date(&d, "100%% done"),
|
||||
"100% done",
|
||||
"%% escapes to a literal %"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_date_unknown_specifier_passes_through() {
|
||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
// We support Y/m/d/%; %H is not supported and should pass through verbatim.
|
||||
assert_eq!(export::format_date(&d, "%H:00"), "%H:00");
|
||||
}
|
||||
|
||||
// ===== compute_root_path =====
|
||||
|
||||
#[test]
|
||||
fn compute_root_path_is_empty_at_top_level() {
|
||||
assert_eq!(export::compute_root_path("Home"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_root_path_adds_one_dotdot_per_subdir() {
|
||||
assert_eq!(export::compute_root_path("diary/2026-05-11"), "../");
|
||||
assert_eq!(export::compute_root_path("a/b/c"), "../../");
|
||||
}
|
||||
|
||||
// ===== output_path_for =====
|
||||
|
||||
#[test]
|
||||
fn output_path_for_basic() {
|
||||
let c = cfg("/tmp/x");
|
||||
assert_eq!(
|
||||
export::output_path_for(&c.html, "Home"),
|
||||
PathBuf::from("/tmp/x/_html/Home.html")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_path_for_nested_preserves_subdirs() {
|
||||
let c = cfg("/tmp/x");
|
||||
assert_eq!(
|
||||
export::output_path_for(&c.html, "diary/2026-05-11"),
|
||||
PathBuf::from("/tmp/x/_html/diary/2026-05-11.html")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_path_for_slugifies_only_the_filename_when_enabled() {
|
||||
let mut c = cfg("/tmp/x");
|
||||
c.html.html_filename_parameterization = true;
|
||||
let out = export::output_path_for(&c.html, "Notes/My Page!");
|
||||
// subdir keeps casing, filename becomes lowercase + url-safe.
|
||||
assert_eq!(out, PathBuf::from("/tmp/x/_html/notes/my-page.html"));
|
||||
}
|
||||
|
||||
// ===== is_excluded =====
|
||||
|
||||
#[test]
|
||||
fn is_excluded_matches_globstar() {
|
||||
let patterns = vec!["_drafts/**".into()];
|
||||
assert!(export::is_excluded(&patterns, "_drafts/foo"));
|
||||
assert!(export::is_excluded(&patterns, "_drafts/x/y"));
|
||||
assert!(!export::is_excluded(&patterns, "notes/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_excluded_matches_simple_glob() {
|
||||
let patterns = vec!["*.tmp".into()];
|
||||
assert!(export::is_excluded(&patterns, "foo.tmp"));
|
||||
assert!(!export::is_excluded(&patterns, "foo.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_excluded_matches_question_mark() {
|
||||
let patterns = vec!["??".into()];
|
||||
assert!(export::is_excluded(&patterns, "ab"));
|
||||
assert!(!export::is_excluded(&patterns, "abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_excluded_no_match_returns_false() {
|
||||
let patterns = vec!["foo".into()];
|
||||
assert!(!export::is_excluded(&patterns, "bar"));
|
||||
}
|
||||
|
||||
// ===== TOC HTML =====
|
||||
|
||||
#[test]
|
||||
fn build_toc_html_produces_nested_lists() {
|
||||
let ast = parse("= One =\n== Two ==\n=== Three ===\n= Four =\n");
|
||||
let html = export::build_toc_html(&ast);
|
||||
assert!(html.contains("ul class=\"toc\""));
|
||||
assert!(html.contains(">One<"));
|
||||
assert!(html.contains(">Two<"));
|
||||
assert!(html.contains(">Three<"));
|
||||
assert!(html.contains(">Four<"));
|
||||
// Verify the nesting: Two should be inside an inner <ul> after One.
|
||||
let one_idx = html.find(">One<").unwrap();
|
||||
let two_idx = html.find(">Two<").unwrap();
|
||||
let three_idx = html.find(">Three<").unwrap();
|
||||
assert!(one_idx < two_idx && two_idx < three_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_toc_html_empty_for_no_headings() {
|
||||
let ast = parse("Just a paragraph.\n");
|
||||
assert!(export::build_toc_html(&ast).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_toc_html_escapes_special_chars_in_title() {
|
||||
let ast = parse("= A & B <ok> =\n");
|
||||
let html = export::build_toc_html(&ast);
|
||||
assert!(html.contains("A & B <ok>"));
|
||||
}
|
||||
|
||||
// ===== template_for =====
|
||||
|
||||
#[test]
|
||||
fn template_for_prefers_metadata_template() {
|
||||
let c = cfg("/tmp/x");
|
||||
let ast = parse("%template post\n= Hi =\n");
|
||||
let src = export::template_for(&c.html, &ast);
|
||||
assert_eq!(
|
||||
src.primary,
|
||||
Some(PathBuf::from("/tmp/x/_templates/post.tpl"))
|
||||
);
|
||||
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_for_falls_back_when_no_directive() {
|
||||
let c = cfg("/tmp/x");
|
||||
let ast = parse("= Hi =\n");
|
||||
let src = export::template_for(&c.html, &ast);
|
||||
assert!(src.primary.is_none());
|
||||
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
||||
}
|
||||
|
||||
// ===== render_page_html =====
|
||||
|
||||
#[test]
|
||||
fn render_page_html_passes_through_valid_html_tags() {
|
||||
// `<sub>`/`<kbd>` are in the default valid_html_tags → verbatim; `<script>`
|
||||
// is not → escaped.
|
||||
let ast = parse("H<sub>2</sub>O press <kbd>Ctrl</kbd> <script>x</script>\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"Home",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("H<sub>2</sub>O"), "sub verbatim: {html}");
|
||||
assert!(html.contains("<kbd>Ctrl</kbd>"), "kbd verbatim: {html}");
|
||||
assert!(html.contains("<script>"), "script escaped: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_ignore_newline_controls_soft_breaks() {
|
||||
// Paragraph + list item, each with a soft (single) newline inside.
|
||||
let ast = parse("alpha\nbeta\n\n- one\n two\n");
|
||||
// Default (true/true): soft breaks render as spaces, no <br>.
|
||||
let c = cfg("/tmp/x");
|
||||
let def = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"Home",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!def.contains("<br"), "default joins with spaces: {def}");
|
||||
assert!(def.contains("alpha beta"), "para space: {def}");
|
||||
|
||||
// false/false: soft breaks become <br /> in both paragraph and list item.
|
||||
let mut c2 = cfg("/tmp/x");
|
||||
c2.html.text_ignore_newline = false;
|
||||
c2.html.list_ignore_newline = false;
|
||||
let br = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"Home",
|
||||
DiaryDate::today_utc(),
|
||||
&c2.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(br.contains("alpha<br />beta"), "para <br>: {br}");
|
||||
assert!(
|
||||
br.contains("one<br />two") || br.contains("one<br /> two"),
|
||||
"list <br>: {br}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_substitutes_emoji_when_enabled() {
|
||||
let ast = parse("ship it :rocket: and :tada:\n");
|
||||
let c = cfg("/tmp/x"); // emoji_enable defaults true
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"Home",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("🚀"), "rocket: {html}");
|
||||
assert!(html.contains("🎉"), "tada: {html}");
|
||||
assert!(!html.contains(":rocket:"), "shortcode replaced: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_emoji_off_leaves_shortcodes() {
|
||||
let ast = parse("plain :rocket: here\n");
|
||||
let mut c = cfg("/tmp/x");
|
||||
c.html.emoji_enable = false;
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"Home",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains(":rocket:"), "left as-is: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_substitutes_title_content_and_extras() {
|
||||
let ast = parse("%title My Page\n= One =\nbody text\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let template = "<title>{{title}}</title><body>{{content}}</body><footer>{{date}}</footer>";
|
||||
let extras = HashMap::new();
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some(template.into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
extras,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"));
|
||||
assert!(html.contains("<footer>2026-05-11</footer>"));
|
||||
assert!(html.contains("body text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_uses_metadata_date_when_set() {
|
||||
let ast = parse("%date 2025-01-02\n= Hi =\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("DATE={{date}}".into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("DATE=2025-01-02"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_numbers_headers_when_enabled() {
|
||||
let ast = parse("= Intro =\n== Background ==\n== Methods ==\n= Results =\n");
|
||||
let mut c = cfg("/tmp/x");
|
||||
c.html.html_header_numbering = 1; // number from h1
|
||||
c.html.html_header_numbering_sym = ".".into();
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
None,
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Results\">2. Results</h1>"),
|
||||
"got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_numbering_skips_headers_above_start_level() {
|
||||
let ast = parse("= Title =\n== Section ==\n=== Sub ===\n");
|
||||
let mut c = cfg("/tmp/x");
|
||||
c.html.html_header_numbering = 2; // start at h2; h1 stays unnumbered
|
||||
// empty sym → number followed by a bare space
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
None,
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Title\">Title</h1>"),
|
||||
"h1 unnumbered, got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Section\">1 Section</h2>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_no_header_numbering_by_default() {
|
||||
let ast = parse("= Intro =\n== Sub ==\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
None,
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
|
||||
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_root_path_for_nested_pages() {
|
||||
let ast = parse("= D =\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("CSS={{root_path}}{{css}}".into()),
|
||||
"diary/2026-05-11",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("CSS=../style.css"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_extra_var_overrides_default() {
|
||||
let ast = parse("= H =\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let mut extras = HashMap::new();
|
||||
extras.insert("date".into(), "OVERRIDE".into());
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("D={{date}}".into()),
|
||||
"H",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
extras,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("D=OVERRIDE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_substitutes_vimwiki_percent_template() {
|
||||
// A stock vimwiki template (%title%/%content%/%root_path%/%wiki_css%/%date%)
|
||||
// must render fully — every placeholder resolved, no literal `%…%` left.
|
||||
let ast = parse("%title My Page\n= One =\nbody text\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let template = "<title>%title%</title>\
|
||||
<link href=\"%root_path%%wiki_css%\">\
|
||||
<body>%content%</body><footer>%date%</footer>";
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some(template.into()),
|
||||
"diary/2026-05-11",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"), "title: {html}");
|
||||
assert!(html.contains("body text"), "content: {html}");
|
||||
assert!(
|
||||
html.contains("href=\"../style.css\""),
|
||||
"root_path+css: {html}"
|
||||
);
|
||||
assert!(html.contains("<footer>2026-05-11</footer>"), "date: {html}");
|
||||
assert!(!html.contains('%'), "placeholder left behind: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_strips_wiki_extension_from_links() {
|
||||
// A link written with the extension (`[[todo.wiki]]`) must export to
|
||||
// `todo.html`, not `todo.wiki.html` — matching in-editor navigation.
|
||||
let ast = parse("[[todo.wiki|Tasks]] and [[posts/index|Posts]]\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"index",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("href=\"todo.html\""), "stripped: {html}");
|
||||
assert!(!html.contains("todo.wiki.html"), "not double-ext: {html}");
|
||||
// A link without the extension is unaffected.
|
||||
assert!(html.contains("href=\"posts/index.html\""), "plain: {html}");
|
||||
}
|
||||
|
||||
// ===== fallback_template + DEFAULT_CSS =====
|
||||
|
||||
#[test]
|
||||
fn fallback_template_references_root_path_and_css() {
|
||||
let t = export::fallback_template("custom.css");
|
||||
assert!(t.contains("{{title}}"));
|
||||
assert!(t.contains("{{content}}"));
|
||||
assert!(t.contains("{{root_path}}custom.css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_css_is_non_empty() {
|
||||
assert!(!export::DEFAULT_CSS.is_empty());
|
||||
assert!(export::DEFAULT_CSS.contains("font-family"));
|
||||
}
|
||||
|
||||
// ===== write_page (disk-touching) =====
|
||||
|
||||
#[test]
|
||||
fn write_page_writes_html_to_disk() {
|
||||
let root = temp_root("write");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
let ast = parse("= Hello =\nworld\n");
|
||||
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "Hello", &c, false).unwrap();
|
||||
assert_eq!(outcome.page, "Hello");
|
||||
assert!(outcome.output_path.exists());
|
||||
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
|
||||
assert!(body.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_page_creates_subdirs() {
|
||||
let root = temp_root("subdir");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
let ast = parse("= Daily =\n");
|
||||
let outcome =
|
||||
nuwiki_lsp::commands::export_ops::write_page(&ast, "diary/2026-05-11", &c, false).unwrap();
|
||||
assert!(outcome.output_path.exists());
|
||||
assert!(outcome
|
||||
.output_path
|
||||
.to_string_lossy()
|
||||
.contains("diary/2026-05-11.html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_page_uses_template_file_when_present() {
|
||||
let root = temp_root("template");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
// Write a template file the renderer should pick up.
|
||||
std::fs::create_dir_all(&c.html.template_path).unwrap();
|
||||
std::fs::write(
|
||||
c.html.template_path.join("default.tpl"),
|
||||
"<x>{{title}}|{{content}}</x>",
|
||||
)
|
||||
.unwrap();
|
||||
let ast = parse("%title T\n= H =\nbody\n");
|
||||
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "P", &c, false).unwrap();
|
||||
let body = std::fs::read_to_string(outcome.output_path).unwrap();
|
||||
assert!(body.starts_with("<x>T|"));
|
||||
assert!(body.ends_with("</x>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_css_writes_default_when_missing() {
|
||||
let root = temp_root("css");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
||||
assert!(res.created);
|
||||
assert!(res.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_css_is_idempotent_when_present() {
|
||||
let root = temp_root("css2");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
||||
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
||||
assert!(!res.created);
|
||||
}
|
||||
|
||||
// ===== RSS =====
|
||||
|
||||
#[test]
|
||||
fn write_rss_emits_valid_xml_with_entries() {
|
||||
use nuwiki_lsp::diary::DiaryEntry;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
let root = temp_root("rss");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
c.name = "Test Wiki".into();
|
||||
let entries = vec![
|
||||
DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
|
||||
},
|
||||
DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
||||
uri: Url::parse("file:///tmp/diary/2026-05-10.wiki").unwrap(),
|
||||
},
|
||||
];
|
||||
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.starts_with("<?xml"));
|
||||
assert!(xml.contains("<title>Test Wiki</title>"));
|
||||
// Newest first.
|
||||
let i11 = xml.find("2026-05-11").unwrap();
|
||||
let i10 = xml.find("2026-05-10").unwrap();
|
||||
assert!(i11 < i10);
|
||||
// No base_url → item links keep the file:// URI.
|
||||
assert!(xml.contains("<link>file:///tmp/diary/2026-05-11.wiki</link>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rss_uses_base_url_for_public_links() {
|
||||
use nuwiki_lsp::diary::DiaryEntry;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
let root = temp_root("rss_base");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
c.html.base_url = "https://example.com/wiki/".into();
|
||||
c.diary_rel_path = "diary".into();
|
||||
let entries = vec![DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
|
||||
}];
|
||||
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
// Channel link points at the diary index page (upstream fidelity); item
|
||||
// link is the public per-entry URL.
|
||||
assert!(
|
||||
xml.contains("<link>https://example.com/wiki/diary/diary.html</link>"),
|
||||
"channel link: {xml}"
|
||||
);
|
||||
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
|
||||
// guid is the bare date, isPermaLink=false.
|
||||
assert!(
|
||||
xml.contains("<guid isPermaLink=\"false\">2026-05-11</guid>"),
|
||||
"guid: {xml}"
|
||||
);
|
||||
// atom:link self + a pubDate are present.
|
||||
assert!(xml.contains("rel=\"self\""), "atom:link: {xml}");
|
||||
assert!(xml.contains("<pubDate>"), "pubDate: {xml}");
|
||||
assert!(!xml.contains("file:///tmp/diary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_orphan_html_deletes_sourceless_keeps_protected() {
|
||||
let root = temp_root("prune");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
c.html.user_htmls = vec!["keep.html".into()];
|
||||
let html = &c.html.html_path;
|
||||
std::fs::create_dir_all(html).unwrap();
|
||||
// A sourced page (Home.wiki exists) → kept.
|
||||
std::fs::write(root.join("Home.wiki"), "= Home =\n").unwrap();
|
||||
std::fs::write(html.join("Home.html"), "<h1>Home</h1>").unwrap();
|
||||
// An orphan (no source) → pruned.
|
||||
std::fs::write(html.join("Ghost.html"), "<h1>Ghost</h1>").unwrap();
|
||||
// A user_htmls-protected orphan → kept.
|
||||
std::fs::write(html.join("keep.html"), "<p>manual</p>").unwrap();
|
||||
// The CSS file → never pruned.
|
||||
std::fs::write(html.join(&c.html.css_name), "body{}").unwrap();
|
||||
|
||||
let pruned = nuwiki_lsp::commands::export_ops::prune_orphan_html(&c);
|
||||
assert_eq!(pruned.len(), 1, "only the orphan: {pruned:?}");
|
||||
assert!(!html.join("Ghost.html").exists(), "orphan deleted");
|
||||
assert!(html.join("Home.html").exists(), "sourced kept");
|
||||
assert!(html.join("keep.html").exists(), "user_htmls kept");
|
||||
assert!(html.join(&c.html.css_name).exists(), "css kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rss_honours_rss_name_and_max_items() {
|
||||
use nuwiki_lsp::diary::DiaryEntry;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
let root = temp_root("rss_cap");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
c.html.rss_name = "feed.xml".into();
|
||||
c.html.rss_max_items = 2;
|
||||
let entries: Vec<DiaryEntry> = (1..=5)
|
||||
.map(|d| DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, d).unwrap(),
|
||||
uri: Url::parse(&format!("file:///tmp/diary/2026-05-0{d}.wiki")).unwrap(),
|
||||
})
|
||||
.collect();
|
||||
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
|
||||
assert!(path.ends_with("feed.xml"), "rss_name: {path:?}");
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
// Capped at 2 items (newest first: 05-05, 05-04).
|
||||
assert_eq!(xml.matches("<item>").count(), 2, "cap: {xml}");
|
||||
assert!(xml.contains("2026-05-05") && xml.contains("2026-05-04"));
|
||||
assert!(!xml.contains("2026-05-03"), "older items dropped: {xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_page_invokes_custom_wiki2html_converter() {
|
||||
let root = temp_root("custom_w2h");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
// Source file must exist on disk — the converter receives its path.
|
||||
std::fs::write(root.join("Hello.wiki"), "= Hello =\nworld\n").unwrap();
|
||||
// A tiny shell "converter": writes a marker into the output path it is told
|
||||
// to produce. nuwiki appends args positionally after `_` ($0), so
|
||||
// $1=force, $2=syntax, $3=ext, $4=output_dir, $5=input_file, …
|
||||
let script = "mkdir -p \"$4\" && printf 'CUSTOM:%s' \"$5\" > \"$4\"/Hello.html";
|
||||
c.html.custom_wiki2html = format!("sh -c {} _", shell_words_quote(script));
|
||||
let outcome =
|
||||
nuwiki_lsp::commands::export_ops::write_page(&parse("= Hello =\n"), "Hello", &c, true)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.page, "Hello");
|
||||
assert!(
|
||||
outcome.output_path.exists(),
|
||||
"converter must produce output"
|
||||
);
|
||||
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
|
||||
assert!(
|
||||
body.starts_with("CUSTOM:"),
|
||||
"marker from converter, got {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal single-quote shell escaping for the test command above.
|
||||
fn shell_words_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_export_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.export.currentToHtml",
|
||||
"nuwiki.export.allToHtml",
|
||||
"nuwiki.export.allToHtmlForce",
|
||||
"nuwiki.export.browse",
|
||||
"nuwiki.export.rss",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
//! LSP-side workspace plumbing:
|
||||
//! - `WorkspaceEditBuilder` (changes-map vs document-changes promotion)
|
||||
//! and `TextEdit` UTF-8/UTF-16 conversion.
|
||||
//! - `Config` desugar from `initializationOptions` JSON (per-wiki keys,
|
||||
//! legacy single-wiki shape).
|
||||
//! - `Wiki` resolution: building wikis from raw config and matching
|
||||
//! URIs to the longest-prefix wiki root.
|
||||
//! - `collect_diagnostics` composition (parser errors + link-health).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, DocumentNode, ErrorNode, ParagraphNode, Position as AstPosition,
|
||||
Span, TextNode,
|
||||
};
|
||||
use nuwiki_lsp::collect_diagnostics;
|
||||
use nuwiki_lsp::config::{config_from_json, Config, DiagnosticConfig, LinkSeverity, WikiConfig};
|
||||
use nuwiki_lsp::edits::{
|
||||
op_create, op_delete, op_rename, text_edit_delete, text_edit_insert, text_edit_replace,
|
||||
WorkspaceEditBuilder,
|
||||
};
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, Wiki, WikiId};
|
||||
use serde_json::json;
|
||||
use tower_lsp::lsp_types::{DocumentChanges, Position as LspPosition, Url};
|
||||
|
||||
// ===== Edits =====
|
||||
|
||||
fn span_one_line(byte_col: u32, len: u32) -> Span {
|
||||
Span::new(
|
||||
AstPosition {
|
||||
line: 0,
|
||||
column: byte_col,
|
||||
offset: byte_col as usize,
|
||||
},
|
||||
AstPosition {
|
||||
line: 0,
|
||||
column: byte_col + len,
|
||||
offset: (byte_col + len) as usize,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_replace_passthrough_in_utf8() {
|
||||
let edit = text_edit_replace(span_one_line(0, 5), "world".into(), "hello there", true);
|
||||
assert_eq!(edit.range.start.character, 0);
|
||||
assert_eq!(edit.range.end.character, 5);
|
||||
assert_eq!(edit.new_text, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_replace_converts_to_utf16_for_multibyte() {
|
||||
// "héllo" — h(1B) é(2B) l(1B) l(1B) o(1B); UTF-16 chars: 1+1+1+1+1=5.
|
||||
// span covers bytes 0..3 (h + é); UTF-16 character count = 2.
|
||||
let edit = text_edit_replace(span_one_line(0, 3), "X".into(), "héllo", false);
|
||||
assert_eq!(edit.range.start.character, 0);
|
||||
assert_eq!(edit.range.end.character, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_insert_produces_zero_width_range() {
|
||||
let edit = text_edit_insert(
|
||||
LspPosition {
|
||||
line: 0,
|
||||
character: 4,
|
||||
},
|
||||
"X".into(),
|
||||
);
|
||||
assert_eq!(edit.range.start, edit.range.end);
|
||||
assert_eq!(edit.new_text, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_delete_has_empty_new_text() {
|
||||
let edit = text_edit_delete(span_one_line(0, 3), "abcdef", true);
|
||||
assert_eq!(edit.new_text, "");
|
||||
assert_eq!(edit.range.end.character, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_with_only_edits_uses_changes_map() {
|
||||
let uri = Url::parse("file:///tmp/a.wiki").unwrap();
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
b.edit(
|
||||
uri.clone(),
|
||||
text_edit_replace(span_one_line(0, 3), "new".into(), "old text", true),
|
||||
);
|
||||
let we = b.build();
|
||||
assert!(we.changes.is_some());
|
||||
assert!(we.document_changes.is_none());
|
||||
let changes = we.changes.unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[&uri].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_with_file_op_promotes_to_document_changes() {
|
||||
let old = Url::parse("file:///tmp/A.wiki").unwrap();
|
||||
let new = Url::parse("file:///tmp/B.wiki").unwrap();
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
b.file_op(op_rename(old.clone(), new.clone()));
|
||||
b.edit(
|
||||
new.clone(),
|
||||
text_edit_replace(span_one_line(0, 1), "X".into(), "Y", true),
|
||||
);
|
||||
let we = b.build();
|
||||
assert!(we.changes.is_none());
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected Operations variant");
|
||||
};
|
||||
// Rename comes first (file ops always precede content edits in the
|
||||
// builder), then the text edit on the renamed file.
|
||||
assert_eq!(ops.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_is_empty_until_something_pushed() {
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
assert!(b.is_empty());
|
||||
b.file_op(op_delete(Url::parse("file:///x").unwrap()));
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_create_round_trips_through_builder() {
|
||||
let uri = Url::parse("file:///tmp/new.wiki").unwrap();
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
b.file_op(op_create(uri));
|
||||
let we = b.build();
|
||||
assert!(we.document_changes.is_some());
|
||||
}
|
||||
|
||||
// ===== Config =====
|
||||
|
||||
#[test]
|
||||
fn config_default_is_empty() {
|
||||
let c = Config::default();
|
||||
assert!(c.wikis.is_empty());
|
||||
assert_eq!(c.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_0_single_wiki_desugars_to_one_entry() {
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/vimwiki",
|
||||
"file_extension": ".wiki",
|
||||
"syntax": "vimwiki",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/vimwiki"));
|
||||
assert_eq!(cfg.wikis[0].file_extension, ".wiki");
|
||||
assert_eq!(cfg.wikis[0].syntax, "vimwiki");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_wiki_shorthand_honours_top_level_per_wiki_keys() {
|
||||
// Regression: a single-wiki user sets per-wiki keys at the top level
|
||||
// (alongside wiki_root); they must reach the synthesized wiki, not just
|
||||
// file_extension/syntax. Previously toc_header_level etc. were dropped.
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/vimwiki",
|
||||
"toc_header": "Table of Contents",
|
||||
"toc_header_level": 2,
|
||||
"links_header_level": 3,
|
||||
"auto_export": true,
|
||||
"html_path": "/tmp/out",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
let w = &cfg.wikis[0];
|
||||
assert_eq!(w.toc_header, "Table of Contents");
|
||||
assert_eq!(w.toc_header_level, 2);
|
||||
assert_eq!(w.links_header_level, 3);
|
||||
assert!(w.html.auto_export);
|
||||
assert_eq!(w.html.html_path, PathBuf::from("/tmp/out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_wikis_list_overrides_legacy_shape() {
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/IGNORED",
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal", "name": "personal" },
|
||||
{ "root": "/tmp/work", "syntax": "vimwiki" },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 2);
|
||||
assert_eq!(cfg.wikis[0].name, "personal");
|
||||
assert_eq!(cfg.wikis[1].root, PathBuf::from("/tmp/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_json_yields_no_wikis() {
|
||||
let cfg = config_from_json(json!({}));
|
||||
assert!(cfg.wikis.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_leaves_existing_config_unchanged() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.wikis
|
||||
.push(WikiConfig::from_root(PathBuf::from("/tmp/a")));
|
||||
cfg.apply_change(&json!("not a config object"));
|
||||
// Existing wikis still present.
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
}
|
||||
|
||||
// ===== Wiki aggregate =====
|
||||
|
||||
fn wiki_at(id: u32, root: &str) -> Wiki {
|
||||
Wiki::new(WikiId(id), WikiConfig::from_root(PathBuf::from(root)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_wikis_assigns_sequential_ids() {
|
||||
let configs = vec![
|
||||
WikiConfig::from_root(PathBuf::from("/tmp/a")),
|
||||
WikiConfig::from_root(PathBuf::from("/tmp/b")),
|
||||
];
|
||||
let wikis = build_wikis(&configs);
|
||||
assert_eq!(wikis.len(), 2);
|
||||
assert_eq!(wikis[0].id, WikiId(0));
|
||||
assert_eq!(wikis[1].id, WikiId(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uri_to_wiki_picks_longest_prefix() {
|
||||
let wikis = vec![wiki_at(0, "/wiki"), wiki_at(1, "/wiki/sub")];
|
||||
let uri = Url::from_file_path("/wiki/sub/page.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uri_to_wiki_misses_outside_any_root() {
|
||||
let wikis = vec![wiki_at(0, "/wiki")];
|
||||
let uri = Url::from_file_path("/other/page.wiki").unwrap();
|
||||
assert!(resolve_uri_to_wiki(&wikis, &uri).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_contains_respects_root_prefix() {
|
||||
let w = wiki_at(0, "/wiki");
|
||||
assert!(w.contains(&Url::from_file_path("/wiki/note.wiki").unwrap()));
|
||||
assert!(!w.contains(&Url::from_file_path("/other/note.wiki").unwrap()));
|
||||
}
|
||||
|
||||
// ===== Diagnostics composition =====
|
||||
|
||||
#[test]
|
||||
fn collect_diagnostics_emits_one_per_error_node() {
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_one_line(0, 3),
|
||||
raw: "oops".into(),
|
||||
message: "broken".into(),
|
||||
}),
|
||||
BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "ok".into(),
|
||||
})],
|
||||
}),
|
||||
],
|
||||
};
|
||||
let cfg = DiagnosticConfig::default();
|
||||
let diags = collect_diagnostics(&doc, "abc ok", None, None, None, &cfg, true);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].message, "broken");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_diagnostics_link_health_emits_nothing_without_index() {
|
||||
// No index → link-health source is skipped, even when severity is on.
|
||||
let doc = DocumentNode::default();
|
||||
let cfg = DiagnosticConfig {
|
||||
link_severity: LinkSeverity::Error,
|
||||
};
|
||||
let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true);
|
||||
assert!(diags.is_empty());
|
||||
}
|
||||
|
||||
// ===== Per-wiki config defaults + JSON parsing =====
|
||||
|
||||
#[test]
|
||||
fn defaults_carry_vimwiki_per_wiki_keys() {
|
||||
let cfg = WikiConfig::empty();
|
||||
assert_eq!(cfg.index, "index");
|
||||
assert_eq!(cfg.diary_frequency, "daily");
|
||||
assert_eq!(cfg.diary_caption_level, 0); // upstream default
|
||||
assert_eq!(cfg.diary_sort, "desc");
|
||||
assert_eq!(cfg.diary_header, "Diary");
|
||||
// diary_months defaults to the 12 English month names.
|
||||
assert_eq!(cfg.diary_months.len(), 12);
|
||||
assert_eq!(cfg.diary_months[0], "January");
|
||||
assert_eq!(cfg.diary_months[11], "December");
|
||||
assert_eq!(cfg.listsyms, " .oOX");
|
||||
assert!(cfg.listsyms_propagate);
|
||||
assert_eq!(cfg.list_margin, -1);
|
||||
assert_eq!(cfg.links_space_char, " ");
|
||||
assert!(!cfg.auto_toc);
|
||||
assert!(!cfg.auto_generate_links);
|
||||
assert!(!cfg.auto_generate_tags);
|
||||
assert!(!cfg.auto_diary_index);
|
||||
// Generated-section captions match upstream defaults.
|
||||
assert_eq!(cfg.toc_header, "Contents");
|
||||
assert_eq!(cfg.toc_header_level, 1);
|
||||
assert_eq!(cfg.links_header, "Generated Links");
|
||||
assert_eq!(cfg.tags_header, "Generated Tags");
|
||||
assert_eq!(cfg.listsym_rejected, "-");
|
||||
// No external converter / public base URL by default.
|
||||
assert_eq!(cfg.html.custom_wiki2html, "");
|
||||
assert_eq!(cfg.html.custom_wiki2html_args, "");
|
||||
assert_eq!(cfg.html.base_url, "");
|
||||
// HTML section numbering is off by default (vimwiki's `0`).
|
||||
assert_eq!(cfg.html.html_header_numbering, 0);
|
||||
assert_eq!(cfg.html.html_header_numbering_sym, "");
|
||||
// New config defaults match upstream.
|
||||
assert!(cfg.create_link);
|
||||
assert_eq!(cfg.dir_link, "");
|
||||
assert_eq!(cfg.bullet_types, vec!["-", "*", "#"]);
|
||||
assert!(!cfg.cycle_bullets);
|
||||
assert!(!cfg.generated_links_caption);
|
||||
assert_eq!(cfg.toc_link_format, 0);
|
||||
assert!(!cfg.table_reduce_last_col);
|
||||
assert_eq!(cfg.html.rss_name, "rss.xml");
|
||||
assert_eq!(cfg.html.rss_max_items, 10);
|
||||
assert!(cfg.html.list_ignore_newline);
|
||||
assert!(cfg.html.text_ignore_newline);
|
||||
assert!(cfg.html.emoji_enable);
|
||||
assert!(cfg.html.user_htmls.is_empty());
|
||||
assert!(cfg.html.valid_html_tags.contains(&"sub".to_string()));
|
||||
// color_dic ships a populated palette (vimwiki seeds one).
|
||||
assert!(cfg.html.color_dic.contains_key("red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_wiki_parses_config_batch_keys() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [{
|
||||
"root": "/tmp/w",
|
||||
"create_link": false,
|
||||
"dir_link": "index",
|
||||
"bullet_types": ["+", "-"],
|
||||
"cycle_bullets": true,
|
||||
"generated_links_caption": 1,
|
||||
"toc_link_format": 1,
|
||||
"table_reduce_last_col": true,
|
||||
"rss_name": "feed.xml",
|
||||
"rss_max_items": 5,
|
||||
"valid_html_tags": "b,mark",
|
||||
"list_ignore_newline": 0,
|
||||
"text_ignore_newline": false,
|
||||
"emoji_enable": 0,
|
||||
"user_htmls": ["404.html"],
|
||||
"color_tag_template": "<u>__CONTENT__</u>",
|
||||
}],
|
||||
}));
|
||||
let w = &cfg.wikis[0];
|
||||
assert!(!w.create_link);
|
||||
assert_eq!(w.dir_link, "index");
|
||||
assert_eq!(w.bullet_types, vec!["+", "-"]);
|
||||
assert!(w.cycle_bullets);
|
||||
assert!(w.generated_links_caption);
|
||||
assert_eq!(w.toc_link_format, 1);
|
||||
assert!(w.table_reduce_last_col);
|
||||
assert_eq!(w.html.rss_name, "feed.xml");
|
||||
assert_eq!(w.html.rss_max_items, 5);
|
||||
assert_eq!(w.html.valid_html_tags, vec!["b", "mark"]);
|
||||
assert!(!w.html.list_ignore_newline);
|
||||
assert!(!w.html.text_ignore_newline);
|
||||
assert!(!w.html.emoji_enable);
|
||||
assert_eq!(w.html.user_htmls, vec!["404.html"]);
|
||||
assert_eq!(w.html.color_tag_template, "<u>__CONTENT__</u>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_wiki_parses_every_new_key() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [{
|
||||
"root": "/tmp/w",
|
||||
"index": "home",
|
||||
"diary_frequency": "weekly",
|
||||
"diary_weekly_style": "date",
|
||||
"diary_start_week_day": "sunday",
|
||||
"diary_caption_level": 2,
|
||||
"diary_sort": "asc",
|
||||
"diary_header": "Journal",
|
||||
"diary_months": ["Jan","Feb","Mar","Apr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],
|
||||
"listsyms": "abcde",
|
||||
"listsym_rejected": "✗",
|
||||
"listsyms_propagate": false,
|
||||
"list_margin": 2,
|
||||
"links_space_char": "_",
|
||||
"auto_toc": true,
|
||||
"auto_generate_links": true,
|
||||
"auto_generate_tags": 1,
|
||||
"auto_diary_index": true,
|
||||
"toc_header": "Table of Contents",
|
||||
"toc_header_level": 2,
|
||||
"links_header": "All Pages",
|
||||
"tags_header": "Tag Index",
|
||||
"tags_header_level": 3,
|
||||
"custom_wiki2html": "~/bin/my_wiki2html.sh",
|
||||
"custom_wiki2html_args": "--flag",
|
||||
"base_url": "https://example.com/wiki/",
|
||||
"html_header_numbering": 2,
|
||||
"html_header_numbering_sym": ".",
|
||||
}],
|
||||
}));
|
||||
let w = &cfg.wikis[0];
|
||||
assert_eq!(w.index, "home");
|
||||
assert_eq!(w.diary_frequency, "weekly");
|
||||
assert_eq!(w.diary_weekly_style, "date");
|
||||
assert_eq!(w.diary_start_week_day, "sunday");
|
||||
assert_eq!(w.diary_caption_level, 2);
|
||||
assert_eq!(w.diary_sort, "asc");
|
||||
assert_eq!(w.diary_header, "Journal");
|
||||
assert_eq!(w.diary_months[4], "Mai");
|
||||
assert_eq!(w.diary_months[11], "Dez");
|
||||
assert_eq!(w.listsyms, "abcde");
|
||||
assert_eq!(w.listsym_rejected, "✗");
|
||||
assert_eq!(w.list_syms().rejected_glyph(), '✗');
|
||||
assert!(!w.listsyms_propagate);
|
||||
assert_eq!(w.list_margin, 2);
|
||||
assert_eq!(w.links_space_char, "_");
|
||||
assert!(w.auto_toc);
|
||||
assert!(w.auto_generate_links);
|
||||
assert!(w.auto_generate_tags); // int 1 → true
|
||||
assert!(w.auto_diary_index);
|
||||
assert_eq!(w.toc_header, "Table of Contents");
|
||||
assert_eq!(w.toc_header_level, 2);
|
||||
assert_eq!(w.links_header, "All Pages");
|
||||
assert_eq!(w.links_header_level, 1); // unspecified → default
|
||||
assert_eq!(w.tags_header, "Tag Index");
|
||||
assert_eq!(w.tags_header_level, 3);
|
||||
// custom_wiki2html keeps the literal command string (tilde-expansion is
|
||||
// the converter's job, not ours); args + base_url round-trip verbatim.
|
||||
assert_eq!(w.html.custom_wiki2html, "~/bin/my_wiki2html.sh");
|
||||
assert_eq!(w.html.custom_wiki2html_args, "--flag");
|
||||
assert_eq!(w.html.base_url, "https://example.com/wiki/");
|
||||
assert_eq!(w.html.html_header_numbering, 2);
|
||||
assert_eq!(w.html.html_header_numbering_sym, ".");
|
||||
|
||||
// The parsed keys drive a date-mode, Sunday-start diary calendar:
|
||||
// a weekly note is the week-start date (YYYY-MM-DD), stepping ±7 days.
|
||||
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
|
||||
let cal = w.diary_calendar();
|
||||
let wed = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 27).unwrap());
|
||||
assert_eq!(
|
||||
cal.next(wed),
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 31).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_wiki_derives_list_margin_zero_when_unset() {
|
||||
// vimwiki sets list_margin = 0 for markdown wikis (vs -1 elsewhere) when
|
||||
// the key is absent; an explicit value still wins.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [{ "root": "/tmp/md", "syntax": "markdown" }],
|
||||
}));
|
||||
assert_eq!(cfg.wikis[0].list_margin, 0);
|
||||
|
||||
let vw = config_from_json(serde_json::json!({
|
||||
"wikis": [{ "root": "/tmp/vw", "syntax": "vimwiki" }],
|
||||
}));
|
||||
assert_eq!(vw.wikis[0].list_margin, -1);
|
||||
|
||||
let explicit = config_from_json(serde_json::json!({
|
||||
"wikis": [{ "root": "/tmp/md2", "syntax": "markdown", "list_margin": 4 }],
|
||||
}));
|
||||
assert_eq!(explicit.wikis[0].list_margin, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_caption_level_accepts_negative() {
|
||||
// vimwiki allows diary_caption_level = -1 (min: -1); the field is i8 so it
|
||||
// parses instead of failing deserialization.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [{ "root": "/tmp/w", "diary_caption_level": -1 }],
|
||||
}));
|
||||
assert_eq!(cfg.wikis[0].diary_caption_level, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_calendar_defaults_to_iso_weeks() {
|
||||
let cfg = WikiConfig::empty();
|
||||
assert_eq!(cfg.diary_weekly_style, "iso");
|
||||
assert_eq!(cfg.diary_start_week_day, "monday");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_severity_parses_from_client_config() {
|
||||
for (input, expected) in [
|
||||
("off", LinkSeverity::Off),
|
||||
("hint", LinkSeverity::Hint),
|
||||
("warn", LinkSeverity::Warning),
|
||||
("warning", LinkSeverity::Warning),
|
||||
("error", LinkSeverity::Error),
|
||||
("ERROR", LinkSeverity::Error),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/w",
|
||||
"diagnostic": { "link_severity": input },
|
||||
}));
|
||||
assert_eq!(
|
||||
cfg.diagnostic.link_severity, expected,
|
||||
"input {input:?} should map to {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_severity_invalid_or_absent_falls_back_to_default() {
|
||||
// Unknown string → default (Warning).
|
||||
let bad = config_from_json(json!({
|
||||
"wiki_root": "/tmp/w",
|
||||
"diagnostic": { "link_severity": "loud" },
|
||||
}));
|
||||
assert_eq!(bad.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
|
||||
// No diagnostic key at all → default.
|
||||
let none = config_from_json(json!({ "wiki_root": "/tmp/w" }));
|
||||
assert_eq!(none.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_change_preserves_severity_on_partial_update() {
|
||||
let mut cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/w",
|
||||
"diagnostic": { "link_severity": "error" },
|
||||
}));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
|
||||
|
||||
// A later update that omits `diagnostic` must not reset severity.
|
||||
cfg.apply_change(&json!({ "wiki_root": "/tmp/w2" }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_change_minimal_diagnostic_payload_is_not_unwrapped() {
|
||||
// A lone `{ "diagnostic": { … } }` is a real flat payload, not a
|
||||
// `{ "nuwiki": { … } }` namespace wrapper. The single-key unwrap must
|
||||
// skip it (it's a recognised top-level key) so the severity applies.
|
||||
let mut cfg = config_from_json(json!({ "wiki_root": "/tmp/w" }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
|
||||
cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
|
||||
|
||||
// The Neovim namespace wrapper still unwraps correctly.
|
||||
cfg.apply_change(&json!({ "nuwiki": { "diagnostic": { "link_severity": "hint" } } }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_change_via_apply_change_is_observable_in_collected_diagnostics() {
|
||||
// Contract behind `did_change_configuration`'s diagnostic re-publish:
|
||||
// once `apply_change` updates the severity, re-running `collect_diagnostics`
|
||||
// (what the handler does for every open doc) must emit broken-link
|
||||
// diagnostics at the *new* severity. Guards the data path the handler
|
||||
// depends on — apply_change writing the severity AND collect_diagnostics
|
||||
// reading it.
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use tower_lsp::lsp_types::DiagnosticSeverity;
|
||||
|
||||
let root = "/tmp/cfgchange";
|
||||
let src = "[[GhostPage]]\n";
|
||||
let ast = VimwikiSyntax::new().parse(src);
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
let uri = Url::from_file_path(format!("{root}/Home.wiki")).unwrap();
|
||||
idx.upsert(uri.clone(), &ast);
|
||||
|
||||
let mut cfg = config_from_json(json!({
|
||||
"wiki_root": root,
|
||||
"diagnostic": { "link_severity": "warn" },
|
||||
}));
|
||||
let warn = collect_diagnostics(
|
||||
&ast,
|
||||
src,
|
||||
Some(&uri),
|
||||
Some(&idx),
|
||||
Some("Home"),
|
||||
&cfg.diagnostic,
|
||||
true,
|
||||
);
|
||||
assert_eq!(warn.len(), 1, "broken link should warn");
|
||||
assert_eq!(warn[0].severity, Some(DiagnosticSeverity::WARNING));
|
||||
|
||||
// Simulate the client raising severity via didChangeConfiguration.
|
||||
cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } }));
|
||||
let err = collect_diagnostics(
|
||||
&ast,
|
||||
src,
|
||||
Some(&uri),
|
||||
Some(&idx),
|
||||
Some("Home"),
|
||||
&cfg.diagnostic,
|
||||
true,
|
||||
);
|
||||
assert_eq!(err.len(), 1);
|
||||
assert_eq!(
|
||||
err[0].severity,
|
||||
Some(DiagnosticSeverity::ERROR),
|
||||
"re-collected diagnostics must reflect the new severity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_flat_and_neovim_wrapped_payloads_desugar_identically() {
|
||||
// Parity guard for the two client shapes:
|
||||
// * Vim (`autoload/nuwiki/lsp.vim`) → flat init_options dict.
|
||||
// * Neovim (`lua/nuwiki/lsp.lua`) → `{ nuwiki = { … } }` for
|
||||
// `workspace/didChangeConfiguration` (server_settings()).
|
||||
// Both must produce the same wikis; otherwise switching editors would
|
||||
// silently change how the server resolves links/diary/HTML paths.
|
||||
let inner = json!({
|
||||
"wiki_root": "/tmp/base",
|
||||
"file_extension": ".md",
|
||||
"syntax": "vimwiki",
|
||||
"log_level": "debug",
|
||||
"diagnostic": { "link_severity": "error" },
|
||||
"wikis": [
|
||||
{
|
||||
"name": "personal",
|
||||
"root": "/tmp/personal",
|
||||
"file_extension": ".wiki",
|
||||
"index": "home",
|
||||
"diary_rel_path": "journal",
|
||||
},
|
||||
{ "name": "work", "root": "/tmp/work" },
|
||||
],
|
||||
});
|
||||
|
||||
let flat = config_from_json(inner.clone());
|
||||
let wrapped = config_from_json(json!({ "nuwiki": inner }));
|
||||
|
||||
assert_eq!(
|
||||
flat.diagnostic.link_severity,
|
||||
wrapped.diagnostic.link_severity
|
||||
);
|
||||
assert_eq!(flat.diagnostic.link_severity, LinkSeverity::Error);
|
||||
assert_eq!(flat.wikis.len(), wrapped.wikis.len());
|
||||
for (a, b) in flat.wikis.iter().zip(wrapped.wikis.iter()) {
|
||||
assert_eq!(a.name, b.name);
|
||||
assert_eq!(a.root, b.root);
|
||||
assert_eq!(a.file_extension, b.file_extension);
|
||||
assert_eq!(a.syntax, b.syntax);
|
||||
assert_eq!(a.index, b.index);
|
||||
assert_eq!(a.diary_rel_path, b.diary_rel_path);
|
||||
}
|
||||
|
||||
// And the desugared list matches the per-wiki values both clients send.
|
||||
assert_eq!(flat.wikis[0].name, "personal");
|
||||
assert_eq!(flat.wikis[0].root, PathBuf::from("/tmp/personal"));
|
||||
assert_eq!(flat.wikis[0].file_extension, ".wiki");
|
||||
assert_eq!(flat.wikis[0].index, "home");
|
||||
assert_eq!(flat.wikis[0].diary_rel_path, "journal");
|
||||
assert_eq!(flat.wikis[1].name, "work");
|
||||
assert_eq!(flat.wikis[1].root, PathBuf::from("/tmp/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_single_wiki_shape_picks_up_defaults() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wiki_root": "/tmp/legacy",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
assert_eq!(cfg.wikis[0].index, "index");
|
||||
assert_eq!(cfg.wikis[0].diary_frequency, "daily");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vimwiki_compat_payload_sets_toc_level_per_wiki() {
|
||||
// The exact shape the Vim client emits when translating g:vimwiki_list +
|
||||
// g:vimwiki_toc_header_level=2 (drop-in vimwiki compat).
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "~/vimwiki",
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal", "toc_header_level": 2, "html_header_numbering": 2 },
|
||||
{ "root": "/tmp/ifood", "toc_header_level": 2 },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 2);
|
||||
assert_eq!(cfg.wikis[0].toc_header_level, 2);
|
||||
assert_eq!(cfg.wikis[0].html.html_header_numbering, 2);
|
||||
assert_eq!(cfg.wikis[1].toc_header_level, 2);
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
//! Link-health diagnostics + TOC/links/orphans queries.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use nuwiki_lsp::config::LinkSeverity;
|
||||
use nuwiki_lsp::diagnostics::{self, BrokenLinkKind};
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use tower_lsp::lsp_types::{DiagnosticSeverity, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let ast = parse(src);
|
||||
let path = format!("{root}/{name}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
idx.upsert(uri, &ast);
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
fn home_uri(root: &str) -> Url {
|
||||
Url::from_file_path(format!("{root}/Home.wiki")).unwrap()
|
||||
}
|
||||
|
||||
// ===== severity_to_lsp =====
|
||||
|
||||
#[test]
|
||||
fn severity_off_returns_none() {
|
||||
assert!(diagnostics::severity_to_lsp(LinkSeverity::Off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_levels_round_trip() {
|
||||
assert_eq!(
|
||||
diagnostics::severity_to_lsp(LinkSeverity::Hint),
|
||||
Some(DiagnosticSeverity::HINT)
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics::severity_to_lsp(LinkSeverity::Warning),
|
||||
Some(DiagnosticSeverity::WARNING)
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics::severity_to_lsp(LinkSeverity::Error),
|
||||
Some(DiagnosticSeverity::ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
// ===== link_health: wiki targets =====
|
||||
|
||||
#[test]
|
||||
fn link_to_missing_page_warns() {
|
||||
let root = "/tmp/lh1";
|
||||
let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]);
|
||||
let src = "[[GhostPage]]\n";
|
||||
let ast = parse(src);
|
||||
let uri = home_uri(root);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&uri),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert!(diags[0].message.contains("GhostPage"));
|
||||
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_to_existing_page_silent() {
|
||||
let root = "/tmp/lh2";
|
||||
let idx = build_index(root, &[("Home", "[[Other]]\n"), ("Other", "= Other =\n")]);
|
||||
let src = "[[Other]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_with_valid_anchor_silent() {
|
||||
let root = "/tmp/lh3";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#section-one]]\n"),
|
||||
("Target", "= Target =\n== Section One ==\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#section-one]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_with_missing_anchor_warns() {
|
||||
let root = "/tmp/lh4";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#ghost-anchor]]\n"),
|
||||
("Target", "= Target =\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#ghost-anchor]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert!(diags[0].message.to_lowercase().contains("anchor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_link_resolves_against_current_page() {
|
||||
let root = "/tmp/lh5";
|
||||
let idx = build_index(root, &[("Home", "= Home =\n== Intro ==\n[[#intro]]\n")]);
|
||||
let src = "= Home =\n== Intro ==\n[[#intro]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_link_to_missing_anchor_warns() {
|
||||
let root = "/tmp/lh6";
|
||||
let idx = build_index(root, &[("Home", "[[#nowhere]]\n")]);
|
||||
let src = "[[#nowhere]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert!(diags[0].message.contains("nowhere"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_severity_off_emits_nothing() {
|
||||
let root = "/tmp/lh7";
|
||||
let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]);
|
||||
let src = "[[GhostPage]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Off,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_never_emits_diagnostic() {
|
||||
let root = "/tmp/lh8";
|
||||
let idx = build_index(root, &[("Home", "https://example.com\n")]);
|
||||
let src = "https://example.com\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Error,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_as_anchor_resolves() {
|
||||
let root = "/tmp/lh9";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Notes#release]]\n"),
|
||||
("Notes", "= Notes =\n:release:\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Notes#release]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
// ===== BrokenLinkKind classification =====
|
||||
|
||||
#[test]
|
||||
fn classify_missing_page() {
|
||||
let kind = BrokenLinkKind::MissingPage("X".into());
|
||||
assert_eq!(kind.tag(), "missing-page");
|
||||
assert!(kind.message().contains("X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_missing_anchor() {
|
||||
let kind = BrokenLinkKind::MissingAnchor {
|
||||
page: "P".into(),
|
||||
anchor: "a".into(),
|
||||
};
|
||||
assert_eq!(kind.tag(), "missing-anchor");
|
||||
assert!(kind.message().contains("a"));
|
||||
assert!(kind.message().contains("P"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_missing_file() {
|
||||
let kind = BrokenLinkKind::MissingFile(PathBuf::from("/tmp/nope.txt"));
|
||||
assert_eq!(kind.tag(), "missing-file");
|
||||
assert!(kind.message().contains("nope.txt"));
|
||||
}
|
||||
|
||||
// ===== heading_text =====
|
||||
|
||||
#[test]
|
||||
fn heading_text_strips_formatting() {
|
||||
let src = "= *Bold* heading =\n";
|
||||
let ast = parse(src);
|
||||
let h = match &ast.children[0] {
|
||||
nuwiki_core::ast::BlockNode::Heading(h) => h,
|
||||
_ => panic!("expected heading"),
|
||||
};
|
||||
let t = diagnostics::heading_text(&h.children);
|
||||
assert!(t.contains("Bold"));
|
||||
assert!(t.contains("heading"));
|
||||
}
|
||||
|
||||
// ===== TOC text generation =====
|
||||
|
||||
#[test]
|
||||
fn build_toc_text_nests_by_level() {
|
||||
let items = vec![
|
||||
(1u8, "Top".into(), "top".into()),
|
||||
(2u8, "Sub".into(), "sub".into()),
|
||||
(1u8, "Other".into(), "other".into()),
|
||||
];
|
||||
let out = ops::build_toc_text(&items, "Contents", 1, 0, 0);
|
||||
assert!(out.starts_with("= Contents =\n"));
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "= Contents =");
|
||||
assert_eq!(lines[1], "- [[#top|Top]]");
|
||||
assert_eq!(lines[2], " - [[#sub|Sub]]");
|
||||
assert_eq!(lines[3], "- [[#other|Other]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_toc_text_with_no_headings_is_just_the_heading() {
|
||||
let out = ops::build_toc_text(&[], "Contents", 1, 0, 0);
|
||||
assert_eq!(out, "= Contents =\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_link_format_1_drops_the_description() {
|
||||
// vimwiki toc_link_format: 1 = `[[#anchor]]` (no `|title`); 0 = with title.
|
||||
let items = vec![(1u8, "Top".into(), "top".into())];
|
||||
let f0 = ops::build_toc_text(&items, "Contents", 1, 0, 0);
|
||||
assert!(f0.contains("- [[#top|Top]]"), "format 0: {f0}");
|
||||
let f1 = ops::build_toc_text(&items, "Contents", 1, 0, 1);
|
||||
assert!(f1.contains("- [[#top]]"), "format 1: {f1}");
|
||||
assert!(!f1.contains("|Top"), "format 1 has no description: {f1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_links_caption_emits_first_heading() {
|
||||
use std::collections::BTreeMap;
|
||||
let pages = vec!["About".to_string(), "Notes".to_string()];
|
||||
let mut caps = BTreeMap::new();
|
||||
caps.insert("About".to_string(), "About Us".to_string());
|
||||
// Notes has no caption → falls back to bare [[Notes]].
|
||||
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, Some(&caps));
|
||||
assert!(out.contains("- [[About|About Us]]"), "captioned: {out}");
|
||||
assert!(out.contains("- [[Notes]]"), "fallback: {out}");
|
||||
// Without the caption map, both are bare.
|
||||
let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
|
||||
assert!(
|
||||
bare.contains("- [[About]]") && !bare.contains("About Us"),
|
||||
"bare: {bare}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_rebuild_edit_only_acts_when_section_present() {
|
||||
let pages = vec!["Home".to_string(), "About".to_string()];
|
||||
let uri = Url::from_file_path("/w/Home.wiki").unwrap();
|
||||
let without = "= Home =\nbody\n";
|
||||
assert!(
|
||||
ops::links_rebuild_edit(
|
||||
without,
|
||||
&parse(without),
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None
|
||||
)
|
||||
.is_none(),
|
||||
"must not insert a links section into a page that lacks one"
|
||||
);
|
||||
let with = "= Generated Links =\n- [[old]]\n";
|
||||
assert!(ops::links_rebuild_edit(
|
||||
with,
|
||||
&parse(with),
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_range_stops_at_same_level_sibling() {
|
||||
// A non-level-1 generated section (e.g. a level-2 tags index) must not
|
||||
// swallow the following same-level section when regenerated.
|
||||
let src = "== Foo ==\n- a\n== Bar ==\n- b\n";
|
||||
let ast = parse(src);
|
||||
let (start, end) = ops::find_section_range(&ast, "Foo").expect("section found");
|
||||
let bar_off = src.find("== Bar ==").unwrap();
|
||||
assert_eq!(start.offset, 0);
|
||||
assert!(
|
||||
end.offset <= bar_off,
|
||||
"section absorbed the sibling: end={} bar={}",
|
||||
end.offset,
|
||||
bar_off
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captions_honour_custom_header_and_level() {
|
||||
// toc_header="Table of Contents", level 2 → `== Table of Contents ==`.
|
||||
let out = ops::build_toc_text(&[], "Table of Contents", 2, 0, 0);
|
||||
assert_eq!(out, "== Table of Contents ==\n");
|
||||
// links_header at level 3.
|
||||
let pages = vec!["A".to_string(), "B".to_string()];
|
||||
let links = ops::build_links_text(&pages, "All Pages", 3, None, 0, None);
|
||||
assert!(links.starts_with("=== All Pages ===\n"));
|
||||
// level clamps to 1..=6.
|
||||
assert!(ops::build_toc_text(&[], "X", 9, 0, 0).starts_with("====== X ======"));
|
||||
assert!(ops::build_toc_text(&[], "X", 0, 0, 0).starts_with("= X ="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_margin_indents_generated_bullets_not_headings() {
|
||||
// vimwiki `list_margin` = leading spaces before each generated bullet.
|
||||
// The heading stays at column 0; bullets get `margin` spaces (TOC keeps
|
||||
// its per-depth nesting *after* the base margin).
|
||||
let pages = vec!["A".to_string(), "B".to_string()];
|
||||
let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2, None);
|
||||
assert!(links.contains("\n - [[A]]\n"), "2-space margin: {links:?}");
|
||||
assert!(
|
||||
links.starts_with("= Generated Links =\n"),
|
||||
"heading unindented: {links:?}"
|
||||
);
|
||||
|
||||
let toc = ops::build_toc_text(
|
||||
&[
|
||||
(1, "Top".into(), "top".into()),
|
||||
(2, "Sub".into(), "sub".into()),
|
||||
],
|
||||
"Contents",
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
);
|
||||
// Top bullet: 2-space margin; Sub bullet: margin + one nesting level.
|
||||
assert!(toc.contains("\n - [[#top|Top]]\n"), "top margin: {toc:?}");
|
||||
assert!(toc.contains("\n - [[#sub|Sub]]\n"), "nested: {toc:?}");
|
||||
}
|
||||
|
||||
// ===== Links text generation =====
|
||||
|
||||
#[test]
|
||||
fn build_links_text_excludes_current_page() {
|
||||
let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()];
|
||||
let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0, None);
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "= Generated Links =");
|
||||
assert!(lines.contains(&"- [[A]]"));
|
||||
assert!(lines.contains(&"- [[B]]"));
|
||||
assert!(!lines.iter().any(|l| l.contains("Home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_links_text_no_excludes_includes_all() {
|
||||
let pages = vec!["A".to_string(), "B".to_string()];
|
||||
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
|
||||
assert!(out.contains("[[A]]"));
|
||||
assert!(out.contains("[[B]]"));
|
||||
}
|
||||
|
||||
// ===== find_section_range =====
|
||||
|
||||
#[test]
|
||||
fn find_section_range_matches_case_insensitive() {
|
||||
let src = "= contents =\n- [[A]]\n- [[B]]\n";
|
||||
let ast = parse(src);
|
||||
let (_, _) = ops::find_section_range(&ast, "Contents").expect("found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_range_misses_when_absent() {
|
||||
let src = "= Welcome =\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::find_section_range(&ast, "Contents").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_range_matches_any_heading_level() {
|
||||
// Section replacement accepts any heading level so
|
||||
// VimwikiGenerateTagLinks stays idempotent when tag sections live
|
||||
// under non-h1 headings (e.g. `== Tag: foo ==`).
|
||||
let src = "== Contents ==\n- [[A]]\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::find_section_range(&ast, "Contents").is_some());
|
||||
}
|
||||
|
||||
// ===== toc_edit =====
|
||||
|
||||
#[test]
|
||||
fn toc_edit_inserts_at_top_when_no_existing_toc() {
|
||||
let src = "= One =\n== Two ==\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let edit =
|
||||
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let edits = &changes[&uri];
|
||||
assert_eq!(edits.len(), 1);
|
||||
let te = &edits[0];
|
||||
// Insertion at position 0,0 → zero-width range.
|
||||
assert_eq!(te.range.start, te.range.end);
|
||||
assert!(te.new_text.starts_with("= Contents =\n"));
|
||||
// Anchor is the raw heading text (vimwiki scheme), matching the heading id
|
||||
// the HTML renderer emits on export.
|
||||
assert!(te.new_text.contains("[[#One|One]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_edit_inserts_at_cursor_line() {
|
||||
// A fresh TOC goes at the cursor line (0-based) the client sends.
|
||||
let src = "= One =\nbody text\n== Two ==\nmore\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
// Cursor on line 1 ("body text").
|
||||
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(1)).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
// Zero-width insert at line 1, column 0.
|
||||
assert_eq!(te.range.start, te.range.end);
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
assert_eq!(te.range.start.character, 0);
|
||||
// A leading blank separates it from the preceding text.
|
||||
assert!(
|
||||
te.new_text.starts_with("\n= Contents ="),
|
||||
"got: {:?}",
|
||||
te.new_text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_edit_cursor_line_clamped_and_existing_replaced_in_place() {
|
||||
// An existing TOC is replaced in place regardless of the cursor line.
|
||||
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(99)).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
// Replacement starts at line 0 (the existing section), not the cursor.
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
assert!(te.new_text.contains("[[#Real|Real]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_edit_replaces_existing_toc() {
|
||||
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let edit =
|
||||
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let edits = &changes[&uri];
|
||||
let te = &edits[0];
|
||||
assert!(te.new_text.contains("[[#Real|Real]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
// The replacement range must start at line 0.
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_edit_returns_none_for_empty_doc() {
|
||||
let src = "";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/empty.wiki").unwrap();
|
||||
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_rebuild_edit_only_acts_when_toc_already_present() {
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
|
||||
// No existing TOC → rebuild-on-save must not insert one.
|
||||
let without = "= One =\n== Two ==\n";
|
||||
let ast = parse(without);
|
||||
assert!(
|
||||
ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0, 0).is_none(),
|
||||
"auto_toc should not insert a TOC where none existed"
|
||||
);
|
||||
|
||||
// Existing TOC → rebuild refreshes it.
|
||||
let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
|
||||
let ast = parse(with);
|
||||
let edit =
|
||||
ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0, 0).expect("rebuild edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("[[#Real|Real]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
}
|
||||
|
||||
// ===== links_edit =====
|
||||
|
||||
#[test]
|
||||
fn links_edit_inserts_when_section_absent() {
|
||||
let src = "Hello\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let pages = vec!["A".into(), "B".into(), "Home".into()];
|
||||
let edit = ops::links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("[[A]]"));
|
||||
assert!(te.new_text.contains("[[B]]"));
|
||||
assert!(!te.new_text.contains("[[Home]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_edit_inserts_at_cursor_line() {
|
||||
// A fresh Generated Links section goes at the cursor line, not the EOF.
|
||||
let src = "= Home =\nbody\nmore\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let pages = vec!["A".into()];
|
||||
let edit = ops::links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
Some(1),
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
assert_eq!(te.range.start.character, 0);
|
||||
assert!(
|
||||
te.new_text.starts_with("\n= Generated Links ="),
|
||||
"got: {:?}",
|
||||
te.new_text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_edit_replaces_when_section_present() {
|
||||
let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let pages = vec!["Fresh".into()];
|
||||
let edit = ops::links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("[[Fresh]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_edit_returns_none_for_empty_page_list() {
|
||||
let src = "Hi\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
assert!(ops::links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
"Home",
|
||||
&[],
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// ===== find_orphans =====
|
||||
|
||||
#[test]
|
||||
fn find_orphans_lists_pages_with_no_backlinks() {
|
||||
let root = "/tmp/orph";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[A]]\n"),
|
||||
("A", "= A =\n"),
|
||||
("B", "= B =\n"), // not linked from anywhere
|
||||
],
|
||||
);
|
||||
let orphans = ops::find_orphans(&idx);
|
||||
let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect();
|
||||
// Home + B have no backlinks. A is linked from Home.
|
||||
assert!(names.contains(&"Home"));
|
||||
assert!(names.contains(&"B"));
|
||||
assert!(!names.contains(&"A"));
|
||||
}
|
||||
|
||||
// ===== collect_wiki_links (AST walker) =====
|
||||
|
||||
#[test]
|
||||
fn collect_wiki_links_finds_nested() {
|
||||
let src = "= [[A]] =\n*[[B]]* and [[C|desc]]\n- [[D]]\n";
|
||||
let ast = parse(src);
|
||||
let links = nuwiki_lsp::diagnostics::collect_wiki_links(&ast);
|
||||
let paths: Vec<&str> = links
|
||||
.iter()
|
||||
.filter_map(|l| l.target.path.as_deref())
|
||||
.collect();
|
||||
for expected in ["A", "B", "C", "D"] {
|
||||
assert!(paths.contains(&expected), "missing {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Source-relative wiki links (vimwiki default) =====
|
||||
|
||||
#[test]
|
||||
fn wiki_link_resolves_source_relative_first() {
|
||||
let root = "/tmp/srcrel1";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("tips/index", "[[llm-wiki-pattern|LLM]]\n"),
|
||||
("tips/llm-wiki-pattern", "= LLM =\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[llm-wiki-pattern|LLM]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&Url::from_file_path(format!("{root}/tips/index.wiki")).unwrap()),
|
||||
&idx,
|
||||
"tips/index",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"expected no diagnostics, got: {:?}",
|
||||
diags
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_link_falls_back_to_root_relative() {
|
||||
// A source-relative miss should fall through to root-relative — this
|
||||
// keeps `[[posts/foo]]` from `index.wiki` working even though there's
|
||||
// no `posts/posts/foo`.
|
||||
let root = "/tmp/srcrel2";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[("index", "[[posts/foo|Foo]]\n"), ("posts/foo", "= Foo =\n")],
|
||||
);
|
||||
let src = "[[posts/foo|Foo]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"index",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_link_absolute_skips_source_relative() {
|
||||
// `[[/Page]]` is explicitly root-relative even from a subdir.
|
||||
let root = "/tmp/srcrel3";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("tips/index", "[[/RootPage]]\n"),
|
||||
("RootPage", "= Root =\n"),
|
||||
// Intentionally also create tips/RootPage so a source-relative
|
||||
// resolution would (wrongly) prefer it — the absolute form must
|
||||
// ignore source-relative.
|
||||
("tips/RootPage", "= Tips Root =\n"),
|
||||
],
|
||||
);
|
||||
let target = idx
|
||||
.resolve_wiki_path("RootPage", "tips/index", true)
|
||||
.expect("absolute link should resolve");
|
||||
assert!(
|
||||
target.as_str().ends_with("RootPage.wiki"),
|
||||
"expected root RootPage, got {target}",
|
||||
);
|
||||
assert!(
|
||||
!target.as_str().contains("/tips/RootPage"),
|
||||
"absolute link should skip tips/RootPage, got {target}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_link_parent_dir_collapses() {
|
||||
// `[[../Other]]` from `posts/foo` resolves to `Other` at the wiki root.
|
||||
let root = "/tmp/srcrel4";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("posts/foo", "[[../Other|Other]]\n"),
|
||||
("Other", "= Other =\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[../Other|Other]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&Url::from_file_path(format!("{root}/posts/foo.wiki")).unwrap()),
|
||||
&idx,
|
||||
"posts/foo",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
// ===== Anchor matching against raw heading text =====
|
||||
|
||||
#[test]
|
||||
fn anchor_matches_raw_heading_text() {
|
||||
// Users write `[[Page#Some Heading]]` (raw text), the index keys by
|
||||
// slugify form. Slugifying the request before comparison makes both
|
||||
// shapes work.
|
||||
let root = "/tmp/anchor1";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#Some Heading]]\n"),
|
||||
("Target", "= Target =\n== Some Heading ==\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#Some Heading]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_matches_unicode_heading() {
|
||||
// Real-world content: heading with em dash and accented chars.
|
||||
let root = "/tmp/anchor2";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#Homelab — VLANs]]\n"),
|
||||
("Target", "= Target =\n== Homelab — VLANs ==\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#Homelab — VLANs]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
// ===== COMMANDS completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_link_health_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.toc.generate",
|
||||
"nuwiki.links.generate",
|
||||
"nuwiki.workspace.checkLinks",
|
||||
"nuwiki.workspace.findOrphans",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Multi-wiki — interwiki resolution + picker commands.
|
||||
//!
|
||||
//! The cross-wiki resolver methods on `Backend` are exercised indirectly
|
||||
//! through `tower_lsp::LspService`'s test harness via a synthetic
|
||||
//! `Backend`. To keep this test suite
|
||||
//! lightweight we drive the *pure* resolution primitives — the parser
|
||||
//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex`
|
||||
//! lookup-by-name — and verify the multi-wiki config shape end-to-end.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, WikiId};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn first_link(src: &str) -> nuwiki_core::ast::WikiLinkNode {
|
||||
let doc = parse(src);
|
||||
for block in &doc.children {
|
||||
if let nuwiki_core::ast::BlockNode::Paragraph(p) = block {
|
||||
for inline in &p.children {
|
||||
if let InlineNode::WikiLink(w) = inline {
|
||||
return w.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("no wikilink in: {src}");
|
||||
}
|
||||
|
||||
// ===== Parser-level: `wiki<N>:` / `wn.<Name>:` produce Interwiki =====
|
||||
|
||||
#[test]
|
||||
fn numbered_interwiki_link_parses_to_wiki_index() {
|
||||
let w = first_link("[[wiki1:OtherPage]]\n");
|
||||
assert_eq!(w.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(w.target.wiki_index, Some(1));
|
||||
assert_eq!(w.target.path.as_deref(), Some("OtherPage"));
|
||||
assert!(w.target.wiki_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_interwiki_link_parses_to_wiki_name() {
|
||||
let w = first_link("[[wn.work:Project]]\n");
|
||||
assert_eq!(w.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(w.target.wiki_name.as_deref(), Some("work"));
|
||||
assert_eq!(w.target.path.as_deref(), Some("Project"));
|
||||
assert!(w.target.wiki_index.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_wikilink_is_not_interwiki() {
|
||||
let w = first_link("[[Home]]\n");
|
||||
assert_eq!(w.target.kind, LinkKind::Wiki);
|
||||
assert!(w.target.wiki_index.is_none());
|
||||
assert!(w.target.wiki_name.is_none());
|
||||
}
|
||||
|
||||
// ===== Config: `wikis = [...]` shape =====
|
||||
|
||||
#[test]
|
||||
fn multi_wiki_config_loads_two_entries() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal", "name": "personal" },
|
||||
{ "root": "/tmp/work", "name": "work" },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 2);
|
||||
assert_eq!(cfg.wikis[0].name, "personal");
|
||||
assert_eq!(cfg.wikis[1].name, "work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_0_single_root_still_works_alongside_multi_wiki_shape() {
|
||||
// Single-wiki shorthand is the legacy form; ensure it still desugars.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wiki_root": "/tmp/legacy",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/legacy"));
|
||||
}
|
||||
|
||||
// ===== Wiki aggregate + URI routing =====
|
||||
|
||||
#[test]
|
||||
fn build_wikis_assigns_sequential_ids_starting_at_zero() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/p" },
|
||||
{ "root": "/tmp/q" },
|
||||
{ "root": "/tmp/r" },
|
||||
],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
assert_eq!(wikis.len(), 3);
|
||||
for (i, w) in wikis.iter().enumerate() {
|
||||
assert_eq!(w.id, WikiId(i as u32));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uri_to_wiki_routes_to_the_owning_root() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal" },
|
||||
{ "root": "/tmp/work" },
|
||||
],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
let uri = Url::from_file_path("/tmp/work/Project.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_root_picks_longest_prefix_match() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/notes" },
|
||||
{ "root": "/notes/sub" },
|
||||
],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
let uri = Url::from_file_path("/notes/sub/page.wiki").unwrap();
|
||||
// The nested wiki wins because its root is a longer prefix.
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
|
||||
}
|
||||
|
||||
// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) =====
|
||||
|
||||
/// Verify the cross-wiki resolution semantics:
|
||||
/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not
|
||||
/// the source wiki's. The pure-data check here mirrors what
|
||||
/// `Backend::resolve_target_uri` does.
|
||||
#[test]
|
||||
fn interwiki_resolution_uses_target_wiki_index() {
|
||||
// Build two wikis with overlapping page names so we can tell whose
|
||||
// index was consulted.
|
||||
let mut work = WorkspaceIndex::new(Some(PathBuf::from("/tmp/work")));
|
||||
work.upsert(
|
||||
Url::from_file_path("/tmp/work/Project.wiki").unwrap(),
|
||||
&parse("= Work Project =\n"),
|
||||
);
|
||||
|
||||
let mut personal = WorkspaceIndex::new(Some(PathBuf::from("/tmp/personal")));
|
||||
personal.upsert(
|
||||
Url::from_file_path("/tmp/personal/Project.wiki").unwrap(),
|
||||
&parse("= Personal Project =\n"),
|
||||
);
|
||||
|
||||
// Simulate the resolver: source is personal, target is `wiki1:Project`
|
||||
// which should hit the work index (assumed to be index 1).
|
||||
let w = first_link("[[wiki1:Project]]\n");
|
||||
assert_eq!(w.target.wiki_index, Some(1));
|
||||
let resolved = work.pages_by_name.get("Project").cloned();
|
||||
assert!(resolved.is_some(), "work wiki should resolve Project");
|
||||
assert!(
|
||||
resolved
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.contains("/tmp/work/Project.wiki"),
|
||||
"must come from the work wiki, not personal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_resolution_falls_back_when_wiki_missing() {
|
||||
// Only one wiki registered; `[[wiki2:X]]` references the third
|
||||
// wiki by index — no match expected.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [ { "root": "/tmp/only" } ],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
// Index 2 is out of range.
|
||||
assert!(wikis.get(2).is_none());
|
||||
}
|
||||
|
||||
// ===== Wiki commands: COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_wiki_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.wiki.listAll",
|
||||
"nuwiki.wiki.select",
|
||||
"nuwiki.wiki.openIndex",
|
||||
"nuwiki.wiki.tabOpenIndex",
|
||||
"nuwiki.wiki.gotoPage",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Multi-wiki link kind matrix =====
|
||||
|
||||
#[test]
|
||||
fn link_kind_matrix_for_typical_targets() {
|
||||
let cases = &[
|
||||
("[[Home]]", LinkKind::Wiki),
|
||||
("[[/Page]]", LinkKind::Wiki), // root-relative still Wiki
|
||||
("[[//abs/path]]", LinkKind::Local), // filesystem-absolute
|
||||
("[[wiki1:X]]", LinkKind::Interwiki),
|
||||
("[[wn.work:Y]]", LinkKind::Interwiki),
|
||||
("[[file:foo.pdf]]", LinkKind::File),
|
||||
("[[local:foo.pdf]]", LinkKind::Local),
|
||||
("[[diary:2026-05-11]]", LinkKind::Diary),
|
||||
("[[#anchor]]", LinkKind::AnchorOnly),
|
||||
];
|
||||
for (src, expected) in cases {
|
||||
let w = first_link(&format!("{src}\n"));
|
||||
assert_eq!(w.target.kind, *expected, "case: {src}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Tests for the navigation pieces: WorkspaceIndex queries,
|
||||
//! page-name derivation, anchor slugify, position lookup, and link
|
||||
//! resolution. The async LSP handlers are exercised through these
|
||||
//! helpers; end-to-end JSON-RPC drive lives elsewhere.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::index::{page_name_from_uri, slugify, WorkspaceIndex};
|
||||
use nuwiki_lsp::nav::{find_inline_at, lsp_to_byte_pos};
|
||||
use tower_lsp::lsp_types::{Position as LspPosition, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
// ===== Slugify =====
|
||||
|
||||
#[test]
|
||||
fn slugify_basic_title() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_strips_punctuation_and_collapses_dashes() {
|
||||
assert_eq!(slugify("Hello, World! Again"), "hello-world-again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_unicode_lowercase() {
|
||||
assert_eq!(slugify("Crème Brûlée"), "crème-brûlée");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_no_trailing_dash() {
|
||||
assert_eq!(slugify("Title — "), "title");
|
||||
}
|
||||
|
||||
// ===== Page name from URI =====
|
||||
|
||||
#[test]
|
||||
fn page_name_relative_to_root() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let uri = Url::from_file_path("/wiki/dir/Page.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&root)), "dir/Page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_at_root() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let uri = Url::from_file_path("/wiki/Index.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&root)), "Index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_without_root_falls_back_to_stem() {
|
||||
let uri = Url::from_file_path("/tmp/foo.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, None), "foo");
|
||||
}
|
||||
|
||||
// ===== Workspace index =====
|
||||
|
||||
#[test]
|
||||
fn upsert_indexes_headings_and_outgoing_links() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let ast = parse("= Home =\n[[Other]] and [[#Section]]\n");
|
||||
idx.upsert(uri.clone(), &ast);
|
||||
|
||||
let page = idx.page(&uri).expect("page indexed");
|
||||
assert_eq!(page.name, "Home");
|
||||
assert_eq!(page.headings.len(), 1);
|
||||
assert_eq!(page.headings[0].title, "Home");
|
||||
assert_eq!(page.headings[0].anchor, "home");
|
||||
assert_eq!(page.outgoing.len(), 2);
|
||||
assert_eq!(page.outgoing[0].target_page.as_deref(), Some("Other"));
|
||||
assert_eq!(page.outgoing[0].kind, LinkKind::Wiki);
|
||||
assert_eq!(page.outgoing[1].kind, LinkKind::AnchorOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_registers_page_by_name_and_records_backlinks() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
|
||||
idx.upsert(home.clone(), &parse("[[About]]\n"));
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
assert_eq!(idx.page_by_name("About").unwrap().uri, about);
|
||||
|
||||
let backlinks = idx.backlinks_for("About");
|
||||
assert_eq!(backlinks.len(), 1);
|
||||
assert_eq!(backlinks[0].source_uri, home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_prior_indexing_for_same_uri() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse("[[A]]\n"));
|
||||
assert_eq!(idx.backlinks_for("A").len(), 1);
|
||||
|
||||
// Re-upsert with different content: old backlinks must drop.
|
||||
idx.upsert(uri.clone(), &parse("[[B]]\n"));
|
||||
assert_eq!(idx.backlinks_for("A").len(), 0);
|
||||
assert_eq!(idx.backlinks_for("B").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_page_and_backlinks() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse("[[About]]\n"));
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
idx.remove(&home);
|
||||
assert!(idx.page(&home).is_none());
|
||||
assert!(idx.backlinks_for("About").is_empty());
|
||||
// The target page is untouched.
|
||||
assert!(idx.page(&about).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_wiki_link_to_uri() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
let target = nuwiki_core::ast::LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("About".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&about));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_anchor_only_link_returns_source_page() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse("= H =\n[[#Section]]\n"));
|
||||
|
||||
let target = nuwiki_core::ast::LinkTarget {
|
||||
kind: LinkKind::AnchorOnly,
|
||||
anchor: Some("section".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&home));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_names_lists_indexed_pages_sorted() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
for name in ["Charlie", "Alpha", "Bravo"] {
|
||||
let uri = Url::from_file_path(format!("/wiki/{name}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse(&format!("= {name} =\n")));
|
||||
}
|
||||
let names = idx.page_names();
|
||||
assert_eq!(names, vec!["Alpha", "Bravo", "Charlie"]);
|
||||
}
|
||||
|
||||
// ===== Position conversion =====
|
||||
|
||||
#[test]
|
||||
fn lsp_to_byte_pos_passthrough_in_utf8() {
|
||||
let p = LspPosition {
|
||||
line: 2,
|
||||
character: 5,
|
||||
};
|
||||
let (line, col) = lsp_to_byte_pos(p, "ignored", true);
|
||||
assert_eq!((line, col), (2, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_to_byte_pos_converts_from_utf16() {
|
||||
// 'é' is one UTF-16 code unit, two UTF-8 bytes. Char "2" after 'é':
|
||||
// UTF-16 col 2 → byte col 3.
|
||||
let text = "héllo\n";
|
||||
let p = LspPosition {
|
||||
line: 0,
|
||||
character: 2,
|
||||
};
|
||||
let (line, col) = lsp_to_byte_pos(p, text, false);
|
||||
assert_eq!(line, 0);
|
||||
assert_eq!(col, 3);
|
||||
}
|
||||
|
||||
// ===== Position lookup =====
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_wikilink_under_cursor() {
|
||||
let src = "see [[Other]] now\n";
|
||||
let doc = parse(src);
|
||||
// Cursor inside "Other" — byte col 6 falls inside "[[Other]]" (which
|
||||
// starts at byte 4 and ends at byte 13).
|
||||
let node = find_inline_at(&doc, 0, 6).expect("wikilink at cursor");
|
||||
assert!(matches!(node, InlineNode::WikiLink(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_wikilink_when_cursor_on_description() {
|
||||
// Regression: pressing <CR> on the *title* part of a piped link used to
|
||||
// descend into the description's Text node, so goto-definition saw a Text
|
||||
// (not a WikiLink) and reported "No locations found".
|
||||
let src = "see [[Other|My Title]] now\n";
|
||||
let doc = parse(src);
|
||||
// Byte col 16 is inside "Title" (the description), past the '|' at 11.
|
||||
let node = find_inline_at(&doc, 0, 16).expect("wikilink at cursor");
|
||||
assert!(matches!(node, InlineNode::WikiLink(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_none_for_plain_text() {
|
||||
let src = "just text\n";
|
||||
let doc = parse(src);
|
||||
let node = find_inline_at(&doc, 0, 3);
|
||||
// "just text" → Text node, which is what we get back from the inner
|
||||
// walk (not None). Verify it's a Text.
|
||||
assert!(matches!(node, Some(InlineNode::Text(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_descends_into_inline_containers() {
|
||||
let src = "*bold text*\n";
|
||||
let doc = parse(src);
|
||||
// Inside "bold" — should return the inner Text, not the wrapping Bold.
|
||||
let node = find_inline_at(&doc, 0, 3).expect("hit");
|
||||
assert!(matches!(node, InlineNode::Text(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_none_past_eol() {
|
||||
let src = "short\n";
|
||||
let doc = parse(src);
|
||||
assert!(find_inline_at(&doc, 0, 999).is_none());
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Tests for the semantic-token builder, encoder, range filter, and
|
||||
//! UTF-8/UTF-16 conversion.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::semantic_tokens::{
|
||||
build_data, collect_tokens, encode, legend, LineIndex, RawToken, MOD_CENTERED, MOD_LEVEL1,
|
||||
MOD_LEVEL2, TOKEN_BOLD, TOKEN_CODE, TOKEN_CODE_BLOCK, TOKEN_COMMENT, TOKEN_DEFINITION_TERM,
|
||||
TOKEN_HEADING, TOKEN_ITALIC, TOKEN_KEYWORD, TOKEN_MATH_BLOCK, TOKEN_TRANSCLUSION,
|
||||
TOKEN_TYPE_NAMES, TOKEN_URL, TOKEN_WIKILINK,
|
||||
};
|
||||
use tower_lsp::lsp_types::{Position, Range};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn first_with_kind(tokens: &[RawToken], kind: u32) -> Option<&RawToken> {
|
||||
tokens.iter().find(|t| t.kind == kind)
|
||||
}
|
||||
|
||||
// ===== Legend =====
|
||||
|
||||
#[test]
|
||||
fn legend_lists_all_custom_token_types() {
|
||||
let l = legend();
|
||||
assert_eq!(l.token_types.len(), TOKEN_TYPE_NAMES.len());
|
||||
let names: Vec<String> = l
|
||||
.token_types
|
||||
.iter()
|
||||
.map(|t| t.as_str().to_owned())
|
||||
.collect();
|
||||
assert!(names.contains(&"vimwikiHeading".to_string()));
|
||||
assert!(names.contains(&"vimwikiWikilink".to_string()));
|
||||
assert!(names.contains(&"vimwikiTransclusion".to_string()));
|
||||
}
|
||||
|
||||
// ===== Per-construct emission =====
|
||||
|
||||
#[test]
|
||||
fn heading_emits_one_token_with_level_modifier() {
|
||||
let doc = parse("== Hi ==\n");
|
||||
let toks = collect_tokens(&doc, "== Hi ==\n");
|
||||
let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token");
|
||||
assert_eq!(h.mods & MOD_LEVEL2, MOD_LEVEL2);
|
||||
assert_eq!(h.mods & MOD_CENTERED, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_carries_centered_modifier() {
|
||||
let src = " = Title =\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token");
|
||||
assert_eq!(h.mods & MOD_LEVEL1, MOD_LEVEL1);
|
||||
assert_eq!(h.mods & MOD_CENTERED, MOD_CENTERED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_does_not_emit_inner_inline_tokens() {
|
||||
// Headings render as a single styled span, so bold inside is shadowed.
|
||||
let src = "= Hi *bold* =\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_HEADING));
|
||||
assert!(
|
||||
!toks.iter().any(|t| t.kind == TOKEN_BOLD),
|
||||
"should not have emitted Bold inside a Heading"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_emits_inline_tokens_only() {
|
||||
let src = "see *bold* and `code`\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_BOLD));
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_CODE));
|
||||
assert!(!toks.iter().any(|t| t.kind == TOKEN_HEADING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italic_token_for_underscored_run() {
|
||||
let src = "_emphasis_\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_ITALIC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_token_for_each_keyword() {
|
||||
let src = "TODO write tests, FIXME later\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let kw_count = toks.iter().filter(|t| t.kind == TOKEN_KEYWORD).count();
|
||||
assert_eq!(kw_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_and_external_link_get_different_types() {
|
||||
let src = "[[Page]] and [[https://example.com|docs]]\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_WIKILINK));
|
||||
assert!(
|
||||
toks.iter()
|
||||
.any(|t| t.kind == nuwiki_lsp::semantic_tokens::TOKEN_EXTERNAL_LINK),
|
||||
"URL inside [[...]] should be vimwikiExternalLink, got {toks:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_emits_url_token() {
|
||||
let src = "see https://example.com here\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_URL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_emits_transclusion_token() {
|
||||
let src = "{{img.png|alt}}\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_TRANSCLUSION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_token_for_single_line_comment() {
|
||||
let src = "%% hidden\nstuff\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_COMMENT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_term_token() {
|
||||
let src = "Term:: Definition\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_DEFINITION_TERM));
|
||||
}
|
||||
|
||||
// ===== Multi-line fences =====
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_splits_into_one_token_per_line() {
|
||||
let src = "{{{rust\nlet x = 1;\nlet y = 2;\n}}}\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let code_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_CODE_BLOCK).collect();
|
||||
// Spans line 0 (open fence) through line 3 (close fence) = 4 lines, but
|
||||
// empty trailing slices are dropped, so we expect 4 line tokens.
|
||||
assert!(
|
||||
code_tokens.len() >= 3,
|
||||
"expected multi-line split, got {code_tokens:?}"
|
||||
);
|
||||
// No token should cross a line boundary.
|
||||
for t in &code_tokens {
|
||||
assert!(t.byte_len > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_block_splits_into_one_token_per_line() {
|
||||
let src = "{{$\nx=1\ny=2\n}}$\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let math_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_MATH_BLOCK).collect();
|
||||
assert!(math_tokens.len() >= 3);
|
||||
}
|
||||
|
||||
// ===== Sorting =====
|
||||
|
||||
#[test]
|
||||
fn tokens_are_sorted_by_line_then_column() {
|
||||
let src = "= H =\n*bold* and _italic_\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
for w in toks.windows(2) {
|
||||
let a = (w[0].line, w[0].byte_col);
|
||||
let b = (w[1].line, w[1].byte_col);
|
||||
assert!(a <= b, "tokens not sorted: {a:?} then {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Encoding =====
|
||||
|
||||
#[test]
|
||||
fn encode_produces_correct_delta_quintuples() {
|
||||
let src = "= H =\n*bold*\n";
|
||||
let raws = vec![
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 0,
|
||||
byte_len: 5,
|
||||
kind: TOKEN_HEADING,
|
||||
mods: MOD_LEVEL1,
|
||||
},
|
||||
RawToken {
|
||||
line: 1,
|
||||
byte_col: 0,
|
||||
byte_len: 6,
|
||||
kind: TOKEN_BOLD,
|
||||
mods: 0,
|
||||
},
|
||||
];
|
||||
let idx = LineIndex::new(src);
|
||||
let data = encode(&raws, src, &idx, true);
|
||||
assert_eq!(data.len(), 10);
|
||||
// First token: delta_line=0, delta_col=0, len=5, type=HEADING, mods=LEVEL1
|
||||
assert_eq!(&data[0..5], &[0, 0, 5, TOKEN_HEADING, MOD_LEVEL1]);
|
||||
// Second token: delta_line=1 (new line), delta_col=0 (absolute), len=6, type=BOLD, mods=0
|
||||
assert_eq!(&data[5..10], &[1, 0, 6, TOKEN_BOLD, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_uses_relative_column_within_same_line() {
|
||||
let src = "abc def ghi\n";
|
||||
let raws = vec![
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 0,
|
||||
byte_len: 3,
|
||||
kind: TOKEN_BOLD,
|
||||
mods: 0,
|
||||
},
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 4,
|
||||
byte_len: 3,
|
||||
kind: TOKEN_ITALIC,
|
||||
mods: 0,
|
||||
},
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 8,
|
||||
byte_len: 3,
|
||||
kind: TOKEN_CODE,
|
||||
mods: 0,
|
||||
},
|
||||
];
|
||||
let idx = LineIndex::new(src);
|
||||
let data = encode(&raws, src, &idx, true);
|
||||
assert_eq!(&data[0..5], &[0, 0, 3, TOKEN_BOLD, 0]);
|
||||
// Second: delta_col = 4 - 0 = 4
|
||||
assert_eq!(&data[5..10], &[0, 4, 3, TOKEN_ITALIC, 0]);
|
||||
// Third: delta_col = 8 - 4 = 4
|
||||
assert_eq!(&data[10..15], &[0, 4, 3, TOKEN_CODE, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_converts_to_utf16_for_multibyte_chars() {
|
||||
// "_é_" — italic delimiters around é. Span covers 4 bytes (1 + 2 + 1)
|
||||
// but 3 UTF-16 code units.
|
||||
let src = "_é_\n";
|
||||
let raws = vec![RawToken {
|
||||
line: 0,
|
||||
byte_col: 0,
|
||||
byte_len: 4,
|
||||
kind: TOKEN_ITALIC,
|
||||
mods: 0,
|
||||
}];
|
||||
let idx = LineIndex::new(src);
|
||||
let data = encode(&raws, src, &idx, false);
|
||||
// length should be in UTF-16 code units (3), not bytes (4).
|
||||
assert_eq!(data[2], 3);
|
||||
}
|
||||
|
||||
// ===== Range filter =====
|
||||
|
||||
#[test]
|
||||
fn range_filter_keeps_only_overlapping_tokens() {
|
||||
let src = "= L1 =\n= L2 =\n= L3 =\n";
|
||||
let doc = parse(src);
|
||||
// Restrict to line 1 only.
|
||||
let range = Range {
|
||||
start: Position {
|
||||
line: 1,
|
||||
character: 0,
|
||||
},
|
||||
end: Position {
|
||||
line: 2,
|
||||
character: 0,
|
||||
},
|
||||
};
|
||||
let data = build_data(&doc, src, true, Some(range));
|
||||
// Expect exactly 1 heading token (the L2 one).
|
||||
assert_eq!(data.len(), 5, "got {data:?}");
|
||||
// Verify it's on line 1 (delta_line = 1 from prev_line = 0).
|
||||
assert_eq!(data[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_produces_empty_token_stream() {
|
||||
let doc = parse("");
|
||||
let data = build_data(&doc, "", true, None);
|
||||
assert!(data.is_empty());
|
||||
}
|
||||
|
||||
// ===== End-to-end through builder =====
|
||||
|
||||
#[test]
|
||||
fn build_data_matches_collect_then_encode() {
|
||||
let src = "= H1 =\n_em_ TODO\n";
|
||||
let doc = parse(src);
|
||||
let direct = build_data(&doc, src, true, None);
|
||||
let raws = collect_tokens(&doc, src);
|
||||
let idx = LineIndex::new(src);
|
||||
let manual = encode(&raws, src, &idx, true);
|
||||
assert_eq!(direct, manual);
|
||||
}
|
||||
Reference in New Issue
Block a user