e75ad6ca89
Hand-rolled date primitive (no chrono/time dep) plus the diary
command surface that closes the `:VimwikiMakeDiaryNote` ergonomics gap.
nuwiki-core/src/date.rs:
- `DiaryDate { year, month, day }` with strict `YYYY-MM-DD` parsing
(rejects missing zero-padding, alternate separators, whitespace,
impossible calendar dates including the leap-year exceptions).
- `next_day` / `prev_day` via Howard Hinnant's days-from-civil
algorithm — proleptic Gregorian, walks across month/year/leap-day
boundaries.
- `today_utc()` from `SystemTime::now()`. UTC-only by design: a
local-tz implementation would need a tz database we don't ship.
- `Ord` by epoch days, `Display`/`format` round-trip with `parse`.
WikiConfig (P15 resolution):
- `diary_rel_path: String` (default `"diary"`) — mirrors vimwiki's
`g:vimwiki_diary_rel_path`.
- `diary_index: String` (default `"diary"`) — the stem inside the
diary subdir.
- `diary_dir()`, `diary_index_path()`, `diary_path_for(date)` —
centralise the path math so commands stay declarative.
- `RawWiki` (init-options wire format) accepts both fields, so users
with `g:vimwiki_diary_rel_path = "journal"` migrate cleanly.
WorkspaceIndex:
- New `diary_rel_path: Option<String>` field set by `Wiki::new`.
- `IndexedPage.diary_date: Option<DiaryDate>` — populated in `upsert`
when the URI is under `<root>/<diary_rel_path>/` and the stem
parses. `diary_date_for_uri` is the standalone classifier.
- The classifier *requires* the URI to be inside the diary subdir
when a root is known; with no root it accepts any file whose stem
is a date (kept for the ad-hoc test/scratch case).
nuwiki-lsp/src/diary.rs:
- `uri_for_date` / `index_uri` — bridge from `DiaryDate` and config
to `file://` URIs.
- `list_entries(index) → Vec<DiaryEntry>` — ascending, plus
`list_entries_filtered(year, month)` for calendar hooks.
- `next_entry` / `prev_entry` — strict less-than/greater-than
comparison so navigating from a non-diary page (uses today as
pivot) still moves to the right entry.
- `build_index_body` — newest-first, grouped under `== YYYY ==` /
`=== Month ===` subheadings, emits `- [[diary/YYYY-MM-DD]]`. Matches
`:VimwikiDiaryGenerateLinks`.
- `DiaryEntry` JSON shape: `{ date: "YYYY-MM-DD", uri }`. Manual
serde impl so DiaryDate stays serde-free in nuwiki-core.
Commands (9 new):
- `nuwiki.diary.openToday` / `openYesterday` / `openTomorrow` →
`{ uri, date }`.
- `nuwiki.diary.openIndex` → `{ uri }`.
- `nuwiki.diary.generateIndex` → WorkspaceEdit. Three paths:
live document in `backend.documents` → full-doc replace; on-disk
index → read + full-doc replace; missing → CreateFile + insert.
- `nuwiki.diary.next` / `prev` → pivots on the current page's
`diary_date`, falls back to today.
- `nuwiki.diary.listEntries` → optional `year` / `month` filter.
- `nuwiki.diary.openForDate` → strict `YYYY-MM-DD` arg.
All commands accept an optional `uri` to scope to a particular wiki
when multi-wiki lands; the dispatcher falls back to `default_wiki()`.
Tests: 35 new in `phase16_diary.rs` (date parser exhaustively,
calendar arithmetic across boundaries + leaps, path conventions,
upsert classification including the "date filename outside diary dir"
no-op case, list/filter/nav, index body grouping, serde shape, command
list completeness). Total 309 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
469 lines
16 KiB
Rust
469 lines
16 KiB
Rust
//! Workspace index — the in-memory model of every `.wiki` page nuwiki
|
|
//! has seen, used to power Phase 8 nav features.
|
|
//!
|
|
//! Per P8 (SPEC §11), the initial scan runs as a background tokio task so
|
|
//! the server stays responsive on startup. Per-document updates land here
|
|
//! synchronously via `upsert` whenever the LSP backend re-parses on
|
|
//! `didOpen` / `didChange`.
|
|
//!
|
|
//! The index keeps only the projections nav needs (headings + outgoing
|
|
//! links) — the full AST already lives in the backend's `documents`
|
|
//! `DashMap`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use nuwiki_core::ast::{
|
|
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
|
TableNode, TagScope,
|
|
};
|
|
use nuwiki_core::date::DiaryDate;
|
|
use tower_lsp::lsp_types::Url;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct IndexedPage {
|
|
pub uri: Url,
|
|
/// Page name relative to the workspace root, without the `.wiki`
|
|
/// extension. Falls back to the URL's last path segment if no root.
|
|
pub name: String,
|
|
pub title: Option<String>,
|
|
pub headings: Vec<HeadingInfo>,
|
|
pub outgoing: Vec<OutgoingLink>,
|
|
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
|
|
/// containing `WorkspaceIndex` is the reverse map.
|
|
pub tags: Vec<TagInfo>,
|
|
/// Phase 16: `Some(date)` when this page is a diary entry — i.e. its
|
|
/// file lives under `<root>/<diary_rel_path>/` and its stem parses as
|
|
/// `YYYY-MM-DD`. Used by the diary navigation commands.
|
|
pub diary_date: Option<DiaryDate>,
|
|
}
|
|
|
|
/// One tag occurrence on a page. `name` is the bare tag string (no
|
|
/// surrounding colons). Multiple tags on the same source line each get
|
|
/// their own `TagInfo` so anchor lookup can return a precise location.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TagInfo {
|
|
pub name: String,
|
|
pub scope: TagScope,
|
|
pub span: Span,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct HeadingInfo {
|
|
pub title: String,
|
|
pub level: u8,
|
|
pub anchor: String,
|
|
pub span: Span,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OutgoingLink {
|
|
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
|
/// that don't address another page in the workspace (`AnchorOnly`,
|
|
/// `Raw`, `File`, `Local`, `Diary`, …).
|
|
pub target_page: Option<String>,
|
|
pub anchor: Option<String>,
|
|
pub kind: LinkKind,
|
|
pub span: Span,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Backlink {
|
|
pub source_uri: Url,
|
|
pub source_span: Span,
|
|
pub target_anchor: Option<String>,
|
|
}
|
|
|
|
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
|
|
/// `[[Page#tag]]` resolution and `nuwiki.tags.search` (Phase 13).
|
|
#[derive(Debug, Clone)]
|
|
pub struct TagOccurrence {
|
|
pub uri: Url,
|
|
pub span: Span,
|
|
pub scope: TagScope,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct WorkspaceIndex {
|
|
pub root: Option<PathBuf>,
|
|
/// Phase 16: subdir relative to `root` that holds diary entries.
|
|
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
|
/// classify each indexed URI without a back-reference to the config.
|
|
pub diary_rel_path: Option<String>,
|
|
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
|
pub pages_by_name: HashMap<String, Url>,
|
|
pub backlinks: HashMap<String, Vec<Backlink>>,
|
|
/// Phase 12: tag name → every occurrence across the workspace. Used by
|
|
/// the v1.1 nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
|
/// inclusion) and the Phase 13 `nuwiki.tags.search` command.
|
|
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
|
|
}
|
|
|
|
impl WorkspaceIndex {
|
|
pub fn new(root: Option<PathBuf>) -> Self {
|
|
Self {
|
|
root,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn with_diary_rel_path(mut self, rel: Option<String>) -> Self {
|
|
self.diary_rel_path = rel;
|
|
self
|
|
}
|
|
|
|
/// Insert or update an indexed page. Removes any prior indexing for
|
|
/// the same URI first (handles renames and re-parses without leaking
|
|
/// stale backlinks).
|
|
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
|
self.remove(&uri);
|
|
let name = page_name_from_uri(&uri, self.root.as_deref());
|
|
let diary_date =
|
|
diary_date_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
|
|
let mut page = IndexedPage {
|
|
uri: uri.clone(),
|
|
name: name.clone(),
|
|
title: ast.metadata.title.clone(),
|
|
headings: Vec::new(),
|
|
outgoing: Vec::new(),
|
|
tags: Vec::new(),
|
|
diary_date,
|
|
};
|
|
index_blocks(&ast.children, &mut page);
|
|
|
|
for link in &page.outgoing {
|
|
if let Some(tgt) = &link.target_page {
|
|
self.backlinks
|
|
.entry(tgt.clone())
|
|
.or_default()
|
|
.push(Backlink {
|
|
source_uri: uri.clone(),
|
|
source_span: link.span,
|
|
target_anchor: link.anchor.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Phase 12: maintain the reverse tag map alongside the per-page list.
|
|
for tag in &page.tags {
|
|
self.tags_by_name
|
|
.entry(tag.name.clone())
|
|
.or_default()
|
|
.push(TagOccurrence {
|
|
uri: uri.clone(),
|
|
span: tag.span,
|
|
scope: tag.scope,
|
|
});
|
|
}
|
|
|
|
self.pages_by_name.insert(name, uri.clone());
|
|
self.pages_by_uri.insert(uri, page);
|
|
}
|
|
|
|
pub fn remove(&mut self, uri: &Url) {
|
|
if let Some(page) = self.pages_by_uri.remove(uri) {
|
|
self.pages_by_name.remove(&page.name);
|
|
}
|
|
for entries in self.backlinks.values_mut() {
|
|
entries.retain(|b| &b.source_uri != uri);
|
|
}
|
|
self.backlinks.retain(|_, v| !v.is_empty());
|
|
for entries in self.tags_by_name.values_mut() {
|
|
entries.retain(|t| &t.uri != uri);
|
|
}
|
|
self.tags_by_name.retain(|_, v| !v.is_empty());
|
|
}
|
|
|
|
/// All occurrences of a tag across the workspace. Empty slice when the
|
|
/// tag isn't indexed.
|
|
pub fn tags_for(&self, name: &str) -> &[TagOccurrence] {
|
|
self.tags_by_name
|
|
.get(name)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[])
|
|
}
|
|
|
|
/// Resolve a `LinkTarget` to a page URI in this workspace.
|
|
/// `source_page` is the name of the page the link originates from —
|
|
/// needed for `AnchorOnly` resolution.
|
|
pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
|
|
match target.kind {
|
|
LinkKind::Wiki => target
|
|
.path
|
|
.as_deref()
|
|
.and_then(|p| self.pages_by_name.get(p)),
|
|
LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
|
|
// Interwiki / Diary / File / Local / Raw aren't resolved
|
|
// against the workspace index here. The handlers fall back to
|
|
// best-effort resolution (e.g. building file:// URLs).
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn page(&self, uri: &Url) -> Option<&IndexedPage> {
|
|
self.pages_by_uri.get(uri)
|
|
}
|
|
|
|
pub fn page_by_name(&self, name: &str) -> Option<&IndexedPage> {
|
|
self.pages_by_name
|
|
.get(name)
|
|
.and_then(|u| self.pages_by_uri.get(u))
|
|
}
|
|
|
|
pub fn page_names(&self) -> Vec<String> {
|
|
let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect();
|
|
v.sort();
|
|
v
|
|
}
|
|
|
|
pub fn backlinks_for(&self, page_name: &str) -> &[Backlink] {
|
|
self.backlinks
|
|
.get(page_name)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[])
|
|
}
|
|
}
|
|
|
|
/// Return a parsed [`DiaryDate`] when `uri` is a diary entry — its file
|
|
/// path is under `<root>/<diary_rel_path>/` and its stem parses as
|
|
/// `YYYY-MM-DD`. Anything else returns `None`.
|
|
pub fn diary_date_for_uri(
|
|
uri: &Url,
|
|
root: Option<&Path>,
|
|
diary_rel_path: Option<&str>,
|
|
) -> Option<DiaryDate> {
|
|
let path = uri.to_file_path().ok()?;
|
|
let stem = path.file_stem()?.to_str()?;
|
|
let parsed = DiaryDate::parse(stem)?;
|
|
// Without a root we can't enforce the subdir requirement — accept any
|
|
// file whose stem looks like a date so that ad-hoc test setups work.
|
|
let Some(root) = root else {
|
|
return Some(parsed);
|
|
};
|
|
let parent = path.parent()?;
|
|
let expected = root.join(diary_rel_path.unwrap_or("diary"));
|
|
if parent.starts_with(&expected) {
|
|
Some(parsed)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Derive a page name from a `file://` URL. With a workspace root the name
|
|
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
|
/// just the filename stem.
|
|
pub fn page_name_from_uri(uri: &Url, root: Option<&Path>) -> String {
|
|
if let (Some(root), Ok(p)) = (root, uri.to_file_path()) {
|
|
if let Ok(rel) = p.strip_prefix(root) {
|
|
let stripped = rel.with_extension("");
|
|
return stripped
|
|
.to_string_lossy()
|
|
.replace(std::path::MAIN_SEPARATOR, "/");
|
|
}
|
|
}
|
|
// Fallback: last path segment, strip .wiki if present.
|
|
if let Ok(p) = uri.to_file_path() {
|
|
return p
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
}
|
|
uri.path_segments()
|
|
.and_then(|mut s| s.next_back())
|
|
.map(|s| {
|
|
s.strip_suffix(".wiki")
|
|
.unwrap_or(s)
|
|
.trim_start_matches('/')
|
|
.to_string()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Normalise heading text into a URL-safe anchor slug.
|
|
pub fn slugify(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut prev_dash = false;
|
|
for ch in s.chars() {
|
|
if ch.is_alphanumeric() {
|
|
for c in ch.to_lowercase() {
|
|
out.push(c);
|
|
}
|
|
prev_dash = false;
|
|
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash && !out.is_empty() {
|
|
out.push('-');
|
|
prev_dash = true;
|
|
}
|
|
// other punctuation: drop
|
|
}
|
|
while out.ends_with('-') {
|
|
out.pop();
|
|
}
|
|
out
|
|
}
|
|
|
|
// ===== Indexing helpers =====
|
|
|
|
fn index_blocks(blocks: &[BlockNode], page: &mut IndexedPage) {
|
|
for block in blocks {
|
|
index_block(block, page);
|
|
}
|
|
}
|
|
|
|
fn index_block(block: &BlockNode, page: &mut IndexedPage) {
|
|
match block {
|
|
BlockNode::Heading(h) => {
|
|
let title = inline_to_text(&h.children);
|
|
page.headings.push(HeadingInfo {
|
|
title: title.clone(),
|
|
level: h.level,
|
|
anchor: slugify(&title),
|
|
span: h.span,
|
|
});
|
|
index_inlines(&h.children, page);
|
|
}
|
|
BlockNode::Paragraph(p) => index_inlines(&p.children, page),
|
|
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
|
for c in children {
|
|
index_block(c, page);
|
|
}
|
|
}
|
|
BlockNode::List(ListNode { items, .. }) => {
|
|
for item in items {
|
|
index_inlines(&item.children, page);
|
|
if let Some(sub) = &item.sublist {
|
|
index_block(&BlockNode::List(sub.clone()), page);
|
|
}
|
|
}
|
|
}
|
|
BlockNode::DefinitionList(dl) => {
|
|
for item in &dl.items {
|
|
if let Some(term) = &item.term {
|
|
index_inlines(term, page);
|
|
}
|
|
for def in &item.definitions {
|
|
index_inlines(def, page);
|
|
}
|
|
}
|
|
}
|
|
BlockNode::Table(TableNode { rows, .. }) => {
|
|
for row in rows {
|
|
for cell in &row.cells {
|
|
index_inlines(&cell.children, page);
|
|
}
|
|
}
|
|
}
|
|
BlockNode::Tag(t) => {
|
|
for name in &t.tags {
|
|
page.tags.push(TagInfo {
|
|
name: name.clone(),
|
|
scope: t.scope,
|
|
span: t.span,
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) {
|
|
for n in inlines {
|
|
match n {
|
|
InlineNode::WikiLink(w) => {
|
|
page.outgoing.push(OutgoingLink {
|
|
target_page: w.target.path.clone(),
|
|
anchor: w.target.anchor.clone(),
|
|
kind: w.target.kind,
|
|
span: w.span,
|
|
});
|
|
if let Some(desc) = &w.description {
|
|
index_inlines(desc, page);
|
|
}
|
|
}
|
|
InlineNode::ExternalLink(e) => {
|
|
if let Some(desc) = &e.description {
|
|
index_inlines(desc, page);
|
|
}
|
|
}
|
|
InlineNode::Bold(b) => index_inlines(&b.children, page),
|
|
InlineNode::Italic(i) => index_inlines(&i.children, page),
|
|
InlineNode::BoldItalic(bi) => index_inlines(&bi.children, page),
|
|
InlineNode::Strikethrough(s) => index_inlines(&s.children, page),
|
|
InlineNode::Superscript(s) => index_inlines(&s.children, page),
|
|
InlineNode::Subscript(s) => index_inlines(&s.children, page),
|
|
InlineNode::Color(c) => index_inlines(&c.children, page),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn inline_to_text(inlines: &[InlineNode]) -> String {
|
|
let mut out = String::new();
|
|
inline_to_text_into(inlines, &mut out);
|
|
out.trim().to_string()
|
|
}
|
|
|
|
fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
|
|
for n in inlines {
|
|
match n {
|
|
InlineNode::Text(t) => out.push_str(&t.content),
|
|
InlineNode::Bold(b) => inline_to_text_into(&b.children, out),
|
|
InlineNode::Italic(i) => inline_to_text_into(&i.children, out),
|
|
InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out),
|
|
InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out),
|
|
InlineNode::Code(c) => out.push_str(&c.content),
|
|
InlineNode::Superscript(s) => inline_to_text_into(&s.children, out),
|
|
InlineNode::Subscript(s) => inline_to_text_into(&s.children, out),
|
|
InlineNode::MathInline(m) => out.push_str(&m.content),
|
|
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
|
InlineNode::WikiLink(w) => {
|
|
if let Some(d) = &w.description {
|
|
inline_to_text_into(d, out);
|
|
} else if let Some(p) = &w.target.path {
|
|
out.push_str(p);
|
|
}
|
|
}
|
|
InlineNode::ExternalLink(e) => match &e.description {
|
|
Some(d) => inline_to_text_into(d, out),
|
|
None => out.push_str(&e.url),
|
|
},
|
|
InlineNode::Keyword(_) => {}
|
|
InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
|
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Filesystem walking =====
|
|
|
|
/// Recursively collect every `.wiki` file under `root`, skipping dotfiles
|
|
/// and dot-directories.
|
|
pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> {
|
|
let mut out = Vec::new();
|
|
let mut stack = vec![root.to_path_buf()];
|
|
while let Some(dir) = stack.pop() {
|
|
let entries = match std::fs::read_dir(&dir) {
|
|
Ok(e) => e,
|
|
Err(_) => continue,
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let Some(name) = path.file_name() else {
|
|
continue;
|
|
};
|
|
if name.to_string_lossy().starts_with('.') {
|
|
continue;
|
|
}
|
|
let Ok(meta) = entry.metadata() else {
|
|
continue;
|
|
};
|
|
if meta.is_dir() {
|
|
stack.push(path);
|
|
} else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wiki") {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
out.sort();
|
|
out
|
|
}
|