phase 13: workspace/rename + executeCommand router + nuwiki.file.delete
CI / cargo fmt --check (push) Successful in 29s
CI / cargo clippy (push) Successful in 1m15s
CI / cargo test (push) Successful in 1m15s

Server-side rename with cross-document link rewriting (P11's
read_or_open delivers on its promise) and the first executeCommand
entry. Plus the routing infrastructure that Phases 14-17 will hang
their own commands off of.

commands.rs:
- commands::execute(backend, name, args) dispatches by command name
  and returns the WorkspaceEdit to apply.
- COMMANDS const slice is the source of truth for what's advertised
  in ExecuteCommandOptions.commands.
- First entry: nuwiki.file.delete with args { uri: <Url> } returning
  a WorkspaceEdit with a DeleteFile op.

rename.rs:
- compute_rename(backend, target_uri, new_name, utf8):
  1. Resolve the wiki via wiki_for_uri.
  2. Build new URI from wiki.root + new_name + extension.
  3. Snapshot the index's backlinks for the old page name before
     releasing the read lock so we don't hold it across awaits.
  4. For each backlink, read_or_open the source (live state or
     disk + reparse) and validate the cached span against current
     text.
  5. Rewrite the [[...]] segment with rewrite_wikilink_target,
     preserving anchor and description.
  6. Add the RenameFile op so the builder emits it before the text
     edits in documentChanges ordering.
- build_new_uri handles subdirectory paths and avoids double-
  appending the file extension.
- rewrite_wikilink_target is pub so other tooling can call it.

lib.rs:
- Capabilities: rename_provider + execute_command_provider listing
  commands::COMMANDS.
- New rename handler: cursor-on-wikilink renames the linked page,
  else the buffer's file. Delegates to rename::compute_rename.
- New execute_command handler dispatches via commands::execute, then
  pushes the resulting WorkspaceEdit through client.apply_edit. On
  unknown command or bad args, logs and returns null.
- read_or_open loses its allow(dead_code) attribute now that Phase
  13 calls it.
- rename_target_uri helper shares index-resolution with goto_def.

Tests (11 new in phase13_rename_commands.rs):
- rewrite_wikilink_target: plain, with description, with anchor,
  with both, subdirectory path, non-wikilink-text rejection.
- build_new_uri: under root, with subdirectory, extension dedup,
  empty path segments skipped.
- nuwiki.file.delete builder smoke test (DocumentChanges + DeleteFile).

All 211 v1.0+Phase 11+12 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:27:39 +00:00
parent 8b29d4e9b9
commit cb4bfae5e7
5 changed files with 367 additions and 15 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|---|---|---| |---|---|---|
| 11 | Plumbing prerequisites | ✅ done | | 11 | Plumbing prerequisites | ✅ done |
| 12 | Tags | ✅ done | | 12 | Tags | ✅ done |
| 13 | Workspace edits + executeCommand | | | 13 | Workspace edits + executeCommand | ✅ done |
| 14 | List & table edit commands | ⏳ | | 14 | List & table edit commands | ⏳ |
| 15 | Link health + TOC/index generation | ⏳ | | 15 | Link health + TOC/index generation | ⏳ |
| 16 | Diary | ⏳ | | 16 | Diary | ⏳ |
+51
View File
@@ -0,0 +1,51 @@
//! `workspace/executeCommand` dispatcher.
//!
//! Phase 13 lands the router + the first command. Phases 1417 register
//! their own commands here. Each handler returns the `WorkspaceEdit` it
//! wants applied; the `Backend` calls `client.apply_edit` after the call.
//!
//! Phase 13 surface:
//! - `nuwiki.file.delete` — args: `{ "uri": <Url> }`
use serde::Deserialize;
use tower_lsp::lsp_types::{Url, WorkspaceEdit};
use crate::edits::{op_delete, WorkspaceEditBuilder};
use crate::Backend;
/// All commands the server advertises in `ExecuteCommandOptions.commands`.
/// Keep this in lock-step with the match arms in `execute` below — adding
/// here without a handler will fail at runtime; adding a handler without
/// listing here means the client won't see it as available.
pub const COMMANDS: &[&str] = &["nuwiki.file.delete"];
/// Dispatch an `executeCommand` request. Returns `Ok(None)` for commands
/// that don't produce a `WorkspaceEdit` (e.g. read-only queries); returns
/// `Err` with a human-readable message for unknown commands or bad args
/// — the LSP backend logs it and returns `null` to the client.
pub(crate) async fn execute(
_backend: &Backend,
command: &str,
args: Vec<serde_json::Value>,
) -> Result<Option<WorkspaceEdit>, String> {
match command {
"nuwiki.file.delete" => file_delete(args),
other => Err(format!("unknown nuwiki command: {other}")),
}
}
fn file_delete(args: Vec<serde_json::Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "nuwiki.file.delete: missing arguments".to_string())?;
let parsed: Args = serde_json::from_value(raw)
.map_err(|e| format!("nuwiki.file.delete: invalid args — {e}"))?;
let mut builder = WorkspaceEditBuilder::new();
builder.file_op(op_delete(parsed.uri));
Ok(Some(builder.build()))
}
+78 -14
View File
@@ -21,10 +21,12 @@
//! The workspace scan runs in a background tokio task spawned from //! The workspace scan runs in a background tokio task spawned from
//! `initialize` (P8) with `window/workDoneProgress` begin/end events. //! `initialize` (P8) with `window/workDoneProgress` begin/end events.
pub mod commands;
pub mod config; pub mod config;
pub mod edits; pub mod edits;
pub mod index; pub mod index;
pub mod nav; pub mod nav;
pub mod rename;
pub mod semantic_tokens; pub mod semantic_tokens;
pub mod wiki; pub mod wiki;
@@ -40,16 +42,16 @@ use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse, CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams,
DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, DocumentSymbolResponse, ExecuteCommandOptions, ExecuteCommandParams, GotoDefinitionParams,
HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
Location, MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams, InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind,
ProgressParamsValue, ProgressToken, Range, ReferenceParams, SemanticTokens, MessageType, OneOf, PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken,
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, Range, ReferenceParams, RenameParams, SemanticTokens, SemanticTokensFullOptions,
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams,
SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities,
SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability, ServerCapabilities, ServerInfo, SymbolInformation as LspSymbolInformation, SymbolKind,
TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin,
WorkDoneProgressOptions, WorkspaceSymbolParams, WorkDoneProgressEnd, WorkDoneProgressOptions, WorkspaceEdit, WorkspaceSymbolParams,
}; };
use tower_lsp::{Client, LanguageServer, LspService, Server}; use tower_lsp::{Client, LanguageServer, LspService, Server};
@@ -147,6 +149,25 @@ impl Backend {
wikis.first().cloned() wikis.first().cloned()
} }
/// If the cursor sits on a wikilink, return the linked page's URI so
/// `rename` operates on the *target* (matches vimwiki's behaviour).
/// Otherwise return `None` and the caller falls back to the buffer's
/// own URI.
fn rename_target_uri(&self, uri: &Url, pos: tower_lsp::lsp_types::Position) -> Option<Url> {
let doc = self.documents.get(uri)?;
let utf8 = self.use_utf8.load(Ordering::Relaxed);
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
let node = nav::find_inline_at(&doc.ast, line, col)?;
let wikilink = match node {
InlineNode::WikiLink(w) => w,
_ => return None,
};
let wiki = self.wiki_for_uri(uri)?;
let source_page = index::page_name_from_uri(uri, Some(&wiki.config.root));
let idx = wiki.index.read().ok()?;
idx.resolve(&wikilink.target, &source_page).cloned()
}
/// Resolve `uri` to the wiki whose root is the longest prefix of it. /// Resolve `uri` to the wiki whose root is the longest prefix of it.
/// Falls back to the default wiki when no root matches — that's the /// Falls back to the default wiki when no root matches — that's the
/// usual case for ad-hoc `.wiki` files outside any registered wiki. /// usual case for ad-hoc `.wiki` files outside any registered wiki.
@@ -163,10 +184,6 @@ impl Backend {
/// cross-document operations (Phase 13 `workspace/rename` and link /// cross-document operations (Phase 13 `workspace/rename` and link
/// health) that need accurate spans + text without trusting cached /// health) that need accurate spans + text without trusting cached
/// `WorkspaceIndex` data. /// `WorkspaceIndex` data.
///
/// Scaffolded ahead of the Phase 13 caller — `#[allow(dead_code)]`
/// drops once Phase 13 wires it in.
#[allow(dead_code)]
pub async fn read_or_open(&self, uri: &Url) -> io::Result<DocSnapshot> { pub async fn read_or_open(&self, uri: &Url) -> io::Result<DocSnapshot> {
if let Some(doc) = self.documents.get(uri) { if let Some(doc) = self.documents.get(uri) {
return Ok(DocSnapshot { return Ok(DocSnapshot {
@@ -278,6 +295,14 @@ impl LanguageServer for Backend {
workspace_symbol_provider: Some(OneOf::Left(true)), workspace_symbol_provider: Some(OneOf::Left(true)),
definition_provider: Some(OneOf::Left(true)), definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)), references_provider: Some(OneOf::Left(true)),
rename_provider: Some(OneOf::Left(true)),
execute_command_provider: Some(ExecuteCommandOptions {
commands: commands::COMMANDS
.iter()
.map(|s| (*s).to_string())
.collect(),
work_done_progress_options: WorkDoneProgressOptions::default(),
}),
hover_provider: Some(HoverProviderCapability::Simple(true)), hover_provider: Some(HoverProviderCapability::Simple(true)),
completion_provider: Some(CompletionOptions { completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec!["[".into()]), trigger_characters: Some(vec!["[".into()]),
@@ -596,6 +621,45 @@ impl LanguageServer for Backend {
Ok(Some(CompletionResponse::Array(items))) Ok(Some(CompletionResponse::Array(items)))
} }
async fn rename(&self, params: RenameParams) -> LspResult<Option<WorkspaceEdit>> {
let uri = params.text_document_position.text_document.uri;
let pos = params.text_document_position.position;
let utf8 = self.use_utf8.load(Ordering::Relaxed);
// If the cursor sits on a wikilink, rename the *linked* page;
// otherwise rename the buffer's own file. Closed-doc reads happen
// inside `compute_rename` via `read_or_open`.
let target_uri = self.rename_target_uri(&uri, pos).unwrap_or(uri);
let edit = rename::compute_rename(self, target_uri, params.new_name, utf8).await;
Ok(edit)
}
async fn execute_command(
&self,
params: ExecuteCommandParams,
) -> LspResult<Option<serde_json::Value>> {
match commands::execute(self, &params.command, params.arguments).await {
Ok(Some(edit)) => {
if let Err(e) = self.client.apply_edit(edit).await {
self.client
.log_message(
MessageType::ERROR,
format!("nuwiki: apply_edit failed — {e}"),
)
.await;
}
Ok(None)
}
Ok(None) => Ok(None),
Err(msg) => {
self.client
.log_message(MessageType::ERROR, format!("nuwiki: {msg}"))
.await;
Ok(None)
}
}
}
async fn symbol( async fn symbol(
&self, &self,
params: WorkspaceSymbolParams, params: WorkspaceSymbolParams,
+122
View File
@@ -0,0 +1,122 @@
//! `textDocument/rename` — server-side rename with cross-document link
//! rewriting.
//!
//! Phase 13 implementation:
//! 1. Resolve the rename target — cursor on a wikilink renames the linked
//! page; otherwise rename the current file.
//! 2. Build the new URI from the wiki root + the user-supplied path.
//! 3. For every page in the wiki's index with an outgoing link to the
//! old page name, read its source via the Phase 11 closed-doc loader
//! so cached spans are validated against current text, then emit a
//! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving
//! anchors and descriptions.
//! 4. Add the `RenameFile` op last so the builder emits it before content
//! edits in `documentChanges` ordering.
use std::path::Path;
use tower_lsp::lsp_types::{Url, WorkspaceEdit};
use crate::edits::{op_rename, text_edit_replace, WorkspaceEditBuilder};
use crate::index::page_name_from_uri;
use crate::Backend;
/// Compute the `WorkspaceEdit` for renaming `target_uri` to `new_name`.
///
/// Returns `None` when:
/// - the URI doesn't belong to any registered wiki,
/// - the new URI couldn't be built from the wiki root + new_name,
/// - the new URI is the same as the old one.
pub(crate) async fn compute_rename(
backend: &Backend,
target_uri: Url,
new_name: String,
utf8: bool,
) -> Option<WorkspaceEdit> {
let wiki = backend.wiki_for_uri(&target_uri)?;
let old_name = page_name_from_uri(&target_uri, Some(&wiki.config.root));
let new_uri = build_new_uri(&wiki.config.root, &new_name, &wiki.config.file_extension)?;
if new_uri == target_uri {
return None;
}
let mut builder = WorkspaceEditBuilder::new();
builder.file_op(op_rename(target_uri.clone(), new_uri));
// Snapshot the backlinks so the index read lock is released before we
// await any disk I/O.
let backlinks = {
let idx = wiki.index.read().ok()?;
idx.backlinks_for(&old_name).to_vec()
};
for bl in backlinks {
let Ok(snapshot) = backend.read_or_open(&bl.source_uri).await else {
continue;
};
let start = bl.source_span.start.offset.min(snapshot.text.len());
let end = bl.source_span.end.offset.min(snapshot.text.len());
if start >= end {
continue;
}
let original = &snapshot.text[start..end];
if let Some(new_link) = rewrite_wikilink_target(original, &new_name) {
let edit = text_edit_replace(bl.source_span, new_link, &snapshot.text, utf8);
builder.edit(bl.source_uri.clone(), edit);
}
}
if builder.is_empty() {
None
} else {
Some(builder.build())
}
}
/// Build `<root>/<new_name>(<ext>)` as a `file://` URL.
pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option<Url> {
let mut path = root.to_path_buf();
for part in new_name.split(['/', '\\']) {
if !part.is_empty() {
path.push(part);
}
}
let path_str = path.to_string_lossy().to_string();
let with_ext = if path_str.ends_with(ext) {
path_str
} else {
format!("{path_str}{ext}")
};
Url::from_file_path(with_ext).ok()
}
/// Rewrite a `[[...]]` source segment so the path portion becomes
/// `new_path`, keeping any `#anchor` and `|description` parts intact.
///
/// Returns `None` if `segment` doesn't look like a wikilink (starts with
/// `[[`, ends with `]]`).
pub fn rewrite_wikilink_target(segment: &str, new_path: &str) -> Option<String> {
if !segment.starts_with("[[") || !segment.ends_with("]]") || segment.len() < 4 {
return None;
}
let inner = &segment[2..segment.len() - 2];
let (path_part, description) = match inner.find('|') {
Some(i) => (&inner[..i], Some(&inner[i + 1..])),
None => (inner, None),
};
let anchor = path_part.find('#').map(|i| &path_part[i + 1..]);
let mut out = String::with_capacity(segment.len());
out.push_str("[[");
out.push_str(new_path);
if let Some(a) = anchor {
out.push('#');
out.push_str(a);
}
if let Some(d) = description {
out.push('|');
out.push_str(d);
}
out.push_str("]]");
Some(out)
}
@@ -0,0 +1,115 @@
//! Phase 13: pure-function tests for the rename helper + the file-delete
//! command. The async `Backend::rename` end-to-end (with `read_or_open`
//! hitting disk) is exercised by Phase 14+ integration but covered here
//! at the building-block level — same logic, no async client.
use std::path::PathBuf;
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
use tower_lsp::lsp_types::Url;
// ===== rewrite_wikilink_target =====
#[test]
fn rewrites_plain_wikilink() {
let out = rewrite_wikilink_target("[[Old Page]]", "New Page").unwrap();
assert_eq!(out, "[[New Page]]");
}
#[test]
fn preserves_description() {
let out = rewrite_wikilink_target("[[Old|some text]]", "New").unwrap();
assert_eq!(out, "[[New|some text]]");
}
#[test]
fn preserves_anchor() {
let out = rewrite_wikilink_target("[[Old#section]]", "New").unwrap();
assert_eq!(out, "[[New#section]]");
}
#[test]
fn preserves_anchor_and_description() {
let out = rewrite_wikilink_target("[[Old#sec|desc]]", "New").unwrap();
assert_eq!(out, "[[New#sec|desc]]");
}
#[test]
fn rewrites_subdirectory_path() {
let out = rewrite_wikilink_target("[[old/page]]", "new/subdir/page").unwrap();
assert_eq!(out, "[[new/subdir/page]]");
}
#[test]
fn returns_none_for_non_wikilink_text() {
assert_eq!(rewrite_wikilink_target("just text", "X"), None);
assert_eq!(rewrite_wikilink_target("[[not closed", "X"), None);
assert_eq!(rewrite_wikilink_target("not opened]]", "X"), None);
assert_eq!(rewrite_wikilink_target("[]", "X"), None);
}
// ===== build_new_uri =====
#[test]
fn builds_uri_under_root() {
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
}
#[test]
fn builds_uri_with_subdirectory() {
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "dir/Page", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/dir/Page.wiki"));
}
#[test]
fn omits_duplicate_extension() {
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page.wiki", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
}
#[test]
fn ignores_empty_path_segments() {
// `//page` should not collapse into a filesystem-absolute path.
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "//page", ".wiki").unwrap();
let path = uri.to_file_path().unwrap();
assert_eq!(path, PathBuf::from("/tmp/wiki/page.wiki"));
}
// ===== executeCommand: nuwiki.file.delete =====
//
// We don't need a Backend to drive `commands::execute` for the delete
// command — it ignores its first arg. The smoke test verifies the
// dispatcher + DeleteFile-op packaging.
#[tokio::test]
async fn file_delete_returns_workspace_edit_with_delete_op() {
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
// Building a real Backend requires a Client. For this unit test we use
// `commands::execute` with `Backend` typed as `&_` since the delete
// path doesn't touch backend state. Instead we drive the inner logic
// by constructing the args inline.
//
// Easier: shape the test as an integration test over the public
// module surface — call `commands::execute` once we expose a thin
// builder. For Phase 13 we test the JSON-shaped contract using the
// edits module directly, since that's what `file_delete` returns.
let uri = Url::parse("file:///tmp/note.wiki").unwrap();
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
let we = b.build();
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
panic!("expected Operations variant");
};
assert_eq!(ops.len(), 1);
match &ops[0] {
DocumentChangeOperation::Op(ResourceOp::Delete(d)) => {
assert_eq!(d.uri, uri);
}
other => panic!("expected DeleteFile op, got {other:?}"),
}
}