feat: add native LSP hover action and popup

This commit is contained in:
Valdemar Lorenzen
2026-10-09 14:29:58 +02:00
parent 529d1fd5b3
commit f093091002
10 changed files with 842 additions and 49 deletions
+252 -28
View File
@@ -65,10 +65,25 @@ enum Input {
event: DocumentEventKind,
served: bool,
},
Definition(Source),
Position(PositionKind, Source),
Diagnostics(usize),
}
#[derive(Clone, Copy)]
enum PositionKind {
Definition,
Hover,
}
impl PositionKind {
fn name(self) -> &'static str {
match self {
Self::Definition => "definition",
Self::Hover => "hover",
}
}
}
struct Source {
buffer_id: usize,
revision: u64,
@@ -172,22 +187,35 @@ impl Lsp {
}
pub fn definition(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
self.position(context, request_id, PositionKind::Definition)
}
pub fn hover(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
self.position(context, request_id, PositionKind::Hover)
}
fn position(
&mut self,
context: &Context,
request_id: u64,
kind: PositionKind,
) -> Result<(), String> {
let source = Source {
buffer_id: context.buffer_id,
revision: context.revision,
row: context
.row
.checked_sub(1)
.ok_or("LSP definition row is invalid")?,
.ok_or_else(|| format!("LSP {} row is invalid", kind.name()))?,
column: context
.column
.checked_sub(1)
.ok_or("LSP definition column is invalid")?,
.ok_or_else(|| format!("LSP {} column is invalid", kind.name()))?,
request_id,
};
self.command(
context.buffer_id,
Input::Definition(source),
Input::Position(kind, source),
Some(request_id),
)
}
@@ -379,6 +407,7 @@ struct SyncOptions {
enum Request {
Initialize,
Definition(Source),
Hover(Source),
Shutdown,
}
@@ -392,10 +421,11 @@ struct Runtime {
snapshots: HashMap<usize, Arc<DocumentSnapshot>>,
documents: HashMap<usize, Document>,
requests: HashMap<u64, Request>,
pending: VecDeque<Source>,
pending: VecDeque<(PositionKind, Source)>,
next_id: u64,
ready: bool,
definition: bool,
hover: bool,
sync: SyncOptions,
}
@@ -422,6 +452,7 @@ impl Runtime {
next_id: 1,
ready: false,
definition: false,
hover: false,
sync: SyncOptions::default(),
};
runtime.request(
@@ -439,6 +470,8 @@ impl Runtime {
"synchronization": {"dynamicRegistration": false, "willSave": false,
"willSaveWaitUntil": false, "didSave": true},
"definition": {"dynamicRegistration": false, "linkSupport": true},
"hover": {"dynamicRegistration": false,
"contentFormat": ["plaintext", "markdown"]},
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true}
}
}
@@ -457,7 +490,7 @@ impl Runtime {
event,
served,
}) => self.document(snapshot, event, served)?,
Ok(Input::Definition(source)) => self.definition(source)?,
Ok(Input::Position(kind, source)) => self.position(kind, source)?,
Ok(Input::Diagnostics(id)) => {
let text = if !self.documents.contains_key(&id) {
"no running server for this document".to_owned()
@@ -634,7 +667,7 @@ impl Runtime {
Ok(())
}
fn definition(&mut self, source: Source) -> Result<(), String> {
fn position(&mut self, kind: PositionKind, source: Source) -> Result<(), String> {
let Some(doc) = self.documents.get(&source.buffer_id) else {
return Ok(());
};
@@ -645,23 +678,33 @@ impl Runtime {
if self.pending.len() == QUEUE_SIZE {
return Err("LSP pending command queue is full".into());
}
self.pending.push_back(source);
self.pending.push_back((kind, source));
return Ok(());
}
if !self.definition {
return self.emit(message(
let (supported, method, unsupported) = match kind {
PositionKind::Definition => (
self.definition,
"textDocument/definition",
"server does not support go-to-definition",
Some(source.request_id),
));
),
PositionKind::Hover => (
self.hover,
"textDocument/hover",
"server does not support hover",
),
};
if !supported {
return self.emit(message(unsupported, Some(source.request_id)));
}
let position = outbound_position(&doc.snapshot.text, source.row, source.column)?;
self.request(
"textDocument/definition",
Some(json!({
"textDocument": {"uri": doc.uri}, "position": position
})),
Request::Definition(source),
)
let params = json!({
"textDocument": {"uri": doc.uri}, "position": position
});
let request = match kind {
PositionKind::Definition => Request::Definition(source),
PositionKind::Hover => Request::Hover(source),
};
self.request(method, Some(params), request)
}
fn request(
@@ -721,27 +764,41 @@ impl Runtime {
}
} else {
let id = frame["id"].as_u64().ok_or("unexpected LSP response")?;
if object.contains_key("result") == object.contains_key("error") {
return Err("unexpected LSP response".into());
}
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()))
}
_ => Err("unexpected LSP response".into()),
};
}
if let Some(error) = object.get("error") {
let text = error["message"]
.as_str()
.filter(|_| error["code"].as_i64().is_some())
.ok_or("invalid LSP error response")?;
.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()))
}
_ => Err("invalid LSP error response".into()),
};
};
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::Shutdown => Ok(()),
}
} 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::Shutdown => self.notify("exit", None),
}
}
@@ -762,6 +819,9 @@ impl Runtime {
self.definition = capabilities
.get("definitionProvider")
.is_some_and(|value| value == true || value.is_object());
self.hover = capabilities
.get("hoverProvider")
.is_some_and(|value| value == true || value.is_object());
self.notify("initialized", Some(json!({})))?;
self.notify(
"workspace/didChangeConfiguration",
@@ -776,8 +836,8 @@ impl Runtime {
}
self.documents.insert(id, doc);
}
while let Some(source) = self.pending.pop_front() {
self.definition(source)?;
while let Some((kind, source)) = self.pending.pop_front() {
self.position(kind, source)?;
}
Ok(())
}
@@ -908,6 +968,31 @@ impl Runtime {
}
}
fn hover_result(&self, source: Source, result: Result<&Value, String>) -> Result<(), String> {
let Some(doc) = self
.documents
.get(&source.buffer_id)
.filter(|doc| doc.snapshot.revision == source.revision)
else {
return Ok(());
};
match result.and_then(|result| hover_text(result, &doc.snapshot.text)) {
Ok(Some(text)) => self.emit(PluginUpdate {
request_id: Some(source.request_id),
effects: vec![UpdateEffect::Hover {
buffer_id: source.buffer_id,
revision: source.revision,
text,
}],
}),
Ok(None) => self.emit(message("no hover information", Some(source.request_id))),
Err(reason) => self.emit(message(
&format!("hover failed: {reason}"),
Some(source.request_id),
)),
}
}
fn definition_effects(
&self,
source: &Source,
@@ -1583,6 +1668,91 @@ fn message(text: &str, request_id: Option<u64>) -> PluginUpdate {
}
}
fn hover_text(result: &Value, source: &str) -> Result<Option<String>, String> {
if result.is_null() {
return Ok(None);
}
let object = result.as_object().ok_or("invalid LSP hover result")?;
if let Some(value) = object.get("range") {
range(&normalized(source), value)?;
}
let contents = object.get("contents").ok_or("missing LSP hover contents")?;
let mut text = String::new();
match contents {
Value::Array(items) => {
for item in items {
append_hover(&mut text, marked_string(item)?)?;
}
}
Value::Object(object) if object.contains_key("kind") => {
match object.get("kind").and_then(Value::as_str) {
Some("plaintext" | "markdown") => {}
_ => return Err("invalid LSP hover markup kind".into()),
}
append_hover(
&mut text,
object
.get("value")
.and_then(Value::as_str)
.ok_or("invalid LSP hover markup value")?,
)?;
}
value => append_hover(&mut text, marked_string(value)?)?,
}
Ok((!text.trim().is_empty()).then_some(text))
}
fn marked_string(value: &Value) -> Result<&str, String> {
match value {
Value::String(text) => Ok(text),
Value::Object(object) if object.get("language").and_then(Value::as_str).is_some() => object
.get("value")
.and_then(Value::as_str)
.ok_or_else(|| "invalid LSP marked string value".into()),
_ => Err("invalid LSP marked string".into()),
}
}
fn append_hover(text: &mut String, value: &str) -> Result<(), String> {
if value.is_empty() {
return Ok(());
}
if !text.is_empty() {
if text.len() + 2 > MAX_BODY {
return Err("LSP hover contents exceed 4 MiB".into());
}
text.push_str("\n\n");
}
let mut characters = value.chars().peekable();
while let Some(character) = characters.next() {
let character = match character {
'\r' => {
if characters.peek() == Some(&'\n') {
characters.next();
}
'\n'
}
'\n' | '\t' => character,
character if character.is_control() => ' ',
character => character,
};
let length = if character == '\t' {
4
} else {
character.len_utf8()
};
if text.len() + length > MAX_BODY {
return Err("LSP hover contents exceed 4 MiB".into());
}
if character == '\t' {
text.push_str(" ");
} else {
text.push(character);
}
}
Ok(())
}
fn clean(text: &str) -> String {
text.chars()
.map(|character| {
@@ -1655,13 +1825,13 @@ fn outbound_position(text: &str, row: usize, column: usize) -> Result<Value, Str
let line = text
.split('\n')
.nth(row)
.ok_or("definition source row is outside the document")?;
.ok_or("LSP source row is outside the document")?;
let mut units = 0;
let mut clusters = line.graphemes(true);
for _ in 0..column {
units += clusters
.next()
.ok_or("definition source column is outside the line")?
.ok_or("LSP source column is outside the line")?
.encode_utf16()
.count();
}
@@ -1797,4 +1967,58 @@ mod tests {
assert!(uri_path(uri).is_err(), "{uri}");
}
}
#[test]
fn hover_contents_keep_lines_and_reject_malformed_values() {
let source = "A😀e\u{301}Z\r\n";
for kind in ["plaintext", "markdown"] {
assert_eq!(
hover_text(
&json!({
"contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"},
"range": {"start": {"line": 0, "character": 2},
"end": {"line": 0, "character": 4}}
}),
source,
)
.unwrap(),
Some("😀e\u{301}\n code\nnext ".into()),
);
}
for contents in [
json!("line\ncode"),
json!({"language": "rust", "value": "line\ncode"}),
] {
assert_eq!(
hover_text(&json!({"contents": contents}), source).unwrap(),
Some("line\ncode".into()),
);
}
assert_eq!(
hover_text(
&json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}),
source,
)
.unwrap(),
Some("first\n\nfn main() {\n}".into()),
);
for value in [
Value::Null,
json!({"contents": ""}),
json!({"contents": []}),
] {
assert_eq!(hover_text(&value, source).unwrap(), None);
}
for value in [
json!({}),
json!({"contents": {"kind": "html", "value": "x"}}),
json!({"contents": {"language": "rust", "value": false}}),
json!({"contents": [{"kind": "markdown", "value": "x"}]}),
json!({"contents": "x", "range": {"start": {"line": 1, "character": 0},
"end": {"line": 0, "character": 0}}}),
json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}),
] {
assert!(hover_text(&value, source).is_err());
}
}
}