Files
nuwiki/crates/nuwiki-lsp/tests/nav.rs
T
gffranco 4d461d2425
CI / cargo fmt --check (push) Successful in 27s
CI / cargo clippy (push) Successful in 1m6s
CI / cargo test (push) Successful in 1m17s
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>
2026-05-11 00:27:06 +00:00

229 lines
7.2 KiB
Rust

//! 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());
}