diff --git a/crates/block.rs b/crates/block.rs new file mode 100644 index 0000000..fb59e98 --- /dev/null +++ b/crates/block.rs @@ -0,0 +1,196 @@ +//! 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, + pub centered: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParagraphNode { + pub span: Span, + pub children: Vec, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreformattedNode { + pub span: Span, + pub content: String, + pub language: Option, + /// Verbatim text after the opening `{{{`, trailing whitespace trimmed + /// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as + /// raw attributes on the `
` tag, so we preserve it for byte-parity.
+    pub attrs: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MathBlockNode {
+    pub span: Span,
+    pub content: String,
+    pub environment: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ListNode {
+    pub span: Span,
+    pub ordered: bool,
+    pub symbol: ListSymbol,
+    pub items: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ListItemNode {
+    pub span: Span,
+    pub symbol: ListSymbol,
+    pub level: usize,
+    pub checkbox: Option,
+    pub children: Vec,
+    pub sublist: Option,
+}
+
+#[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,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DefinitionItemNode {
+    pub span: Span,
+    pub term: Option>,
+    pub definitions: Vec>,
+}
+
+#[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,
+    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,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TableRowNode {
+    pub span: Span,
+    pub cells: Vec,
+    pub is_header: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TableCellNode {
+    pub span: Span,
+    pub children: Vec,
+    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,
+    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,
+}
diff --git a/crates/export.rs b/crates/export.rs
new file mode 100644
index 0000000..73c0cb6
--- /dev/null
+++ b/crates/export.rs
@@ -0,0 +1,591 @@
+//! 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 = 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. `/.` if set.
+///   2. `/.`.
+///   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,
+    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#"
+
+
+
+{{{{title}}}}
+
+
+
+{{{{content}}}}
+
+
+"#
+    )
+}
+
+/// 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 `
    ` 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("
      "); + let mut depth = 0usize; + for h in &headings { + let target = h.level.saturating_sub(min_level) as usize; + while depth < target { + out.push_str("
        "); + depth += 1; + } + while depth > target { + out.push_str("
      "); + 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!("
    • {anchor}
    • ")); + } + while depth > 0 { + out.push_str("
    "); + depth -= 1; + } + out.push_str("
"); + 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 ``/`\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("H2O"), "sub verbatim: {html}"); + assert!(html.contains("Ctrl"), "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
. + 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(" 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
beta"), "para
: {br}"); + assert!( + br.contains("one
two") || br.contains("one
two"), + "list
: {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}}{{content}}
{{date}}
"; + 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("My Page")); + assert!(html.contains("
2026-05-11
")); + 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( + "" + ), + "got: {html}" + ); + assert!( + html.contains( + "" + ), + "got: {html}" + ); + assert!( + html.contains( + "" + ), + "got: {html}" + ); + assert!( + html.contains( + "" + ), + "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 unnumbered, got: {html}" + ); + assert!( + html.contains( + "" + ), + "got: {html}" + ); + assert!( + html.contains( + "" + ), + "got: {html}" + ); +} + +#[test] +fn render_page_html_same_page_anchor_uses_current_filename() { + // vimwiki parity (#4): `[[#Section]]` resolves to `.html#Section`, + // not a bare `#Section`, where is the file being rendered. + let ast = parse("= Contents =\n\n[[#Contents]]\n"); + let c = cfg("/tmp/x"); + let html = export::render_page_html( + &ast, + None, + "index", + DiaryDate::from_ymd(2026, 5, 11).unwrap(), + &c.html, + None, + HashMap::new(), + ) + .unwrap(); + assert!( + html.contains(""), + "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( + "" + ), + "got: {html}" + ); + assert!( + html.contains( + "" + ), + "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%\ +\ +%content%
%date%
"; + 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("My Page"), "title: {html}"); + assert!(html.contains("body text"), "content: {html}"); + assert!( + html.contains("href=\"../style.css\""), + "root_path+css: {html}" + ); + assert!(html.contains("
2026-05-11
"), "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"), + "{{title}}|{{content}}", + ) + .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("T|")); + assert!(body.ends_with("")); +} + +#[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("Test Wiki")); + // 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("file:///tmp/diary/2026-05-11.wiki")); +} + +#[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("https://example.com/wiki/diary/diary.html"), + "channel link: {xml}" + ); + assert!(xml.contains("https://example.com/wiki/diary/2026-05-11.html")); + // guid is the bare date, isPermaLink=false. + assert!( + xml.contains("2026-05-11"), + "guid: {xml}" + ); + // atom:link self + a pubDate are present. + assert!(xml.contains("rel=\"self\""), "atom:link: {xml}"); + assert!(xml.contains(""), "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"), "

Home

").unwrap(); + // An orphan (no source) → pruned. + std::fs::write(html.join("Ghost.html"), "

Ghost

").unwrap(); + // A user_htmls-protected orphan → kept. + std::fs::write(html.join("keep.html"), "

manual

").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 = (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("").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}"); + } +} diff --git a/crates/html_renderer.rs b/crates/html_renderer.rs new file mode 100644 index 0000000..7ab0598 --- /dev/null +++ b/crates/html_renderer.rs @@ -0,0 +1,597 @@ +//! 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>, has_header: bool) -> DocumentNode { + let span = Span::default(); + let rows: Vec = 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")); + // vimwiki structure:
+ //
. A lone heading has hier == flat. + assert!(out.starts_with(&format!( + "" + )), "got: {out}"); + } +} + +#[test] +fn centered_heading_gets_centered_class() { + let out = render(" = T =\n"); + assert!(out.contains("

T

")); +} + +#[test] +fn heading_keyword_renders_as_plain_text_not_badge() { + // `== TODO ==` is a section title, not an inline keyword: it must render as + // a normal header (with a correct, non-empty id), not a `` + // badge. Regression for the exported header losing its header styling + id. + let out = render("== TODO ==\n"); + assert!( + out.contains( + "

\ + TODO

" + ), + "got: {out}" + ); + assert!(!out.contains("class=\"todo\""), "no keyword badge: {out}"); + + // A keyword mid-title keeps its text in the id (no dropped word / double + // space) and still renders plainly. + let out2 = render("= My TODO list =\n"); + assert!(out2.contains("id=\"My TODO list\""), "got: {out2}"); + assert!(!out2.contains("class=\"todo\""), "got: {out2}"); +} + +#[test] +fn wikilink_description_keeps_inner_brackets() { + // A `]` inside a wikilink description (e.g. a `[TICKET-1]`) must not close + // the link early. Regression for descriptions with brackets being mangled. + let out = render("[[page|Task [B-1]]]\n"); + assert!( + out.contains("Task [B-1]"), + "got: {out}" + ); + // Two bracketed links on one line both resolve. + let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n"); + assert!(out2.contains("X [1]"), "got: {out2}"); + assert!(out2.contains("Y [2]"), "got: {out2}"); +} + +#[test] +fn paragraph_wraps_inline_content() { + let out = render("Hello world\n"); + assert_eq!(out.trim(), "

Hello world

"); +} + +#[test] +fn horizontal_rule() { + let out = render("----\n"); + assert!(out.contains("
")); +} + +#[test] +fn blockquote_lines_become_paragraphs() { + let out = render("> first\n> second\n"); + assert!(out.contains("
")); + assert!(out.contains("

first

")); + assert!(out.contains("

second

")); +} + +#[test] +fn preformatted_block_emits_pre_with_raw_fence_attrs() { + // vimwiki drops the verbatim fence text into the
 tag (`{{{rust` →
+    // `
`), with no inner  wrapper.
+    let out = render("{{{rust\nfn main() {}\n}}}\n");
+    assert!(out.contains("
"), "got: {out}");
+    assert!(out.contains("fn main() {}"));
+    assert!(out.contains("
")); + assert!(!out.contains("plain"), "got: {out}"); +} + +#[test] +fn preformatted_block_keeps_full_attr_string() { + let out = render("{{{class=\"brush: python\"\nx\n}}}\n"); + assert!(out.contains("
"), "got: {out}");
+}
+
+#[test]
+fn math_block_emits_div() {
+    let out = render("{{$\nx=1\n}}$\n");
+    assert!(out.contains("
")); + assert!(out.contains("x=1")); +} + +#[test] +fn unordered_list_with_checkbox() { + let out = render("- [X] done\n- not done\n"); + assert!(out.contains("
    ")); + // vimwiki-compatible: class on the
  • , no (the stylesheet draws it). + assert!(out.contains("
  • done
  • "), "{out}"); + assert!(out.contains("
  • not done
  • "), "{out}"); + assert!(!out.contains("a"), "{out}"); + assert!(out.contains("
  • b
  • "), "{out}"); + assert!(out.contains("
  • c
  • "), "{out}"); + assert!(out.contains("
  • d
  • "), "{out}"); + assert!(out.contains("
  • e
  • "), "{out}"); + assert!(out.contains("
  • f
  • "), "{out}"); + assert!(!out.contains("task-"), "no legacy task-* classes: {out}"); + assert!(!out.contains("")); + assert!(out.contains("
  • first
  • ")); +} + +#[test] +fn nested_list_renders_recursively() { + let out = render("- top\n - nested\n"); + assert!(out.contains("
      ")); + let inner_idx = out + .find("
        \n
      • top") + .map(|i| i + "
          \n
        • top".len()) + .unwrap_or(0); + assert!(out[inner_idx..].contains("
            ")); + assert!(out.contains("nested")); +} + +#[test] +fn definition_list() { + let out = render("Term:: Defn\n"); + assert!(out.contains("
            ")); + assert!(out.contains("
            Term
            ")); + assert!(out.contains("
            Defn
            ")); +} + +#[test] +fn table_with_header_separator() { + let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n"); + assert!(out.contains("")); + assert!(out.contains("")); + assert!(out.contains("")); + assert!(out.contains("")); + assert!(out.contains("")); +} + +#[test] +fn single_comment_becomes_html_comment() { + let out = render("%% hidden\n"); + assert!(out.contains("")); +} + +// ===== Inline ===== + +#[test] +fn bold_italic_strike_super_sub() { + let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n"); + assert!(out.contains("b")); + assert!(out.contains("i")); + assert!(out.contains("s")); + assert!(out.contains("sup")); + assert!(out.contains("sub")); +} + +#[test] +fn inline_code_is_escaped() { + let out = render("Use `Vec` here\n"); + assert!(out.contains("Vec<u32>")); +} + +#[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", "TODO"), + ("DONE x\n", "DONE"), + ("STARTED x\n", "STARTED"), + ("FIXME x\n", "FIXME"), + ("FIXED x\n", "FIXED"), + ("XXX x\n", "XXX"), + ("STOPPED x\n", "STOPPED"), + ]; + 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("https://example.com")); +} + +#[test] +fn wikilink_with_default_resolver() { + let out = render("[[Page]]\n"); + assert!(out.contains("Page")); +} + +#[test] +fn wikilink_with_anchor_default_resolver() { + let out = render("[[Page#Section]]\n"); + assert!(out.contains("")); +} + +#[test] +fn anchor_only_link() { + let out = render("[[#Section]]\n"); + assert!(out.contains("")); +} + +#[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( + "" + ), + "heading id: {out}" + ); + assert!( + out.contains(""), + "anchor href: {out}" + ); +} + +#[test] +fn toc_header_heading_wrapped_in_div_toc() { + // The TOC section heading gets a `
            ` 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("

            Contents

            "), + "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( + "
            " + ), + "got: {out}" + ); + assert!(!out.contains("class=\"toc\""), "got: {out}"); +} + +#[test] +fn interwiki_numbered_link() { + let out = render("[[wiki1:Page]]\n"); + assert!(out.contains("")); +} + +#[test] +fn interwiki_named_link() { + let out = render("[[wn.Notes:Page]]\n"); + assert!(out.contains("")); +} + +#[test] +fn diary_link() { + let out = render("[[diary:2026-05-10]]\n"); + assert!(out.contains("")); +} + +#[test] +fn external_link_routes_to_external_link_node() { + let out = render("[[https://example.com|docs]]\n"); + assert!(out.contains("docs")); +} + +#[test] +fn transclusion_becomes_img() { + let out = render("{{img.png|cat photo|class=hi}}\n"); + assert!(out.contains(" 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("")); +} + +// ===== 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( + "{{title}}\ + {{content}}", + ); + let out = renderer.render_to_string(&doc).unwrap(); + assert!(out.contains("My Page")); + assert!(out.contains("")); +} + +#[test] +fn template_with_missing_title_substitutes_empty_string() { + let doc = VimwikiSyntax::new().parse("= Heading =\n"); + let renderer = HtmlRenderer::new().with_template("{{title}}{{content}}"); + let out = renderer.render_to_string(&doc).unwrap(); + assert!(out.contains("")); + assert!(out.contains("")); +} + +#[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%%content%", + ) + .with_var("root_path", "../"); + let out = renderer.render_to_string(&doc).unwrap(); + assert!(out.contains("My Page"), "title: {out}"); + assert!( + out.contains(""), + "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("hi"), + "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("__CONTENT__"); + let out = renderer.render_to_string(&doc).unwrap(); + assert!( + out.contains("hi"), + "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("hi"), + "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 [ + "", + "bold", + "italic", + "code", + "
              ", + "
              ", + "
              ", + "
            a
            1
            ", + "
            ",
            +    ] {
            +        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#"
            "#), "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#""#), "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=")); +} diff --git a/crates/inline.rs b/crates/inline.rs new file mode 100644 index 0000000..6b37e52 --- /dev/null +++ b/crates/inline.rs @@ -0,0 +1,205 @@ +//! 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, +} + +impl Keyword { + /// The literal source text of the keyword (e.g. `"TODO"`). Used both for + /// the rendered span and for anchor/id derivation via [`inline_text`]. + pub fn label(self) -> &'static str { + match self { + Keyword::Todo => "TODO", + Keyword::Done => "DONE", + Keyword::Started => "STARTED", + Keyword::Fixme => "FIXME", + Keyword::Fixed => "FIXED", + Keyword::Xxx => "XXX", + Keyword::Stopped => "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 `
            ` + /// (`= 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::Keyword(k) => out.push_str(k.keyword.label()), + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ItalicNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoldItalicNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StrikethroughNode { + pub span: Span, + pub children: Vec, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptNode { + pub span: Span, + pub children: Vec, +} + +#[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 +/// `` 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, +} diff --git a/crates/lexer.rs b/crates/lexer.rs new file mode 100644 index 0000000..12ba237 --- /dev/null +++ b/crates/lexer.rs @@ -0,0 +1,1292 @@ +//! Vimwiki lexer. +//! +//! Hand-rolled, two-pass: +//! +//! - **Block pass:** scan the source line by line, recognise structural +//! constructs (headings, lists, tables, fences, comments, etc.), emit +//! the matching block-level token, and within text-bearing lines invoke +//! the inline pass on the line content. +//! - **Inline pass:** scan a text run byte by byte, accumulate plain text, +//! and emit inline marker tokens (bold, italic, code, links, …). +//! +//! Both passes share one `Vec` so the parser sees a single, +//! ordered stream. The lexer is permissive: every delimiter is emitted; the +//! parser pairs them and falls back to literal text on mismatches. Spans are +//! stored as 0-indexed byte offsets. +//! +//! Multi-line constructs (`{{{ }}}`, `{{$ }}$`, `%%+ +%%`) flip a +//! `BlockMode` so subsequent lines are treated as raw content until the +//! matching closer is seen. + +use std::collections::HashMap; + +use crate::ast::{CheckboxState, Keyword, ListSymbol, Position, Span}; +use crate::listsyms::ListSyms; +use crate::syntax::{Lexer, TokenStream}; + +/// A single vimwiki token. The `kind` carries the variant, `span` carries +/// 0-indexed byte offsets into the source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VimwikiToken { + pub kind: VimwikiTokenKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VimwikiTokenKind { + // ----- Line structure ----- + /// End of a non-empty line (`\n`). + Newline, + /// A line that contained only whitespace. Replaces both the (empty) + /// content and the trailing newline. + BlankLine, + + // ----- Headings ----- + /// Opening `=`s of a heading. `centered` is true when the line had + /// leading whitespace before the `=` run. + HeadingOpen { + level: u8, + centered: bool, + }, + /// Closing `=`s on the same line as `HeadingOpen`. + HeadingClose, + + // ----- Bare structure ----- + HorizontalRule, + BlockquoteMarker, + BlockquoteIndent, + + // ----- Lists ----- + ListMarker { + symbol: ListSymbol, + indent: u32, + }, + Checkbox(CheckboxState), + + // ----- Definition lists ----- + DefinitionTermMarker, + + // ----- Tables ----- + /// `|` cell separator. + TableSep, + /// A header-separator row, e.g. `|---|---|`. Carries per-cell + /// Markdown-style alignment markers (`:--`, `--:`, `:--:`). + TableHeaderRow(Vec), + /// A cell whose only content is `>`, meaning "merge with cell to the left". + TableColSpan, + /// A cell whose only content is `\/`, meaning "merge with cell above". + TableRowSpan, + + // ----- Multi-line fences ----- + PreformattedOpen { + language: Option, + attrs: HashMap, + /// Verbatim text after `{{{` (trailing whitespace trimmed), preserved + /// so the HTML renderer can reproduce vimwiki's raw `
            ` attrs.
            +        raw: String,
            +    },
            +    PreformattedClose,
            +    PreformattedLine(String),
            +
            +    MathBlockOpen {
            +        environment: Option,
            +    },
            +    MathBlockClose,
            +    MathBlockLine(String),
            +
            +    /// Single-line comment: `%% ...`
            +    CommentLine(String),
            +    /// Opening of a multi-line comment: `%%+`
            +    CommentMultiOpen,
            +    CommentMultiClose,
            +    CommentMultiLine(String),
            +
            +    // ----- Tags -----
            +    /// `:tag1:tag2:` — colon-delimited tag line. The lexer treats this as
            +    /// block-level (must be the entire trimmed content of the line).
            +    /// Scope (file / heading / standalone) is the parser's job.
            +    Tag(Vec),
            +
            +    // ----- Page placeholders -----
            +    PlaceholderTitle(Option),
            +    PlaceholderNohtml,
            +    PlaceholderTemplate(Option),
            +    PlaceholderDate(Option),
            +
            +    // ----- Inline content -----
            +    Text(String),
            +    BoldDelim,
            +    ItalicDelim,
            +    StrikethroughDelim,
            +    SuperscriptDelim,
            +    SubscriptDelim,
            +    Code(String),
            +    MathInline(String),
            +    Keyword(Keyword),
            +
            +    // ----- Links / transclusions -----
            +    WikiLinkOpen,
            +    WikiLinkClose,
            +    WikiLinkSep,
            +    TransclusionOpen,
            +    TransclusionClose,
            +    TransclusionSep,
            +    RawUrl(String),
            +
            +    /// Lex error — never aborts the whole document.
            +    Error(String),
            +}
            +
            +#[derive(Debug, Default, Clone)]
            +pub struct VimwikiLexer {
            +    listsyms: ListSyms,
            +}
            +
            +impl VimwikiLexer {
            +    pub fn new() -> Self {
            +        Self::default()
            +    }
            +
            +    /// Recognise checkbox glyphs from a custom palette
            +    /// (vimwiki's `g:vimwiki_listsyms`) instead of the default `" .oOX"`.
            +    pub fn with_listsyms(mut self, listsyms: ListSyms) -> Self {
            +        self.listsyms = listsyms;
            +        self
            +    }
            +}
            +
            +impl Lexer for VimwikiLexer {
            +    type Token = VimwikiToken;
            +
            +    fn lex(&self, text: &str) -> TokenStream {
            +        let mut state = LexState::new(text, &self.listsyms);
            +        state.run();
            +        TokenStream::from_vec(state.tokens)
            +    }
            +}
            +
            +// ===== Internal state machine =====
            +
            +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
            +enum BlockMode {
            +    Normal,
            +    Preformatted,
            +    MathBlock,
            +    MultilineComment,
            +}
            +
            +struct LexState<'src> {
            +    src: &'src str,
            +    /// Byte offset of the start of the current line.
            +    line_start_offset: usize,
            +    /// Index of the current line (0-based).
            +    line: u32,
            +    tokens: Vec,
            +    mode: BlockMode,
            +    listsyms: &'src ListSyms,
            +}
            +
            +impl<'src> LexState<'src> {
            +    fn new(src: &'src str, listsyms: &'src ListSyms) -> Self {
            +        Self {
            +            src,
            +            line_start_offset: 0,
            +            line: 0,
            +            tokens: Vec::new(),
            +            mode: BlockMode::Normal,
            +            listsyms,
            +        }
            +    }
            +
            +    fn run(&mut self) {
            +        while self.line_start_offset < self.src.len() {
            +            let line_text = self.current_line();
            +            let line_len = line_text.len();
            +
            +            match self.mode {
            +                BlockMode::Normal => self.lex_normal_line(line_text),
            +                BlockMode::Preformatted => self.lex_preformatted_line(line_text),
            +                BlockMode::MathBlock => self.lex_math_block_line(line_text),
            +                BlockMode::MultilineComment => self.lex_multi_comment_line(line_text),
            +            }
            +
            +            // Skip past the line's trailing newline (if any) and advance line counters.
            +            let after_content = self.line_start_offset + line_len;
            +            if after_content < self.src.len() && self.src.as_bytes()[after_content] == b'\n' {
            +                self.line_start_offset = after_content + 1;
            +                self.line += 1;
            +            } else {
            +                self.line_start_offset = after_content;
            +            }
            +        }
            +    }
            +
            +    /// Slice of the current line, not including the trailing newline.
            +    fn current_line(&self) -> &'src str {
            +        let rest = &self.src[self.line_start_offset..];
            +        match rest.find('\n') {
            +            Some(idx) => &rest[..idx],
            +            None => rest,
            +        }
            +    }
            +
            +    /// Position at byte offset `col` within the current line.
            +    fn pos_in_line(&self, col: u32) -> Position {
            +        Position {
            +            line: self.line,
            +            column: col,
            +            offset: self.line_start_offset + col as usize,
            +        }
            +    }
            +
            +    fn line_start_pos(&self) -> Position {
            +        self.pos_in_line(0)
            +    }
            +
            +    fn line_end_pos(&self, line_text: &str) -> Position {
            +        self.pos_in_line(line_text.len() as u32)
            +    }
            +
            +    fn push(&mut self, kind: VimwikiTokenKind, span: Span) {
            +        self.tokens.push(VimwikiToken { kind, span });
            +    }
            +
            +    /// Emit a token whose span runs from byte-col `start_col` to `end_col`
            +    /// within the current line.
            +    fn emit(&mut self, kind: VimwikiTokenKind, start_col: u32, end_col: u32) {
            +        let span = Span::new(self.pos_in_line(start_col), self.pos_in_line(end_col));
            +        self.push(kind, span);
            +    }
            +
            +    fn emit_newline(&mut self, line_text: &str) {
            +        let nl_col = line_text.len() as u32;
            +        // Newline is just \n at column = line_text.len() spanning one byte.
            +        let span = Span::new(self.pos_in_line(nl_col), self.pos_in_line(nl_col + 1));
            +        self.push(VimwikiTokenKind::Newline, span);
            +    }
            +
            +    // ===== Mode dispatch =====
            +
            +    fn lex_preformatted_line(&mut self, line: &str) {
            +        // Closing fence: line whose first non-space content is `}}}`.
            +        if line.trim() == "}}}" {
            +            let start_col = line.find("}}}").unwrap() as u32;
            +            self.emit(
            +                VimwikiTokenKind::PreformattedClose,
            +                start_col,
            +                start_col + 3,
            +            );
            +            self.mode = BlockMode::Normal;
            +            self.emit_newline(line);
            +            return;
            +        }
            +        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
            +        self.push(VimwikiTokenKind::PreformattedLine(line.to_owned()), span);
            +        self.emit_newline(line);
            +    }
            +
            +    fn lex_math_block_line(&mut self, line: &str) {
            +        if line.trim() == "}}$" {
            +            let start_col = line.find("}}$").unwrap() as u32;
            +            self.emit(VimwikiTokenKind::MathBlockClose, start_col, start_col + 3);
            +            self.mode = BlockMode::Normal;
            +            self.emit_newline(line);
            +            return;
            +        }
            +        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
            +        self.push(VimwikiTokenKind::MathBlockLine(line.to_owned()), span);
            +        self.emit_newline(line);
            +    }
            +
            +    fn lex_multi_comment_line(&mut self, line: &str) {
            +        // Closer is `+%%`, possibly with content before it on the same line.
            +        if let Some(idx) = line.find("+%%") {
            +            let prefix = &line[..idx];
            +            if !prefix.is_empty() {
            +                let span = Span::new(self.pos_in_line(0), self.pos_in_line(prefix.len() as u32));
            +                self.push(VimwikiTokenKind::CommentMultiLine(prefix.to_owned()), span);
            +            }
            +            let close_col = idx as u32;
            +            self.emit(
            +                VimwikiTokenKind::CommentMultiClose,
            +                close_col,
            +                close_col + 3,
            +            );
            +            self.mode = BlockMode::Normal;
            +            self.emit_newline(line);
            +            return;
            +        }
            +        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
            +        self.push(VimwikiTokenKind::CommentMultiLine(line.to_owned()), span);
            +        self.emit_newline(line);
            +    }
            +
            +    fn lex_normal_line(&mut self, line: &str) {
            +        // Whitespace-only line → BlankLine (replaces both content and the
            +        // following newline).
            +        if line.bytes().all(|b| b == b' ' || b == b'\t') {
            +            let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
            +            self.push(VimwikiTokenKind::BlankLine, span);
            +            // Also account for the newline byte if present.
            +            // We don't emit a Newline because BlankLine subsumes both.
            +            return;
            +        }
            +
            +        // Block patterns in priority order. The first match wins.
            +        if self.try_lex_multi_comment_open(line)
            +            || self.try_lex_single_comment(line)
            +            || self.try_lex_preformatted_open(line)
            +            || self.try_lex_math_block_open(line)
            +            || self.try_lex_placeholder(line)
            +            || self.try_lex_horizontal_rule(line)
            +            || self.try_lex_heading(line)
            +            || self.try_lex_tag(line)
            +            || self.try_lex_table_row(line)
            +            || self.try_lex_blockquote(line)
            +            || self.try_lex_list_item(line)
            +            || self.try_lex_definition_term(line)
            +        {
            +            return;
            +        }
            +
            +        // Default: paragraph line — lex inline.
            +        self.lex_inline(line, 0, line.len() as u32);
            +        self.emit_newline(line);
            +    }
            +
            +    // ===== Block patterns =====
            +
            +    fn try_lex_multi_comment_open(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim_start();
            +        if !trimmed.starts_with("%%+") {
            +            return false;
            +        }
            +        let indent = (line.len() - trimmed.len()) as u32;
            +        self.emit(VimwikiTokenKind::CommentMultiOpen, indent, indent + 3);
            +        // Same line might also contain `+%%` (one-line multi).
            +        let after_open = (indent + 3) as usize;
            +        let rest = &line[after_open..];
            +        if let Some(end_rel) = rest.find("+%%") {
            +            let prefix = &rest[..end_rel];
            +            if !prefix.is_empty() {
            +                let p_start = indent + 3;
            +                let p_end = p_start + prefix.len() as u32;
            +                self.push(
            +                    VimwikiTokenKind::CommentMultiLine(prefix.to_owned()),
            +                    Span::new(self.pos_in_line(p_start), self.pos_in_line(p_end)),
            +                );
            +            }
            +            let close_col = (after_open + end_rel) as u32;
            +            self.emit(
            +                VimwikiTokenKind::CommentMultiClose,
            +                close_col,
            +                close_col + 3,
            +            );
            +            self.emit_newline(line);
            +        } else {
            +            // Capture remainder of this line as content, then enter
            +            // multiline-comment mode for following lines.
            +            if !rest.is_empty() {
            +                let p_start = indent + 3;
            +                let p_end = p_start + rest.len() as u32;
            +                self.push(
            +                    VimwikiTokenKind::CommentMultiLine(rest.to_owned()),
            +                    Span::new(self.pos_in_line(p_start), self.pos_in_line(p_end)),
            +                );
            +            }
            +            self.emit_newline(line);
            +            self.mode = BlockMode::MultilineComment;
            +        }
            +        true
            +    }
            +
            +    fn try_lex_single_comment(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim_start();
            +        if !trimmed.starts_with("%%") || trimmed.starts_with("%%+") {
            +            return false;
            +        }
            +        let indent = (line.len() - trimmed.len()) as u32;
            +        let content = &trimmed[2..]; // skip "%%"
            +        let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
            +        self.push(VimwikiTokenKind::CommentLine(content.to_owned()), span);
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_preformatted_open(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim_start();
            +        if !trimmed.starts_with("{{{") {
            +            return false;
            +        }
            +        let indent = (line.len() - trimmed.len()) as u32;
            +        // `{{{[lang][;key=val[;key=val]*]?` style. Vimwiki accepts a single
            +        // word after the fence (language hint) and/or class/attribute key=val
            +        // pairs separated by spaces. Keep parsing forgiving.
            +        let after = trimmed[3..].trim();
            +        let (language, attrs) = parse_fence_attrs(after);
            +        // vimwiki keeps everything after `{{{` (minus trailing whitespace) and
            +        // drops it verbatim into the `
            ` tag, so capture it unparsed.
            +        let raw = trimmed[3..].trim_end().to_string();
            +        let kind = VimwikiTokenKind::PreformattedOpen {
            +            language,
            +            attrs,
            +            raw,
            +        };
            +        let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
            +        self.push(kind, span);
            +        self.mode = BlockMode::Preformatted;
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_math_block_open(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim_start();
            +        if !trimmed.starts_with("{{$") {
            +            return false;
            +        }
            +        let indent = (line.len() - trimmed.len()) as u32;
            +        let after = trimmed[3..].trim();
            +        // `{{$%env%` — environment is between `%`s.
            +        let environment = after
            +            .strip_prefix('%')
            +            .and_then(|s| s.strip_suffix('%'))
            +            .map(str::to_owned);
            +        let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
            +        self.push(VimwikiTokenKind::MathBlockOpen { environment }, span);
            +        self.mode = BlockMode::MathBlock;
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_placeholder(&mut self, line: &str) -> bool {
            +        // Placeholders must start at column 0 (no leading whitespace).
            +        if !line.starts_with('%') {
            +            return false;
            +        }
            +        let kind = if let Some(rest) = line.strip_prefix("%title") {
            +            let value = rest.trim();
            +            let v = if value.is_empty() {
            +                None
            +            } else {
            +                Some(value.to_owned())
            +            };
            +            VimwikiTokenKind::PlaceholderTitle(v)
            +        } else if line.trim_end() == "%nohtml" {
            +            VimwikiTokenKind::PlaceholderNohtml
            +        } else if let Some(rest) = line.strip_prefix("%template") {
            +            let value = rest.trim();
            +            let v = if value.is_empty() {
            +                None
            +            } else {
            +                Some(value.to_owned())
            +            };
            +            VimwikiTokenKind::PlaceholderTemplate(v)
            +        } else if let Some(rest) = line.strip_prefix("%date") {
            +            let value = rest.trim();
            +            let v = if value.is_empty() {
            +                None
            +            } else {
            +                Some(value.to_owned())
            +            };
            +            VimwikiTokenKind::PlaceholderDate(v)
            +        } else {
            +            return false;
            +        };
            +        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
            +        self.push(kind, span);
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_horizontal_rule(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim();
            +        if trimmed.len() < 4 || !trimmed.bytes().all(|b| b == b'-') {
            +            return false;
            +        }
            +        // Four or more dashes. Allow surrounding whitespace.
            +        let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
            +        self.push(VimwikiTokenKind::HorizontalRule, span);
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_heading(&mut self, line: &str) -> bool {
            +        let bytes = line.as_bytes();
            +        let leading_ws = bytes
            +            .iter()
            +            .take_while(|&&b| b == b' ' || b == b'\t')
            +            .count();
            +        let centered = leading_ws > 0;
            +
            +        // Count `=`s after leading whitespace.
            +        let mut level = 0usize;
            +        while level < 6 && leading_ws + level < bytes.len() && bytes[leading_ws + level] == b'=' {
            +            level += 1;
            +        }
            +        if level == 0 {
            +            return false;
            +        }
            +        // Must be followed by at least one space.
            +        if leading_ws + level >= bytes.len() || bytes[leading_ws + level] != b' ' {
            +            return false;
            +        }
            +
            +        // Trailing `=`s of the same level.
            +        let trimmed_end = line.trim_end_matches([' ', '\t']);
            +        let trailing_eqs = trimmed_end
            +            .as_bytes()
            +            .iter()
            +            .rev()
            +            .take_while(|&&b| b == b'=')
            +            .count();
            +        if trailing_eqs != level {
            +            return false;
            +        }
            +
            +        let title_start = leading_ws + level;
            +        let title_end = trimmed_end.len() - trailing_eqs;
            +        if title_end <= title_start {
            +            return false;
            +        }
            +
            +        let open_col = leading_ws as u32;
            +        let open_end = (leading_ws + level) as u32;
            +        self.emit(
            +            VimwikiTokenKind::HeadingOpen {
            +                level: level as u8,
            +                centered,
            +            },
            +            open_col,
            +            open_end,
            +        );
            +
            +        // Inline content between markers (trim one space on each side if present).
            +        let mut t_start = title_start;
            +        let mut t_end = title_end;
            +        if t_start < t_end && bytes[t_start] == b' ' {
            +            t_start += 1;
            +        }
            +        if t_end > t_start && bytes[t_end - 1] == b' ' {
            +            t_end -= 1;
            +        }
            +        if t_end > t_start {
            +            self.lex_inline(&line[t_start..t_end], t_start as u32, t_end as u32);
            +        }
            +
            +        let close_col = title_end as u32;
            +        let close_end = (title_end + trailing_eqs) as u32;
            +        self.emit(VimwikiTokenKind::HeadingClose, close_col, close_end);
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_table_row(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim();
            +        if !trimmed.starts_with('|') || !trimmed.ends_with('|') || trimmed.len() < 2 {
            +            return false;
            +        }
            +
            +        // Header separator row: every cell is `-`s (with optional `:`
            +        // anchors at one or both ends for alignment) or whitespace.
            +        if trimmed
            +            .split('|')
            +            .filter(|s| !s.is_empty())
            +            .all(is_sep_cell)
            +        {
            +            let aligns: Vec = trimmed
            +                .split('|')
            +                .filter(|s| !s.is_empty())
            +                .map(parse_sep_alignment)
            +                .collect();
            +            let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
            +            self.push(VimwikiTokenKind::TableHeaderRow(aligns), span);
            +            self.emit_newline(line);
            +            return true;
            +        }
            +
            +        // Walk the line and emit TableSep tokens at each `|`, with cell
            +        // content lexed inline between them.
            +        let bytes = line.as_bytes();
            +        let mut i = 0usize;
            +        // Skip leading whitespace.
            +        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
            +            i += 1;
            +        }
            +        // Trailing whitespace bound.
            +        let mut end = bytes.len();
            +        while end > i && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\t') {
            +            end -= 1;
            +        }
            +
            +        // Collect cell boundaries.
            +        let mut sep_positions = Vec::new();
            +        let mut k = i;
            +        while k < end {
            +            if bytes[k] == b'|' {
            +                sep_positions.push(k);
            +            }
            +            k += 1;
            +        }
            +
            +        if sep_positions.len() < 2 {
            +            return false;
            +        }
            +
            +        for window in sep_positions.windows(2) {
            +            let sep_col = window[0] as u32;
            +            self.emit(VimwikiTokenKind::TableSep, sep_col, sep_col + 1);
            +            let cell_start = window[0] + 1;
            +            let cell_end = window[1];
            +            let cell_text = &line[cell_start..cell_end];
            +            let cell_trimmed = cell_text.trim();
            +            match cell_trimmed {
            +                ">" => {
            +                    let s = (cell_start + cell_text.find('>').unwrap()) as u32;
            +                    self.emit(VimwikiTokenKind::TableColSpan, s, s + 1);
            +                }
            +                "\\/" => {
            +                    let s = (cell_start + cell_text.find("\\/").unwrap()) as u32;
            +                    self.emit(VimwikiTokenKind::TableRowSpan, s, s + 2);
            +                }
            +                _ if !cell_trimmed.is_empty() => {
            +                    self.lex_inline(cell_text, cell_start as u32, cell_end as u32);
            +                }
            +                _ => {}
            +            }
            +        }
            +        // Emit the last separator.
            +        let last_sep = *sep_positions.last().unwrap() as u32;
            +        self.emit(VimwikiTokenKind::TableSep, last_sep, last_sep + 1);
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_blockquote(&mut self, line: &str) -> bool {
            +        // `>` prefix style.
            +        let trimmed = line.trim_start();
            +        let indent = (line.len() - trimmed.len()) as u32;
            +        if let Some(rest) =
            +            trimmed
            +                .strip_prefix("> ")
            +                .or_else(|| if trimmed == ">" { Some("") } else { None })
            +        {
            +            self.emit(VimwikiTokenKind::BlockquoteMarker, indent, indent + 1);
            +            if !rest.is_empty() {
            +                let inline_start = indent + 2;
            +                let inline_end = inline_start + rest.len() as u32;
            +                self.lex_inline(rest, inline_start, inline_end);
            +            }
            +            self.emit_newline(line);
            +            return true;
            +        }
            +
            +        // 4-space indent style. To avoid swallowing list items, only treat as
            +        // a blockquote when the indented content does NOT start with a list
            +        // marker.
            +        if let Some(rest) = line.strip_prefix("    ") {
            +            if rest.chars().next().is_none_or(|c| c == ' ' || c == '\t')
            +                || looks_like_list_marker(rest)
            +            {
            +                return false;
            +            }
            +            self.emit(VimwikiTokenKind::BlockquoteIndent, 0, 4);
            +            self.lex_inline(rest, 4, line.len() as u32);
            +            self.emit_newline(line);
            +            return true;
            +        }
            +        false
            +    }
            +
            +    fn try_lex_list_item(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim_start();
            +        let indent_bytes = line.len() - trimmed.len();
            +        let indent = indent_bytes as u32;
            +
            +        let (symbol, marker_len) = match list_marker_at(trimmed) {
            +            Some(v) => v,
            +            None => return false,
            +        };
            +
            +        // Marker must be followed by a space.
            +        if marker_len >= trimmed.len() || trimmed.as_bytes()[marker_len] != b' ' {
            +            return false;
            +        }
            +
            +        let marker_start = indent;
            +        let marker_end = indent + marker_len as u32;
            +        self.emit(
            +            VimwikiTokenKind::ListMarker { symbol, indent },
            +            marker_start,
            +            marker_end,
            +        );
            +
            +        // Past marker + the single required space.
            +        let mut cursor = marker_len + 1;
            +        let after = &trimmed[cursor..];
            +
            +        // Optional checkbox `[ ]` / `[.]` / `[o]` / `[O]` / `[X]` / `[-]`,
            +        // followed by a space.
            +        if let Some((cb, cb_len)) = checkbox_at(after, self.listsyms) {
            +            let cb_start = indent + cursor as u32;
            +            let cb_end = cb_start + cb_len as u32;
            +            self.emit(VimwikiTokenKind::Checkbox(cb), cb_start, cb_end);
            +            cursor += cb_len;
            +            // Eat the trailing space if present.
            +            if cursor < trimmed.len() && trimmed.as_bytes()[cursor] == b' ' {
            +                cursor += 1;
            +            }
            +        }
            +
            +        let inline_start = indent + cursor as u32;
            +        let inline_end = line.len() as u32;
            +        if (inline_end as usize) > (inline_start as usize) {
            +            let abs_start = self.line_start_offset + inline_start as usize;
            +            let abs_end = self.line_start_offset + inline_end as usize;
            +            let slice = &self.src[abs_start..abs_end];
            +            self.lex_inline(slice, inline_start, inline_end);
            +        }
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    /// `:tag1:tag2:tag3:` — colon-delimited tag line (block-level).
            +    ///
            +    /// The whole trimmed line must match: starts and ends with `:`, with
            +    /// non-empty whitespace-free names between adjacent colons. Empty
            +    /// segments or whitespace inside tag names disqualify the line.
            +    /// Doesn't conflict with `::` (definition list marker) because that
            +    /// pattern requires text *before* the `::`.
            +    fn try_lex_tag(&mut self, line: &str) -> bool {
            +        let trimmed = line.trim();
            +        if trimmed.len() < 3 || !trimmed.starts_with(':') || !trimmed.ends_with(':') {
            +            return false;
            +        }
            +        // `:tag1:tag2:` splits as `["", "tag1", "tag2", ""]`; the empty
            +        // strings at the ends are expected, everything in between must be
            +        // non-empty and contain no whitespace.
            +        let parts: Vec<&str> = trimmed.split(':').collect();
            +        if parts.first() != Some(&"") || parts.last() != Some(&"") {
            +            return false;
            +        }
            +        let names = &parts[1..parts.len() - 1];
            +        if names.is_empty()
            +            || names
            +                .iter()
            +                .any(|t| t.is_empty() || t.chars().any(char::is_whitespace))
            +        {
            +            return false;
            +        }
            +        let indent = (line.len() - line.trim_start().len()) as u32;
            +        let end_col = indent + trimmed.len() as u32;
            +        self.emit(
            +            VimwikiTokenKind::Tag(names.iter().map(|s| (*s).to_owned()).collect()),
            +            indent,
            +            end_col,
            +        );
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    fn try_lex_definition_term(&mut self, line: &str) -> bool {
            +        // `Term:: Definition` or `Term::` (continuation lines follow).
            +        // The `::` must be preceded by non-`:` text and followed by either
            +        // EOL or a single space + content.
            +        let bytes = line.as_bytes();
            +        let mut i = 0usize;
            +        let mut found = None;
            +        while i + 1 < bytes.len() {
            +            if bytes[i] == b':' && bytes[i + 1] == b':' {
            +                // Must not be preceded by another `:` (avoid `:::`).
            +                if i > 0 && bytes[i - 1] == b':' {
            +                    i += 1;
            +                    continue;
            +                }
            +                // Must not be followed by another `:`.
            +                if i + 2 < bytes.len() && bytes[i + 2] == b':' {
            +                    i += 1;
            +                    continue;
            +                }
            +                found = Some(i);
            +                break;
            +            }
            +            i += 1;
            +        }
            +        let split = match found {
            +            Some(s) if s > 0 => s,
            +            _ => return false,
            +        };
            +
            +        // Term inline.
            +        self.lex_inline(&line[..split], 0, split as u32);
            +        let marker_col = split as u32;
            +        self.emit(
            +            VimwikiTokenKind::DefinitionTermMarker,
            +            marker_col,
            +            marker_col + 2,
            +        );
            +
            +        let after = &line[split + 2..];
            +        if !after.is_empty() {
            +            let leading = after
            +                .bytes()
            +                .take_while(|b| *b == b' ' || *b == b'\t')
            +                .count();
            +            if leading > 0 {
            +                // Treat the post-`::` whitespace as a separator; lex the rest as inline.
            +                let inline_start = (split + 2 + leading) as u32;
            +                let inline_end = line.len() as u32;
            +                if inline_end > inline_start {
            +                    self.lex_inline(&after[leading..], inline_start, inline_end);
            +                }
            +            } else {
            +                // No space after `::`: still try to lex inline.
            +                let inline_start = (split + 2) as u32;
            +                self.lex_inline(after, inline_start, line.len() as u32);
            +            }
            +        }
            +        self.emit_newline(line);
            +        true
            +    }
            +
            +    // ===== Inline pass =====
            +    //
            +    // Inline content lives at byte offsets [start_col, end_col) within the
            +    // current line. Span columns are relative to the line.
            +
            +    fn lex_inline(&mut self, slice: &str, line_col_start: u32, _line_col_end: u32) {
            +        let bytes = slice.as_bytes();
            +        let mut buf = String::new();
            +        let mut buf_start: Option = None;
            +        let mut i = 0usize;
            +
            +        let flush = |this: &mut Self, buf: &mut String, buf_start: &mut Option, end: u32| {
            +            if !buf.is_empty() {
            +                let s = buf_start.take().unwrap();
            +                this.emit(VimwikiTokenKind::Text(std::mem::take(buf)), s, end);
            +            }
            +        };
            +
            +        while i < bytes.len() {
            +            let abs_col = line_col_start + i as u32;
            +            let rest = &slice[i..];
            +
            +            // 1) Multi-char delimiters and constructs (longest match wins).
            +            if let Some(after_open) = rest.strip_prefix("[[") {
            +                flush(self, &mut buf, &mut buf_start, abs_col);
            +                self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
            +                i += 2;
            +                // Lex until the closing `]]`. A single `]` inside the body —
            +                // e.g. a `[TICKET-1]` in the description — is literal text, so
            +                // we balance inner `[ ]` rather than stopping at the first `]]`.
            +                let close_abs_in_slice = find_wikilink_close(after_open).map(|c| i + c);
            +                let inner_end = close_abs_in_slice.unwrap_or(slice.len());
            +                self.lex_link_body(slice, i, line_col_start, inner_end, true);
            +                i = inner_end;
            +                if close_abs_in_slice.is_some() {
            +                    let close_col = line_col_start + i as u32;
            +                    self.emit(VimwikiTokenKind::WikiLinkClose, close_col, close_col + 2);
            +                    i += 2;
            +                }
            +                continue;
            +            }
            +            if rest.starts_with("{{{") {
            +                // Inline {{{ is rare; fall through as text.
            +                buf_start.get_or_insert(abs_col);
            +                buf.push('{');
            +                i += 1;
            +                continue;
            +            }
            +            if rest.starts_with("{{") {
            +                flush(self, &mut buf, &mut buf_start, abs_col);
            +                self.emit(VimwikiTokenKind::TransclusionOpen, abs_col, abs_col + 2);
            +                i += 2;
            +                let close_rel = slice[i..].find("}}");
            +                let inner_end_rel = close_rel.map(|c| i + c).unwrap_or(slice.len());
            +                self.lex_link_body(slice, i, line_col_start, inner_end_rel, false);
            +                i = inner_end_rel;
            +                if close_rel.is_some() {
            +                    let close_col = line_col_start + i as u32;
            +                    self.emit(
            +                        VimwikiTokenKind::TransclusionClose,
            +                        close_col,
            +                        close_col + 2,
            +                    );
            +                    i += 2;
            +                }
            +                continue;
            +            }
            +            if rest.starts_with("~~") {
            +                flush(self, &mut buf, &mut buf_start, abs_col);
            +                self.emit(VimwikiTokenKind::StrikethroughDelim, abs_col, abs_col + 2);
            +                i += 2;
            +                continue;
            +            }
            +            if rest.starts_with(",,") {
            +                flush(self, &mut buf, &mut buf_start, abs_col);
            +                self.emit(VimwikiTokenKind::SubscriptDelim, abs_col, abs_col + 2);
            +                i += 2;
            +                continue;
            +            }
            +
            +            // 2) Inline code: `...`
            +            if bytes[i] == b'`' {
            +                if let Some(end_rel) = slice[i + 1..].find('`') {
            +                    flush(self, &mut buf, &mut buf_start, abs_col);
            +                    let content = &slice[i + 1..i + 1 + end_rel];
            +                    let span_end = abs_col + (end_rel + 2) as u32;
            +                    self.emit(
            +                        VimwikiTokenKind::Code(content.to_owned()),
            +                        abs_col,
            +                        span_end,
            +                    );
            +                    i += end_rel + 2;
            +                    continue;
            +                }
            +            }
            +
            +            // 3) Inline math: $...$  (single $ on each side; bail to text if unmatched)
            +            if bytes[i] == b'$' {
            +                if let Some(end_rel) = slice[i + 1..].find('$') {
            +                    flush(self, &mut buf, &mut buf_start, abs_col);
            +                    let content = &slice[i + 1..i + 1 + end_rel];
            +                    let span_end = abs_col + (end_rel + 2) as u32;
            +                    self.emit(
            +                        VimwikiTokenKind::MathInline(content.to_owned()),
            +                        abs_col,
            +                        span_end,
            +                    );
            +                    i += end_rel + 2;
            +                    continue;
            +                }
            +            }
            +
            +            // 4) Single-char delimiters.
            +            let single = match bytes[i] {
            +                b'*' => Some(VimwikiTokenKind::BoldDelim),
            +                b'_' => Some(VimwikiTokenKind::ItalicDelim),
            +                b'^' => Some(VimwikiTokenKind::SuperscriptDelim),
            +                _ => None,
            +            };
            +            if let Some(kind) = single {
            +                flush(self, &mut buf, &mut buf_start, abs_col);
            +                self.emit(kind, abs_col, abs_col + 1);
            +                i += 1;
            +                continue;
            +            }
            +
            +            // 5) Raw URL — at a word boundary.
            +            if is_word_boundary(bytes, i) {
            +                if let Some(url_len) = match_url(rest) {
            +                    flush(self, &mut buf, &mut buf_start, abs_col);
            +                    let url = &rest[..url_len];
            +                    self.emit(
            +                        VimwikiTokenKind::RawUrl(url.to_owned()),
            +                        abs_col,
            +                        abs_col + url_len as u32,
            +                    );
            +                    i += url_len;
            +                    continue;
            +                }
            +                // 6) Keyword.
            +                if let Some((kw_len, kw)) = match_keyword(rest) {
            +                    flush(self, &mut buf, &mut buf_start, abs_col);
            +                    self.emit(
            +                        VimwikiTokenKind::Keyword(kw),
            +                        abs_col,
            +                        abs_col + kw_len as u32,
            +                    );
            +                    i += kw_len;
            +                    continue;
            +                }
            +            }
            +
            +            // 7) Default: accumulate into Text. Walk to next byte boundary.
            +            let ch = slice[i..].chars().next().unwrap();
            +            let ch_len = ch.len_utf8();
            +            buf_start.get_or_insert(abs_col);
            +            buf.push(ch);
            +            i += ch_len;
            +        }
            +
            +        let end_col = line_col_start + slice.len() as u32;
            +        flush(self, &mut buf, &mut buf_start, end_col);
            +    }
            +
            +    /// Lex inline content within `[[...]]` or `{{...}}`, where `|` is a
            +    /// separator rather than literal pipe. `is_wikilink` chooses the token
            +    /// variant for the separator.
            +    fn lex_link_body(
            +        &mut self,
            +        slice: &str,
            +        start: usize,
            +        line_col_start: u32,
            +        end: usize,
            +        is_wikilink: bool,
            +    ) {
            +        let bytes = slice.as_bytes();
            +        let mut buf = String::new();
            +        let mut buf_start: Option = None;
            +        let mut i = start;
            +
            +        let flush =
            +            |this: &mut Self, buf: &mut String, buf_start: &mut Option, end_col: u32| {
            +                if !buf.is_empty() {
            +                    let s = buf_start.take().unwrap();
            +                    this.emit(VimwikiTokenKind::Text(std::mem::take(buf)), s, end_col);
            +                }
            +            };
            +
            +        while i < end {
            +            let abs_col = line_col_start + i as u32;
            +            if bytes[i] == b'|' {
            +                flush(self, &mut buf, &mut buf_start, abs_col);
            +                let kind = if is_wikilink {
            +                    VimwikiTokenKind::WikiLinkSep
            +                } else {
            +                    VimwikiTokenKind::TransclusionSep
            +                };
            +                self.emit(kind, abs_col, abs_col + 1);
            +                i += 1;
            +                continue;
            +            }
            +            let ch = slice[i..].chars().next().unwrap();
            +            let ch_len = ch.len_utf8();
            +            buf_start.get_or_insert(abs_col);
            +            buf.push(ch);
            +            i += ch_len;
            +        }
            +        flush(self, &mut buf, &mut buf_start, line_col_start + end as u32);
            +    }
            +}
            +
            +// ===== Helpers =====
            +
            +/// Parse the bit after `{{{` on a fence line. Format is forgiving:
            +/// `lang` (single word) followed by optional `key=value` pairs separated
            +/// by spaces.
            +/// Byte offset of the closing `]]` for a wikilink body that begins at the start
            +/// of `s` (just after the opening `[[`). Inner `[ ]` pairs are balanced so a
            +/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
            +/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
            +/// body text. Returns `None` if no closing `]]` is found.
            +fn find_wikilink_close(s: &str) -> Option {
            +    let b = s.as_bytes();
            +    let mut depth: u32 = 0;
            +    let mut i = 0;
            +    while i < b.len() {
            +        match b[i] {
            +            b'[' => depth += 1,
            +            b']' if depth > 0 => depth -= 1, // closes an inner '['
            +            b']' if i + 1 < b.len() && b[i + 1] == b']' => return Some(i),
            +            _ => {}
            +        }
            +        i += 1;
            +    }
            +    None
            +}
            +
            +fn parse_fence_attrs(s: &str) -> (Option, HashMap) {
            +    let mut attrs = HashMap::new();
            +    let mut language: Option = None;
            +    for tok in s.split_whitespace() {
            +        if let Some(eq) = tok.find('=') {
            +            let (k, v) = tok.split_at(eq);
            +            let v = &v[1..];
            +            // Strip optional surrounding quotes from value.
            +            let v = v.trim_matches('"').trim_matches('\'');
            +            attrs.insert(k.to_owned(), v.to_owned());
            +        } else if language.is_none() {
            +            language = Some(tok.to_owned());
            +        }
            +    }
            +    (language, attrs)
            +}
            +
            +fn is_word_boundary(bytes: &[u8], i: usize) -> bool {
            +    if i == 0 {
            +        return true;
            +    }
            +    let prev = bytes[i - 1];
            +    !(prev.is_ascii_alphanumeric() || prev == b'_')
            +}
            +
            +fn is_word_end(bytes: &[u8], i: usize) -> bool {
            +    if i >= bytes.len() {
            +        return true;
            +    }
            +    let next = bytes[i];
            +    !(next.is_ascii_alphanumeric() || next == b'_')
            +}
            +
            +const URL_SCHEMES: &[&str] = &["https://", "http://", "ftp://", "mailto:", "file://"];
            +
            +fn match_url(rest: &str) -> Option {
            +    let bytes = rest.as_bytes();
            +    let scheme_len = URL_SCHEMES.iter().find_map(|s| {
            +        if rest.starts_with(s) {
            +            Some(s.len())
            +        } else {
            +            None
            +        }
            +    })?;
            +    let mut end = scheme_len;
            +    while end < bytes.len() {
            +        let b = bytes[end];
            +        // Conservative URL char set; stops at whitespace and most punctuation
            +        // that's typically not part of a URL tail.
            +        let ok = b.is_ascii_alphanumeric()
            +            || matches!(
            +                b,
            +                b'-' | b'_'
            +                    | b'.'
            +                    | b'~'
            +                    | b'/'
            +                    | b':'
            +                    | b'?'
            +                    | b'#'
            +                    | b'['
            +                    | b']'
            +                    | b'@'
            +                    | b'!'
            +                    | b'$'
            +                    | b'&'
            +                    | b'\''
            +                    | b'('
            +                    | b')'
            +                    | b'*'
            +                    | b'+'
            +                    | b','
            +                    | b';'
            +                    | b'='
            +                    | b'%'
            +            );
            +        if !ok {
            +            break;
            +        }
            +        end += 1;
            +    }
            +    // Trim trailing punctuation that's almost always sentence punctuation.
            +    while end > scheme_len {
            +        match bytes[end - 1] {
            +            b'.' | b',' | b';' | b':' | b'!' | b'?' | b')' | b']' => end -= 1,
            +            _ => break,
            +        }
            +    }
            +    if end <= scheme_len {
            +        None
            +    } else {
            +        Some(end)
            +    }
            +}
            +
            +const KEYWORDS: &[(&str, Keyword)] = &[
            +    ("TODO", Keyword::Todo),
            +    ("DONE", Keyword::Done),
            +    ("STARTED", Keyword::Started),
            +    ("FIXME", Keyword::Fixme),
            +    ("FIXED", Keyword::Fixed),
            +    ("XXX", Keyword::Xxx),
            +    ("STOPPED", Keyword::Stopped),
            +];
            +
            +fn match_keyword(rest: &str) -> Option<(usize, Keyword)> {
            +    let bytes = rest.as_bytes();
            +    KEYWORDS.iter().find_map(|(text, kw)| {
            +        if rest.starts_with(text) && is_word_end(bytes, text.len()) {
            +            Some((text.len(), *kw))
            +        } else {
            +            None
            +        }
            +    })
            +}
            +
            +fn list_marker_at(s: &str) -> Option<(ListSymbol, usize)> {
            +    let bytes = s.as_bytes();
            +    if bytes.is_empty() {
            +        return None;
            +    }
            +    // Single-char markers: -, *, #
            +    match bytes[0] {
            +        b'-' => return Some((ListSymbol::Dash, 1)),
            +        b'*' => return Some((ListSymbol::Star, 1)),
            +        b'#' => return Some((ListSymbol::Hash, 1)),
            +        _ => {}
            +    }
            +    // Numeric: `1.` or `1)`. Allow multi-digit.
            +    let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
            +    if digits > 0 && bytes.len() > digits {
            +        match bytes[digits] {
            +            b'.' => return Some((ListSymbol::Numeric, digits + 1)),
            +            b')' => return Some((ListSymbol::NumericParen, digits + 1)),
            +            _ => {}
            +        }
            +    }
            +    // Single-letter alpha or roman: `a)`, `A)`, `i)`, `I)`.
            +    if bytes.len() >= 2 && bytes[1] == b')' {
            +        let c = bytes[0];
            +        if c.is_ascii_lowercase() {
            +            // `i)` / `v)` / `x)` are roman by convention; without context we
            +            // treat any single lowercase letter as alpha. Roman vs alpha is
            +            // ambiguous from a single char; vimwiki itself relies on context.
            +            // Mark `i` as RomanParen as a small concession to the spec.
            +            let sym = if c == b'i' {
            +                ListSymbol::RomanParen
            +            } else {
            +                ListSymbol::AlphaParen
            +            };
            +            return Some((sym, 2));
            +        }
            +        if c.is_ascii_uppercase() {
            +            let sym = if c == b'I' {
            +                ListSymbol::RomanUpperParen
            +            } else {
            +                ListSymbol::AlphaUpperParen
            +            };
            +            return Some((sym, 2));
            +        }
            +    }
            +    None
            +}
            +
            +fn looks_like_list_marker(s: &str) -> bool {
            +    list_marker_at(s).is_some_and(|(_, len)| s.as_bytes().get(len) == Some(&b' '))
            +}
            +
            +/// Is this cell content a valid separator-cell body? Allows leading /
            +/// trailing `:` for alignment and inner runs of `-` plus whitespace.
            +fn is_sep_cell(cell: &str) -> bool {
            +    let t = cell.trim();
            +    if t.is_empty() {
            +        return false;
            +    }
            +    let body = t.trim_start_matches(':').trim_end_matches(':');
            +    !body.is_empty() && body.chars().all(|c| c == '-')
            +}
            +
            +fn parse_sep_alignment(cell: &str) -> crate::ast::TableAlign {
            +    let t = cell.trim();
            +    let l = t.starts_with(':');
            +    let r = t.ends_with(':');
            +    match (l, r) {
            +        (true, true) => crate::ast::TableAlign::Center,
            +        (true, false) => crate::ast::TableAlign::Left,
            +        (false, true) => crate::ast::TableAlign::Right,
            +        _ => crate::ast::TableAlign::Default,
            +    }
            +}
            +
            +/// Recognise a `[g]` checkbox at the start of `s`, where `g` is a single
            +/// glyph drawn from `listsyms` (or the rejected marker). Returns the bucketed
            +/// state and the byte length consumed (including the brackets).
            +fn checkbox_at(s: &str, listsyms: &ListSyms) -> Option<(CheckboxState, usize)> {
            +    let rest = s.strip_prefix('[')?;
            +    let mut chars = rest.char_indices();
            +    let (_, glyph) = chars.next()?;
            +    let (close_off, ']') = chars.next()? else {
            +        return None;
            +    };
            +    let state = listsyms.state_of(glyph)?;
            +    // `[` + glyph + `]`: 1 + glyph.len_utf8() + 1, i.e. the close bracket
            +    // offset within `rest` plus one for the opening bracket and one for `]`.
            +    Some((state, 1 + close_off + 1))
            +}
            diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs
            index a8f8049..73c0cb6 100644
            --- a/crates/nuwiki-lsp/src/export.rs
            +++ b/crates/nuwiki-lsp/src/export.rs
            @@ -227,17 +227,32 @@ pub fn render_page_html(
                 }
             
                 let mut r = HtmlRenderer::new();
            -    if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
            +    {
            +        // Same-page anchor links (`[[#Section]]`) resolve to the *current*
            +        // page's html file plus the fragment (`index.html#Section`), matching
            +        // vimwiki — rather than a bare `#Section`.
            +        let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
                     // 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();
            +        let ext = wiki_extension
            +            .filter(|e| !e.trim_start_matches('.').is_empty())
            +            .map(|e| e.to_string());
                     r = r.with_link_resolver(move |target| {
            +            if matches!(target.kind, LinkKind::AnchorOnly) {
            +                return match &target.anchor {
            +                    Some(a) => format!("{current_html}#{a}"),
            +                    None => current_html.clone(),
            +                };
            +            }
                         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());
            +            if let Some(ext) = &ext {
            +                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)
            @@ -382,14 +397,195 @@ 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";
            +/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
            +/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
            +/// licensed) so exports look identical to upstream out of the box and the
            +/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
            +pub const DEFAULT_CSS: &str = r#"body {
            +  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
            +  margin: 2em 4em 2em 4em;
            +  font-size: 120%;
            +  line-height: 130%;
            +}
            +
            +h1, h2, h3, h4, h5, h6 {
            +  font-weight: bold;
            +  line-height:100%;
            +  margin-top: 1.5em;
            +  margin-bottom: 0.5em;
            +}
            +
            +h1 {font-size: 2em; color: #000000;}
            +h2 {font-size: 1.8em; color: #404040;}
            +h3 {font-size: 1.6em; color: #707070;}
            +h4 {font-size: 1.4em; color: #909090;}
            +h5 {font-size: 1.2em; color: #989898;}
            +h6 {font-size: 1em; color: #9c9c9c;}
            +
            +p, pre, blockquote, table, ul, ol, dl {
            +  margin-top: 1em;
            +  margin-bottom: 1em;
            +}
            +
            +ul ul, ul ol, ol ol, ol ul {
            +  margin-top: 0.5em;
            +  margin-bottom: 0.5em;
            +}
            +
            +li { margin: 0.3em auto; }
            +
            +ul {
            +  margin-left: 2em;
            +  padding-left: 0;
            +}
            +
            +dt { font-weight: bold; }
            +
            +img { border: none; }
            +
            +pre {
            +  border-left: 5px solid #dcdcdc;
            +  background-color: #f5f5f5;
            +  padding-left: 1em;
            +  font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
            +  font-size: 0.8em;
            +  border-radius: 6px;
            +}
            +
            +p > a {
            +  color: white;
            +  text-decoration: none;
            +  font-size: 0.7em;
            +  padding: 3px 6px;
            +  border-radius: 3px;
            +  background-color: #1e90ff;
            +  text-transform: uppercase;
            +  font-weight: bold;
            +}
            +
            +p > a:hover {
            +  color: #dcdcdc;
            +  background-color: #484848;
            +}
            +
            +li > a {
            +  color: #1e90ff;
            +  font-weight: bold;
            +  text-decoration: none;
            +}
            +
            +li > a:hover { color: #ff4500; }
            +
            +blockquote {
            +  color: #686868;
            +  font-size: 0.8em;
            +  line-height: 120%;
            +  padding: 0.8em;
            +  border-left: 5px solid #dcdcdc;
            +}
            +
            +th, td {
            +  border: 1px solid #ccc;
            +  padding: 0.3em;
            +}
            +
            +th { background-color: #f0f0f0; }
            +
            +hr {
            +  border: none;
            +  border-top: 1px solid #ccc;
            +  width: 100%;
            +}
            +
            +del {
            +  text-decoration: line-through;
            +  color: #777777;
            +}
            +
            +.toc li { list-style-type: none; }
            +
            +.todo {
            +  font-weight: bold;
            +  background-color: #ff4500 ;
            +  color: white;
            +  font-size: 0.8em;
            +  padding: 3px 6px;
            +  border-radius: 3px;
            +}
            +
            +.justleft { text-align: left; }
            +.justright { text-align: right; }
            +.justcenter { text-align: center; }
            +
            +.center {
            +  margin-left: auto;
            +  margin-right: auto;
            +}
            +
            +.tag {
            +  background-color: #eeeeee;
            +  font-family: monospace;
            +  padding: 2px;
            +}
            +
            +.header a {
            +  text-decoration: none;
            +  color: inherit;
            +}
            +
            +/* classes for items of todo lists */
            +
            +.rejected {
            +  /* list-style: none; */
            +  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
            +  background-repeat: no-repeat;
            +  background-position: 0 .2em;
            +  padding-left: 1.5em;
            +}
            +.done0 {
            +  /* list-style: none; */
            +  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
            +  background-repeat: no-repeat;
            +  background-position: 0 .2em;
            +  padding-left: 1.5em;
            +}
            +.done1 {
            +  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
            +  background-repeat: no-repeat;
            +  background-position: 0 .15em;
            +  padding-left: 1.5em;
            +}
            +.done2 {
            +  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
            +  background-repeat: no-repeat;
            +  background-position: 0 .15em;
            +  padding-left: 1.5em;
            +}
            +.done3 {
            +  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
            +  background-repeat: no-repeat;
            +  background-position: 0 .15em;
            +  padding-left: 1.5em;
            +}
            +.done4 {
            +  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
            +  background-repeat: no-repeat;
            +  background-position: 0 .15em;
            +  padding-left: 1.5em;
            +}
            +
            +code {
            +  font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
            +  -webkit-border-radius: 1px;
            +  -moz-border-radius: 1px;
            +  border-radius: 1px;
            +  -moz-background-clip: padding;
            +  -webkit-background-clip: padding-box;
            +  background-clip: padding-box;
            +  padding: 0px 3px;
            +  display: inline-block;
            +  color: #52595d;
            +  border: 1px solid #ccc;
            +  background-color: #f9f9f9;
            +}
            +"#;
            diff --git a/crates/parser.rs b/crates/parser.rs
            new file mode 100644
            index 0000000..89f2ea1
            --- /dev/null
            +++ b/crates/parser.rs
            @@ -0,0 +1,1436 @@
            +//! Vimwiki parser.
            +//!
            +//! Hand-rolled recursive-descent over `Vec`.
            +//! Resilient: never aborts the document. Anything the dispatcher can't
            +//! claim becomes a `BlockNode::Error(ErrorNode)` so progress is
            +//! guaranteed.
            +//!
            +//! ## Inline marker precedence
            +//!
            +//! Within a text run, the lexer emits delimiter tokens
            +//! (`BoldDelim` / `ItalicDelim` / `StrikethroughDelim` /
            +//! `SuperscriptDelim` / `SubscriptDelim`) one at a time. The parser pairs
            +//! them by scanning forward for the *next* token of the same kind, then
            +//! recursing on the slice between them:
            +//!
            +//!   - `*foo*`     → Bold
            +//!   - `_foo_`     → Italic
            +//!   - `~~foo~~`   → Strikethrough
            +//!   - `^foo^`     → Superscript
            +//!   - `,,foo,,`   → Subscript
            +//!   - `_*foo*_`   → Italic(Bold(...))   — semantically bold-italic
            +//!   - `*_foo_*`   → Bold(Italic(...))   — semantically bold-italic
            +//!
            +//! Unmatched delimiters fall back to literal `Text` nodes so input like
            +//! `2 * 3 = 6` survives without a closing pair.
            +//!
            +//! ## Link target classification
            +//!
            +//! `[[ ]]` payloads are inspected to choose between `WikiLinkNode`
            +//! (the default) and `ExternalLinkNode` (when the target is a URL). The
            +//! `LinkTarget` enum follows the vimwiki conventions: leading `/` is root-relative,
            +//! `//` is filesystem-absolute, `wiki:` / `wn.:` are interwiki,
            +//! `diary:` / `file:` / `local:` are their own kinds, and a trailing `/`
            +//! marks a directory link.
            +
            +use crate::ast::{
            +    BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode,
            +    DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode,
            +    InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol,
            +    MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode,
            +    SoftBreakNode, Span, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode,
            +    TableNode, TableRowNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode,
            +};
            +use crate::syntax::{Parser, TokenStream};
            +
            +use super::lexer::{VimwikiToken, VimwikiTokenKind as K};
            +
            +#[derive(Debug, Default, Clone)]
            +pub struct VimwikiParser;
            +
            +impl VimwikiParser {
            +    pub fn new() -> Self {
            +        Self
            +    }
            +}
            +
            +impl Parser for VimwikiParser {
            +    type Token = VimwikiToken;
            +
            +    fn parse(&self, tokens: TokenStream) -> DocumentNode {
            +        let v = tokens.into_vec();
            +        let mut state = ParseState::new(&v);
            +        state.parse_document()
            +    }
            +}
            +
            +// ===== Cursor =====
            +
            +struct ParseState<'a> {
            +    toks: &'a [VimwikiToken],
            +    pos: usize,
            +    /// Count of `HeadingNode`s already pushed to `children`.
            +    /// `headings_seen - 1` is the most recent heading's index.
            +    headings_seen: usize,
            +    /// Source line of the most recently-emitted heading.
            +    /// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag.
            +    last_heading_line: Option,
            +}
            +
            +impl<'a> ParseState<'a> {
            +    fn new(toks: &'a [VimwikiToken]) -> Self {
            +        Self {
            +            toks,
            +            pos: 0,
            +            headings_seen: 0,
            +            last_heading_line: None,
            +        }
            +    }
            +
            +    fn at_eof(&self) -> bool {
            +        self.pos >= self.toks.len()
            +    }
            +
            +    fn peek(&self) -> Option<&'a VimwikiToken> {
            +        self.toks.get(self.pos)
            +    }
            +
            +    fn peek_kind(&self) -> Option<&'a K> {
            +        self.peek().map(|t| &t.kind)
            +    }
            +
            +    fn advance(&mut self) -> Option<&'a VimwikiToken> {
            +        let t = self.toks.get(self.pos);
            +        if t.is_some() {
            +            self.pos += 1;
            +        }
            +        t
            +    }
            +
            +    fn skip_blanks(&mut self) {
            +        while matches!(self.peek_kind(), Some(K::Newline) | Some(K::BlankLine)) {
            +            self.pos += 1;
            +        }
            +    }
            +
            +    fn eat_newline(&mut self) -> bool {
            +        if matches!(self.peek_kind(), Some(K::Newline)) {
            +            self.pos += 1;
            +            true
            +        } else {
            +            false
            +        }
            +    }
            +
            +    // ===== Document =====
            +
            +    fn parse_document(&mut self) -> DocumentNode {
            +        let start_pos = self.toks.first().map(|t| t.span.start).unwrap_or_default();
            +        let end_pos = self.toks.last().map(|t| t.span.end).unwrap_or(start_pos);
            +
            +        let mut metadata = PageMetadata::default();
            +        let mut children = Vec::new();
            +
            +        loop {
            +            self.skip_blanks();
            +            if self.at_eof() {
            +                break;
            +            }
            +            if self.consume_placeholder(&mut metadata) {
            +                continue;
            +            }
            +            let before = self.pos;
            +            let block = self.parse_block();
            +            // File-scope tags get accumulated into metadata so
            +            // callers can read page-level tag membership without walking
            +            // the AST. The TagNode itself stays in `children` so renderers
            +            // see it too.
            +            if let BlockNode::Tag(t) = &block {
            +                if t.scope == TagScope::File {
            +                    for name in &t.tags {
            +                        if !metadata.tags.iter().any(|t| t == name) {
            +                            metadata.tags.push(name.clone());
            +                        }
            +                    }
            +                }
            +            }
            +            children.push(block);
            +            // Resilience: guarantee forward progress.
            +            if self.pos == before {
            +                let span = self.toks.get(self.pos).map(|t| t.span).unwrap_or_default();
            +                children.push(BlockNode::Error(ErrorNode {
            +                    span,
            +                    raw: format!("{:?}", self.toks.get(self.pos).map(|t| &t.kind)),
            +                    message: "parser made no progress".to_owned(),
            +                }));
            +                self.pos += 1;
            +            }
            +        }
            +
            +        DocumentNode {
            +            span: Span::new(start_pos, end_pos),
            +            children,
            +            metadata,
            +        }
            +    }
            +
            +    fn consume_placeholder(&mut self, metadata: &mut PageMetadata) -> bool {
            +        let updated = match self.peek_kind() {
            +            Some(K::PlaceholderTitle(v)) => {
            +                metadata.title = v.clone();
            +                true
            +            }
            +            Some(K::PlaceholderNohtml) => {
            +                metadata.nohtml = true;
            +                true
            +            }
            +            Some(K::PlaceholderTemplate(v)) => {
            +                metadata.template = v.clone();
            +                true
            +            }
            +            Some(K::PlaceholderDate(v)) => {
            +                metadata.date = v.clone();
            +                true
            +            }
            +            _ => false,
            +        };
            +        if updated {
            +            self.advance();
            +            self.eat_newline();
            +        }
            +        updated
            +    }
            +
            +    // ===== Block dispatch =====
            +
            +    fn parse_block(&mut self) -> BlockNode {
            +        let kind = self.peek_kind().expect("parse_block called at EOF");
            +        match kind {
            +            K::HeadingOpen { .. } => self.parse_heading(),
            +            K::HorizontalRule => self.parse_horizontal_rule(),
            +            K::ListMarker { .. } => self.parse_list(),
            +            K::BlockquoteMarker | K::BlockquoteIndent => self.parse_blockquote(),
            +            K::TableSep | K::TableHeaderRow(_) => self.parse_table(),
            +            K::PreformattedOpen { .. } => self.parse_preformatted(),
            +            K::MathBlockOpen { .. } => self.parse_math_block(),
            +            K::CommentLine(_) => self.parse_single_comment(),
            +            K::CommentMultiOpen => self.parse_multi_comment(),
            +            K::Tag(_) => self.parse_tag(),
            +            _ => {
            +                if self.is_definition_start() {
            +                    self.parse_definition_list()
            +                } else {
            +                    self.parse_paragraph()
            +                }
            +            }
            +        }
            +    }
            +
            +    // ===== Tag =====
            +
            +    fn parse_tag(&mut self) -> BlockNode {
            +        let t = self.advance().unwrap();
            +        let names = match &t.kind {
            +            K::Tag(v) => v.clone(),
            +            _ => unreachable!(),
            +        };
            +        let span = t.span;
            +        let scope = self.scope_for_tag(span.start.line);
            +        self.eat_newline();
            +        BlockNode::Tag(TagNode {
            +            span,
            +            tags: names,
            +            scope,
            +        })
            +    }
            +
            +    /// Decide the placement-determined scope of a tag at the given source
            +    /// line, matching vimwiki's rules:
            +    ///   - lines 0 or 1 → `File`
            +    ///   - within two lines after the most recent heading → `Heading(idx)`
            +    ///   - otherwise → `Standalone`
            +    fn scope_for_tag(&self, tag_line: u32) -> TagScope {
            +        if tag_line <= 1 {
            +            return TagScope::File;
            +        }
            +        if let Some(h_line) = self.last_heading_line {
            +            let delta = tag_line.saturating_sub(h_line);
            +            if (1..=2).contains(&delta) && self.headings_seen > 0 {
            +                return TagScope::Heading(self.headings_seen - 1);
            +            }
            +        }
            +        TagScope::Standalone
            +    }
            +
            +    // ===== Heading =====
            +
            +    fn parse_heading(&mut self) -> BlockNode {
            +        let open = self.advance().unwrap();
            +        let span_start = open.span.start;
            +        let (level, centered) = match &open.kind {
            +            K::HeadingOpen { level, centered } => (*level, *centered),
            +            _ => unreachable!(),
            +        };
            +        let inline_start = self.pos;
            +        let mut span_end = open.span.end;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::HeadingClose => {
            +                    span_end = t.span.end;
            +                    let inline = parse_inline_seq(&self.toks[inline_start..self.pos]);
            +                    self.advance();
            +                    self.eat_newline();
            +                    // Record for tag-scope resolution.
            +                    self.headings_seen += 1;
            +                    self.last_heading_line = Some(span_end.line);
            +                    return BlockNode::Heading(HeadingNode {
            +                        span: Span::new(span_start, span_end),
            +                        level,
            +                        centered,
            +                        children: inline,
            +                    });
            +                }
            +                K::Newline | K::BlankLine => break,
            +                _ => {
            +                    self.advance();
            +                }
            +            }
            +        }
            +        // No closing `=` seen: emit a paragraph from what we collected.
            +        let inline = parse_inline_seq(&self.toks[inline_start..self.pos]);
            +        self.eat_newline();
            +        BlockNode::Paragraph(ParagraphNode {
            +            span: Span::new(span_start, span_end),
            +            children: inline,
            +        })
            +    }
            +
            +    // ===== Horizontal rule =====
            +
            +    fn parse_horizontal_rule(&mut self) -> BlockNode {
            +        let t = self.advance().unwrap();
            +        let span = t.span;
            +        self.eat_newline();
            +        BlockNode::HorizontalRule(HorizontalRuleNode { span })
            +    }
            +
            +    // ===== Comments =====
            +
            +    fn parse_single_comment(&mut self) -> BlockNode {
            +        let t = self.advance().unwrap();
            +        let content = match &t.kind {
            +            K::CommentLine(s) => s.clone(),
            +            _ => unreachable!(),
            +        };
            +        let span = t.span;
            +        self.eat_newline();
            +        BlockNode::Comment(CommentNode { span, content })
            +    }
            +
            +    fn parse_multi_comment(&mut self) -> BlockNode {
            +        let open = self.advance().unwrap();
            +        let span_start = open.span.start;
            +        let mut span_end = open.span.end;
            +        let mut content = String::new();
            +        let mut first = true;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::CommentMultiClose => {
            +                    span_end = t.span.end;
            +                    self.advance();
            +                    self.eat_newline();
            +                    return BlockNode::Comment(CommentNode {
            +                        span: Span::new(span_start, span_end),
            +                        content,
            +                    });
            +                }
            +                K::CommentMultiLine(s) => {
            +                    if !first {
            +                        content.push('\n');
            +                    }
            +                    content.push_str(s);
            +                    first = false;
            +                    span_end = t.span.end;
            +                    self.advance();
            +                }
            +                K::Newline => {
            +                    self.advance();
            +                }
            +                _ => {
            +                    self.advance();
            +                }
            +            }
            +        }
            +        // Unterminated: keep what we have.
            +        BlockNode::Comment(CommentNode {
            +            span: Span::new(span_start, span_end),
            +            content,
            +        })
            +    }
            +
            +    // ===== Preformatted =====
            +
            +    fn parse_preformatted(&mut self) -> BlockNode {
            +        let open = self.advance().unwrap();
            +        let span_start = open.span.start;
            +        let mut span_end = open.span.end;
            +        let (language, attrs) = match &open.kind {
            +            K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
            +            _ => unreachable!(),
            +        };
            +        let mut content = String::new();
            +        let mut first = true;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::PreformattedClose => {
            +                    span_end = t.span.end;
            +                    self.advance();
            +                    self.eat_newline();
            +                    return BlockNode::Preformatted(PreformattedNode {
            +                        span: Span::new(span_start, span_end),
            +                        content,
            +                        language,
            +                        attrs,
            +                    });
            +                }
            +                K::PreformattedLine(s) => {
            +                    if !first {
            +                        content.push('\n');
            +                    }
            +                    content.push_str(s);
            +                    first = false;
            +                    span_end = t.span.end;
            +                    self.advance();
            +                }
            +                K::Newline => {
            +                    self.advance();
            +                }
            +                _ => {
            +                    self.advance();
            +                }
            +            }
            +        }
            +        BlockNode::Preformatted(PreformattedNode {
            +            span: Span::new(span_start, span_end),
            +            content,
            +            language,
            +            attrs,
            +        })
            +    }
            +
            +    // ===== Math block =====
            +
            +    fn parse_math_block(&mut self) -> BlockNode {
            +        let open = self.advance().unwrap();
            +        let span_start = open.span.start;
            +        let mut span_end = open.span.end;
            +        let environment = match &open.kind {
            +            K::MathBlockOpen { environment } => environment.clone(),
            +            _ => unreachable!(),
            +        };
            +        let mut content = String::new();
            +        let mut first = true;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::MathBlockClose => {
            +                    span_end = t.span.end;
            +                    self.advance();
            +                    self.eat_newline();
            +                    return BlockNode::MathBlock(MathBlockNode {
            +                        span: Span::new(span_start, span_end),
            +                        content,
            +                        environment,
            +                    });
            +                }
            +                K::MathBlockLine(s) => {
            +                    if !first {
            +                        content.push('\n');
            +                    }
            +                    content.push_str(s);
            +                    first = false;
            +                    span_end = t.span.end;
            +                    self.advance();
            +                }
            +                K::Newline => {
            +                    self.advance();
            +                }
            +                _ => {
            +                    self.advance();
            +                }
            +            }
            +        }
            +        BlockNode::MathBlock(MathBlockNode {
            +            span: Span::new(span_start, span_end),
            +            content,
            +            environment,
            +        })
            +    }
            +
            +    // ===== List =====
            +
            +    fn parse_list(&mut self) -> BlockNode {
            +        let first = self.peek().expect("parse_list at EOF");
            +        let span_start = first.span.start;
            +        let (base_symbol, base_indent) = match &first.kind {
            +            K::ListMarker { symbol, indent } => (*symbol, *indent),
            +            _ => unreachable!(),
            +        };
            +        let mut items = Vec::new();
            +        let mut span_end = span_start;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::ListMarker { indent, .. } if *indent == base_indent => {
            +                    let item = self.parse_list_item();
            +                    span_end = item.span.end;
            +                    items.push(item);
            +                }
            +                _ => break,
            +            }
            +        }
            +        BlockNode::List(ListNode {
            +            span: Span::new(span_start, span_end),
            +            ordered: matches!(
            +                base_symbol,
            +                ListSymbol::Numeric
            +                    | ListSymbol::NumericParen
            +                    | ListSymbol::AlphaParen
            +                    | ListSymbol::AlphaUpperParen
            +                    | ListSymbol::RomanParen
            +                    | ListSymbol::RomanUpperParen
            +            ),
            +            symbol: base_symbol,
            +            items,
            +        })
            +    }
            +
            +    fn parse_list_item(&mut self) -> ListItemNode {
            +        let marker = self.advance().expect("list item without marker");
            +        let span_start = marker.span.start;
            +        let mut span_end = marker.span.end;
            +        let (symbol, level) = match &marker.kind {
            +            K::ListMarker { symbol, indent } => (*symbol, *indent as usize),
            +            _ => unreachable!(),
            +        };
            +
            +        // Optional Checkbox
            +        let checkbox = if let Some(K::Checkbox(state)) = self.peek_kind() {
            +            let state = *state;
            +            let t = self.advance().unwrap();
            +            span_end = t.span.end;
            +            Some(state)
            +        } else {
            +            None
            +        };
            +
            +        // Inline content until end of line.
            +        let inline_start = self.pos;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::Newline | K::BlankLine => break,
            +                _ => {
            +                    span_end = t.span.end;
            +                    self.advance();
            +                }
            +            }
            +        }
            +        let inline_end = self.pos;
            +        let mut children = parse_inline_seq(&self.toks[inline_start..inline_end]);
            +        self.eat_newline();
            +
            +        // Continuation lines (vimwiki parity): lines indented strictly past
            +        // the marker's column attach to this item rather than becoming a
            +        // sibling paragraph. The lexer doesn't model list state, so we
            +        // detect this here by inspecting whatever the next line opens
            +        // with: either a `Text(s)` whose leading whitespace exceeds the
            +        // marker's column, or a `BlockquoteIndent` token (the lexer emits
            +        // it for 4+ leading spaces, swallowing them from the following
            +        // Text). We accept either as a continuation when the implied
            +        // indent is `> level`.
            +        let continuation_threshold = level + 1;
            +        loop {
            +            let Some(first) = self.peek() else { break };
            +            let mut consume_first = false;
            +            let is_continuation = match &first.kind {
            +                K::Text(s) => leading_ws_count(s) >= continuation_threshold,
            +                K::BlockquoteIndent => {
            +                    // `BlockquoteIndent`'s span covers the leading-whitespace
            +                    // run on the current line. Width ≥ threshold means this
            +                    // is a continuation, not a blockquote.
            +                    let width = (first.span.end.column - first.span.start.column) as usize;
            +                    if width >= continuation_threshold {
            +                        consume_first = true;
            +                        true
            +                    } else {
            +                        false
            +                    }
            +                }
            +                _ => false,
            +            };
            +            if !is_continuation {
            +                break;
            +            }
            +            // Eat the synthetic BlockquoteIndent if present — the lexer
            +            // already stripped the whitespace from the following Text so
            +            // we don't want to also emit a leading space ourselves.
            +            if consume_first {
            +                self.advance();
            +            }
            +            let cont_start = self.pos;
            +            while let Some(t) = self.peek() {
            +                match &t.kind {
            +                    K::Newline | K::BlankLine => break,
            +                    _ => {
            +                        span_end = t.span.end;
            +                        self.advance();
            +                    }
            +                }
            +            }
            +            let cont_end = self.pos;
            +            // Drop the leading whitespace on the first text token (only
            +            // present when we came via the Text branch — the
            +            // BlockquoteIndent path already stripped it).
            +            let mut buf: Vec = self.toks[cont_start..cont_end].to_vec();
            +            if !consume_first {
            +                if let Some(t0) = buf.first_mut() {
            +                    if let K::Text(text) = &t0.kind {
            +                        let trimmed = text.trim_start().to_owned();
            +                        t0.kind = K::Text(trimmed);
            +                    }
            +                }
            +            }
            +            // Insert a soft break between the previous content and the
            +            // continuation so the rendered text flows naturally (and honours
            +            // `*_ignore_newline`).
            +            if !children.is_empty() {
            +                children.push(InlineNode::SoftBreak(SoftBreakNode { span: first.span }));
            +            }
            +            let cont = parse_inline_seq(&buf);
            +            children.extend(cont);
            +            if !self.eat_newline() {
            +                break;
            +            }
            +        }
            +
            +        // Sublist: subsequent ListMarker tokens with deeper indent.
            +        let sublist = if let Some(K::ListMarker { indent, .. }) = self.peek_kind() {
            +            if (*indent as usize) > level {
            +                let block = self.parse_list();
            +                if let BlockNode::List(node) = block {
            +                    span_end = node.span.end;
            +                    Some(node)
            +                } else {
            +                    None
            +                }
            +            } else {
            +                None
            +            }
            +        } else {
            +            None
            +        };
            +
            +        ListItemNode {
            +            span: Span::new(span_start, span_end),
            +            symbol,
            +            level,
            +            checkbox,
            +            children,
            +            sublist,
            +        }
            +    }
            +
            +    // ===== Blockquote =====
            +
            +    fn parse_blockquote(&mut self) -> BlockNode {
            +        let first = self.peek().unwrap();
            +        let span_start = first.span.start;
            +        let mut span_end = span_start;
            +        let mut lines: Vec> = Vec::new();
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::BlockquoteMarker | K::BlockquoteIndent => {
            +                    self.advance();
            +                    let inline_start = self.pos;
            +                    while let Some(t2) = self.peek() {
            +                        match &t2.kind {
            +                            K::Newline | K::BlankLine => break,
            +                            _ => {
            +                                span_end = t2.span.end;
            +                                self.advance();
            +                            }
            +                        }
            +                    }
            +                    let inline_end = self.pos;
            +                    let inline = parse_inline_seq(&self.toks[inline_start..inline_end]);
            +                    if !inline.is_empty() {
            +                        lines.push(inline);
            +                    }
            +                    self.eat_newline();
            +                }
            +                _ => break,
            +            }
            +        }
            +        let mut children: Vec = Vec::with_capacity(lines.len());
            +        for line in lines {
            +            let line_start = line
            +                .first()
            +                .and_then(span_start_of_inline)
            +                .unwrap_or(span_start);
            +            let line_end = line.last().and_then(span_end_of_inline).unwrap_or(span_end);
            +            children.push(BlockNode::Paragraph(ParagraphNode {
            +                span: Span::new(line_start, line_end),
            +                children: line,
            +            }));
            +        }
            +        BlockNode::Blockquote(BlockquoteNode {
            +            span: Span::new(span_start, span_end),
            +            children,
            +        })
            +    }
            +
            +    // ===== Table =====
            +
            +    fn parse_table(&mut self) -> BlockNode {
            +        let first = self.peek().unwrap();
            +        let span_start = first.span.start;
            +        let mut span_end = span_start;
            +        let mut rows: Vec = Vec::new();
            +        let mut next_is_header = false;
            +        let mut has_header = false;
            +        let mut alignments: Vec = Vec::new();
            +
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::TableHeaderRow(aligns) => {
            +                    has_header = true;
            +                    if alignments.is_empty() {
            +                        alignments = aligns.clone();
            +                    }
            +                    if let Some(last) = rows.last_mut() {
            +                        last.is_header = true;
            +                    }
            +                    next_is_header = true;
            +                    span_end = t.span.end;
            +                    self.advance();
            +                    self.eat_newline();
            +                }
            +                K::TableSep => {
            +                    let row = self.parse_table_row();
            +                    span_end = row.span.end;
            +                    rows.push(row);
            +                    if next_is_header {
            +                        next_is_header = false;
            +                    }
            +                }
            +                _ => break,
            +            }
            +        }
            +        BlockNode::Table(TableNode {
            +            span: Span::new(span_start, span_end),
            +            rows,
            +            has_header,
            +            alignments,
            +        })
            +    }
            +
            +    fn parse_table_row(&mut self) -> TableRowNode {
            +        // Consume leading TableSep
            +        let first_sep = self.advance().expect("table row without leading |");
            +        let span_start = first_sep.span.start;
            +        let mut span_end = first_sep.span.end;
            +        let mut cells: Vec = Vec::new();
            +
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::Newline | K::BlankLine => break,
            +                K::TableSep => {
            +                    span_end = t.span.end;
            +                    self.advance();
            +                }
            +                K::TableColSpan => {
            +                    let span = t.span;
            +                    span_end = span.end;
            +                    self.advance();
            +                    cells.push(TableCellNode {
            +                        span,
            +                        children: Vec::new(),
            +                        col_span: true,
            +                        row_span: false,
            +                    });
            +                }
            +                K::TableRowSpan => {
            +                    let span = t.span;
            +                    span_end = span.end;
            +                    self.advance();
            +                    cells.push(TableCellNode {
            +                        span,
            +                        children: Vec::new(),
            +                        col_span: false,
            +                        row_span: true,
            +                    });
            +                }
            +                _ => {
            +                    let cell_start_idx = self.pos;
            +                    let cell_start_pos = t.span.start;
            +                    let mut cell_end_pos = t.span.end;
            +                    while let Some(tt) = self.peek() {
            +                        match &tt.kind {
            +                            K::TableSep | K::Newline | K::BlankLine => break,
            +                            _ => {
            +                                cell_end_pos = tt.span.end;
            +                                self.advance();
            +                            }
            +                        }
            +                    }
            +                    let inline = parse_inline_seq(&self.toks[cell_start_idx..self.pos]);
            +                    span_end = cell_end_pos;
            +                    cells.push(TableCellNode {
            +                        span: Span::new(cell_start_pos, cell_end_pos),
            +                        children: inline,
            +                        col_span: false,
            +                        row_span: false,
            +                    });
            +                }
            +            }
            +        }
            +        self.eat_newline();
            +        TableRowNode {
            +            span: Span::new(span_start, span_end),
            +            cells,
            +            is_header: false,
            +        }
            +    }
            +
            +    // ===== Definition list =====
            +
            +    fn is_definition_start(&self) -> bool {
            +        // Walk forward on the current line. If we find a DefinitionTermMarker
            +        // before a Newline/BlankLine, treat as a definition.
            +        let mut i = self.pos;
            +        while let Some(t) = self.toks.get(i) {
            +            match &t.kind {
            +                K::Newline | K::BlankLine => return false,
            +                K::DefinitionTermMarker => return true,
            +                _ => i += 1,
            +            }
            +        }
            +        false
            +    }
            +
            +    fn parse_definition_list(&mut self) -> BlockNode {
            +        let first = self.peek().unwrap();
            +        let span_start = first.span.start;
            +        let mut span_end = span_start;
            +        let mut items: Vec = Vec::new();
            +        while self.is_definition_start() {
            +            let item = self.parse_definition_item();
            +            span_end = item.span.end;
            +            items.push(item);
            +        }
            +        BlockNode::DefinitionList(DefinitionListNode {
            +            span: Span::new(span_start, span_end),
            +            items,
            +        })
            +    }
            +
            +    fn parse_definition_item(&mut self) -> DefinitionItemNode {
            +        let item_start_idx = self.pos;
            +        let item_span_start = self.toks[item_start_idx].span.start;
            +
            +        // Term: tokens until DefinitionTermMarker
            +        let term_start = self.pos;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::DefinitionTermMarker | K::Newline | K::BlankLine => break,
            +                _ => {
            +                    self.advance();
            +                }
            +            }
            +        }
            +        let term_end = self.pos;
            +        let term_inline = if term_end > term_start {
            +            let v = parse_inline_seq(&self.toks[term_start..term_end]);
            +            if v.is_empty() {
            +                None
            +            } else {
            +                Some(v)
            +            }
            +        } else {
            +            None
            +        };
            +
            +        let mut span_end = self
            +            .toks
            +            .get(term_end.saturating_sub(1))
            +            .map(|t| t.span.end)
            +            .unwrap_or(item_span_start);
            +
            +        // DefinitionTermMarker
            +        if matches!(self.peek_kind(), Some(K::DefinitionTermMarker)) {
            +            span_end = self.peek().unwrap().span.end;
            +            self.advance();
            +        }
            +
            +        // Definition tokens until end of line
            +        let def_start = self.pos;
            +        while let Some(t) = self.peek() {
            +            match &t.kind {
            +                K::Newline | K::BlankLine => break,
            +                _ => {
            +                    span_end = t.span.end;
            +                    self.advance();
            +                }
            +            }
            +        }
            +        let def_end = self.pos;
            +        let mut definitions = Vec::new();
            +        if def_end > def_start {
            +            let v = parse_inline_seq(&self.toks[def_start..def_end]);
            +            if !v.is_empty() {
            +                definitions.push(v);
            +            }
            +        }
            +        self.eat_newline();
            +
            +        DefinitionItemNode {
            +            span: Span::new(item_span_start, span_end),
            +            term: term_inline,
            +            definitions,
            +        }
            +    }
            +
            +    // ===== Paragraph =====
            +
            +    fn parse_paragraph(&mut self) -> BlockNode {
            +        let span_start = self.peek().unwrap().span.start;
            +        let inline_start = self.pos;
            +        // `content_end` excludes a trailing newline that just terminates
            +        // the paragraph (instead of joining two content lines), so it
            +        // doesn't leak into `parse_inline_seq` as a stray soft-break " ".
            +        let mut content_end = self.pos;
            +        let mut span_end = span_start;
            +
            +        loop {
            +            match self.peek_kind() {
            +                None => break,
            +                Some(K::Newline) => {
            +                    span_end = self.peek().unwrap().span.end;
            +                    self.advance();
            +                    match self.peek_kind() {
            +                        None => break,
            +                        Some(k) if starts_new_block(k) => break,
            +                        // Soft-break: the newline joins two content lines.
            +                        // Pull it into the inline range as a space.
            +                        _ => content_end = self.pos,
            +                    }
            +                }
            +                Some(k) if starts_new_block(k) => break,
            +                Some(_) => {
            +                    span_end = self.peek().unwrap().span.end;
            +                    self.advance();
            +                    content_end = self.pos;
            +                }
            +            }
            +        }
            +
            +        let children = parse_inline_seq(&self.toks[inline_start..content_end]);
            +        BlockNode::Paragraph(ParagraphNode {
            +            span: Span::new(span_start, span_end),
            +            children,
            +        })
            +    }
            +}
            +
            +fn starts_new_block(kind: &K) -> bool {
            +    matches!(
            +        kind,
            +        K::BlankLine
            +            | K::HeadingOpen { .. }
            +            | K::HorizontalRule
            +            | K::ListMarker { .. }
            +            | K::BlockquoteMarker
            +            | K::BlockquoteIndent
            +            | K::TableSep
            +            | K::TableHeaderRow(_)
            +            | K::PreformattedOpen { .. }
            +            | K::MathBlockOpen { .. }
            +            | K::CommentLine(_)
            +            | K::CommentMultiOpen
            +            | K::PlaceholderTitle(_)
            +            | K::PlaceholderNohtml
            +            | K::PlaceholderTemplate(_)
            +            | K::PlaceholderDate(_)
            +            | K::Tag(_)
            +    )
            +}
            +
            +// ===== Inline pairing =====
            +
            +fn parse_inline_seq(toks: &[VimwikiToken]) -> Vec {
            +    let mut out = Vec::new();
            +    let mut i = 0;
            +    while i < toks.len() {
            +        let token = &toks[i];
            +        match &token.kind {
            +            K::Text(s) => {
            +                out.push(InlineNode::Text(TextNode {
            +                    span: token.span,
            +                    content: s.clone(),
            +                }));
            +                i += 1;
            +            }
            +            K::Code(s) => {
            +                out.push(InlineNode::Code(CodeNode {
            +                    span: token.span,
            +                    content: s.clone(),
            +                }));
            +                i += 1;
            +            }
            +            K::MathInline(s) => {
            +                out.push(InlineNode::MathInline(MathInlineNode {
            +                    span: token.span,
            +                    content: s.clone(),
            +                }));
            +                i += 1;
            +            }
            +            K::Keyword(k) => {
            +                out.push(InlineNode::Keyword(KeywordNode {
            +                    span: token.span,
            +                    keyword: *k,
            +                }));
            +                i += 1;
            +            }
            +            K::RawUrl(u) => {
            +                out.push(InlineNode::RawUrl(RawUrlNode {
            +                    span: token.span,
            +                    url: u.clone(),
            +                }));
            +                i += 1;
            +            }
            +            K::Newline => {
            +                // Soft break inside a multi-line block. A dedicated node lets
            +                // the renderer honour `*_ignore_newline` (space vs `
            `); + // other consumers treat it as whitespace. + out.push(InlineNode::SoftBreak(SoftBreakNode { span: token.span })); + i += 1; + } + K::BoldDelim => { + i = pair_or_text(toks, i, "*", &mut out, K::BoldDelim, |span, kids| { + InlineNode::Bold(BoldNode { + span, + children: kids, + }) + }) + } + K::ItalicDelim => { + i = pair_or_text(toks, i, "_", &mut out, K::ItalicDelim, |span, kids| { + InlineNode::Italic(ItalicNode { + span, + children: kids, + }) + }) + } + K::StrikethroughDelim => { + i = pair_or_text( + toks, + i, + "~~", + &mut out, + K::StrikethroughDelim, + |span, kids| { + InlineNode::Strikethrough(StrikethroughNode { + span, + children: kids, + }) + }, + ) + } + K::SuperscriptDelim => { + i = pair_or_text(toks, i, "^", &mut out, K::SuperscriptDelim, |span, kids| { + InlineNode::Superscript(SuperscriptNode { + span, + children: kids, + }) + }) + } + K::SubscriptDelim => { + i = pair_or_text(toks, i, ",,", &mut out, K::SubscriptDelim, |span, kids| { + InlineNode::Subscript(SubscriptNode { + span, + children: kids, + }) + }) + } + K::WikiLinkOpen => { + let (node, next) = parse_wikilink(toks, i); + out.push(node); + i = next; + } + K::TransclusionOpen => { + let (node, next) = parse_transclusion(toks, i); + out.push(node); + i = next; + } + // Tokens that shouldn't appear in inline context (e.g. dangling + // close delimiters, separators outside their owners): emit as + // literal text so nothing is silently dropped. + K::WikiLinkClose => push_literal(token, "]]", &mut out, &mut i), + K::WikiLinkSep => push_literal(token, "|", &mut out, &mut i), + K::TransclusionClose => push_literal(token, "}}", &mut out, &mut i), + K::TransclusionSep => push_literal(token, "|", &mut out, &mut i), + K::HeadingClose => push_literal(token, "=", &mut out, &mut i), + // Other tokens (block markers, etc.) should not appear here; skip. + _ => { + i += 1; + } + } + } + out +} + +fn push_literal(token: &VimwikiToken, lit: &str, out: &mut Vec, i: &mut usize) { + out.push(InlineNode::Text(TextNode { + span: token.span, + content: lit.into(), + })); + *i += 1; +} + +fn pair_or_text( + toks: &[VimwikiToken], + open_idx: usize, + literal: &str, + out: &mut Vec, + target: K, + build: F, +) -> usize +where + F: FnOnce(Span, Vec) -> InlineNode, +{ + if let Some(close_idx) = find_close(toks, open_idx, &target) { + let inner = parse_inline_seq(&toks[open_idx + 1..close_idx]); + let span = Span::new(toks[open_idx].span.start, toks[close_idx].span.end); + out.push(build(span, inner)); + close_idx + 1 + } else { + out.push(InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: literal.into(), + })); + open_idx + 1 + } +} + +fn find_close(toks: &[VimwikiToken], start: usize, target: &K) -> Option { + for (offset, t) in toks.iter().enumerate().skip(start + 1) { + if &t.kind == target { + return Some(offset); + } + } + None +} + +// ===== Wikilink + transclusion ===== + +fn parse_wikilink(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { + let span_start = toks[open_idx].span.start; + let mut i = open_idx + 1; + let target_start = i; + let mut target_end = i; + let mut sep_idx: Option = None; + let mut close_idx: Option = None; + while i < toks.len() { + match &toks[i].kind { + K::WikiLinkClose => { + close_idx = Some(i); + break; + } + K::WikiLinkSep if sep_idx.is_none() => { + sep_idx = Some(i); + target_end = i; + } + _ => {} + } + i += 1; + } + let close_idx = match close_idx { + Some(c) => c, + None => { + // Unterminated link: emit literal `[[`. + return ( + InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: "[[".into(), + }), + open_idx + 1, + ); + } + }; + + if sep_idx.is_none() { + target_end = close_idx; + } + + let target_text = collect_text(&toks[target_start..target_end]); + let span = Span::new(span_start, toks[close_idx].span.end); + + let description: Option> = sep_idx.map(|sep| { + let inner = parse_inline_seq(&toks[sep + 1..close_idx]); + if inner.is_empty() { + vec![] + } else { + inner + } + }); + + if is_url(&target_text) { + ( + InlineNode::ExternalLink(ExternalLinkNode { + span, + url: target_text, + description, + }), + close_idx + 1, + ) + } else { + let target = parse_link_target(&target_text); + ( + InlineNode::WikiLink(WikiLinkNode { + span, + target, + description, + }), + close_idx + 1, + ) + } +} + +fn parse_transclusion(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { + let span_start = toks[open_idx].span.start; + let mut i = open_idx + 1; + let url_start = i; + let mut url_end = i; + let mut seps: Vec = Vec::new(); + let mut close_idx: Option = None; + while i < toks.len() { + match &toks[i].kind { + K::TransclusionClose => { + close_idx = Some(i); + break; + } + K::TransclusionSep => { + if seps.is_empty() { + url_end = i; + } + seps.push(i); + } + _ => {} + } + i += 1; + } + let close_idx = match close_idx { + Some(c) => c, + None => { + return ( + InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: "{{".into(), + }), + open_idx + 1, + ); + } + }; + if seps.is_empty() { + url_end = close_idx; + } + let url = collect_text(&toks[url_start..url_end]); + let span = Span::new(span_start, toks[close_idx].span.end); + + // After URL: optional alt, then key=val attrs. + let mut alt = None; + let mut attrs = std::collections::HashMap::new(); + if !seps.is_empty() { + let alt_start = seps[0] + 1; + let alt_end = if seps.len() >= 2 { seps[1] } else { close_idx }; + let alt_text = collect_text(&toks[alt_start..alt_end]); + if !alt_text.is_empty() { + alt = Some(alt_text); + } + for window in seps.windows(2) { + let s = window[0]; + let e = window[1]; + let segment = collect_text(&toks[s + 1..e]); + insert_attr(&segment, &mut attrs); + } + // Last attribute segment between final sep and close. + if let Some(&last) = seps.last() { + if last + 1 < close_idx && seps.len() >= 2 { + let segment = collect_text(&toks[last + 1..close_idx]); + insert_attr(&segment, &mut attrs); + } + } + } + + ( + InlineNode::Transclusion(TransclusionNode { + span, + url, + alt, + attrs, + }), + close_idx + 1, + ) +} + +/// Count leading space / tab characters at the start of `s`. Used by +/// `parse_list_item` to detect continuation lines. +fn leading_ws_count(s: &str) -> usize { + s.chars().take_while(|c| *c == ' ' || *c == '\t').count() +} + +/// Split a `key=value` attribute segment, strip surrounding quotes from +/// the value, and insert into `attrs`. Empty keys are skipped. +fn insert_attr(segment: &str, attrs: &mut std::collections::HashMap) { + let Some(eq) = segment.find('=') else { return }; + let k = segment[..eq].trim().to_owned(); + if k.is_empty() { + return; + } + let raw = segment[eq + 1..].trim(); + let v = strip_quotes(raw).to_owned(); + attrs.insert(k, v); +} + +fn strip_quotes(s: &str) -> &str { + let b = s.as_bytes(); + if b.len() >= 2 + && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\'')) + { + &s[1..s.len() - 1] + } else { + s + } +} + +fn collect_text(toks: &[VimwikiToken]) -> String { + let mut out = String::new(); + for t in toks { + if let K::Text(s) = &t.kind { + out.push_str(s); + } + } + out +} + +fn is_url(s: &str) -> bool { + s.starts_with("http://") + || s.starts_with("https://") + || s.starts_with("ftp://") + || s.starts_with("mailto:") + || s.starts_with("file://") +} + +// ===== LinkTarget classification ===== + +fn parse_link_target(text: &str) -> LinkTarget { + let mut target = LinkTarget { + kind: LinkKind::Wiki, + path: None, + wiki_index: None, + wiki_name: None, + anchor: None, + is_absolute: false, + is_directory: false, + }; + + // Split off anchor first. + let (path_part, anchor) = match text.split_once('#') { + Some((p, a)) => (p, Some(a.to_owned())), + None => (text, None), + }; + target.anchor = anchor; + + if path_part.is_empty() { + target.kind = LinkKind::AnchorOnly; + return target; + } + + // `//path` — filesystem-absolute. + if let Some(rest) = path_part.strip_prefix("//") { + target.kind = LinkKind::Local; + target.is_absolute = true; + let (path, is_dir) = strip_directory(rest); + target.is_directory = is_dir; + target.path = Some(path); + return target; + } + + // `/Page` — root-relative wiki link. + if let Some(rest) = path_part.strip_prefix('/') { + target.kind = LinkKind::Wiki; + target.is_absolute = true; + let (path, is_dir) = strip_directory(rest); + target.is_directory = is_dir; + target.path = Some(path); + return target; + } + + // Scheme prefixes. + if let Some(rest) = path_part.strip_prefix("file:") { + target.kind = LinkKind::File; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("local:") { + target.kind = LinkKind::Local; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("diary:") { + target.kind = LinkKind::Diary; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("wn.") { + if let Some((name, page)) = rest.split_once(':') { + target.kind = LinkKind::Interwiki; + target.wiki_name = Some(name.to_owned()); + target.path = Some(page.to_owned()); + return target; + } + } + if let Some((scheme, rest)) = path_part.split_once(':') { + if let Some(num) = scheme.strip_prefix("wiki") { + if let Ok(idx) = num.parse::() { + target.kind = LinkKind::Interwiki; + target.wiki_index = Some(idx); + target.path = Some(rest.to_owned()); + return target; + } + } + } + + // Default: plain wiki link, possibly directory. + let (path, is_dir) = strip_directory(path_part); + target.kind = LinkKind::Wiki; + target.is_directory = is_dir; + target.path = Some(path); + target +} + +fn strip_directory(s: &str) -> (String, bool) { + if let Some(stripped) = s.strip_suffix('/') { + (stripped.to_owned(), true) + } else { + (s.to_owned(), false) + } +} + +// ===== Span helpers ===== + +fn span_start_of_inline(node: &InlineNode) -> Option { + Some(node.span().start) +} + +fn span_end_of_inline(node: &InlineNode) -> Option { + Some(node.span().end) +} diff --git a/crates/tags.rs b/crates/tags.rs new file mode 100644 index 0000000..83afb16 --- /dev/null +++ b/crates/tags.rs @@ -0,0 +1,205 @@ +//! 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 { + 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("
            ")); + // Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves. + assert!(html.contains("todo")); + assert!(html.contains("done")); +} + +#[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
            db