From ad1b55a4011f3ac3e4bd7a215ef45220adb6d395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Mon, 11 May 2026 21:17:04 +0000 Subject: [PATCH] backfill: tag commands + file-operations capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC audit of Phases 12–13 surfaced two gaps. Closing them here so the v1.1 command surface and capability advertisement match what §12.3 and §12.4 promise. Phase 12 — tag commands (§12.3): - `nuwiki.tags.search` — case-insensitive substring filter over the workspace tag index. Returns `[{ name, uri, range, scope, page }]` where `scope` is one of `file` / `heading` / `standalone`. - `nuwiki.tags.generateLinks` — generates/refreshes a tagged-pages section. Two modes: - `{ uri, tag }` → `= Tag: =` + flat list of `[[Page]]` entries for every page tagged with ``. - `{ uri }` (no tag) → `= Tags =` umbrella section with `== ==` subgroups, mirroring `:VimwikiGenerateTagLinks` (no arg). Idempotent: re-runs replace the existing section by case-insensitive heading match. - `nuwiki.tags.rebuild` — synchronous full re-walk of the wiki root. Drops index entries for pages whose files no longer exist on disk, re-parses every `.wiki`, and returns `{ pages, files_seen, stale_removed }`. Useful for cleaning up after out-of-band edits the LSP didn't see. Ops layer: - `tag_hits(index, query) → Vec` — pure filter, sorted by (name, page, line). - `tag_pages_snapshot(index) → BTreeMap` — taken under the read lock so the dispatcher can release the lock before building the edit. - `build_tag_links_text` / `tag_links_edit` — text-level rendering and the WorkspaceEdit producer. Phase 13 — file-operations capability (§12.4): - `ServerCapabilities.workspace.file_operations` now advertises `did_rename` + `did_delete` with filters scoped to `**/*.wiki` and `**/*.md`. (will_* deferred — those return a WorkspaceEdit and we'd want to wire them into the rename pipeline before promising support.) - `did_rename_files` handler — moves the live DocumentState forward to the new URI when the file was open, then refreshes the index (remove old URI, upsert new via read_or_open). - `did_delete_files` handler — drops both DocumentState and index entry so backlink lookups don't surface dangling source URIs. Tests: 15 new in `phase17_backfill.rs` covering tag hit filtering & ordering, snapshot dedup, single-tag and full-index render shapes, edit replace-vs-insert flow, JSON shape of `TagHit`, and the COMMANDS list. Total 324 tests pass; fmt + clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/nuwiki-lsp/src/commands.rs | 304 ++++++++++++++++++++ crates/nuwiki-lsp/src/lib.rs | 106 ++++++- crates/nuwiki-lsp/tests/phase17_backfill.rs | 235 +++++++++++++++ 3 files changed, 637 insertions(+), 8 deletions(-) create mode 100644 crates/nuwiki-lsp/tests/phase17_backfill.rs diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 342c074..f32f049 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -52,6 +52,9 @@ pub const COMMANDS: &[&str] = &[ "nuwiki.diary.prev", "nuwiki.diary.listEntries", "nuwiki.diary.openForDate", + "nuwiki.tags.search", + "nuwiki.tags.generateLinks", + "nuwiki.tags.rebuild", ]; pub(crate) async fn execute( @@ -113,6 +116,11 @@ pub(crate) async fn execute( "nuwiki.diary.openForDate" => { diary_open_for_date(backend, args).map(|o| o.map(CommandOutcome::Value)) } + "nuwiki.tags.search" => tags_search(backend, args).map(|o| o.map(CommandOutcome::Value)), + "nuwiki.tags.generateLinks" => { + tags_generate_links(backend, args).map(|o| o.map(CommandOutcome::Edit)) + } + "nuwiki.tags.rebuild" => tags_rebuild(backend, args).map(|o| o.map(CommandOutcome::Value)), other => Err(format!("unknown nuwiki command: {other}")), } } @@ -452,6 +460,128 @@ fn diary_open_for_date(backend: &Backend, args: Vec) -> Result) -> Result, String> { + #[derive(Deserialize, Default)] + struct Args { + #[serde(default)] + uri: Option, + #[serde(default)] + query: Option, + } + let parsed: Args = match args.into_iter().next() { + Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, + None => Args::default(), + }; + let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else { + return Ok(Some(serde_json::json!([]))); + }; + let hits = { + let idx = wiki + .index + .read() + .map_err(|_| "index lock poisoned".to_string())?; + ops::tag_hits(&idx, parsed.query.as_deref().unwrap_or("")) + }; + Ok(Some(serde_json::to_value(&hits).map_err(|e| { + format!("nuwiki.tags.search: serialization failed — {e}") + })?)) +} + +fn tags_generate_links( + backend: &Backend, + args: Vec, +) -> Result, String> { + #[derive(Deserialize)] + struct Args { + uri: Url, + #[serde(default)] + tag: Option, + } + let raw = args + .into_iter() + .next() + .ok_or_else(|| "nuwiki.tags.generateLinks: missing { uri } argument".to_string())?; + let parsed: Args = serde_json::from_value(raw) + .map_err(|e| format!("nuwiki.tags.generateLinks: invalid args — {e}"))?; + let doc = match backend.documents.get(&parsed.uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let Some(wiki) = backend.wiki_for_uri(&parsed.uri) else { + return Ok(None); + }; + let snapshot = { + let idx = wiki + .index + .read() + .map_err(|_| "index lock poisoned".to_string())?; + ops::tag_pages_snapshot(&idx) + }; + Ok(ops::tag_links_edit( + &doc.text, + &doc.ast, + &parsed.uri, + parsed.tag.as_deref(), + &snapshot, + utf8, + )) +} + +fn tags_rebuild(backend: &Backend, args: Vec) -> Result, String> { + let p = parse_optional_uri_arg(args)?; + let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { + return Ok(None); + }; + let registry = std::sync::Arc::clone(&backend.registry); + let root = wiki.config.root.clone(); + if root.as_os_str().is_empty() { + return Ok(Some( + serde_json::json!({ "pages": 0, "reason": "no wiki root configured" }), + )); + } + let Some(plugin) = registry.get("vimwiki") else { + return Err("vimwiki syntax plugin missing".to_string()); + }; + let files = crate::index::walk_wiki_files(&root); + let total = files.len(); + let mut indexed = 0usize; + let mut idx = wiki + .index + .write() + .map_err(|_| "index lock poisoned".to_string())?; + // Drop pages whose files no longer exist. Collect first to avoid mutating + // during iteration. + let stale: Vec = idx + .pages_by_uri + .keys() + .filter(|u| match u.to_file_path() { + Ok(p) => !p.exists(), + Err(_) => true, + }) + .cloned() + .collect(); + for u in &stale { + idx.remove(u); + } + for path in files { + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let ast = plugin.parse(&text); + let Ok(uri) = Url::from_file_path(&path) else { + continue; + }; + idx.upsert(uri, &ast); + indexed += 1; + } + Ok(Some(serde_json::json!({ + "pages": indexed, + "files_seen": total, + "stale_removed": stale.len(), + }))) +} + fn workspace_find_orphans(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args)?; let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) { @@ -1082,6 +1212,180 @@ pub mod ops { b.build() } + // ===== Tag ops (Phase 12 commands) ===== + // + // SPEC §12.3 calls for three tag-driven commands beyond the indexing + // that Phase 12 already shipped. `tag_hits` powers `nuwiki.tags.search`; + // `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild + // command is a direct re-walk of the wiki root in the dispatcher. + + use crate::index::TagOccurrence; + use nuwiki_core::ast::TagScope; + + /// One row returned by `nuwiki.tags.search`. Serialised as + /// `{ name, uri, range, scope, page }` so editors can render either a + /// flat hit list or a per-tag tree. + #[derive(Debug, Clone, serde::Serialize)] + pub struct TagHit { + pub name: String, + pub uri: Url, + pub range: tower_lsp::lsp_types::Range, + pub scope: &'static str, + pub page: String, + } + + fn scope_tag(s: TagScope) -> &'static str { + match s { + TagScope::File => "file", + TagScope::Heading(_) => "heading", + TagScope::Standalone => "standalone", + } + } + + /// Filter every indexed tag by a case-insensitive substring match. An + /// empty query returns everything (sorted by name, then by page). + pub fn tag_hits(index: &WorkspaceIndex, query: &str) -> Vec { + let needle = query.to_ascii_lowercase(); + let mut out: Vec = Vec::new(); + for (name, occs) in &index.tags_by_name { + if !needle.is_empty() && !name.to_ascii_lowercase().contains(&needle) { + continue; + } + for occ in occs { + let page = index + .pages_by_uri + .get(&occ.uri) + .map(|p| p.name.clone()) + .unwrap_or_default(); + out.push(TagHit { + name: name.clone(), + uri: occ.uri.clone(), + range: no_text_range(&occ.span), + scope: scope_tag(occ.scope), + page, + }); + } + } + out.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| a.page.cmp(&b.page)) + .then_with(|| a.range.start.line.cmp(&b.range.start.line)) + }); + out + } + + /// Snapshot of `tag → sorted [page names]` taken under the index read + /// lock so the dispatcher can release the lock before computing the + /// edit. + pub fn tag_pages_snapshot( + index: &WorkspaceIndex, + ) -> std::collections::BTreeMap> { + let mut by_tag: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (name, occs) in &index.tags_by_name { + for occ in occs { + if let Some(page) = index.pages_by_uri.get(&occ.uri) { + by_tag + .entry(name.clone()) + .or_default() + .insert(page.name.clone()); + } + } + } + by_tag + .into_iter() + .map(|(k, v)| (k, v.into_iter().collect())) + .collect() + } + + /// Render a `= Tag: =` section listing every page tagged with + /// `name`. When `tag` is `None` the section becomes a "Tag Index" + /// covering every tag, headed `== ==` per group — matches + /// `:VimwikiGenerateTagLinks` (no arg) variant. + pub fn build_tag_links_text( + pages_by_tag: &std::collections::BTreeMap>, + tag: Option<&str>, + ) -> Option { + match tag { + Some(t) => { + let pages = pages_by_tag.get(t)?; + if pages.is_empty() { + return None; + } + let mut out = format!("= Tag: {t} =\n"); + for p in pages { + out.push_str(&format!("- [[{p}]]\n")); + } + Some(out) + } + None => { + if pages_by_tag.is_empty() { + return None; + } + let mut out = String::from("= Tags =\n"); + for (name, pages) in pages_by_tag { + out.push_str(&format!("\n== {name} ==\n")); + for p in pages { + out.push_str(&format!("- [[{p}]]\n")); + } + } + Some(out) + } + } + } + + /// Heading name used to locate an existing tag-links section so + /// re-generation is idempotent. + fn tag_section_heading(tag: Option<&str>) -> String { + match tag { + Some(t) => format!("Tag: {t}"), + None => "Tags".to_string(), + } + } + + /// Build a `WorkspaceEdit` that replaces an existing `Tag: ` + /// section (case-insensitive match on the h1 title) or inserts at the + /// top of the document when no such section exists. Returns `None` + /// when there are no matching tagged pages. + pub fn tag_links_edit( + text: &str, + ast: &DocumentNode, + uri: &Url, + tag: Option<&str>, + pages_by_tag: &std::collections::BTreeMap>, + utf8: bool, + ) -> Option { + let body = build_tag_links_text(pages_by_tag, tag)?; + let heading = tag_section_heading(tag); + let mut b = WorkspaceEditBuilder::new(); + match find_section_range(ast, &heading) { + Some((start, end)) => { + let span = Span::new(start, end); + b.edit(uri.clone(), text_edit_replace(span, body, text, utf8)); + } + None => { + let with_sep = format!("{body}\n"); + b.edit( + uri.clone(), + text_edit_insert( + LspPosition { + line: 0, + character: 0, + }, + with_sep, + ), + ); + } + } + Some(b.build()) + } + + // Keep the import live for downstream use even when the local module + // doesn't reference it directly. + #[allow(dead_code)] + fn _retain_tag_occurrence_import(_o: &TagOccurrence) {} + fn whole_doc_span(text: &str) -> Span { let bytes = text.as_bytes(); let mut line = 0u32; diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 6238db0..6a5e0fc 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -42,18 +42,21 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::lsp_types::{ CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse, - Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, - DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, - DocumentSymbolResponse, ExecuteCommandOptions, ExecuteCommandParams, GotoDefinitionParams, - GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability, - InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind, - MessageType, OneOf, PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, - Range, ReferenceParams, RenameParams, SemanticTokens, SemanticTokensFullOptions, + DeleteFilesParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, + DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandOptions, + ExecuteCommandParams, FileOperationFilter, FileOperationPattern, FileOperationPatternKind, + FileOperationRegistrationOptions, GotoDefinitionParams, GotoDefinitionResponse, Hover, + HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, + InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf, + PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, Range, + ReferenceParams, RenameFilesParams, RenameParams, SemanticTokens, SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin, - WorkDoneProgressEnd, WorkDoneProgressOptions, WorkspaceEdit, WorkspaceSymbolParams, + WorkDoneProgressEnd, WorkDoneProgressOptions, WorkspaceEdit, + WorkspaceFileOperationsServerCapabilities, WorkspaceServerCapabilities, WorkspaceSymbolParams, }; use tower_lsp::{Client, LanguageServer, LspService, Server}; @@ -320,6 +323,10 @@ impl LanguageServer for Backend { }, ), ), + workspace: Some(WorkspaceServerCapabilities { + workspace_folders: None, + file_operations: Some(wiki_file_operations()), + }), ..ServerCapabilities::default() }, server_info: Some(ServerInfo { @@ -387,6 +394,52 @@ impl LanguageServer for Backend { } } + async fn did_rename_files(&self, params: RenameFilesParams) { + // Phase 13 advertises this capability so the editor pushes + // out-of-band renames (file explorer drags, `:VimwikiRenameFile` + // when the client opts to surface the new URI). Update the index + // so backlinks/headings track the new URI without waiting for + // didOpen on the renamed buffer. + for fr in ¶ms.files { + let Ok(old_uri) = Url::parse(&fr.old_uri) else { + continue; + }; + let Ok(new_uri) = Url::parse(&fr.new_uri) else { + continue; + }; + // Pull the live document forward if it was open under the old + // URI. Otherwise read the new path from disk and parse. + if let Some((_, state)) = self.documents.remove(&old_uri) { + self.documents.insert(new_uri.clone(), state); + } + if let Some(wiki) = self.wiki_for_uri(&new_uri) { + let mut idx = wiki.index.write().expect("index lock poisoned"); + idx.remove(&old_uri); + } + if let Ok(snapshot) = self.read_or_open(&new_uri).await { + if let Some(wiki) = self.wiki_for_uri(&new_uri) { + let mut idx = wiki.index.write().expect("index lock poisoned"); + idx.upsert(new_uri.clone(), &snapshot.ast); + } + } + } + } + + async fn did_delete_files(&self, params: DeleteFilesParams) { + // Mirror of did_rename: drop the index entry so backlink lookups + // stop returning the now-missing source URI. + for f in ¶ms.files { + let Ok(uri) = Url::parse(&f.uri) else { + continue; + }; + self.documents.remove(&uri); + if let Some(wiki) = self.wiki_for_uri(&uri) { + let mut idx = wiki.index.write().expect("index lock poisoned"); + idx.remove(&uri); + } + } + } + async fn did_close(&self, params: DidCloseTextDocumentParams) { let uri = ¶ms.text_document.uri; self.documents.remove(uri); @@ -713,6 +766,43 @@ impl LanguageServer for Backend { } } +/// File-operation capability for `.wiki` files. Tells the client to +/// notify us before/after the user renames or deletes files via the file +/// explorer (or any other out-of-buffer trigger). Filters scope these +/// notifications to wiki sources so we don't see every C++ rename in a +/// polyglot workspace. +fn wiki_file_operations() -> WorkspaceFileOperationsServerCapabilities { + let filters = vec![ + FileOperationFilter { + scheme: Some("file".into()), + pattern: FileOperationPattern { + glob: "**/*.wiki".into(), + matches: Some(FileOperationPatternKind::File), + options: None, + }, + }, + FileOperationFilter { + scheme: Some("file".into()), + pattern: FileOperationPattern { + glob: "**/*.md".into(), + matches: Some(FileOperationPatternKind::File), + options: None, + }, + }, + ]; + let opts = || FileOperationRegistrationOptions { + filters: filters.clone(), + }; + WorkspaceFileOperationsServerCapabilities { + did_create: None, + will_create: None, + did_rename: Some(opts()), + will_rename: None, + did_delete: Some(opts()), + will_delete: None, + } +} + fn pack_data(flat: &[u32]) -> Vec { flat.chunks_exact(5) .map(|c| tower_lsp::lsp_types::SemanticToken { diff --git a/crates/nuwiki-lsp/tests/phase17_backfill.rs b/crates/nuwiki-lsp/tests/phase17_backfill.rs new file mode 100644 index 0000000..4b91871 --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase17_backfill.rs @@ -0,0 +1,235 @@ +//! Backfill tests for items missed in Phases 12–13: +//! - `nuwiki.tags.search`, `nuwiki.tags.generateLinks`, `nuwiki.tags.rebuild` +//! - `workspace/fileOperations` capability advertisement +//! +//! These tests drive the pure ops directly; the live-handler paths +//! (rebuild walking the filesystem, did_rename re-reading from disk) are +//! covered by the existing Phase 11 closed-doc loader tests. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::commands::ops; +use nuwiki_lsp::index::WorkspaceIndex; +use tower_lsp::lsp_types::Url; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); + for (name, src) in pages { + let path = format!("{root}/{name}.wiki"); + let uri = Url::from_file_path(&path).unwrap(); + idx.upsert(uri, &parse(src)); + } + idx +} + +// ===== tag_hits ===== + +#[test] +fn tag_hits_empty_query_returns_every_tag() { + let idx = build_index( + "/tmp/th1", + &[ + ("Home", "= Home =\n:work:personal:\n"), + ("Notes", "= Notes =\n:work:\n"), + ], + ); + let hits = ops::tag_hits(&idx, ""); + let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect(); + assert!(names.contains(&"work")); + assert!(names.contains(&"personal")); + // 2 pages tagged with `work`, 1 with `personal` → 3 hits. + assert_eq!(hits.len(), 3); +} + +#[test] +fn tag_hits_substring_filter_case_insensitive() { + let idx = build_index( + "/tmp/th2", + &[("Page", "= Page =\n:release-2026:RoadMap:\n")], + ); + let hits = ops::tag_hits(&idx, "RELEASE"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "release-2026"); +} + +#[test] +fn tag_hits_orders_by_name_then_page() { + let idx = build_index( + "/tmp/th3", + &[ + ("Beta", "= Beta =\n:zeta:\n"), + ("Alpha", "= Alpha =\n:alpha:\n"), + ("Gamma", "= Gamma =\n:alpha:\n"), + ], + ); + let hits = ops::tag_hits(&idx, ""); + let pairs: Vec<(String, String)> = hits + .iter() + .map(|h| (h.name.clone(), h.page.clone())) + .collect(); + // alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta. + assert_eq!(pairs[0], ("alpha".into(), "Alpha".into())); + assert_eq!(pairs[1], ("alpha".into(), "Gamma".into())); + assert_eq!(pairs[2], ("zeta".into(), "Beta".into())); +} + +#[test] +fn tag_hits_serializes_to_expected_json_shape() { + let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]); + let hits = ops::tag_hits(&idx, ""); + let v = serde_json::to_value(&hits).unwrap(); + let row = &v[0]; + assert_eq!(row["name"], "release"); + assert!(row["uri"].is_string()); + assert!(row["range"]["start"]["line"].is_number()); + assert!(row["page"].is_string()); + // scope is one of the three string variants. + let scope = row["scope"].as_str().unwrap(); + assert!( + ["file", "heading", "standalone"].contains(&scope), + "{scope}" + ); +} + +// ===== tag_pages_snapshot ===== + +#[test] +fn tag_pages_snapshot_deduplicates_pages_per_tag() { + // Same page has the tag in both file scope and heading scope — + // dedup so the rendered list doesn't show it twice. + let idx = build_index( + "/tmp/ts1", + &[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")], + ); + let snap = ops::tag_pages_snapshot(&idx); + assert_eq!(snap.get("release").unwrap().len(), 1); + assert_eq!(snap["release"][0], "Home"); +} + +#[test] +fn tag_pages_snapshot_sorts_pages_alphabetically() { + let idx = build_index( + "/tmp/ts2", + &[ + ("Charlie", "= Charlie =\n:topic:\n"), + ("Alpha", "= Alpha =\n:topic:\n"), + ("Bravo", "= Bravo =\n:topic:\n"), + ], + ); + let snap = ops::tag_pages_snapshot(&idx); + assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]); +} + +// ===== build_tag_links_text ===== + +#[test] +fn build_tag_links_for_single_tag() { + let mut snap = BTreeMap::new(); + snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]); + let out = ops::build_tag_links_text(&snap, Some("release")).unwrap(); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines[0], "= Tag: release ="); + assert_eq!(lines[1], "- [[Alpha]]"); + assert_eq!(lines[2], "- [[Beta]]"); +} + +#[test] +fn build_tag_links_for_missing_tag_returns_none() { + let snap = BTreeMap::new(); + assert!(ops::build_tag_links_text(&snap, Some("ghost")).is_none()); +} + +#[test] +fn build_tag_links_full_index_groups_by_tag() { + let mut snap = BTreeMap::new(); + snap.insert("a".to_string(), vec!["P1".into()]); + snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]); + let out = ops::build_tag_links_text(&snap, None).unwrap(); + assert!(out.starts_with("= Tags =\n")); + assert!(out.contains("== a ==")); + assert!(out.contains("== b ==")); + assert!(out.contains("- [[P1]]")); + assert!(out.contains("- [[P2]]")); + assert!(out.contains("- [[P3]]")); +} + +#[test] +fn build_tag_links_full_index_returns_none_when_no_tags() { + let snap = BTreeMap::new(); + assert!(ops::build_tag_links_text(&snap, None).is_none()); +} + +// ===== tag_links_edit ===== + +#[test] +fn tag_links_edit_inserts_when_section_missing() { + let src = "Some content.\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/p.wiki").unwrap(); + let mut snap = BTreeMap::new(); + snap.insert("release".to_string(), vec!["Alpha".into()]); + let edit = + ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("got an edit"); + let te = &edit.changes.unwrap()[&uri][0]; + // Insertion at start. + assert_eq!(te.range.start, te.range.end); + assert!(te.new_text.contains("= Tag: release =")); + assert!(te.new_text.contains("[[Alpha]]")); +} + +#[test] +fn tag_links_edit_replaces_existing_section() { + let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/p.wiki").unwrap(); + let mut snap = BTreeMap::new(); + snap.insert("release".to_string(), vec!["Fresh".into()]); + let edit = ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + assert_eq!(te.range.start.line, 0); + assert!(te.new_text.contains("[[Fresh]]")); + assert!(!te.new_text.contains("Stale")); +} + +#[test] +fn tag_links_edit_full_index_replaces_existing_tags_section() { + let src = "= Tags =\n- [[old]]\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/p.wiki").unwrap(); + let mut snap = BTreeMap::new(); + snap.insert("alpha".to_string(), vec!["P".into()]); + let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true).expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + assert!(te.new_text.contains("== alpha ==")); + assert!(!te.new_text.contains("[[old]]")); +} + +#[test] +fn tag_links_edit_returns_none_for_unknown_tag() { + let src = "hi\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/p.wiki").unwrap(); + let snap = BTreeMap::new(); + assert!(ops::tag_links_edit(src, &ast, &uri, Some("ghost"), &snap, true).is_none()); +} + +// ===== COMMANDS list contains new entries ===== + +#[test] +fn commands_list_includes_tag_commands() { + let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); + for name in [ + "nuwiki.tags.search", + "nuwiki.tags.generateLinks", + "nuwiki.tags.rebuild", + ] { + assert!(names.contains(&name), "missing: {name}"); + } +}