feat: preserve diff context during LSP navigation
This commit is contained in:
+117
-68
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user