feat: add native LSP hover action and popup
This commit is contained in:
+208
-2
@@ -62,6 +62,35 @@ pub struct Palette {
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
pub struct Hover {
|
||||
text: String,
|
||||
line_starts: Vec<usize>,
|
||||
/// First visible logical line.
|
||||
pub top: usize,
|
||||
/// First visible extended grapheme, independent of terminal geometry.
|
||||
pub left: usize,
|
||||
max_column: usize,
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
caret: Cursor,
|
||||
}
|
||||
|
||||
impl Hover {
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn lines(&self) -> impl ExactSizeIterator<Item = &str> {
|
||||
self.line_starts.iter().enumerate().map(|(index, &start)| {
|
||||
let end = self
|
||||
.line_starts
|
||||
.get(index + 1)
|
||||
.map_or(self.text.len(), |next| next - 1);
|
||||
&self.text[start..end]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PaletteEntry {
|
||||
pub name: String,
|
||||
pub label: String,
|
||||
@@ -255,6 +284,7 @@ enum Action {
|
||||
FilePicker,
|
||||
FileExplorer,
|
||||
LspDefinition,
|
||||
LspHover,
|
||||
LspDiagnostics,
|
||||
}
|
||||
|
||||
@@ -438,6 +468,7 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
||||
Action::LspDefinition,
|
||||
&[Mode::Normal],
|
||||
),
|
||||
("core.lsp_hover", Action::LspHover, &[Mode::Normal]),
|
||||
(
|
||||
"core.lsp_diagnostics",
|
||||
Action::LspDiagnostics,
|
||||
@@ -837,6 +868,7 @@ pub struct Editor {
|
||||
leader_suffix: Vec<Input>,
|
||||
leader_display: String,
|
||||
palette: Option<Palette>,
|
||||
hover: Option<Hover>,
|
||||
project_root: PathBuf,
|
||||
file_picker: Option<FilePicker>,
|
||||
file_explorer_side: DockSide,
|
||||
@@ -894,6 +926,7 @@ impl Editor {
|
||||
leader_suffix: Vec::new(),
|
||||
leader_display: String::new(),
|
||||
palette: None,
|
||||
hover: None,
|
||||
project_root: std::env::current_dir()?,
|
||||
file_picker: None,
|
||||
file_explorer_side: DockSide::Right,
|
||||
@@ -921,6 +954,7 @@ impl Editor {
|
||||
"bdelete",
|
||||
"bdelete!",
|
||||
"lsp_definition",
|
||||
"lsp_hover",
|
||||
"lsp_diagnostics",
|
||||
]
|
||||
.into_iter()
|
||||
@@ -1136,6 +1170,7 @@ impl Editor {
|
||||
|
||||
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
|
||||
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
|
||||
self.clear_hover();
|
||||
if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) {
|
||||
return Err("Navigation path must be nonempty and contain no controls".into());
|
||||
}
|
||||
@@ -1173,6 +1208,91 @@ impl Editor {
|
||||
self.palette.as_ref()
|
||||
}
|
||||
|
||||
/// Borrowed popup state, visible only while its source remains current.
|
||||
pub fn hover(&self) -> Option<&Hover> {
|
||||
self.hover.as_ref().filter(|hover| {
|
||||
hover.buffer_id == self.active.id
|
||||
&& hover.revision == self.text_revision()
|
||||
&& hover.caret == self.cursor()
|
||||
&& self.mode() == Mode::Normal
|
||||
&& self.palette.is_none()
|
||||
&& !self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show_hover(&mut self, text: String) -> Result<(), String> {
|
||||
validate_hover_text(&text)?;
|
||||
if self.mode() != Mode::Normal
|
||||
|| self.palette.is_some()
|
||||
|| self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
return Err("Hover requires Normal editor focus".into());
|
||||
}
|
||||
self.hover = if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let text = match normalize_newlines(&text) {
|
||||
Cow::Borrowed(_) => text,
|
||||
Cow::Owned(normalized) => normalized,
|
||||
};
|
||||
let mut line_starts = Vec::new();
|
||||
let mut offset = 0;
|
||||
let mut max_column = 0;
|
||||
for line in text.split('\n') {
|
||||
line_starts.push(offset);
|
||||
offset += line.len() + 1;
|
||||
max_column = max_column.max(grapheme_count(line).saturating_sub(1));
|
||||
}
|
||||
Some(Hover {
|
||||
text,
|
||||
line_starts,
|
||||
top: 0,
|
||||
left: 0,
|
||||
max_column,
|
||||
buffer_id: self.active.id,
|
||||
revision: self.text_revision(),
|
||||
caret: self.cursor(),
|
||||
})
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_hover(&mut self) {
|
||||
self.hover = None;
|
||||
}
|
||||
|
||||
fn handle_hover(&mut self, input: &Input) -> bool {
|
||||
if self.hover().is_none() {
|
||||
self.clear_hover();
|
||||
return false;
|
||||
}
|
||||
let hover = self.hover.as_mut().expect("current hover exists");
|
||||
match input {
|
||||
Input::Up | Input::Ctrl('k') => hover.top = hover.top.saturating_sub(1),
|
||||
Input::Down | Input::Ctrl('j') => {
|
||||
hover.top = hover.top.saturating_add(1).min(hover.line_starts.len() - 1);
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') => hover.left = hover.left.saturating_sub(1),
|
||||
Input::Right | Input::Ctrl('l') => {
|
||||
hover.left = hover.left.saturating_add(1).min(hover.max_column);
|
||||
}
|
||||
Input::Home => hover.left = 0,
|
||||
Input::End => hover.left = hover.max_column,
|
||||
Input::Escape => self.clear_hover(),
|
||||
_ => {
|
||||
self.clear_hover();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn file_picker(&self) -> Option<&FilePicker> {
|
||||
self.file_picker.as_ref()
|
||||
}
|
||||
@@ -1852,6 +1972,9 @@ impl Editor {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if self.handle_hover(&input) {
|
||||
return None;
|
||||
}
|
||||
let picker_focused = self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
@@ -1975,6 +2098,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn act(&mut self, action: Action) -> Option<Invocation> {
|
||||
self.clear_hover();
|
||||
let count = self.count.take().unwrap_or(1);
|
||||
self.clear_leader();
|
||||
match action {
|
||||
@@ -1990,6 +2114,7 @@ impl Editor {
|
||||
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
|
||||
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
|
||||
Action::LspDefinition => return self.execute("lsp_definition"),
|
||||
Action::LspHover => return self.execute("lsp_hover"),
|
||||
Action::LspDiagnostics => return self.execute("lsp_diagnostics"),
|
||||
Action::Insert
|
||||
| Action::InsertFirstNonblank
|
||||
@@ -2202,6 +2327,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn park_source(&mut self) {
|
||||
self.clear_hover();
|
||||
if self.active.mode == Mode::Insert {
|
||||
self.finish_insert();
|
||||
}
|
||||
@@ -2379,6 +2505,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
||||
self.clear_hover();
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
self.palette = None;
|
||||
@@ -2410,10 +2537,10 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
match name {
|
||||
"lsp_definition" | "lsp_diagnostics" if !args.is_empty() => {
|
||||
"lsp_definition" | "lsp_hover" | "lsp_diagnostics" if !args.is_empty() => {
|
||||
self.set_message(format!("The {name} command takes no arguments"));
|
||||
}
|
||||
"lsp_definition" | "lsp_diagnostics" => {
|
||||
"lsp_definition" | "lsp_hover" | "lsp_diagnostics" => {
|
||||
return Some(Invocation {
|
||||
plugin: "core".into(),
|
||||
command: name.into(),
|
||||
@@ -2496,6 +2623,7 @@ impl Editor {
|
||||
})
|
||||
.then(|| self.snapshot());
|
||||
if before.is_some() {
|
||||
self.clear_hover();
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
self.clear_selection();
|
||||
@@ -3920,6 +4048,14 @@ fn valid_command(command: &str) -> bool {
|
||||
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
|
||||
}
|
||||
|
||||
pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> {
|
||||
if text.len() > MAX_EDIT_BYTES {
|
||||
Err("Hover exceeds 4 MiB".into())
|
||||
} else {
|
||||
validate_text(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_paste_text(text: &str) -> Result<(), String> {
|
||||
if text.len() > MAX_EDIT_BYTES {
|
||||
Err("Paste exceeds 4 MiB".into())
|
||||
@@ -4117,6 +4253,76 @@ mod tests {
|
||||
editor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("alpha\nbeta".into())])
|
||||
.unwrap();
|
||||
set_carets(&mut editor, &[(0, 1), (1, 2)], 1);
|
||||
let targets = editor.active.targets.clone();
|
||||
let primary = editor.active.primary;
|
||||
let text = editor.context().text;
|
||||
let revision = editor.text_revision();
|
||||
let history = (editor.active.undo.len(), editor.active.redo.len());
|
||||
editor
|
||||
.bind("normal", "K", Some("core.lsp_hover".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "9");
|
||||
assert_eq!(
|
||||
editor.handle(Input::Char('K')),
|
||||
Some(Invocation {
|
||||
plugin: "core".into(),
|
||||
command: "lsp_hover".into(),
|
||||
args: String::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(editor.count(), None);
|
||||
editor
|
||||
.show_hover("界e\u{301}👩🚀\tend\r\nsecond\nlast".into())
|
||||
.unwrap();
|
||||
assert!(editor.show_hover("\u{1b}[31m".into()).is_err());
|
||||
assert_eq!(editor.hover().unwrap().lines().nth(1), Some("second"));
|
||||
for input in [
|
||||
Input::Down,
|
||||
Input::Ctrl('j'),
|
||||
Input::Down,
|
||||
Input::Right,
|
||||
Input::Ctrl('l'),
|
||||
] {
|
||||
assert_eq!(editor.handle(input), None);
|
||||
}
|
||||
assert_eq!(
|
||||
(editor.hover().unwrap().top, editor.hover().unwrap().left),
|
||||
(2, 2)
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.hover().is_none());
|
||||
assert_eq!(editor.active.targets, targets);
|
||||
assert_eq!(editor.active.primary, primary);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(
|
||||
(editor.active.undo.len(), editor.active.redo.len()),
|
||||
history
|
||||
);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.selection_count(), 1);
|
||||
editor.show_hover("documentation".into()).unwrap();
|
||||
editor.handle(Input::Char('i'));
|
||||
assert!(editor.hover().is_none());
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
editor.handle(Input::Char('X'));
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "alpha\nbeXta");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, text);
|
||||
editor.show_hover("documentation".into()).unwrap();
|
||||
editor.apply(vec![Effect::Replace("new".into())]).unwrap();
|
||||
assert!(editor.hover().is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod system_clipboard {
|
||||
use super::*;
|
||||
|
||||
+252
-28
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ Set VAMP_RUBY to choose the Ruby executable.
|
||||
|
||||
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
|
||||
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
|
||||
LSP commands: :lsp_definition, :lsp_diagnostics";
|
||||
LSP commands: :lsp_definition, :lsp_diagnostics, :lsp_hover
|
||||
Hover binding: config.bind \"K\", config.core.lsp_hover (no default key)";
|
||||
|
||||
struct Options {
|
||||
file: Option<PathBuf>,
|
||||
|
||||
@@ -67,6 +67,11 @@ pub enum UpdateEffect {
|
||||
row: usize,
|
||||
column: usize,
|
||||
},
|
||||
Hover {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
text: String,
|
||||
},
|
||||
Message {
|
||||
text: String,
|
||||
},
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ module Vamp
|
||||
copy_system paste_system_before paste_system_after
|
||||
select_lines clone_above clone_below select_next skip_occurrence select_all
|
||||
file_picker file_explorer
|
||||
lsp_definition lsp_diagnostics
|
||||
lsp_definition lsp_diagnostics lsp_hover
|
||||
].each do |name|
|
||||
action = Action.new("core.#{name}".freeze).freeze
|
||||
define_singleton_method(name) { action }
|
||||
|
||||
+86
-5
@@ -41,6 +41,8 @@ pub struct EditorSession {
|
||||
lsp: Lsp,
|
||||
lsp_request_id: u64,
|
||||
lsp_source_cursor: Option<Cursor>,
|
||||
pending_hover: bool,
|
||||
hover_owner: Option<String>,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
@@ -124,6 +126,8 @@ impl EditorSession {
|
||||
lsp: Lsp::new(config.lsp),
|
||||
lsp_request_id: 0,
|
||||
lsp_source_cursor: None,
|
||||
pending_hover: false,
|
||||
hover_owner: None,
|
||||
};
|
||||
session.synchronize_documents();
|
||||
Ok(session)
|
||||
@@ -134,6 +138,7 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) {
|
||||
self.cancel_pending_hover();
|
||||
let invocation = self.editor.handle(input);
|
||||
self.synchronize_documents();
|
||||
if let Some(invocation) = invocation {
|
||||
@@ -143,6 +148,7 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
pub fn execute(&mut self, command: &str) {
|
||||
self.cancel_pending_hover();
|
||||
let invocation = self.editor.execute(command);
|
||||
self.synchronize_documents();
|
||||
if let Some(invocation) = invocation {
|
||||
@@ -154,7 +160,7 @@ impl EditorSession {
|
||||
fn dispatch(&mut self, invocation: Invocation) {
|
||||
if invocation.plugin == "core" {
|
||||
let result = match invocation.command.as_str() {
|
||||
"lsp_definition" => {
|
||||
"lsp_definition" | "lsp_hover" => {
|
||||
if let Some(id) = self.lsp_request_id.checked_add(1) {
|
||||
self.lsp_request_id = id;
|
||||
let context = self.editor.context();
|
||||
@@ -164,7 +170,12 @@ impl EditorSession {
|
||||
};
|
||||
self.requests.entry("core".into()).or_default().latest = Some((id, source));
|
||||
self.lsp_source_cursor = Some(self.editor.cursor());
|
||||
self.lsp.definition(&context, id)
|
||||
self.pending_hover = invocation.command == "lsp_hover";
|
||||
if self.pending_hover {
|
||||
self.lsp.hover(&context, id)
|
||||
} else {
|
||||
self.lsp.definition(&context, id)
|
||||
}
|
||||
} else {
|
||||
Err("LSP request IDs exhausted".into())
|
||||
}
|
||||
@@ -202,11 +213,24 @@ impl EditorSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_pending_hover(&mut self) {
|
||||
if self.pending_hover {
|
||||
if let Some(requests) = self.requests.get_mut("core") {
|
||||
requests.latest = None;
|
||||
}
|
||||
self.pending_hover = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn retire(&mut self, owner: &str, error: &str) {
|
||||
// Drop also stops a live host whose protocol-valid effects were rejected.
|
||||
drop(self.plugins.remove(owner));
|
||||
self.requests.remove(owner);
|
||||
self.editor.disable_plugin(owner, error);
|
||||
if self.hover_owner.as_deref() == Some(owner) {
|
||||
self.editor.clear_hover();
|
||||
self.hover_owner = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn synchronize_documents(&mut self) -> bool {
|
||||
@@ -313,7 +337,7 @@ impl EditorSession {
|
||||
.any(|document| document.id == source.buffer_id && document.revision == source.revision)
|
||||
}
|
||||
|
||||
fn navigation_is_current(
|
||||
fn request_is_current(
|
||||
&self,
|
||||
owner: &str,
|
||||
request_id: Option<u64>,
|
||||
@@ -335,7 +359,33 @@ impl EditorSession {
|
||||
})
|
||||
}
|
||||
|
||||
fn hover_is_current(
|
||||
&self,
|
||||
owner: &str,
|
||||
request_id: Option<u64>,
|
||||
source: InvocationSource,
|
||||
) -> bool {
|
||||
self.request_is_current(owner, request_id, source)
|
||||
&& self.editor.mode() == Mode::Normal
|
||||
&& self.editor.palette().is_none()
|
||||
&& self
|
||||
.editor
|
||||
.file_picker()
|
||||
.is_none_or(|picker| !picker.focused)
|
||||
}
|
||||
|
||||
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
|
||||
if owner == "core"
|
||||
&& let Some(id) = update.request_id
|
||||
&& self
|
||||
.requests
|
||||
.get(owner)
|
||||
.and_then(|requests| requests.latest)
|
||||
.map(|latest| latest.0)
|
||||
!= Some(id)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
// Validate the whole applicable annotation batch before changing any buffer.
|
||||
for effect in &update.effects {
|
||||
match effect {
|
||||
@@ -357,7 +407,7 @@ impl EditorSession {
|
||||
buffer_id: *buffer_id,
|
||||
revision: *revision,
|
||||
};
|
||||
if self.navigation_is_current(owner, update.request_id, source)
|
||||
if self.request_is_current(owner, update.request_id, source)
|
||||
&& (path.as_os_str().is_empty()
|
||||
|| path.to_string_lossy().chars().any(char::is_control))
|
||||
{
|
||||
@@ -366,6 +416,19 @@ impl EditorSession {
|
||||
);
|
||||
}
|
||||
}
|
||||
UpdateEffect::Hover {
|
||||
buffer_id,
|
||||
revision,
|
||||
text,
|
||||
} => {
|
||||
let source = InvocationSource {
|
||||
buffer_id: *buffer_id,
|
||||
revision: *revision,
|
||||
};
|
||||
if self.hover_is_current(owner, update.request_id, source) {
|
||||
crate::editor::validate_hover_text(text)?;
|
||||
}
|
||||
}
|
||||
UpdateEffect::Message { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +455,7 @@ impl EditorSession {
|
||||
buffer_id,
|
||||
revision,
|
||||
};
|
||||
if self.navigation_is_current(owner, update.request_id, source) {
|
||||
if self.request_is_current(owner, update.request_id, source) {
|
||||
if let Err(error) =
|
||||
self.editor.navigate_to(path, TextPosition { row, column })
|
||||
{
|
||||
@@ -402,6 +465,24 @@ impl EditorSession {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
UpdateEffect::Hover {
|
||||
buffer_id,
|
||||
revision,
|
||||
text,
|
||||
} => {
|
||||
let source = InvocationSource {
|
||||
buffer_id,
|
||||
revision,
|
||||
};
|
||||
if self.hover_is_current(owner, update.request_id, source) {
|
||||
self.editor.show_hover(text)?;
|
||||
self.hover_owner = Some(owner.into());
|
||||
if owner == "core" {
|
||||
self.pending_hover = false;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
UpdateEffect::Message { text } => {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
|
||||
+100
-1
@@ -29,7 +29,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Input, Mode, Palette},
|
||||
editor::{Editor, Hover, Input, Mode, Palette},
|
||||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||||
plugin::{Diagnostic, DiagnosticSeverity},
|
||||
session::EditorSession,
|
||||
@@ -631,6 +631,67 @@ fn draw(
|
||||
editor.selection_navigation_hint(),
|
||||
);
|
||||
}
|
||||
if let Some(hover) = editor.hover() {
|
||||
draw_hover(frame, hover, viewport);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(84)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(
|
||||
area.height
|
||||
.min(hover.lines().len().saturating_add(3).min(18) as u16),
|
||||
),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
frame.render_widget(Clear, panel);
|
||||
let bordered = panel.height >= 4 && panel.width >= 4;
|
||||
let block = Block::default()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(" Hover ");
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
}
|
||||
let [text_area, help_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(content.height >= 3)),
|
||||
])
|
||||
.areas(content);
|
||||
let lines: Vec<Line> = hover
|
||||
.lines()
|
||||
.skip(hover.top)
|
||||
.take(usize::from(text_area.height))
|
||||
.map(|line| {
|
||||
Line::from(visible_line(
|
||||
line,
|
||||
display_column(line, hover.left),
|
||||
usize::from(text_area.width),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), text_area);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Arrows/Ctrl+h/j/k/l scroll · Esc close")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn dock_layout(area: Rect, side: DockSide, focused: bool) -> (Rect, Option<Rect>) {
|
||||
@@ -977,6 +1038,44 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.show_hover("x界e\u{301}👩🚀z\nlast".into()).unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap();
|
||||
let mut syntax = SyntaxCache::new();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
for _ in 0..2 {
|
||||
editor.handle(Input::Right);
|
||||
}
|
||||
let rendered = terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!(rendered.buffer[(2, 0)].symbol(), "e\u{301}");
|
||||
assert_eq!(rendered.buffer[(3, 0)].symbol(), "👩🚀");
|
||||
assert_eq!(rendered.buffer[(5, 0)].symbol(), "z");
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(2, 0)
|
||||
);
|
||||
assert_eq!((top, left), (0, 0));
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Home);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).ends_with("last"));
|
||||
for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(editor.cursor(), crate::editor::Cursor::default());
|
||||
assert_eq!(editor.lines(), &[""]);
|
||||
}
|
||||
|
||||
fn annotation(
|
||||
start: (usize, usize),
|
||||
end: (usize, usize),
|
||||
|
||||
Reference in New Issue
Block a user