cb4bfae5e7
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>
123 lines
4.2 KiB
Rust
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)
|
|
}
|