From f09309100234129cf66604a81606ece4a712f20e Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Fri, 9 Oct 2026 14:29:58 +0200 Subject: [PATCH] feat: add native LSP hover action and popup --- README.md | 22 +++- examples/config.rb | 1 + src/editor.rs | 210 +++++++++++++++++++++++++++++++++- src/lsp.rs | 280 ++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 3 +- src/plugin.rs | 5 + src/ruby_host.rb | 2 +- src/session.rs | 91 ++++++++++++++- src/tui.rs | 101 +++++++++++++++- tests/lsp.rs | 176 +++++++++++++++++++++++++++- 10 files changed, 842 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 3613f80..91a2331 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ One crate; no workspace or terminal-coordinate types in the editing backend: - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. - `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and semantic byte spans; it has no terminal color/geometry or editing dependency. - `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, and versioned asynchronous update contracts. It has no LSP protocol types. -- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, and definition results. Its private configuration resolver supplies presets without restricting language IDs. +- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, and definition/hover results. Its private configuration resolver supplies presets without restricting language IDs. - `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership. - `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. @@ -222,6 +222,7 @@ Frontends inspect `leader_pending()` for the cached display of a pending leader Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes and owner/server retirement clear them. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. +Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport. The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. @@ -260,6 +261,7 @@ Vamp.configure do |config| } config.bind "leader d", config.core.lsp_definition config.bind "leader D", config.core.lsp_diagnostics + config.bind "K", config.core.lsp_hover end ``` @@ -273,11 +275,15 @@ The native module initializes each server, sends `initialization_options` as `in `:lsp_definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed. -For programmatic bindings, use `config.core.lsp_definition` and `config.core.lsp_diagnostics`, immutable Action objects eligible in Normal mode. Both route to the same native implementation as their Ex commands; no LSP keys are bound by default. These actions are also available in the command palette. +For programmatic bindings, use `config.core.lsp_definition`, `config.core.lsp_diagnostics`, and `config.core.lsp_hover`, immutable Action objects eligible in Normal mode. Each routes to the same native implementation as its Ex command; no LSP keys are bound by default. These actions are also available in the command palette. Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp_diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts. -Native LSP currently provides synchronization, diagnostics, and go-to-definitionโ€”not completion, hover, rename, formatting, code actions, workspace edits, or semantic highlighting. Tree-sitter remains the syntax highlighter. The former Ruby LSP plugin has been removed; migrate its `servers` entries to `config.lsp[language_id]` objects without the `language_id` field. +`:lsp_hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it. + +Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized. + +Native LSP currently provides synchronization, diagnostics, go-to-definition, and hoverโ€”not completion, rename, formatting, code actions, workspace edits, or semantic highlighting. Tree-sitter remains the syntax highlighter. The former Ruby LSP plugin has been removed; migrate its `servers` entries to `config.lsp[language_id]` objects without the `language_id` field. ## Ruby configuration @@ -326,7 +332,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati | Normal | `insert`, `append`, `open_below`, `open_above` | | Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` | | Normal | `join_lines`, `undo`, `redo`, `command` | -| Normal | `lsp_definition`, `lsp_diagnostics` | +| Normal | `lsp_definition`, `lsp_diagnostics`, `lsp_hover` | | Normal | `search`, `paste_before`, `paste_after` | | Normal | `paste_system_before`, `paste_system_after` | | Normal, Visual | `select`, `search_next`, `search_previous` | @@ -385,10 +391,12 @@ This is crash isolation, **not a security sandbox**: Ruby configuration and plug `Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input. -Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, revision-tagged `diagnostics`, or request-correlated `navigate`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations. +Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations. Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics. +`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content. + ### Messaging Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed text effects (`insert`, `replace`, `message`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB. @@ -397,7 +405,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required ## Current limits -- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, or graphical frontend. Native opt-in LSP currently supports diagnostics and definitions only. Native system clipboard access currently supports macOS and Linux; Separate mode retains internal editing on unsupported/headless environments. +- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, or graphical frontend. Native opt-in LSP currently supports diagnostics, definitions, and plain-text hover. Native system clipboard access currently supports macOS and Linux; Separate mode retains internal editing on unsupported/headless environments. - Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. - Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing. - Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload. @@ -416,3 +424,5 @@ Regression checks exercise Unicode edits, undo/redo, saving and failed saves, ca Unix terminal-hook checks run in isolated child processes and real PTYs to keep global hooks out of the parallel test runner. They cover entry failure, normal return, I/O error, panic cleanup before caller diagnostics, original panic-payload propagation, and later panics producing no stale terminal-restoration output. LSP integration checks launch real framed-protocol fixture subprocesses directly from native Rust: fragmented/adjacent frames, initialization/custom settings, arbitrary language IDs, opt-in matching-only startup, negotiated synchronization, open/change/save/close/shutdown, UTF-16 and encoded Unicode paths, dirty targets without enabled LSPs, stale/superseded requests and diagnostics, moved carets, unsupported requests, crashes, malformed frames, unavailable servers, and surviving native editing. Executable PTY smoke exercised idle diagnostic redraw, active-server edits, definition navigation into a dirty non-LSP buffer, Unicode cursor placement, settings, saving, and clean shutdown; its server's parent PID confirmed there was no Ruby LSP host. No desktop clipboard or user files are touched. Windows transport/process cleanup is implemented but not exercised on this macOS workstation. + +Hover regression checks cover typed actions, UTF-16 primary positions, legal content forms, terminal-control sanitization, delayed initialization, cancelled/superseded replies, invalid ranges, missing capabilities/results, popup scrolling/dismissal, tiny Unicode-clipped layouts, and preserved native editing. Executable PTY smoke exercised a multiline hover popup, scrolling without moving the hardware cursor, Escape dismissal, and subsequent insert/save/clean shutdown. diff --git a/examples/config.rb b/examples/config.rb index dde8624..63e54b4 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -21,6 +21,7 @@ Vamp.configure do |config| # } # config.bind "leader d", config.core.lsp_definition # config.bind "leader D", config.core.lsp_diagnostics + # config.bind "K", config.core.lsp_hover config.bind "g", "greet" config.bind "leader g", "greet" config.bind "ctrl+r", config.core.redo diff --git a/src/editor.rs b/src/editor.rs index dac7dd7..15faa8a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -62,6 +62,35 @@ pub struct Palette { pub selected: usize, } +pub struct Hover { + text: String, + line_starts: Vec, + /// 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 { + 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, leader_display: String, palette: Option, + hover: Option, project_root: PathBuf, file_picker: Option, 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 { + 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 { + 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::*; diff --git a/src/lsp.rs b/src/lsp.rs index 158eacc..a513ac2 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -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>, documents: HashMap, requests: HashMap, - pending: VecDeque, + 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) -> PluginUpdate { } } +fn hover_text(result: &Value, source: &str) -> Result, 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, diff --git a/src/plugin.rs b/src/plugin.rs index f4f6a92..777e28d 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -67,6 +67,11 @@ pub enum UpdateEffect { row: usize, column: usize, }, + Hover { + buffer_id: usize, + revision: u64, + text: String, + }, Message { text: String, }, diff --git a/src/ruby_host.rb b/src/ruby_host.rb index a3a3cf3..623145a 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -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 } diff --git a/src/session.rs b/src/session.rs index afeb124..8f103c6 100644 --- a/src/session.rs +++ b/src/session.rs @@ -41,6 +41,8 @@ pub struct EditorSession { lsp: Lsp, lsp_request_id: u64, lsp_source_cursor: Option, + pending_hover: bool, + hover_owner: Option, } 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, @@ -335,7 +359,33 @@ impl EditorSession { }) } + fn hover_is_current( + &self, + owner: &str, + request_id: Option, + 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 { + 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); diff --git a/src/tui.rs b/src/tui.rs index 1c16790..4a9acc1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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 = 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) { @@ -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), diff --git a/tests/lsp.rs b/tests/lsp.rs index 2006834..7c6a4c0 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -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); + } +}