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,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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user