feat: preserve diff context during LSP navigation

This commit is contained in:
Valdemar Lorenzen
2026-10-12 14:03:19 +02:00
parent 6eb99a792a
commit 1b8b669ca0
10 changed files with 2559 additions and 126 deletions
+117 -68
View File
@@ -20,7 +20,7 @@ use crate::{
plugin::{
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
SemanticTokenModifier, TextPosition, UpdateEffect,
SemanticTokenModifier, TextPosition, UpdateEffect, Utf16Range,
},
snippet,
};
@@ -624,8 +624,8 @@ struct SyncOptions {
enum Request {
Initialize,
Definition(Source),
Hover(Source),
Definition(Source, Arc<DocumentSnapshot>),
Hover(Source, Arc<DocumentSnapshot>),
Completion(Source, Arc<DocumentSnapshot>),
SemanticTokens(Arc<DocumentSnapshot>),
Shutdown,
@@ -1001,8 +1001,8 @@ impl Runtime {
params["context"] = json!({"triggerKind": 1});
}
let request = match kind {
PositionKind::Definition => Request::Definition(source),
PositionKind::Hover => Request::Hover(source),
PositionKind::Definition => Request::Definition(source, Arc::clone(&doc.snapshot)),
PositionKind::Hover => Request::Hover(source, Arc::clone(&doc.snapshot)),
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
};
self.request(method, Some(params), request)
@@ -1153,8 +1153,8 @@ impl Runtime {
let context = self.requests.remove(&id).ok_or("unexpected LSP response")?;
if object.contains_key("result") == object.contains_key("error") {
return match context {
Request::Hover(source) => {
self.hover_result(source, Err("unexpected LSP response".into()))
Request::Hover(source, snapshot) => {
self.hover_result(source, snapshot, Err("unexpected LSP response".into()))
}
Request::Completion(source, snapshot) => self.completion_result(
source,
@@ -1173,9 +1173,11 @@ impl Runtime {
.filter(|_| error["code"].as_i64().is_some());
let Some(text) = text else {
return match context {
Request::Hover(source) => {
self.hover_result(source, Err("invalid LSP error response".into()))
}
Request::Hover(source, snapshot) => self.hover_result(
source,
snapshot,
Err("invalid LSP error response".into()),
),
Request::Completion(source, snapshot) => self.completion_result(
source,
snapshot,
@@ -1189,11 +1191,12 @@ impl Runtime {
};
match context {
Request::Initialize => Err(format!("initialization failed: {text}")),
Request::Definition(source) => self.emit(message(
&format!("definition failed: {text}"),
Some(source.request_id),
)),
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
Request::Definition(source, snapshot) => {
self.definition_result(source, snapshot, Err(text.to_owned()))
}
Request::Hover(source, snapshot) => {
self.hover_result(source, snapshot, Err(text.to_owned()))
}
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Err(text.to_owned()))
}
@@ -1205,8 +1208,12 @@ impl Runtime {
} else {
match context {
Request::Initialize => self.initialized(&frame["result"]),
Request::Definition(source) => self.definition_result(source, &frame["result"]),
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
Request::Definition(source, snapshot) => {
self.definition_result(source, snapshot, Ok(&frame["result"]))
}
Request::Hover(source, snapshot) => {
self.hover_result(source, snapshot, Ok(&frame["result"]))
}
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Ok(&frame["result"]))
}
@@ -1394,16 +1401,20 @@ impl Runtime {
self.emit_diagnostics(&doc.snapshot, diagnostics)
}
fn definition_result(&self, source: Source, result: &Value) -> Result<(), String> {
fn definition_result(
&self,
source: Source,
snapshot: Arc<DocumentSnapshot>,
result: Result<&Value, String>,
) -> Result<(), String> {
if self
.documents
.get(&source.buffer_id)
.is_none_or(|doc| doc.snapshot.revision != source.revision)
.is_none_or(|doc| !Arc::ptr_eq(&doc.snapshot, &snapshot))
{
return Ok(());
}
let result = self.definition_effects(&source, result);
match result {
match result.and_then(|result| Self::definition_effects(&source, result)) {
Ok(effects) => self.emit(PluginUpdate {
request_id: Some(source.request_id),
effects,
@@ -1415,11 +1426,16 @@ impl Runtime {
}
}
fn hover_result(&self, source: Source, result: Result<&Value, String>) -> Result<(), String> {
fn hover_result(
&self,
source: Source,
snapshot: Arc<DocumentSnapshot>,
result: Result<&Value, String>,
) -> Result<(), String> {
let Some(doc) = self
.documents
.get(&source.buffer_id)
.filter(|doc| doc.snapshot.revision == source.revision)
.filter(|doc| Arc::ptr_eq(&doc.snapshot, &snapshot))
else {
return Ok(());
};
@@ -1440,11 +1456,7 @@ impl Runtime {
}
}
fn definition_effects(
&self,
source: &Source,
result: &Value,
) -> Result<Vec<UpdateEffect>, String> {
fn definition_effects(source: &Source, result: &Value) -> Result<Vec<UpdateEffect>, String> {
let (location, count) = match result {
Value::Null => return Ok(message("no definition found", None).effects),
Value::Array(locations) if locations.is_empty() => {
@@ -1462,53 +1474,25 @@ impl Runtime {
.as_str()
.ok_or("invalid definition URI")?,
)?;
let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
let cached = self.snapshots.values().find(|doc| {
doc.path.as_ref().is_some_and(|candidate| {
let candidate = absolute(Path::new(candidate), &self.root);
std::fs::canonicalize(&candidate).unwrap_or(candidate) == identity
})
});
let disk;
let text = if let Some(doc) = cached {
&doc.text
} else {
if !std::fs::metadata(&path)
.map_err(|error| error.to_string())?
.is_file()
{
return Err("definition target must be a regular file".into());
}
let mut bytes = Vec::new();
std::fs::File::open(&path)
.map_err(|error| error.to_string())?
.take((MAX_BODY + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| error.to_string())?;
if bytes.len() > MAX_BODY {
return Err("definition target exceeds 4 MiB".into());
}
disk = String::from_utf8(bytes).map_err(|_| "definition target is not UTF-8")?;
&disk
};
let text = normalized(text);
if link {
range(&text, &location["targetRange"])?;
}
let (start, _) = range(
&text,
// Keep the server's document identity and UTF-16 coordinates together.
// The editor resolves historical/working text, including lazily loaded
// comparison files, before converting the target position.
let target_range = link
.then(|| range_coordinates(&location["targetRange"]))
.transpose()?;
let range = range_coordinates(
&location[if link {
"targetSelectionRange"
} else {
"range"
}],
)?;
let mut effects = vec![UpdateEffect::Navigate {
let mut effects = vec![UpdateEffect::NavigateUtf16 {
buffer_id: source.buffer_id,
revision: source.revision,
path,
row: start.row,
column: start.column,
range,
target_range,
}];
if count > 1 {
effects.push(UpdateEffect::Message {
@@ -2887,10 +2871,17 @@ fn position(text: &str, value: &Value, round_up: bool) -> Result<TextPosition, S
Ok(TextPosition { row, column })
}
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
if coordinates(&value["start"])? > coordinates(&value["end"])? {
fn range_coordinates(value: &Value) -> Result<Utf16Range, String> {
let start = coordinates(&value["start"])?;
let end = coordinates(&value["end"])?;
if start > end {
return Err("reversed LSP range".into());
}
Ok(Utf16Range { start, end })
}
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
range_coordinates(value)?;
Ok((
position(text, &value["start"], false)?,
position(text, &value["end"], true)?,
@@ -3002,6 +2993,64 @@ fn uri_path(uri: &str) -> Result<PathBuf, String> {
mod tests {
use super::*;
#[test]
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
let source = Source {
buffer_id: 7,
revision: 3,
row: 0,
column: 0,
request_id: 11,
};
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
let span = json!({
"start": {"line": 4, "character": 5},
"end": {"line": 4, "character": 8}
});
for location in [
json!({"uri": file_uri(path).unwrap(), "range": span}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": span, "targetSelectionRange": span}),
] {
let effects = Runtime::definition_effects(&source, &location).unwrap();
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
start: (4, 5),
end: (4, 8),
});
assert!(matches!(
effects.as_slice(),
[UpdateEffect::NavigateUtf16 {
buffer_id: 7, revision: 3, path: target,
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
}] if target == path && *target_range == expected_target_range
));
}
for invalid in [
json!({"start": {"line": 4, "character": 8},
"end": {"line": 4, "character": 5}}),
json!({"start": {"line": -1, "character": 0},
"end": {"line": 0, "character": 0}}),
] {
for location in [
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": invalid, "targetSelectionRange": span}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": span, "targetSelectionRange": invalid}),
] {
assert!(Runtime::definition_effects(&source, &location).is_err());
}
}
assert!(
serde_json::from_value::<UpdateEffect>(json!({
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
"path": path, "range": {"start": [4,5], "end": [4,8]},
"target_range": null
}))
.is_err()
);
}
#[test]
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
let options = semantic_options(Some(&json!({