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)
|
||
|
|
}
|