//! `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 { 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 `/()` as a `file://` URL. pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option { 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 { 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) }