use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::super::refactor_core::{EditSpan, IdentifierSpan};
pub(crate) use super::super::source_positions::{Encoding, LineIndex, Position};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Range {
pub start: Position,
pub end: Position,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TextEdit {
pub range: Range,
pub new_text: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct VersionedDocument {
pub uri: String,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum DocumentChange {
Edit {
#[serde(rename = "textDocument")]
text_document: VersionedDocument,
edits: Vec<TextEdit>,
},
Resource { kind: String },
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WorkspaceEdit {
#[serde(default)]
pub changes: Option<BTreeMap<String, Vec<TextEdit>>>,
#[serde(default)]
pub document_changes: Option<Vec<DocumentChange>>,
}
impl WorkspaceEdit {
pub(crate) fn into_file_edits(self) -> Result<BTreeMap<PathBuf, Vec<TextEdit>>, String> {
let mut out: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
if let Some(changes) = self.document_changes {
for change in changes {
match change {
DocumentChange::Edit {
text_document,
edits,
} => out
.entry(uri_to_path(&text_document.uri)?)
.or_default()
.extend(edits),
DocumentChange::Resource { kind } => {
return Err(format!("the server's edit needs a `{kind}` file operation"));
}
}
}
} else if let Some(changes) = self.changes {
for (uri, edits) in changes {
out.entry(uri_to_path(&uri)?).or_default().extend(edits);
}
}
Ok(out)
}
}
impl Encoding {
pub(crate) fn from_wire(raw: Option<&str>) -> Self {
match raw {
Some("utf-8") => Self::Utf8,
_ => Self::Utf16,
}
}
}
impl LineIndex<'_> {
fn span(&self, start: usize, end: usize) -> IdentifierSpan {
let start_pos = self.position(start, Encoding::Utf8);
let end_pos = self.position(end, Encoding::Utf8);
IdentifierSpan {
start_byte: start,
end_byte: end,
start_row: start_pos.line as usize,
start_col: start_pos.character as usize,
end_row: end_pos.line as usize,
end_col: end_pos.character as usize,
}
}
}
pub(crate) fn lower_edits(
source: &str,
edits: &[TextEdit],
encoding: Encoding,
) -> Result<Vec<EditSpan>, String> {
let index = LineIndex::new(source);
let mut spans = Vec::with_capacity(edits.len());
for edit in edits {
let (Some(start), Some(end)) = (
index.offset(edit.range.start, encoding),
index.offset(edit.range.end, encoding),
) else {
return Err(format!(
"edit range {}:{}-{}:{} is outside the document",
edit.range.start.line,
edit.range.start.character,
edit.range.end.line,
edit.range.end.character
));
};
if end < start {
return Err("edit range ends before it starts".into());
}
let before = source.get(start..end).unwrap_or_default().to_string();
spans.push(EditSpan {
span: index.span(start, end),
before,
after: edit.new_text.clone(),
});
}
spans.sort_by_key(|edit| edit.span.start_byte);
if spans
.windows(2)
.any(|pair| pair[1].span.start_byte < pair[0].span.end_byte)
{
return Err("the server returned overlapping edits".into());
}
Ok(spans)
}
pub(crate) fn path_to_uri(path: &Path) -> String {
let raw = path.to_string_lossy().replace('\\', "/");
let mut out = String::from("file://");
if !raw.starts_with('/') {
out.push('/');
}
for byte in raw.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~/:".contains(&byte) {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
pub(crate) fn uri_to_path(uri: &str) -> Result<PathBuf, String> {
let rest = uri
.strip_prefix("file://")
.ok_or_else(|| format!("unsupported document URI `{uri}`"))?;
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
let bytes = rest.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
if bytes[at] == b'%' && at + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[at + 1..at + 3]).unwrap_or("");
if let Ok(value) = u8::from_str_radix(hex, 16) {
decoded.push(value);
at += 3;
continue;
}
}
decoded.push(bytes[at]);
at += 1;
}
let text = String::from_utf8(decoded).map_err(|_| format!("non-UTF-8 URI `{uri}`"))?;
let windows_drive = text.len() > 2 && text.as_bytes()[2] == b':' && text.starts_with('/');
Ok(PathBuf::from(if cfg!(windows) && windows_drive {
text.strip_prefix('/').unwrap_or(&text)
} else {
text.as_str()
}))
}
#[cfg(test)]
#[path = "edits_tests.rs"]
mod tests;