Files
nuwiki/crates/nuwiki-lsp/tests/navigation.rs
T
gffranco 21b485c91b Remove stale phase/spec references from code comments
Strip "Phase N", "SPEC §X.Y", and "v1.x" planning labels from comments
across the Rust crates and editor layers now that those tracking
artifacts are gone. Comments only — no logic, strings, or test names
touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:51 -03:00

229 lines
7.2 KiB
Rust

//! Tests for the 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 elsewhere.
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());
}