//! 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, pub headings: Vec, pub outgoing: Vec, /// Phase 12: every `TagNode` on the page. `tags_by_name` on the /// containing `WorkspaceIndex` is the reverse map. pub tags: Vec, /// Phase 16: `Some(date)` when this page is a diary entry — i.e. its /// file lives under `//` and its stem parses as /// `YYYY-MM-DD`. Used by the diary navigation commands. pub diary_date: Option, } /// 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, pub anchor: Option, pub kind: LinkKind, pub span: Span, } #[derive(Debug, Clone)] pub struct Backlink { pub source_uri: Url, pub source_span: Span, pub target_anchor: Option, } /// 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, /// 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, pub pages_by_uri: HashMap, pub pages_by_name: HashMap, pub backlinks: HashMap>, /// 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>, } impl WorkspaceIndex { pub fn new(root: Option) -> Self { Self { root, ..Default::default() } } pub fn with_diary_rel_path(mut self, rel: Option) -> 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 { let mut v: Vec = 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 `//` 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 { 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 { 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 }