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
+171 -5
View File
@@ -70,7 +70,7 @@ held = []
definition_count = 0
source_uri = uri(source)
loop do
if initialize_id && !initialized_sent && (mode != 'gated' || File.exist?(release))
if initialize_id && !initialized_sent && (!['gated', 'hover-gated'].include?(mode) || File.exist?(release))
sync = case mode
when 'numeric' then 1
when 'none' then {'openClose' => false, 'change' => 0, 'save' => false}
@@ -78,11 +78,14 @@ loop do
else {'openClose' => true, 'change' => 1, 'save' => {'includeText' => true}}
end
result(initialize_id, {'capabilities' => {'positionEncoding' => 'utf-16',
'textDocumentSync' => sync, 'definitionProvider' => mode == 'unsupported' ? false : {'workDoneProgress' => false}}})
'textDocumentSync' => sync, 'definitionProvider' => mode == 'unsupported' ? false : {'workDoneProgress' => false},
'hoverProvider' => mode == 'hover-unsupported' ? false : {'workDoneProgress' => false}}})
initialized_sent = true
end
if !held.empty? && File.exist?(release) && mode == 'stale'
held.each { |request| result(request['id'], location(target)) }
if !held.empty? && File.exist?(release) && ['stale', 'hover-stale'].include?(mode)
held.each do |request|
result(request['id'], request['method'] == 'textDocument/hover' ? {'contents' => 'stale hover'} : location(target))
end
held.clear
emit({'method' => 'window/showMessage', 'params' => {'type' => 3, 'message' => 'stale response complete'}})
end
@@ -156,6 +159,28 @@ loop do
result(frame['id'], nil)
end
end
when 'textDocument/hover'
if mode == 'hover-stale'
held << frame
elsif mode == 'hover-superseded'
held << frame
if held.length == 2
result(held[0]['id'], {'contents' => 'old hover'})
result(held[1]['id'], {'contents' => 'new hover'})
held.clear
end
else
contents = case mode
when 'hover-markdown' then {'kind' => 'markdown', 'value' => "**Header**\n```rust\nfn main() {}\n```"}
when 'hover-marked' then ['explanation', {'language' => 'rust', 'value' => "fn main() {\n}"}, 'tail']
when 'hover-string' then "one\nsecond"
when 'hover-empty' then nil
when 'hover-malformed' then {'kind' => 'html', 'value' => 'bad'}
else {'kind' => 'plaintext', 'value' => "hover first\r\n😀e\u{301}\tsecond\e\u{85}\nlast"}
end
hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)}
result(frame['id'], hover)
end
when 'shutdown'
result(frame['id'], nil)
when 'exit'
@@ -220,7 +245,7 @@ impl Fixture {
"settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}},
});
let config = format!(
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp_definition\n config.bind 'leader D', config.core.lsp_diagnostics\nend\n",
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp_definition\n config.bind 'leader D', config.core.lsp_diagnostics\n config.bind 'K', config.core.lsp_hover\nend\n",
serde_json::to_string(&options.to_string()).unwrap(),
);
let config_path = self.root.join("config.rb");
@@ -793,3 +818,144 @@ fn lsp_session_shutdown_finishes_the_server_protocol_before_returning() {
drop(session);
assert_eq!(fixture.method_count("shutdown"), 1);
}
#[test]
fn lsp_hover_typed_action_preserves_multiline_content_and_primary_utf16_position() {
for (mode, expected) in [
("hover-plain", "hover first\n😀e\u{301} second \nlast"),
("hover-markdown", "**Header**\n```rust\nfn main() {}\n```"),
("hover-marked", "explanation\n\nfn main() {\n}\n\ntail"),
("hover-string", "one\nsecond"),
] {
let fixture = Fixture::new();
let mut session = fixture.open(mode);
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
for _ in 0..3 {
session.handle(Input::Right);
}
let before = session.editor().cursor();
session.handle(Input::Char('K'));
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
assert_eq!(session.editor().hover().unwrap().text(), expected);
assert_eq!(session.editor().cursor(), before);
assert_eq!(session.editor().mode(), Mode::Normal);
assert!(!session.editor().dirty());
let frames = fixture.frames();
let hover = frames
.iter()
.find(|frame| frame["method"] == "textDocument/hover")
.unwrap();
assert_eq!(
hover["params"]["position"],
json!({"line": 0, "character": 5})
);
let initialize = frames
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap();
assert_eq!(
initialize["params"]["capabilities"]["textDocument"]["hover"]["contentFormat"],
json!(["plaintext", "markdown"])
);
session.handle(Input::Down);
assert_eq!(session.editor().cursor(), before);
assert_eq!(session.editor().hover().unwrap().top, 1);
session.handle(Input::Escape);
assert!(session.editor().hover().is_none());
}
}
#[test]
fn lsp_hover_queues_before_initialize_and_ignores_cancelled_and_superseded_requests() {
let fixture = Fixture::new();
let mut session = fixture.open("hover-gated");
session.execute("lsp_diagnostics");
until(&mut session, &fixture, |session| {
session.editor().message().contains("server initializing")
});
assert_eq!(fixture.method_count("textDocument/hover"), 0);
session.execute("lsp_hover");
fixture.release();
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
drop(session);
for input in [Input::Escape, Input::Right, Input::Char('i')] {
let fixture = Fixture::new();
let mut session = fixture.open("hover-stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp_hover");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/hover") == 1
});
session.handle(input);
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert!(session.editor().hover().is_none());
}
let fixture = Fixture::new();
let mut session = fixture.open("hover-superseded");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp_hover");
session.execute("lsp_hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
assert_eq!(session.editor().hover().unwrap().text(), "new hover");
}
#[test]
fn lsp_hover_absent_unsupported_and_invalid_results_preserve_native_editing() {
for mode in [
"hover-empty",
"hover-unsupported",
"hover-malformed",
"hover-invalid-range",
] {
let fixture = Fixture::new();
let mut session = fixture.open(mode);
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp_hover");
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains(if mode == "hover-empty" {
"no hover information"
} else if mode == "hover-unsupported" {
"does not support hover"
} else {
"hover failed"
})
});
assert!(session.editor().hover().is_none());
assert_eq!(
fixture.method_count("textDocument/hover"),
usize::from(mode != "hover-unsupported")
);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
assert_eq!(fixture.method_count("textDocument/definition"), 0);
}
}