//! Link-related LSP commands. //! //! Covers: //! - `nuwiki.link.pasteWikilink` / `nuwiki.link.pasteUrl` — page-name //! synthesis from a URI and the textual shape the handler inserts //! at the cursor. //! - Wikilink resolution, including the regression where `` on a //! wikilink to a not-yet-created page must still resolve to a URI //! so the editor can open (and on save create) the future page. //! The resolver itself is private; we exercise the synthesised-path //! semantics via the public config helpers + index lookup. use std::path::PathBuf; use nuwiki_core::ast::LinkKind; use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; use nuwiki_lsp::config::WikiConfig; use nuwiki_lsp::index::{page_name_from_uri, WorkspaceIndex}; use tower_lsp::lsp_types::Url; fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { VimwikiSyntax::new().parse(src) } // ===== paste helpers ===== #[test] fn commands_list_advertises_link_helpers() { let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); for name in [ "nuwiki.link.pasteWikilink", "nuwiki.link.pasteUrl", "nuwiki.link.catUrl", // Both GenerateLinks forms must be advertised so coc/vim-lsp accept // them (the scoped form was previously sent but unregistered). "nuwiki.links.generate", "nuwiki.links.generateForPath", ] { assert!(names.contains(&name), "missing: {name}"); } } #[test] fn page_name_for_root_level_page() { let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki")); let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap(); assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home"); } #[test] fn page_name_for_subdir_page() { let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki")); let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap(); assert_eq!( page_name_from_uri(&uri, Some(&cfg.root)), "diary/2026-05-11" ); } #[test] fn paste_url_format_matches_export_output_path_for() { // The URL the `pasteUrl` handler emits is `.html`, where // `` walks path segments. Mirror that derivation and assert // the shape we'd insert at cursor. let name = "diary/2026-05-11"; let mut url = String::new(); for (i, seg) in name.split('/').enumerate() { if i > 0 { url.push('/'); } url.push_str(seg); } url.push_str(".html"); assert_eq!(url, "diary/2026-05-11.html"); } #[test] fn paste_wikilink_shape_for_root_page() { // `[[]]` is what the handler inserts at the cursor. let name = "Notes"; assert_eq!(format!("[[{name}]]"), "[[Notes]]"); } // ===== resolve follow-link to non-existent page ===== #[test] fn link_to_indexed_page_resolves_via_index() { let root = "/tmp/follow-link-1"; let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap(); idx.upsert(target.clone(), &parse("= notes =\n")); let resolved = idx.pages_by_name.get("Notes").cloned(); assert_eq!(resolved, Some(target)); } #[test] fn missing_page_is_not_in_index() { let root = "/tmp/follow-link-2"; let idx = WorkspaceIndex::new(Some(PathBuf::from(root))); // Empty index — "Missing" isn't there, so the fallback synthesise // path is the only way to follow `[[Missing]]`. assert!(!idx.pages_by_name.contains_key("Missing")); } #[test] fn config_root_plus_path_plus_ext_is_a_valid_uri() { // Sanity: the synthesise logic constructs /.. let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3")); assert_eq!(cfg.file_extension, ".wiki"); let target_path = cfg.root.join("Missing.wiki"); assert!(Url::from_file_path(&target_path).is_ok()); } #[test] fn wikilink_to_subdir_page_parses_with_slash_in_path() { // Synthesis must walk path segments, not just concatenate. let doc = parse("[[notes/Daily]]\n"); let inline = match &doc.children[0] { nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0], _ => panic!("expected paragraph"), }; let target = match inline { nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target, _ => panic!("expected wikilink"), }; assert_eq!(target.kind, LinkKind::Wiki); assert_eq!(target.path.as_deref(), Some("notes/Daily")); }