backfill: tag commands + file-operations capability
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: <name> =` + flat list of `[[Page]]`
entries for every page tagged with `<name>`.
- `{ uri }` (no tag) → `= Tags =` umbrella section with `== <tag>
==` 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<TagHit>` — pure filter, sorted by
(name, page, line).
- `tag_pages_snapshot(index) → BTreeMap<tag, [pages]>` — 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Value>) -> Result<Option<Val
|
||||
))
|
||||
}
|
||||
|
||||
fn tags_search(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
|
||||
#[derive(Deserialize, Default)]
|
||||
struct Args {
|
||||
#[serde(default)]
|
||||
uri: Option<Url>,
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
}
|
||||
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<Value>,
|
||||
) -> Result<Option<WorkspaceEdit>, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Args {
|
||||
uri: Url,
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
}
|
||||
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<Value>) -> Result<Option<Value>, 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<Url> = 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<Value>) -> Result<Option<Value>, 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<TagHit> {
|
||||
let needle = query.to_ascii_lowercase();
|
||||
let mut out: Vec<TagHit> = 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<String, Vec<String>> {
|
||||
let mut by_tag: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
|
||||
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: <name> =` section listing every page tagged with
|
||||
/// `name`. When `tag` is `None` the section becomes a "Tag Index"
|
||||
/// covering every tag, headed `== <tag> ==` per group — matches
|
||||
/// `:VimwikiGenerateTagLinks` (no arg) variant.
|
||||
pub fn build_tag_links_text(
|
||||
pages_by_tag: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
tag: Option<&str>,
|
||||
) -> Option<String> {
|
||||
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: <name>`
|
||||
/// 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<String, Vec<String>>,
|
||||
utf8: bool,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
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;
|
||||
|
||||
@@ -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<tower_lsp::lsp_types::SemanticToken> {
|
||||
flat.chunks_exact(5)
|
||||
.map(|c| tower_lsp::lsp_types::SemanticToken {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user