phase 8: navigation — definition, refs, hover, completion, ws/symbol
P8 resolved (SPEC §11): lazy + background indexing with progress. Initial scan runs in a tokio task spawned from `initialized`; the server stays responsive immediately and nav features answer with partial data until the index is complete. WorkspaceIndex (`index.rs`): - Per-URI `IndexedPage` keeps name, title, headings, outgoing links. - Reverse maps: `pages_by_name`, `backlinks: HashMap<page, …>`. - `upsert` scrubs stale entries first so re-parses don't leak. - `resolve` handles Wiki + AnchorOnly link kinds against the index; other kinds (Interwiki/Diary/File/Local/Raw) fall through. - `slugify` for anchors (lowercases, collapses runs, drops trailing dashes, preserves Unicode), `page_name_from_uri` for workspace- rooted names, `walk_wiki_files` for the background scanner. Backend wiring (`lib.rs`): - `initialize` captures workspace root from `workspaceFolders` or the deprecated `root_uri`; advertises definition/references/hover/ completion (trigger `[`)/workspace_symbol providers. - `initialized` spawns the indexer, wrapped in `window/workDoneProgress` begin/end notifications. - `did_open`/`did_change` synchronously update the index alongside publishing diagnostics; `did_close` keeps the indexed projection (file may still exist on disk and other pages link to it) and only clears diagnostics + drops the live document state. Position lookup (`nav.rs`): - `lsp_to_byte_pos` translates between LSP encoding (UTF-8 or UTF-16) and our byte columns. - `find_inline_at` descends blocks → inlines → inline containers, returning the most specific inline whose span covers the cursor. Handlers: - definition: wikilinks resolve to a target URI; anchored links return the range at the matching heading. - references: backlinks of the cursor's link target, or of the current page when the cursor isn't on a link. Ranges fall back to byte-column LSP positions when the source doc isn't open. - hover: markdown preview with page title + outline (first 8 headings), or a "not in index" fallback. - completion: fires only inside an unterminated `[[ … ` on the current line; suggests every indexed page name. - workspace/symbol: case-insensitive substring over every indexed heading. Tests (20 new): slugify (basic / punctuation / Unicode / trailing dash), page-name derivation under various roots, upsert / replace / remove, resolve for Wiki + AnchorOnly, sorted `page_names`, UTF-8 passthrough + UTF-16 conversion, position lookup hits and misses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
//! 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,
|
||||
};
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkspaceIndex {
|
||||
pub root: Option<PathBuf>,
|
||||
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
||||
pub pages_by_name: HashMap<String, Url>,
|
||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
||||
}
|
||||
|
||||
impl WorkspaceIndex {
|
||||
pub fn new(root: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
root,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 mut page = IndexedPage {
|
||||
uri: uri.clone(),
|
||||
name: name.clone(),
|
||||
title: ast.metadata.title.clone(),
|
||||
headings: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
};
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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(&[])
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user