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:
@@ -3,10 +3,10 @@
|
||||
A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a
|
||||
Rust-based language server (LSP).
|
||||
|
||||
> **Status:** Phase 7 (semantic tokens) complete. The server now serves
|
||||
> `semanticTokens/full` + `/range` with custom `vimwiki*` token types and
|
||||
> `level1`..`level6` + `centered` modifiers. Navigation features and
|
||||
> editor glue still pending.
|
||||
> **Status:** Phase 8 (navigation) complete. The server now serves
|
||||
> definition, references, hover, completion, `workspace/symbol`, plus a
|
||||
> background-indexed workspace per P8. Only editor glue and the release
|
||||
> pipeline are still pending.
|
||||
|
||||
See [`SPEC.md`](./SPEC.md) for the full project specification.
|
||||
|
||||
@@ -22,8 +22,8 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|
||||
| 5 | Renderer | ✅ done |
|
||||
| 6 | LSP Foundation | ✅ done |
|
||||
| 7 | Semantic Tokens | ✅ done |
|
||||
| 8 | Navigation | ⏳ next |
|
||||
| 9 | Editor Glue | ⏳ |
|
||||
| 8 | Navigation | ✅ done |
|
||||
| 9 | Editor Glue | ⏳ next |
|
||||
| 10 | CI/CD release pipeline | ⏳ |
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# nuwiki — Project Specification
|
||||
|
||||
> Last updated: 2026-05-10
|
||||
> Status: Phase 7 (Semantic Tokens) complete — moving to Phase 8 (Navigation)
|
||||
> Status: Phase 8 (Navigation) complete — moving to Phase 9 (Editor Glue)
|
||||
|
||||
---
|
||||
|
||||
@@ -591,6 +591,6 @@ These decisions are required before or during the phase indicated.
|
||||
| P5 | **Minimum Neovim version** | Phase 9 | 0.8 (`vim.lsp.start`) · 0.11 (`vim.lsp.config`) | 0.8 = broader compat; 0.11 = cleaner Lua API |
|
||||
| P6 | **Minimum Vim version** | Phase 9 | Vim 8.2 · Vim 9.0 · Vim 9.1 | LSP client plugins work best on 9.0+; recommend documenting 9.1 as minimum |
|
||||
| ~~P7~~ | ~~**Semantic token type mapping**~~ | ~~Phase 7~~ | ✅ **Custom vimwiki-specific token types** | ~20 types (`vimwikiHeading`, `vimwikiBold`, …) + `level1`..`level6` + `centered` modifiers. Phase 9 ships default highlight groups in `syntax/nuwiki.vim` and the Lua glue. |
|
||||
| P8 | **Workspace indexing strategy** | Phase 8 | Eager on startup · Lazy + background | Eager blocks startup on large wikis; recommend lazy with progress notification |
|
||||
| ~~P8~~ | ~~**Workspace indexing strategy**~~ | ~~Phase 8~~ | ✅ **Lazy + background with progress** | Initial scan runs as a background tokio task; nav features answer with partial data until complete; progress reported via `window/workDoneProgress`. |
|
||||
| P9 | **macOS runner** | Phase 10 | Stay manual · Register macOS runner
|
||||
| Revisit if a Mac is available; no action needed now |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+433
-10
@@ -1,36 +1,52 @@
|
||||
//! LSP protocol bridge for nuwiki.
|
||||
//!
|
||||
//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) and
|
||||
//! exposes the Phase 6 feature set:
|
||||
//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) plus a
|
||||
//! workspace-wide index (SPEC §6.10 + P8) and exposes every LSP method
|
||||
//! through Phase 8:
|
||||
//!
|
||||
//! - `initialize` / `initialized` / `shutdown`
|
||||
//! - `textDocument/didOpen`, `didChange` (full sync), `didClose`
|
||||
//! - `textDocument/publishDiagnostics` from `BlockNode::Error` nodes
|
||||
//! - `textDocument/documentSymbol` — nested outline from headings
|
||||
//! - `textDocument/semanticTokens/full` + `/range`
|
||||
//! - `textDocument/definition`, `references`, `hover`
|
||||
//! - `textDocument/completion` (trigger: `[`)
|
||||
//! - `workspace/symbol` — search across indexed headings
|
||||
//!
|
||||
//! Position encoding is negotiated as UTF-8 when the client supports LSP
|
||||
//! 3.17+ encodings (SPEC §6.11). When the client only supports the legacy
|
||||
//! UTF-16 default, positions get translated through a per-line lookup.
|
||||
//!
|
||||
//! Re-parses are full per change (incremental parsing deferred post-v1).
|
||||
//! The workspace scan runs in a background tokio task spawned from
|
||||
//! `initialize` (P8) with `window/workDoneProgress` begin/end events.
|
||||
|
||||
pub mod index;
|
||||
pub mod nav;
|
||||
pub mod semantic_tokens;
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tower_lsp::jsonrpc::Result as LspResult;
|
||||
use tower_lsp::lsp_types::{
|
||||
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
|
||||
Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
|
||||
DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
|
||||
InitializeParams, InitializeResult, InitializedParams, MessageType, OneOf,
|
||||
PositionEncodingKind, SemanticTokens, SemanticTokensFullOptions, SemanticTokensOptions,
|
||||
SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensRangeResult,
|
||||
SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
|
||||
SymbolKind, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions,
|
||||
GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams,
|
||||
HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location,
|
||||
MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams,
|
||||
ProgressParamsValue, ProgressToken, Range, ReferenceParams, SemanticTokens,
|
||||
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
|
||||
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
|
||||
SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
|
||||
SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability,
|
||||
TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd,
|
||||
WorkDoneProgressOptions, WorkspaceSymbolParams,
|
||||
};
|
||||
use tower_lsp::{Client, LanguageServer, LspService, Server};
|
||||
|
||||
@@ -41,6 +57,8 @@ use nuwiki_core::ast::{
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxRegistry;
|
||||
|
||||
use crate::index::{IndexedPage, WorkspaceIndex};
|
||||
|
||||
/// Run the LSP server over the given async reader/writer pair. The binary
|
||||
/// (`nuwiki-ls`) just calls this with stdin/stdout.
|
||||
pub async fn run<I, O>(reader: I, writer: O)
|
||||
@@ -78,6 +96,8 @@ struct Backend {
|
||||
/// True after `initialize` if the client opted into UTF-8 encoding.
|
||||
/// Otherwise we keep the LSP 3.16 default of UTF-16.
|
||||
use_utf8: Arc<AtomicBool>,
|
||||
/// Workspace index — populated lazily in the background per P8.
|
||||
index: Arc<RwLock<WorkspaceIndex>>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
@@ -89,6 +109,7 @@ impl Backend {
|
||||
documents: Arc::new(DashMap::new()),
|
||||
registry: Arc::new(registry),
|
||||
use_utf8: Arc::new(AtomicBool::new(false)),
|
||||
index: Arc::new(RwLock::new(WorkspaceIndex::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +127,13 @@ impl Backend {
|
||||
};
|
||||
let ast = plugin.parse(&text);
|
||||
let diagnostics = ast_diagnostics(&ast, &text, self.use_utf8.load(Ordering::Relaxed));
|
||||
|
||||
// Keep the workspace index in sync with what the editor is showing.
|
||||
{
|
||||
let mut idx = self.index.write().expect("index lock poisoned");
|
||||
idx.upsert(uri.clone(), &ast);
|
||||
}
|
||||
|
||||
self.documents
|
||||
.insert(uri.clone(), DocumentState { text, ast, version });
|
||||
self.client
|
||||
@@ -133,6 +161,12 @@ impl LanguageServer for Backend {
|
||||
None // server defaults to UTF-16 per LSP 3.16.
|
||||
};
|
||||
|
||||
// Capture the workspace root for the background indexer.
|
||||
let root = workspace_root_from_params(¶ms);
|
||||
if let Some(p) = &root {
|
||||
self.index.write().expect("index lock poisoned").root = Some(p.clone());
|
||||
}
|
||||
|
||||
Ok(InitializeResult {
|
||||
capabilities: ServerCapabilities {
|
||||
position_encoding,
|
||||
@@ -140,6 +174,14 @@ impl LanguageServer for Backend {
|
||||
TextDocumentSyncKind::FULL,
|
||||
)),
|
||||
document_symbol_provider: Some(OneOf::Left(true)),
|
||||
workspace_symbol_provider: Some(OneOf::Left(true)),
|
||||
definition_provider: Some(OneOf::Left(true)),
|
||||
references_provider: Some(OneOf::Left(true)),
|
||||
hover_provider: Some(HoverProviderCapability::Simple(true)),
|
||||
completion_provider: Some(CompletionOptions {
|
||||
trigger_characters: Some(vec!["[".into()]),
|
||||
..CompletionOptions::default()
|
||||
}),
|
||||
semantic_tokens_provider: Some(
|
||||
SemanticTokensServerCapabilities::SemanticTokensOptions(
|
||||
SemanticTokensOptions {
|
||||
@@ -171,6 +213,17 @@ impl LanguageServer for Backend {
|
||||
format!("nuwiki-lsp initialized (positionEncoding={enc})"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Kick off the background workspace scan (P8). Skips if no root.
|
||||
let root = self.index.read().expect("index lock poisoned").root.clone();
|
||||
if let Some(root) = root {
|
||||
let index = Arc::clone(&self.index);
|
||||
let client = self.client.clone();
|
||||
let registry = Arc::clone(&self.registry);
|
||||
tokio::spawn(async move {
|
||||
index_workspace(root, index, client, registry).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> LspResult<()> {
|
||||
@@ -194,10 +247,13 @@ impl LanguageServer for Backend {
|
||||
}
|
||||
|
||||
async fn did_close(&self, params: DidCloseTextDocumentParams) {
|
||||
self.documents.remove(¶ms.text_document.uri);
|
||||
let uri = ¶ms.text_document.uri;
|
||||
self.documents.remove(uri);
|
||||
// Keep the index entry — the file may still exist on disk and other
|
||||
// pages can still link to it. We only drop the live document state.
|
||||
// Clear lingering diagnostics so closed buffers don't keep red squiggles.
|
||||
self.client
|
||||
.publish_diagnostics(params.text_document.uri, Vec::new(), None)
|
||||
.publish_diagnostics(params.text_document.uri.clone(), Vec::new(), None)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -245,6 +301,211 @@ impl LanguageServer for Backend {
|
||||
data: pack_data(&data),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn goto_definition(
|
||||
&self,
|
||||
params: GotoDefinitionParams,
|
||||
) -> LspResult<Option<GotoDefinitionResponse>> {
|
||||
let pos = params.text_document_position_params.position;
|
||||
let uri = ¶ms.text_document_position_params.text_document.uri;
|
||||
let Some(doc) = self.documents.get(uri) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
||||
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
|
||||
let Some(node) = nav::find_inline_at(&doc.ast, line, col) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let location = match node {
|
||||
InlineNode::WikiLink(w) => {
|
||||
let source_page = index::page_name_from_uri(
|
||||
uri,
|
||||
self.index
|
||||
.read()
|
||||
.expect("index lock poisoned")
|
||||
.root
|
||||
.as_deref(),
|
||||
);
|
||||
let idx = self.index.read().expect("index lock poisoned");
|
||||
let target_uri = idx.resolve(&w.target, &source_page).cloned();
|
||||
target_uri.map(|target| {
|
||||
let target_range = w
|
||||
.target
|
||||
.anchor
|
||||
.as_deref()
|
||||
.and_then(|a| heading_range_in(&idx, &target, a, utf8))
|
||||
.unwrap_or_else(zero_range);
|
||||
Location {
|
||||
uri: target,
|
||||
range: target_range,
|
||||
}
|
||||
})
|
||||
}
|
||||
InlineNode::ExternalLink(_) | InlineNode::RawUrl(_) => None,
|
||||
_ => None,
|
||||
};
|
||||
Ok(location.map(GotoDefinitionResponse::Scalar))
|
||||
}
|
||||
|
||||
async fn references(&self, params: ReferenceParams) -> LspResult<Option<Vec<Location>>> {
|
||||
let pos = params.text_document_position.position;
|
||||
let uri = ¶ms.text_document_position.text_document.uri;
|
||||
let Some(doc) = self.documents.get(uri) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
||||
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
|
||||
|
||||
// Identify the page (and optional anchor) the cursor is "asking
|
||||
// about". Two cases:
|
||||
// - cursor is on a wikilink → references to the linked page
|
||||
// - cursor isn't on a link → references to the *current* page
|
||||
let source_page = index::page_name_from_uri(
|
||||
uri,
|
||||
self.index
|
||||
.read()
|
||||
.expect("index lock poisoned")
|
||||
.root
|
||||
.as_deref(),
|
||||
);
|
||||
let target_page = match nav::find_inline_at(&doc.ast, line, col) {
|
||||
Some(InlineNode::WikiLink(w)) => match w.target.kind {
|
||||
nuwiki_core::ast::LinkKind::Wiki => w.target.path.clone(),
|
||||
nuwiki_core::ast::LinkKind::AnchorOnly => Some(source_page.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => Some(source_page),
|
||||
};
|
||||
let Some(page) = target_page else {
|
||||
return Ok(None);
|
||||
};
|
||||
let idx = self.index.read().expect("index lock poisoned");
|
||||
let locations: Vec<Location> = idx
|
||||
.backlinks_for(&page)
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
self.documents.get(&b.source_uri).map(|src| Location {
|
||||
uri: b.source_uri.clone(),
|
||||
range: span_to_lsp_range(&b.source_span, &src.text, utf8),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if locations.is_empty() {
|
||||
// Even without open docs we can still produce LSP ranges from
|
||||
// the stored span; the editor opens the file to display it.
|
||||
let locations: Vec<Location> = idx
|
||||
.backlinks_for(&page)
|
||||
.iter()
|
||||
.map(|b| Location {
|
||||
uri: b.source_uri.clone(),
|
||||
range: span_to_lsp_range_no_text(&b.source_span),
|
||||
})
|
||||
.collect();
|
||||
return Ok(Some(locations));
|
||||
}
|
||||
Ok(Some(locations))
|
||||
}
|
||||
|
||||
async fn hover(&self, params: HoverParams) -> LspResult<Option<Hover>> {
|
||||
let pos = params.text_document_position_params.position;
|
||||
let uri = ¶ms.text_document_position_params.text_document.uri;
|
||||
let Some(doc) = self.documents.get(uri) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
||||
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
|
||||
let Some(node) = nav::find_inline_at(&doc.ast, line, col) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (markdown, hover_span) = match node {
|
||||
InlineNode::WikiLink(w) => {
|
||||
let source_page = index::page_name_from_uri(
|
||||
uri,
|
||||
self.index
|
||||
.read()
|
||||
.expect("index lock poisoned")
|
||||
.root
|
||||
.as_deref(),
|
||||
);
|
||||
let idx = self.index.read().expect("index lock poisoned");
|
||||
let target_uri = idx.resolve(&w.target, &source_page).cloned();
|
||||
let body = match target_uri.as_ref().and_then(|u| idx.page(u)) {
|
||||
Some(page) => preview_for(page, w.target.anchor.as_deref()),
|
||||
None => match &w.target.path {
|
||||
Some(p) => format!("**{p}** — page not in index"),
|
||||
None => "(unresolved link)".into(),
|
||||
},
|
||||
};
|
||||
(body, w.span)
|
||||
}
|
||||
InlineNode::ExternalLink(e) => (format!("[{0}]({0})", e.url), e.span),
|
||||
InlineNode::RawUrl(u) => (format!("<{0}>", u.url), u.span),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
Ok(Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: markdown,
|
||||
}),
|
||||
range: Some(span_to_lsp_range(&hover_span, &doc.text, utf8)),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn completion(&self, params: CompletionParams) -> LspResult<Option<CompletionResponse>> {
|
||||
let uri = ¶ms.text_document_position.text_document.uri;
|
||||
let Some(doc) = self.documents.get(uri) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
||||
let (line, col) =
|
||||
nav::lsp_to_byte_pos(params.text_document_position.position, &doc.text, utf8);
|
||||
// Only fire completion inside an unterminated `[[ ... ` run.
|
||||
if !is_inside_wikilink(&doc.text, line, col) {
|
||||
return Ok(None);
|
||||
}
|
||||
let idx = self.index.read().expect("index lock poisoned");
|
||||
let items: Vec<CompletionItem> = idx
|
||||
.page_names()
|
||||
.into_iter()
|
||||
.map(|name| CompletionItem {
|
||||
label: name.clone(),
|
||||
kind: Some(CompletionItemKind::FILE),
|
||||
insert_text: Some(name),
|
||||
..CompletionItem::default()
|
||||
})
|
||||
.collect();
|
||||
Ok(Some(CompletionResponse::Array(items)))
|
||||
}
|
||||
|
||||
async fn symbol(
|
||||
&self,
|
||||
params: WorkspaceSymbolParams,
|
||||
) -> LspResult<Option<Vec<LspSymbolInformation>>> {
|
||||
let q = params.query.to_lowercase();
|
||||
let idx = self.index.read().expect("index lock poisoned");
|
||||
let mut out = Vec::new();
|
||||
for page in idx.pages_by_uri.values() {
|
||||
for h in &page.headings {
|
||||
if q.is_empty() || h.title.to_lowercase().contains(&q) {
|
||||
#[allow(deprecated)]
|
||||
out.push(LspSymbolInformation {
|
||||
name: h.title.clone(),
|
||||
kind: SymbolKind::STRING,
|
||||
tags: None,
|
||||
deprecated: None,
|
||||
location: Location {
|
||||
uri: page.uri.clone(),
|
||||
range: span_to_lsp_range_no_text(&h.span),
|
||||
},
|
||||
container_name: Some(page.name.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_data(flat: &[u32]) -> Vec<tower_lsp::lsp_types::SemanticToken> {
|
||||
@@ -469,3 +730,165 @@ fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str {
|
||||
nuwiki_core::ast::Keyword::Xxx => "XXX",
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Workspace + navigation helpers =====
|
||||
|
||||
fn workspace_root_from_params(params: &InitializeParams) -> Option<PathBuf> {
|
||||
if let Some(folders) = ¶ms.workspace_folders {
|
||||
if let Some(first) = folders.first() {
|
||||
if let Ok(p) = first.uri.to_file_path() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
if let Some(uri) = ¶ms.root_uri {
|
||||
if let Ok(p) = uri.to_file_path() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Background workspace scan (P8). Walks every `.wiki` file under `root`,
|
||||
/// parses it, and feeds it into the shared index. Wraps the work in a
|
||||
/// `window/workDoneProgress` begin/end pair so editors can show progress.
|
||||
async fn index_workspace(
|
||||
root: PathBuf,
|
||||
index: Arc<RwLock<WorkspaceIndex>>,
|
||||
client: Client,
|
||||
registry: Arc<SyntaxRegistry>,
|
||||
) {
|
||||
let token = ProgressToken::String("nuwiki-index".into());
|
||||
// Best-effort: if the client doesn't pre-create progress tokens we just
|
||||
// send the begin/end directly. tower-lsp permits this.
|
||||
let _ = client
|
||||
.send_notification::<tower_lsp::lsp_types::notification::Progress>(ProgressParams {
|
||||
token: token.clone(),
|
||||
value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
|
||||
title: "Indexing wiki".into(),
|
||||
cancellable: Some(false),
|
||||
message: None,
|
||||
percentage: None,
|
||||
})),
|
||||
})
|
||||
.await;
|
||||
|
||||
let plugin = registry.get("vimwiki");
|
||||
let files = index::walk_wiki_files(&root);
|
||||
for path in files {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(plugin) = plugin else { continue };
|
||||
let ast = plugin.parse(&text);
|
||||
let Ok(uri) = Url::from_file_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(mut idx) = index.write() {
|
||||
idx.upsert(uri, &ast);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = client
|
||||
.send_notification::<tower_lsp::lsp_types::notification::Progress>(ProgressParams {
|
||||
token,
|
||||
value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
|
||||
message: Some("done".into()),
|
||||
})),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn span_to_lsp_range(span: &Span, text: &str, utf8: bool) -> Range {
|
||||
to_lsp_range(span, text, utf8)
|
||||
}
|
||||
|
||||
fn span_to_lsp_range_no_text(span: &Span) -> Range {
|
||||
Range {
|
||||
start: tower_lsp::lsp_types::Position {
|
||||
line: span.start.line,
|
||||
character: span.start.column,
|
||||
},
|
||||
end: tower_lsp::lsp_types::Position {
|
||||
line: span.end.line,
|
||||
character: span.end.column,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_range() -> Range {
|
||||
Range {
|
||||
start: tower_lsp::lsp_types::Position {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
end: tower_lsp::lsp_types::Position {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool) -> Option<Range> {
|
||||
let page = idx.page(uri)?;
|
||||
let h = page.headings.iter().find(|h| h.anchor == anchor)?;
|
||||
Some(span_to_lsp_range_no_text(&h.span))
|
||||
}
|
||||
|
||||
fn is_inside_wikilink(text: &str, line: u32, byte_col: u32) -> bool {
|
||||
// Scan the current line for the most recent unclosed `[[` before the
|
||||
// cursor.
|
||||
let mut cur = 0u32;
|
||||
let mut current_line = 0u32;
|
||||
let bytes = text.as_bytes();
|
||||
while current_line < line && cur < bytes.len() as u32 {
|
||||
if bytes[cur as usize] == b'\n' {
|
||||
current_line += 1;
|
||||
}
|
||||
cur += 1;
|
||||
}
|
||||
let line_start = cur as usize;
|
||||
let limit = (line_start + byte_col as usize).min(bytes.len());
|
||||
let segment = &bytes[line_start..limit];
|
||||
// Find last "[[" and check no "]]" closes it before cursor.
|
||||
let mut open_at: Option<usize> = None;
|
||||
let mut i = 0;
|
||||
while i + 1 < segment.len() {
|
||||
if segment[i] == b'[' && segment[i + 1] == b'[' {
|
||||
open_at = Some(i);
|
||||
i += 2;
|
||||
} else if segment[i] == b']' && segment[i + 1] == b']' {
|
||||
open_at = None;
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
open_at.is_some()
|
||||
}
|
||||
|
||||
fn preview_for(page: &IndexedPage, anchor: Option<&str>) -> String {
|
||||
let mut buf = String::new();
|
||||
let title = page.title.as_deref().unwrap_or(&page.name);
|
||||
buf.push_str("**");
|
||||
buf.push_str(title);
|
||||
buf.push_str("**\n\n");
|
||||
if let Some(anchor) = anchor {
|
||||
if let Some(h) = page.headings.iter().find(|h| h.anchor == anchor) {
|
||||
buf.push_str(&format!("§ {}\n", h.title));
|
||||
} else {
|
||||
buf.push_str(&format!("(anchor `{anchor}` not found)\n"));
|
||||
}
|
||||
} else if let Some(h) = page.headings.first() {
|
||||
buf.push_str(&format!("§ {}\n", h.title));
|
||||
}
|
||||
if !page.headings.is_empty() {
|
||||
buf.push_str("\nOutline:\n");
|
||||
for h in page.headings.iter().take(8) {
|
||||
let indent = " ".repeat((h.level.saturating_sub(1)) as usize);
|
||||
buf.push_str(&format!("{indent}- {}\n", h.title));
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Helpers shared by the Phase 8 navigation handlers (definition,
|
||||
//! references, hover, completion).
|
||||
//!
|
||||
//! Two responsibilities:
|
||||
//!
|
||||
//! 1. **Position conversion** — clients send LSP `Position`s in either
|
||||
//! UTF-8 or UTF-16 encoding; nuwiki AST spans are byte offsets.
|
||||
//! `lsp_to_byte_pos` translates the LSP position into the same shape
|
||||
//! so it can be compared against AST spans.
|
||||
//! 2. **Position lookup** — given a byte position, find the most specific
|
||||
//! `InlineNode` whose span covers it. Used to identify the link under
|
||||
//! the cursor.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, InlineNode, ListItemNode, Span};
|
||||
use tower_lsp::lsp_types::Position as LspPosition;
|
||||
|
||||
use crate::semantic_tokens::LineIndex;
|
||||
|
||||
/// Translate an LSP `Position` (whose `character` is a UTF-16 code-unit
|
||||
/// offset by default, or a UTF-8 byte offset when negotiated) into our
|
||||
/// internal `(line, byte_col)` shape.
|
||||
pub fn lsp_to_byte_pos(p: LspPosition, text: &str, utf8: bool) -> (u32, u32) {
|
||||
if utf8 {
|
||||
return (p.line, p.character);
|
||||
}
|
||||
let idx = LineIndex::new(text);
|
||||
let line_str = idx.line_str(p.line, text);
|
||||
let byte_col = utf16_to_byte_col(line_str, p.character);
|
||||
(p.line, byte_col)
|
||||
}
|
||||
|
||||
fn utf16_to_byte_col(line: &str, target_utf16: u32) -> u32 {
|
||||
let mut byte = 0u32;
|
||||
let mut units = 0u32;
|
||||
for ch in line.chars() {
|
||||
if units >= target_utf16 {
|
||||
break;
|
||||
}
|
||||
units += ch.len_utf16() as u32;
|
||||
byte += ch.len_utf8() as u32;
|
||||
}
|
||||
byte
|
||||
}
|
||||
|
||||
/// Find the most specific inline node whose span covers the given byte
|
||||
/// position. Walks block structure first, then descends into inline
|
||||
/// containers (bold, italic, …) until no child claims the position.
|
||||
pub fn find_inline_at(doc: &DocumentNode, line: u32, byte_col: u32) -> Option<&InlineNode> {
|
||||
for block in &doc.children {
|
||||
if let Some(node) = find_in_block(block, line, byte_col) {
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
match block {
|
||||
BlockNode::Heading(h) => find_in_inlines(&h.children, line, col),
|
||||
BlockNode::Paragraph(p) => find_in_inlines(&p.children, line, col),
|
||||
BlockNode::Blockquote(b) => b.children.iter().find_map(|c| find_in_block(c, line, col)),
|
||||
BlockNode::List(l) => l
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| find_in_list_item(it, line, col)),
|
||||
BlockNode::DefinitionList(dl) => dl.items.iter().find_map(|it| {
|
||||
it.term
|
||||
.as_ref()
|
||||
.and_then(|t| find_in_inlines(t, line, col))
|
||||
.or_else(|| {
|
||||
it.definitions
|
||||
.iter()
|
||||
.find_map(|d| find_in_inlines(d, line, col))
|
||||
})
|
||||
}),
|
||||
BlockNode::Table(t) => t.rows.iter().find_map(|r| {
|
||||
r.cells
|
||||
.iter()
|
||||
.find_map(|c| find_in_inlines(&c.children, line, col))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
find_in_inlines(&item.children, line, col).or_else(|| {
|
||||
item.sublist.as_ref().and_then(|sub| {
|
||||
sub.items
|
||||
.iter()
|
||||
.find_map(|i| find_in_list_item(i, line, col))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> {
|
||||
for n in inlines {
|
||||
if !span_contains(span_of_inline(n), line, col) {
|
||||
continue;
|
||||
}
|
||||
// Descend into inline containers so we return the *innermost* hit.
|
||||
let deeper = match n {
|
||||
InlineNode::Bold(b) => find_in_inlines(&b.children, line, col),
|
||||
InlineNode::Italic(i) => find_in_inlines(&i.children, line, col),
|
||||
InlineNode::BoldItalic(bi) => find_in_inlines(&bi.children, line, col),
|
||||
InlineNode::Strikethrough(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Color(c) => find_in_inlines(&c.children, line, col),
|
||||
InlineNode::WikiLink(w) => w
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| find_in_inlines(d, line, col)),
|
||||
InlineNode::ExternalLink(e) => e
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| find_in_inlines(d, line, col)),
|
||||
_ => None,
|
||||
};
|
||||
return Some(deeper.unwrap_or(n));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn span_contains(s: Span, line: u32, col: u32) -> bool {
|
||||
let start = (s.start.line, s.start.column);
|
||||
let end = (s.end.line, s.end.column);
|
||||
let p = (line, col);
|
||||
p >= start && p < end
|
||||
}
|
||||
|
||||
pub fn span_of_inline(node: &InlineNode) -> Span {
|
||||
match node {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Tests for the Phase 8 navigation pieces: WorkspaceIndex queries,
|
||||
//! page-name derivation, anchor slugify, position lookup, and link
|
||||
//! resolution. The async LSP handlers are exercised through these
|
||||
//! helpers; end-to-end JSON-RPC drive lives in a follow-up phase.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::index::{page_name_from_uri, slugify, WorkspaceIndex};
|
||||
use nuwiki_lsp::nav::{find_inline_at, lsp_to_byte_pos};
|
||||
use tower_lsp::lsp_types::{Position as LspPosition, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
// ===== Slugify =====
|
||||
|
||||
#[test]
|
||||
fn slugify_basic_title() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_strips_punctuation_and_collapses_dashes() {
|
||||
assert_eq!(slugify("Hello, World! Again"), "hello-world-again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_unicode_lowercase() {
|
||||
assert_eq!(slugify("Crème Brûlée"), "crème-brûlée");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_no_trailing_dash() {
|
||||
assert_eq!(slugify("Title — "), "title");
|
||||
}
|
||||
|
||||
// ===== Page name from URI =====
|
||||
|
||||
#[test]
|
||||
fn page_name_relative_to_root() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let uri = Url::from_file_path("/wiki/dir/Page.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&root)), "dir/Page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_at_root() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let uri = Url::from_file_path("/wiki/Index.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&root)), "Index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_without_root_falls_back_to_stem() {
|
||||
let uri = Url::from_file_path("/tmp/foo.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, None), "foo");
|
||||
}
|
||||
|
||||
// ===== Workspace index =====
|
||||
|
||||
#[test]
|
||||
fn upsert_indexes_headings_and_outgoing_links() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let ast = parse("= Home =\n[[Other]] and [[#Section]]\n");
|
||||
idx.upsert(uri.clone(), &ast);
|
||||
|
||||
let page = idx.page(&uri).expect("page indexed");
|
||||
assert_eq!(page.name, "Home");
|
||||
assert_eq!(page.headings.len(), 1);
|
||||
assert_eq!(page.headings[0].title, "Home");
|
||||
assert_eq!(page.headings[0].anchor, "home");
|
||||
assert_eq!(page.outgoing.len(), 2);
|
||||
assert_eq!(page.outgoing[0].target_page.as_deref(), Some("Other"));
|
||||
assert_eq!(page.outgoing[0].kind, LinkKind::Wiki);
|
||||
assert_eq!(page.outgoing[1].kind, LinkKind::AnchorOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_registers_page_by_name_and_records_backlinks() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
|
||||
idx.upsert(home.clone(), &parse("[[About]]\n"));
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
assert_eq!(idx.page_by_name("About").unwrap().uri, about);
|
||||
|
||||
let backlinks = idx.backlinks_for("About");
|
||||
assert_eq!(backlinks.len(), 1);
|
||||
assert_eq!(backlinks[0].source_uri, home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_prior_indexing_for_same_uri() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse("[[A]]\n"));
|
||||
assert_eq!(idx.backlinks_for("A").len(), 1);
|
||||
|
||||
// Re-upsert with different content: old backlinks must drop.
|
||||
idx.upsert(uri.clone(), &parse("[[B]]\n"));
|
||||
assert_eq!(idx.backlinks_for("A").len(), 0);
|
||||
assert_eq!(idx.backlinks_for("B").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_page_and_backlinks() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse("[[About]]\n"));
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
idx.remove(&home);
|
||||
assert!(idx.page(&home).is_none());
|
||||
assert!(idx.backlinks_for("About").is_empty());
|
||||
// The target page is untouched.
|
||||
assert!(idx.page(&about).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_wiki_link_to_uri() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
let target = nuwiki_core::ast::LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("About".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&about));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_anchor_only_link_returns_source_page() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse("= H =\n[[#Section]]\n"));
|
||||
|
||||
let target = nuwiki_core::ast::LinkTarget {
|
||||
kind: LinkKind::AnchorOnly,
|
||||
anchor: Some("section".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&home));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_names_lists_indexed_pages_sorted() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
for name in ["Charlie", "Alpha", "Bravo"] {
|
||||
let uri = Url::from_file_path(format!("/wiki/{name}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse(&format!("= {name} =\n")));
|
||||
}
|
||||
let names = idx.page_names();
|
||||
assert_eq!(names, vec!["Alpha", "Bravo", "Charlie"]);
|
||||
}
|
||||
|
||||
// ===== Position conversion =====
|
||||
|
||||
#[test]
|
||||
fn lsp_to_byte_pos_passthrough_in_utf8() {
|
||||
let p = LspPosition {
|
||||
line: 2,
|
||||
character: 5,
|
||||
};
|
||||
let (line, col) = lsp_to_byte_pos(p, "ignored", true);
|
||||
assert_eq!((line, col), (2, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_to_byte_pos_converts_from_utf16() {
|
||||
// 'é' is one UTF-16 code unit, two UTF-8 bytes. Char "2" after 'é':
|
||||
// UTF-16 col 2 → byte col 3.
|
||||
let text = "héllo\n";
|
||||
let p = LspPosition {
|
||||
line: 0,
|
||||
character: 2,
|
||||
};
|
||||
let (line, col) = lsp_to_byte_pos(p, text, false);
|
||||
assert_eq!(line, 0);
|
||||
assert_eq!(col, 3);
|
||||
}
|
||||
|
||||
// ===== Position lookup =====
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_wikilink_under_cursor() {
|
||||
let src = "see [[Other]] now\n";
|
||||
let doc = parse(src);
|
||||
// Cursor inside "Other" — byte col 6 falls inside "[[Other]]" (which
|
||||
// starts at byte 4 and ends at byte 13).
|
||||
let node = find_inline_at(&doc, 0, 6).expect("wikilink at cursor");
|
||||
assert!(matches!(node, InlineNode::WikiLink(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_none_for_plain_text() {
|
||||
let src = "just text\n";
|
||||
let doc = parse(src);
|
||||
let node = find_inline_at(&doc, 0, 3);
|
||||
// "just text" → Text node, which is what we get back from the inner
|
||||
// walk (not None). Verify it's a Text.
|
||||
assert!(matches!(node, Some(InlineNode::Text(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_descends_into_inline_containers() {
|
||||
let src = "*bold text*\n";
|
||||
let doc = parse(src);
|
||||
// Inside "bold" — should return the inner Text, not the wrapping Bold.
|
||||
let node = find_inline_at(&doc, 0, 3).expect("hit");
|
||||
assert!(matches!(node, InlineNode::Text(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_none_past_eol() {
|
||||
let src = "short\n";
|
||||
let doc = parse(src);
|
||||
assert!(find_inline_at(&doc, 0, 999).is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user