feat: add native LSP hover action and popup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+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),
|
||||
|
||||
+171
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user