//! 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_wikilink_when_cursor_on_description() { // Regression: pressing on the *title* part of a piped link used to // descend into the description's Text node, so goto-definition saw a Text // (not a WikiLink) and reported "No locations found". let src = "see [[Other|My Title]] now\n"; let doc = parse(src); // Byte col 16 is inside "Title" (the description), past the '|' at 11. let node = find_inline_at(&doc, 0, 16).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()); }