use super::paths::{canonicalize_lsp_path, lsp_uri_to_project_relative};
use super::position::{byte_column_for_utf16_position, extract_text_for_range};
use super::types::{LspResourceOp, LspWorkspaceEditTransaction};
use crate::project::ProjectRoot;
use crate::rename::RenameEdit;
use anyhow::{Context, Result, bail};
use serde_json::{Map, Value, json};
use std::fs;
use url::Url;
#[allow(deprecated)]
pub(super) fn workspace_edit_transaction_from_response(
project: &ProjectRoot,
response: Value,
) -> Result<LspWorkspaceEditTransaction> {
let result = response
.get("result")
.context("LSP rename returned no result")?;
let mut edits = Vec::new();
let mut resource_ops = Vec::new();
if let Some(changes) = result.get("changes").and_then(Value::as_object) {
collect_changes(project, changes, &mut edits)?;
}
if let Some(document_changes) = result.get("documentChanges").and_then(Value::as_array) {
for change in document_changes {
if let Some(text_edits) = change.get("edits").and_then(Value::as_array) {
let uri = change
.get("textDocument")
.and_then(|value| value.get("uri"))
.and_then(Value::as_str)
.context("LSP documentChanges textDocument uri missing")?;
collect_text_edits_for_uri(project, uri, text_edits, &mut edits)?;
continue;
}
if let Some(kind) = change.get("kind").and_then(Value::as_str) {
resource_ops.push(resource_op_from_document_change(project, kind, change)?);
continue;
}
bail!("unsupported LSP documentChanges operation in workspace edit");
}
}
let modified_files = edits
.iter()
.map(|edit| &edit.file_path)
.collect::<std::collections::HashSet<_>>()
.len();
let edit_count = edits.len();
Ok(LspWorkspaceEditTransaction {
edits,
resource_ops,
modified_files,
edit_count,
rollback_available: true,
})
}
pub(super) fn workspace_edit_transaction_from_edit(
project: &ProjectRoot,
edit: &Value,
) -> Result<LspWorkspaceEditTransaction> {
workspace_edit_transaction_from_response(project, json!({ "result": edit }))
}
pub(super) fn apply_workspace_edit_transaction(
project: &ProjectRoot,
transaction: &LspWorkspaceEditTransaction,
) -> Result<crate::edit_transaction::ApplyEvidence, crate::edit_transaction::ApplyError> {
let workspace_tx: crate::edit_transaction::WorkspaceEditTransaction =
transaction.clone().into();
workspace_tx.apply_with_evidence(project)
}
fn collect_changes(
project: &ProjectRoot,
changes: &Map<String, Value>,
edits: &mut Vec<RenameEdit>,
) -> Result<()> {
for (uri, text_edits) in changes {
let text_edits = text_edits
.as_array()
.with_context(|| format!("LSP changes entry for {uri} is not an array"))?;
collect_text_edits_for_uri(project, uri, text_edits, edits)?;
}
Ok(())
}
fn collect_text_edits_for_uri(
project: &ProjectRoot,
uri: &str,
text_edits: &[Value],
edits: &mut Vec<RenameEdit>,
) -> Result<()> {
let file_path = lsp_uri_to_project_relative(project, uri)?;
let absolute_path = Url::parse(uri)
.ok()
.and_then(|uri| uri.to_file_path().ok())
.with_context(|| format!("invalid LSP file uri: {uri}"))?;
let canonical_path = canonicalize_lsp_path(absolute_path);
let resolved_path = project.resolve(&canonical_path)?;
let source = fs::read_to_string(&resolved_path).with_context(|| {
format!(
"failed to read LSP rename target {}",
resolved_path.display()
)
})?;
for edit in text_edits {
let range = edit.get("range").context("LSP text edit missing range")?;
let start = range
.get("start")
.context("LSP text edit missing start range")?;
let end = range
.get("end")
.context("LSP text edit missing end range")?;
let line = start.get("line").and_then(Value::as_u64).unwrap_or(0) as usize + 1;
let end_line = end.get("line").and_then(Value::as_u64).unwrap_or(0) as usize + 1;
let column = byte_column_for_utf16_position(
&source,
line,
start.get("character").and_then(Value::as_u64).unwrap_or(0) as usize,
);
let end_column = byte_column_for_utf16_position(
&source,
end_line,
end.get("character").and_then(Value::as_u64).unwrap_or(0) as usize,
);
let old_text = extract_text_for_range(&source, line, column, end_line, end_column);
let new_text = edit
.get("newText")
.and_then(Value::as_str)
.context("LSP text edit missing newText")?
.to_owned();
if is_full_file_replacement(&source, line, column, end_line, end_column, &old_text) {
bail!(
"unsupported_semantic_refactor: full-file WorkspaceEdit replacement is not authoritative enough; return minimal range edits"
);
}
edits.push(RenameEdit {
file_path: file_path.clone(),
line,
column,
old_text,
new_text,
});
}
Ok(())
}
fn is_full_file_replacement(
source: &str,
line: usize,
column: usize,
end_line: usize,
end_column: usize,
old_text: &str,
) -> bool {
if source.is_empty() || line != 1 || column != 1 {
return false;
}
let line_count = source.lines().count().max(1);
if end_line < line_count {
return false;
}
let last_line = source.lines().last().unwrap_or_default();
if end_line == line_count && end_column < last_line.len() + 1 {
return false;
}
old_text == source || old_text == source_without_terminal_newline(source)
}
fn source_without_terminal_newline(source: &str) -> &str {
source
.strip_suffix("\r\n")
.or_else(|| source.strip_suffix('\n'))
.unwrap_or(source)
}
fn resource_op_from_document_change(
project: &ProjectRoot,
kind: &str,
change: &Value,
) -> Result<LspResourceOp> {
let file_path = match kind {
"create" | "delete" => change
.get("uri")
.and_then(Value::as_str)
.map(|uri| lsp_uri_to_project_relative(project, uri))
.transpose()?
.unwrap_or_default(),
"rename" => change
.get("newUri")
.and_then(Value::as_str)
.map(|uri| lsp_uri_to_project_relative(project, uri))
.transpose()?
.unwrap_or_default(),
_ => bail!("unsupported LSP resource operation kind: {kind}"),
};
let old_file_path = change
.get("oldUri")
.and_then(Value::as_str)
.map(|uri| lsp_uri_to_project_relative(project, uri))
.transpose()?;
let new_file_path = change
.get("newUri")
.and_then(Value::as_str)
.map(|uri| lsp_uri_to_project_relative(project, uri))
.transpose()?;
Ok(LspResourceOp {
kind: kind.to_owned(),
file_path,
old_file_path,
new_file_path,
})
}