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
+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
//! `initialize` (P8) with `window/workDoneProgress` begin/end events.
pub mod commands;
pub mod config;
pub mod edits;
pub mod index;
pub mod nav;
pub mod rename;
pub mod semantic_tokens;
pub mod wiki;
@@ -40,16 +42,16 @@ use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams,
DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams,
Location, MarkupContent, MarkupKind, MessageType, OneOf, PositionEncodingKind, ProgressParams,
ProgressParamsValue, ProgressToken, Range, ReferenceParams, SemanticTokens,
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability,
TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd,
WorkDoneProgressOptions, WorkspaceSymbolParams,
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,
SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams,
SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities,
ServerCapabilities, ServerInfo, SymbolInformation as LspSymbolInformation, SymbolKind,
TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin,
WorkDoneProgressEnd, WorkDoneProgressOptions, WorkspaceEdit, WorkspaceSymbolParams,
};
use tower_lsp::{Client, LanguageServer, LspService, Server};
@@ -147,6 +149,25 @@ impl Backend {
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.
/// Falls back to the default wiki when no root matches — that's the
/// 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
/// health) that need accurate spans + text without trusting cached
/// `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> {
if let Some(doc) = self.documents.get(uri) {
return Ok(DocSnapshot {
@@ -278,6 +295,14 @@ impl LanguageServer for Backend {
workspace_symbol_provider: Some(OneOf::Left(true)),
definition_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)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec!["[".into()]),
@@ -596,6 +621,45 @@ impl LanguageServer for Backend {
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(
&self,
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)
}