use std::cmp::Reverse;
use std::path::Path;
use lsp_types::{DocumentChangeOperation, DocumentChanges, OneOf, ResourceOp, WorkspaceEdit};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use utils::display_meta::{ToolDisplayMeta, basename};
use crate::Displayed;
use crate::lsp::common::uri_to_path;
use crate::lsp::error::LspError;
use crate::lsp::registry::LspRegistry;
use crate::lsp::render::{SourceLine, render_locations};
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct LspRenameInput {
#[serde(alias = "file_path")]
pub file_path: String,
pub symbol: String,
#[serde(alias = "new_name")]
pub new_name: String,
#[serde(default)]
pub line: Option<u32>,
}
#[derive(Debug, Clone)]
struct LspFileEdit {
file_path: String,
edits: Vec<lsp_types::TextEdit>,
}
#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct LspRenameOutput {
pub old_name: String,
pub new_name: String,
pub total_edits: usize,
pub files_affected: usize,
pub changes: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl LspRenameOutput {
async fn applied(input: &LspRenameInput, changes: &[LspFileEdit], project_root: &Path) -> Displayed<Self> {
let edited: Vec<SourceLine> = changes
.iter()
.flat_map(|file_edit| {
file_edit.edits.iter().map(|edit| SourceLine::from_range(file_edit.file_path.clone(), &edit.range))
})
.collect();
let total_edits = edited.len();
let files_affected = changes.len();
let output = Self {
old_name: input.symbol.clone(),
new_name: input.new_name.clone(),
total_edits,
files_affected,
changes: render_locations(&edited, project_root, None).await,
success: true,
..Default::default()
};
Displayed::new(output, rename_success_meta(input, total_edits, files_affected))
}
fn failed(input: &LspRenameInput, error: String) -> Displayed<Self> {
let output = Self {
old_name: input.symbol.clone(),
new_name: input.new_name.clone(),
success: false,
error: Some(error),
..Default::default()
};
Displayed::new(output, rename_failure_meta(input))
}
}
pub async fn execute_lsp_rename(
mut input: LspRenameInput,
registry: &LspRegistry,
) -> Result<Displayed<LspRenameOutput>, LspError> {
input.file_path = registry.resolve_file(&input.file_path)?;
let resolved = registry.resolve_symbol(&input.file_path, &input.symbol, input.line).await?;
let workspace_edit =
resolved.client.rename(resolved.uri, resolved.line, resolved.column, input.new_name.clone()).await?;
let Some(edit) = workspace_edit else {
return Ok(LspRenameOutput::failed(&input, "No changes returned from LSP server".to_string()));
};
let changes = match collect_workspace_text_edits(&edit) {
Ok(changes) => changes,
Err(error) => return Ok(LspRenameOutput::failed(&input, error.to_string())),
};
apply_workspace_text_edits(&changes).await?;
Ok(LspRenameOutput::applied(&input, &changes, registry.root_path()).await)
}
fn collect_workspace_text_edits(edit: &WorkspaceEdit) -> Result<Vec<LspFileEdit>, LspError> {
let mut result = if let Some(doc_changes) = &edit.document_changes {
collect_document_changes(doc_changes)?
} else if let Some(changes) = &edit.changes {
changes
.iter()
.filter(|&(_uri, text_edits)| !text_edits.is_empty())
.map(|(uri, text_edits)| LspFileEdit { file_path: uri_to_path(uri), edits: text_edits.clone() })
.collect()
} else {
Vec::new()
};
result.sort_by(|a, b| a.file_path.cmp(&b.file_path));
Ok(result)
}
fn collect_document_changes(doc_changes: &DocumentChanges) -> Result<Vec<LspFileEdit>, LspError> {
match doc_changes {
DocumentChanges::Edits(edits) => Ok(edits
.iter()
.filter_map(|doc_edit| collect_text_document_edit(&doc_edit.text_document.uri, &doc_edit.edits))
.collect()),
DocumentChanges::Operations(ops) => {
ops.iter().map(collect_document_change_operation).filter_map(Result::transpose).collect()
}
}
}
fn collect_document_change_operation(op: &DocumentChangeOperation) -> Result<Option<LspFileEdit>, LspError> {
match op {
DocumentChangeOperation::Edit(doc_edit) => {
Ok(collect_text_document_edit(&doc_edit.text_document.uri, &doc_edit.edits))
}
DocumentChangeOperation::Op(op) => {
let kind = match op {
ResourceOp::Create(_) => "create",
ResourceOp::Rename(_) => "rename",
ResourceOp::Delete(_) => "delete",
};
Err(LspError::InvalidEdit(format!("Rename returned unsupported workspace operation: {kind}")))
}
}
}
fn collect_text_document_edit(
uri: &lsp_types::Uri,
edits: &[OneOf<lsp_types::TextEdit, lsp_types::AnnotatedTextEdit>],
) -> Option<LspFileEdit> {
let edits = edits.iter().map(extract_one_of_text_edit).collect::<Vec<_>>();
(!edits.is_empty()).then(|| LspFileEdit { file_path: uri_to_path(uri), edits })
}
fn extract_one_of_text_edit(edit: &OneOf<lsp_types::TextEdit, lsp_types::AnnotatedTextEdit>) -> lsp_types::TextEdit {
match edit {
OneOf::Left(edit) => edit.clone(),
OneOf::Right(edit) => edit.text_edit.clone(),
}
}
async fn apply_workspace_text_edits(changes: &[LspFileEdit]) -> Result<(), LspError> {
for file_edit in changes {
let content = tokio::fs::read_to_string(&file_edit.file_path).await?;
let updated = apply_file_text_edits(&content, &file_edit.edits)?;
tokio::fs::write(&file_edit.file_path, updated).await?;
}
Ok(())
}
fn apply_file_text_edits(content: &str, edits: &[lsp_types::TextEdit]) -> Result<String, LspError> {
let mut edits = edits.to_vec();
edits.sort_by_key(|e| {
Reverse((e.range.start.line, e.range.start.character, e.range.end.line, e.range.end.character))
});
let mut result = content.to_string();
for edit in edits {
let start = lsp_position_to_byte_offset(&result, edit.range.start)?;
let end = lsp_position_to_byte_offset(&result, edit.range.end)?;
if start > end || end > result.len() {
return Err(LspError::InvalidEdit("Invalid edit range produced by LSP rename".to_string()));
}
result.replace_range(start..end, &edit.new_text);
}
Ok(result)
}
fn lsp_position_to_byte_offset(content: &str, position: lsp_types::Position) -> Result<usize, LspError> {
let target_line = usize::try_from(position.line)
.map_err(|_| LspError::InvalidPosition(format!("Line {} out of range", position.line)))?;
let target_character = usize::try_from(position.character)
.map_err(|_| LspError::InvalidPosition(format!("Character {} out of range", position.character)))?;
let mut line_start = 0usize;
let mut current_line = 0usize;
while current_line < target_line {
let Some(relative_newline) = content[line_start..].find('\n') else {
return Err(LspError::InvalidPosition(format!(
"Line {} not found while applying rename",
position.line + 1
)));
};
line_start += relative_newline + 1;
current_line += 1;
}
let line_end = content[line_start..].find('\n').map_or(content.len(), |idx| line_start + idx);
let line = &content[line_start..line_end];
let mut utf16_units = 0usize;
for (byte_idx, ch) in line.char_indices() {
if utf16_units == target_character {
return Ok(line_start + byte_idx);
}
utf16_units += ch.len_utf16();
if utf16_units > target_character {
return Err(LspError::InvalidPosition(format!(
"Character {} splits a UTF-16 code unit sequence on line {}",
position.character,
position.line + 1
)));
}
}
if utf16_units == target_character {
Ok(line_end)
} else {
Err(LspError::InvalidPosition(format!(
"Character {} out of bounds on line {}",
position.character,
position.line + 1
)))
}
}
fn rename_success_meta(input: &LspRenameInput, total_edits: usize, files_affected: usize) -> ToolDisplayMeta {
ToolDisplayMeta::new(
"LSP rename",
format!("{} → {} ({} edits in {} files)", input.symbol, input.new_name, total_edits, files_affected),
)
}
fn rename_failure_meta(input: &LspRenameInput) -> ToolDisplayMeta {
let file = basename(&input.file_path);
ToolDisplayMeta::new("LSP rename failed", format!("{} in {}", input.symbol, file))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp::test_fixtures::{text_edit, uri};
use lsp_types::{CreateFile, OptionalVersionedTextDocumentIdentifier, Position, TextDocumentEdit};
use std::collections::HashMap;
fn document_edit(path: &str, edit: lsp_types::TextEdit) -> TextDocumentEdit {
TextDocumentEdit {
text_document: OptionalVersionedTextDocumentIdentifier { uri: uri(path), version: Some(1) },
edits: vec![OneOf::Left(edit)],
}
}
#[test]
#[allow(clippy::mutable_key_type)]
fn test_convert_simple_changes() {
let changes = HashMap::from([(
uri("/src/main.rs"),
vec![text_edit(0, 5, 10, "new_name"), text_edit(5, 0, 8, "new_name")],
)]);
let edit = WorkspaceEdit { changes: Some(changes), ..Default::default() };
let result = collect_workspace_text_edits(&edit).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].file_path, "/src/main.rs");
assert_eq!(result[0].edits, [text_edit(0, 5, 10, "new_name"), text_edit(5, 0, 8, "new_name")]);
}
#[test]
fn test_convert_document_changes() {
let doc_edit = document_edit("/src/lib.rs", text_edit(10, 2, 6, "renamed"));
let edit =
WorkspaceEdit { document_changes: Some(DocumentChanges::Edits(vec![doc_edit])), ..Default::default() };
let result = collect_workspace_text_edits(&edit).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].file_path, "/src/lib.rs");
assert_eq!(result[0].edits, [text_edit(10, 2, 6, "renamed")]);
}
#[test]
#[allow(clippy::mutable_key_type)]
fn test_document_changes_take_precedence_over_changes() {
let edit = WorkspaceEdit {
changes: Some(HashMap::from([(uri("/src/changes.rs"), vec![text_edit(0, 0, 3, "old")])])),
document_changes: Some(DocumentChanges::Edits(vec![document_edit(
"/src/document_changes.rs",
text_edit(1, 0, 3, "new"),
)])),
change_annotations: None,
};
let result = collect_workspace_text_edits(&edit).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].file_path, "/src/document_changes.rs");
}
#[test]
fn test_rejects_unsupported_resource_operations() {
let create = CreateFile { uri: uri("/src/new.rs"), options: None, annotation_id: None };
let edit = WorkspaceEdit {
document_changes: Some(DocumentChanges::Operations(vec![DocumentChangeOperation::Op(ResourceOp::Create(
create,
))])),
..Default::default()
};
let err = collect_workspace_text_edits(&edit).unwrap_err().to_string();
assert!(err.contains("unsupported workspace operation: create"), "{err}");
}
#[test]
fn test_apply_file_text_edits_replaces_multiple_ranges_from_end() {
let content = "fn greet() {\n greet();\n greet();\n}\n";
let edits = [text_edit(0, 3, 8, "hello"), text_edit(1, 4, 9, "hello"), text_edit(2, 4, 9, "hello")];
let updated = apply_file_text_edits(content, &edits).unwrap();
assert_eq!(updated, "fn hello() {\n hello();\n hello();\n}\n");
}
#[test]
fn test_lsp_position_to_byte_offset_handles_utf16_columns() {
let offsets: Vec<_> = [0, 1, 3, 4]
.into_iter()
.map(|character| lsp_position_to_byte_offset("a😀z\n", Position::new(0, character)).unwrap())
.collect();
assert_eq!(offsets, [0, 1, 5, 6]);
}
}