backfill: tag commands + file-operations capability
CI / cargo fmt --check (push) Successful in 19s
CI / cargo clippy (push) Successful in 1m11s
CI / cargo test (push) Successful in 1m21s

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:
2026-05-11 21:17:04 +00:00
parent e75ad6ca89
commit ad1b55a401
3 changed files with 637 additions and 8 deletions
+304
View File
@@ -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;
+98 -8
View File
@@ -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 &params.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 &params.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 = &params.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 {