Files
nuwiki/crates/nuwiki-lsp/src/rename.rs
T
gffranco cb4bfae5e7
CI / cargo fmt --check (push) Successful in 29s
CI / cargo clippy (push) Successful in 1m15s
CI / cargo test (push) Successful in 1m15s
phase 13: workspace/rename + executeCommand router + nuwiki.file.delete
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>
2026-05-11 16:27:39 +00:00

123 lines
4.2 KiB
Rust

//! `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)
}