feat: initial nuwiki Rust workspace (core, lsp, ls)
CI / cargo fmt --check (push) Successful in 59s
CI / cargo clippy (push) Successful in 1m20s
CI / cargo test (push) Successful in 1m29s
CI / editor keymaps (push) Failing after 33s

This commit is contained in:
gffranco
2026-06-24 00:01:59 +00:00
commit 29a4efb6bc
70 changed files with 26264 additions and 0 deletions
+192
View File
@@ -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,
}
+188
View File
@@ -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>,
}
+66
View File
@@ -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,
}
+54
View File
@@ -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>,
}
+33
View File
@@ -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 }
}
}
+230
View File
@@ -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);
}
}
+541
View File
@@ -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,
}
}
+10
View File
@@ -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;
+198
View File
@@ -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
+26
View File
@@ -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))
}
}
+115
View File
@@ -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;
}
+58
View File
@@ -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