From bdcd4d9411655ab69245d803e88f88cc278127da Mon Sep 17 00:00:00 2001 From: Poliorcetics Date: Sat, 11 Mar 2023 03:32:14 +0100 Subject: [PATCH] Feat: LSP Type Hints (#5934) * misc: missing inline, outdated link * doc: Add new theme keys and config option to book * fix: don't panic in Tree::try_get(view_id) Necessary for later, where we could be receiving an LSP response for a closed window, in which case we don't want to crash while checking for its existence * fix: reset idle timer on all mouse events * refacto: Introduce Overlay::new and InlineAnnotation::new * refacto: extract make_job_callback from Context::callback * feat: add LSP display_inlay_hint option to config * feat: communicate inlay hints support capabilities of helix to LSP server * feat: Add function to request range of inlay hint from LSP * feat: Save inlay hints in document, per view * feat: Update inlay hints on document changes * feat: Compute inlay hints on idle timeout * nit: Add todo's about inlay hints for later * fix: compute text annotations for current view in view.rs, not document.rs * doc: Improve Document::text_annotations() description * nit: getters don't use 'get_' in front * fix: Drop inlay hints annotations on config refresh if necessary * fix: padding theming for LSP inlay hints * fix: tracking of outdated inlay hints should not be dependant on document revision (because of undos and such) * fix: follow LSP spec and don't highlight padding as virtual text * config: add some LSP inlay hint configs --- book/src/configuration.md | 3 + book/src/themes.md | 109 ++++++++-------- helix-core/src/diagnostic.rs | 2 +- helix-core/src/doc_formatter/test.rs | 56 ++------ helix-core/src/text_annotations.rs | 43 ++++--- helix-lsp/src/client.rs | 32 +++++ helix-term/src/commands.rs | 33 +++-- helix-term/src/commands/lsp.rs | 183 ++++++++++++++++++++++++++- helix-term/src/ui/editor.rs | 6 + helix-term/src/ui/picker.rs | 4 + helix-view/src/document.rs | 148 +++++++++++++++++++++- helix-view/src/editor.rs | 16 +++ helix-view/src/tree.rs | 11 +- helix-view/src/view.rs | 51 +++++++- languages.toml | 72 +++++++++++ 15 files changed, 618 insertions(+), 151 deletions(-) diff --git a/book/src/configuration.md b/book/src/configuration.md index e698646b..ec692cab 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -120,9 +120,12 @@ The following statusline elements can be configured: | `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` | | `display-messages` | Display LSP progress messages below statusline[^1] | `false` | | `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | +| `display-inlay-hints` | Display inlay hints[^2] | `false` | | `display-signature-help-docs` | Display docs under signature help popup | `true` | [^1]: By default, a progress spinner is shown in the statusline beside the file path. +[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. + Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances, please report any bugs you see so we can fix them! ### `[editor.cursor-shape]` Section diff --git a/book/src/themes.md b/book/src/themes.md index 5ddd4f2c..929f821e 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -262,58 +262,61 @@ These scopes are used for theming the editor interface: - `hover` - for hover popup UI -| Key | Notes | -| --- | --- | -| `ui.background` | | -| `ui.background.separator` | Picker separator below input line | -| `ui.cursor` | | -| `ui.cursor.normal` | | -| `ui.cursor.insert` | | -| `ui.cursor.select` | | -| `ui.cursor.match` | Matching bracket etc. | -| `ui.cursor.primary` | Cursor with primary selection | -| `ui.cursor.primary.normal` | | -| `ui.cursor.primary.insert` | | -| `ui.cursor.primary.select` | | -| `ui.gutter` | Gutter | -| `ui.gutter.selected` | Gutter for the line the cursor is on | -| `ui.linenr` | Line numbers | -| `ui.linenr.selected` | Line number for the line the cursor is on | -| `ui.statusline` | Statusline | -| `ui.statusline.inactive` | Statusline (unfocused document) | -| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.separator` | Separator character in statusline | -| `ui.popup` | Documentation popups (e.g. Space + k) | -| `ui.popup.info` | Prompt for multiple key options | -| `ui.window` | Borderlines separating splits | -| `ui.help` | Description box for commands | -| `ui.text` | Command prompts, popup text, etc. | -| `ui.text.focus` | | -| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) | -| `ui.text.info` | The key: command text in `ui.popup.info` boxes | -| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) | -| `ui.virtual.whitespace` | Visible whitespace characters | -| `ui.virtual.indent-guide` | Vertical indent width guides | -| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) | -| `ui.menu` | Code and command completion menus | -| `ui.menu.selected` | Selected autocomplete item | -| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | -| `ui.selection` | For selections in the editing area | -| `ui.selection.primary` | | -| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) | -| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | -| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | -| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) | -| `warning` | Diagnostics warning (gutter) | -| `error` | Diagnostics error (gutter) | -| `info` | Diagnostics info (gutter) | -| `hint` | Diagnostics hint (gutter) | -| `diagnostic` | Diagnostics fallback style (editing area) | -| `diagnostic.hint` | Diagnostics hint (editing area) | -| `diagnostic.info` | Diagnostics info (editing area) | -| `diagnostic.warning` | Diagnostics warning (editing area) | -| `diagnostic.error` | Diagnostics error (editing area) | +| Key | Notes | +| --- | --- | +| `ui.background` | | +| `ui.background.separator` | Picker separator below input line | +| `ui.cursor` | | +| `ui.cursor.normal` | | +| `ui.cursor.insert` | | +| `ui.cursor.select` | | +| `ui.cursor.match` | Matching bracket etc. | +| `ui.cursor.primary` | Cursor with primary selection | +| `ui.cursor.primary.normal` | | +| `ui.cursor.primary.insert` | | +| `ui.cursor.primary.select` | | +| `ui.gutter` | Gutter | +| `ui.gutter.selected` | Gutter for the line the cursor is on | +| `ui.linenr` | Line numbers | +| `ui.linenr.selected` | Line number for the line the cursor is on | +| `ui.statusline` | Statusline | +| `ui.statusline.inactive` | Statusline (unfocused document) | +| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.separator` | Separator character in statusline | +| `ui.popup` | Documentation popups (e.g. Space + k) | +| `ui.popup.info` | Prompt for multiple key options | +| `ui.window` | Borderlines separating splits | +| `ui.help` | Description box for commands | +| `ui.text` | Command prompts, popup text, etc. | +| `ui.text.focus` | | +| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) | +| `ui.text.info` | The key: command text in `ui.popup.info` boxes | +| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) | +| `ui.virtual.whitespace` | Visible whitespace characters | +| `ui.virtual.indent-guide` | Vertical indent width guides | +| `ui.virtual.inlay-hint` | Default style for inlay hints of all kinds | +| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind) | +| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (LSPs are not required to set a kind) | +| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) | +| `ui.menu` | Code and command completion menus | +| `ui.menu.selected` | Selected autocomplete item | +| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | +| `ui.selection` | For selections in the editing area | +| `ui.selection.primary` | | +| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) | +| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | +| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | +| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) | +| `warning` | Diagnostics warning (gutter) | +| `error` | Diagnostics error (gutter) | +| `info` | Diagnostics info (gutter) | +| `hint` | Diagnostics hint (gutter) | +| `diagnostic` | Diagnostics fallback style (editing area) | +| `diagnostic.hint` | Diagnostics hint (editing area) | +| `diagnostic.info` | Diagnostics info (editing area) | +| `diagnostic.warning` | Diagnostics warning (editing area) | +| `diagnostic.error` | Diagnostics error (editing area) | [editor-section]: ./configuration.md#editor-section diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index 6b5da17e..58ddb038 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -35,7 +35,7 @@ pub enum DiagnosticTag { Deprecated, } -/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html) +/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.94.0/lsp_types/struct.Diagnostic.html) #[derive(Debug, Clone)] pub struct Diagnostic { pub range: Range, diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs index e68b31fd..ac8918bb 100644 --- a/helix-core/src/doc_formatter/test.rs +++ b/helix-core/src/doc_formatter/test.rs @@ -119,16 +119,7 @@ fn overlay() { "foobar", 0, false, - &[ - Overlay { - char_idx: 0, - grapheme: "X".into(), - }, - Overlay { - char_idx: 2, - grapheme: "\t".into(), - }, - ] + &[Overlay::new(0, "X"), Overlay::new(2, "\t")], ), "Xo bar " ); @@ -138,18 +129,9 @@ fn overlay() { 0, true, &[ - Overlay { - char_idx: 2, - grapheme: "\t".into(), - }, - Overlay { - char_idx: 5, - grapheme: "\t".into(), - }, - Overlay { - char_idx: 16, - grapheme: "X".into(), - }, + Overlay::new(2, "\t"), + Overlay::new(5, "\t"), + Overlay::new(16, "X"), ] ), "fo f o foo \n.foo Xoo foo foo \n.foo foo foo " @@ -170,24 +152,14 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) - #[test] fn annotation() { assert_eq!( - annotate_text( - "bar", - false, - &[InlineAnnotation { - char_idx: 0, - text: "foo".into(), - }] - ), + annotate_text("bar", false, &[InlineAnnotation::new(0, "foo")]), "foobar " ); assert_eq!( annotate_text( &"foo ".repeat(10), true, - &[InlineAnnotation { - char_idx: 0, - text: "foo ".into(), - }] + &[InlineAnnotation::new(0, "foo ")] ), "foo foo foo foo \n.foo foo foo foo \n.foo foo foo " ); @@ -199,20 +171,8 @@ fn annotation_and_overlay() { "bbar".into(), &TextFormat::new_test(false), TextAnnotations::default() - .add_inline_annotations( - Rc::new([InlineAnnotation { - char_idx: 0, - text: "fooo".into(), - }]), - None - ) - .add_overlay( - Rc::new([Overlay { - char_idx: 0, - grapheme: "\t".into(), - }]), - None - ), + .add_inline_annotations(Rc::new([InlineAnnotation::new(0, "fooo")]), None) + .add_overlay(Rc::new([Overlay::new(0, "\t")]), None), 0, ) .0 diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 1956f6b5..3e48de4d 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -15,6 +15,15 @@ pub struct InlineAnnotation { pub char_idx: usize, } +impl InlineAnnotation { + pub fn new(char_idx: usize, text: impl Into) -> Self { + Self { + char_idx, + text: text.into(), + } + } +} + /// Represents a **single Grapheme** that is part of the document /// that start at `char_idx` that will be replaced with /// a different `grapheme`. @@ -33,22 +42,13 @@ pub struct InlineAnnotation { /// use helix_core::text_annotations::Overlay; /// /// // replaces a -/// Overlay { -/// char_idx: 0, -/// grapheme: "X".into(), -/// }; +/// Overlay::new(0, "X"); /// /// // replaces X͎̊͢͜͝͡ -/// Overlay{ -/// char_idx: 1, -/// grapheme: "\t".into(), -/// }; +/// Overlay::new(1, "\t"); /// /// // replaces b -/// Overlay{ -/// char_idx: 6, -/// grapheme: "X̢̢̟͖̲͌̋̇͑͝".into(), -/// }; +/// Overlay::new(6, "X̢̢̟͖̲͌̋̇͑͝"); /// ``` /// /// The following examples are invalid uses @@ -57,16 +57,10 @@ pub struct InlineAnnotation { /// use helix_core::text_annotations::Overlay; /// /// // overlay is not aligned at grapheme boundary -/// Overlay{ -/// char_idx: 3, -/// grapheme: "x".into(), -/// }; +/// Overlay::new(3, "x"); /// /// // overlay contains multiple graphemes -/// Overlay{ -/// char_idx: 0, -/// grapheme: "xy".into(), -/// }; +/// Overlay::new(0, "xy"); /// ``` #[derive(Debug, Clone)] pub struct Overlay { @@ -74,6 +68,15 @@ pub struct Overlay { pub grapheme: Tendril, } +impl Overlay { + pub fn new(char_idx: usize, grapheme: impl Into) -> Self { + Self { + char_idx, + grapheme: grapheme.into(), + } + } +} + /// Line annotations allow for virtual text between normal /// text lines. They cause `height` empty lines to be inserted /// below the document line that contains `anchor_char_idx`. diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 9fa118fb..9cb7c147 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -315,6 +315,9 @@ impl Client { execute_command: Some(lsp::DynamicRegistrationClientCapabilities { dynamic_registration: Some(false), }), + inlay_hint: Some(lsp::InlayHintWorkspaceClientCapabilities { + refresh_support: Some(false), + }), ..Default::default() }), text_document: Some(lsp::TextDocumentClientCapabilities { @@ -386,6 +389,10 @@ impl Client { publish_diagnostics: Some(lsp::PublishDiagnosticsClientCapabilities { ..Default::default() }), + inlay_hint: Some(lsp::InlayHintClientCapabilities { + dynamic_registration: Some(false), + resolve_support: None, + }), ..Default::default() }), window: Some(lsp::WindowClientCapabilities { @@ -726,6 +733,31 @@ impl Client { Some(self.call::(params)) } + pub fn text_document_range_inlay_hints( + &self, + text_document: lsp::TextDocumentIdentifier, + range: lsp::Range, + work_done_token: Option, + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + match capabilities.inlay_hint_provider { + Some( + lsp::OneOf::Left(true) + | lsp::OneOf::Right(lsp::InlayHintServerCapabilities::Options(_)), + ) => (), + _ => return None, + } + + let params = lsp::InlayHintParams { + text_document, + range, + work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, + }; + + Some(self.call::(params)) + } + pub fn text_document_hover( &self, text_document: lsp::TextDocumentIdentifier, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 803f4051..1c1edece 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -114,17 +114,7 @@ impl<'a> Context<'a> { T: for<'de> serde::Deserialize<'de> + Send + 'static, F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, { - let callback = Box::pin(async move { - let json = call.await?; - let response = serde_json::from_value(json)?; - let call: job::Callback = Callback::EditorCompositor(Box::new( - move |editor: &mut Editor, compositor: &mut Compositor| { - callback(editor, compositor, response) - }, - )); - Ok(call) - }); - self.jobs.callback(callback); + self.jobs.callback(make_job_callback(call, callback)); } /// Returns 1 if no explicit count was provided @@ -134,6 +124,27 @@ impl<'a> Context<'a> { } } +#[inline] +fn make_job_callback( + call: impl Future> + 'static + Send, + callback: F, +) -> std::pin::Pin>>> +where + T: for<'de> serde::Deserialize<'de> + Send + 'static, + F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, +{ + Box::pin(async move { + let json = call.await?; + let response = serde_json::from_value(json)?; + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + callback(editor, compositor, response) + }, + )); + Ok(call) + }) +} + use helix_view::{align_view, Align}; /// A MappableCommand is either a static command like "jump_view_up" or a Typable command like diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 08519366..f9d9856f 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -15,8 +15,13 @@ use tui::{ use super::{align_view, push_jump, Align, Context, Editor, Open}; -use helix_core::{path, Selection}; -use helix_view::{document::Mode, editor::Action, theme::Style}; +use helix_core::{path, text_annotations::InlineAnnotation, Selection}; +use helix_view::{ + document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, + editor::Action, + theme::Style, + Document, View, +}; use crate::{ compositor::{self, Compositor}, @@ -27,7 +32,8 @@ use crate::{ }; use std::{ - borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, path::PathBuf, sync::Arc, + borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, future::Future, path::PathBuf, + sync::Arc, }; /// Gets the language server that is attached to a document, and @@ -1391,3 +1397,174 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { }, ); } + +pub fn compute_inlay_hints_for_all_views(editor: &mut Editor, jobs: &mut crate::job::Jobs) { + if !editor.config().lsp.display_inlay_hints { + return; + } + + for (view, _) in editor.tree.views() { + let doc = match editor.documents.get(&view.doc) { + Some(doc) => doc, + None => continue, + }; + if let Some(callback) = compute_inlay_hints_for_view(view, doc) { + jobs.callback(callback); + } + } +} + +fn compute_inlay_hints_for_view( + view: &View, + doc: &Document, +) -> Option>>>> { + let view_id = view.id; + let doc_id = view.doc; + + let language_server = doc.language_server()?; + + let capabilities = language_server.capabilities(); + + let (future, new_doc_inlay_hints_id) = match capabilities.inlay_hint_provider { + Some( + lsp::OneOf::Left(true) + | lsp::OneOf::Right(lsp::InlayHintServerCapabilities::Options(_)), + ) => { + let doc_text = doc.text(); + let len_lines = doc_text.len_lines(); + + // Compute ~3 times the current view height of inlay hints, that way some scrolling + // will not show half the view with hints and half without while still being faster + // than computing all the hints for the full file (which could be dozens of time + // longer than the view is). + let view_height = view.inner_height(); + let first_visible_line = doc_text.char_to_line(view.offset.anchor); + let first_line = first_visible_line.saturating_sub(view_height); + let last_line = first_visible_line + .saturating_add(view_height.saturating_mul(2)) + .min(len_lines); + + let new_doc_inlay_hint_id = DocumentInlayHintsId { + first_line, + last_line, + }; + // Don't recompute the annotations in case nothing has changed about the view + if !doc.inlay_hints_oudated + && doc + .inlay_hints(view_id) + .map_or(false, |dih| dih.id == new_doc_inlay_hint_id) + { + return None; + } + + let doc_slice = doc_text.slice(..); + let first_char_in_range = doc_slice.line_to_char(first_line); + let last_char_in_range = doc_slice.line_to_char(last_line); + + let range = helix_lsp::util::range_to_lsp_range( + doc_text, + helix_core::Range::new(first_char_in_range, last_char_in_range), + language_server.offset_encoding(), + ); + + ( + language_server.text_document_range_inlay_hints(doc.identifier(), range, None), + new_doc_inlay_hint_id, + ) + } + _ => return None, + }; + + let callback = super::make_job_callback( + future?, + move |editor, _compositor, response: Option>| { + // The config was modified or the window was closed while the request was in flight + if !editor.config().lsp.display_inlay_hints || editor.tree.try_get(view_id).is_none() { + return; + } + + // Add annotations to relevant document, not the current one (it may have changed in between) + let doc = match editor.documents.get_mut(&doc_id) { + Some(doc) => doc, + None => return, + }; + + // If we have neither hints nor an LSP, empty the inlay hints since they're now oudated + let (mut hints, offset_encoding) = match (response, doc.language_server()) { + (Some(h), Some(ls)) if !h.is_empty() => (h, ls.offset_encoding()), + _ => { + doc.set_inlay_hints( + view_id, + DocumentInlayHints::empty_with_id(new_doc_inlay_hints_id), + ); + doc.inlay_hints_oudated = false; + return; + } + }; + + // Most language servers will already send them sorted but ensure this is the case to + // avoid errors on our end. + hints.sort_unstable_by_key(|inlay_hint| inlay_hint.position); + + let mut padding_before_inlay_hints = Vec::new(); + let mut type_inlay_hints = Vec::new(); + let mut parameter_inlay_hints = Vec::new(); + let mut other_inlay_hints = Vec::new(); + let mut padding_after_inlay_hints = Vec::new(); + + let doc_text = doc.text(); + + for hint in hints { + let char_idx = + match helix_lsp::util::lsp_pos_to_pos(doc_text, hint.position, offset_encoding) + { + Some(pos) => pos, + // Skip inlay hints that have no "real" position + None => continue, + }; + + let label = match hint.label { + lsp::InlayHintLabel::String(s) => s, + lsp::InlayHintLabel::LabelParts(parts) => parts + .into_iter() + .map(|p| p.value) + .collect::>() + .join(""), + }; + + let inlay_hints_vec = match hint.kind { + Some(lsp::InlayHintKind::TYPE) => &mut type_inlay_hints, + Some(lsp::InlayHintKind::PARAMETER) => &mut parameter_inlay_hints, + // We can't warn on unknown kind here since LSPs are free to set it or not, for + // example Rust Analyzer does not: every kind will be `None`. + _ => &mut other_inlay_hints, + }; + + if let Some(true) = hint.padding_left { + padding_before_inlay_hints.push(InlineAnnotation::new(char_idx, " ")); + } + + inlay_hints_vec.push(InlineAnnotation::new(char_idx, label)); + + if let Some(true) = hint.padding_right { + padding_after_inlay_hints.push(InlineAnnotation::new(char_idx, " ")); + } + } + + doc.set_inlay_hints( + view_id, + DocumentInlayHints { + id: new_doc_inlay_hints_id, + type_inlay_hints: type_inlay_hints.into(), + parameter_inlay_hints: parameter_inlay_hints.into(), + other_inlay_hints: other_inlay_hints.into(), + padding_before_inlay_hints: padding_before_inlay_hints.into(), + padding_after_inlay_hints: padding_after_inlay_hints.into(), + }, + ); + doc.inlay_hints_oudated = false; + }, + ); + + Some(callback) +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 4abbe01e..7c22df74 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -990,6 +990,8 @@ impl EditorView { } pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { + commands::compute_inlay_hints_for_all_views(cx.editor, cx.jobs); + if let Some(completion) = &mut self.completion { return if completion.ensure_item_resolved(cx) { EventResult::Consumed(None) @@ -1014,6 +1016,10 @@ impl EditorView { event: &MouseEvent, cxt: &mut commands::Context, ) -> EventResult { + if event.kind != MouseEventKind::Moved { + cxt.editor.reset_idle_timer(); + } + let config = cxt.editor.config(); let MouseEvent { kind, diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index ec8b1c7f..bc2f98ee 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -225,6 +225,9 @@ impl FilePicker { let loader = cx.editor.syn_loader.clone(); doc.detect_language(loader); } + + // QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now + // but it could be interesting in the future } EventResult::Consumed(None) @@ -339,6 +342,7 @@ impl Component for FilePicker { inner, doc, offset, + // TODO: compute text annotations asynchronously here (like inlay hints) &TextAnnotations::default(), highlights, &cx.editor.theme, diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index b2a9ddec..19220f28 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -6,7 +6,7 @@ use futures_util::FutureExt; use helix_core::auto_pairs::AutoPairs; use helix_core::doc_formatter::TextFormat; use helix_core::syntax::Highlight; -use helix_core::text_annotations::TextAnnotations; +use helix_core::text_annotations::{InlineAnnotation, TextAnnotations}; use helix_core::Range; use helix_vcs::{DiffHandle, DiffProviderRegistry}; @@ -19,6 +19,7 @@ use std::collections::HashMap; use std::fmt::Display; use std::future::Future; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::str::FromStr; use std::sync::{Arc, Weak}; use std::time::SystemTime; @@ -119,6 +120,14 @@ pub struct Document { text: Rope, selections: HashMap, + /// Inlay hints annotations for the document, by view. + /// + /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`. + pub(crate) inlay_hints: HashMap, + /// Set to `true` when the document is updated, reset to `false` on the next inlay hints + /// update from the LSP + pub inlay_hints_oudated: bool, + path: Option, encoding: &'static encoding::Encoding, @@ -162,6 +171,73 @@ pub struct Document { version_control_head: Option>>>, } +/// Inlay hints for a single `(Document, View)` combo. +/// +/// There are `*_inlay_hints` field for each kind of hints an LSP can send since we offer the +/// option to style theme differently in the theme according to the (currently supported) kinds +/// (`type`, `parameter` and the rest). +/// +/// Inlay hints are always `InlineAnnotation`s, not overlays or line-ones: LSP may choose to place +/// them anywhere in the text and will sometime offer config options to move them where the user +/// wants them but it shouldn't be Helix who decides that so we use the most precise positioning. +/// +/// The padding for inlay hints needs to be stored separately for before and after (the LSP spec +/// uses 'left' and 'right' but not all text is left to right so let's be correct) padding because +/// the 'before' padding must be added to a layer *before* the regular inlay hints and the 'after' +/// padding comes ... after. +#[derive(Debug, Clone)] +pub struct DocumentInlayHints { + /// Identifier for the inlay hints stored in this structure. To be checked to know if they have + /// to be recomputed on idle or not. + pub id: DocumentInlayHintsId, + + /// Inlay hints of `TYPE` kind, if any. + pub type_inlay_hints: Rc<[InlineAnnotation]>, + + /// Inlay hints of `PARAMETER` kind, if any. + pub parameter_inlay_hints: Rc<[InlineAnnotation]>, + + /// Inlay hints that are neither `TYPE` nor `PARAMETER`. + /// + /// LSPs are not required to associate a kind to their inlay hints, for example Rust-Analyzer + /// currently never does (February 2023) and the LSP spec may add new kinds in the future that + /// we want to display even if we don't have some special highlighting for them. + pub other_inlay_hints: Rc<[InlineAnnotation]>, + + /// Inlay hint padding. When creating the final `TextAnnotations`, the `before` padding must be + /// added first, then the regular inlay hints, then the `after` padding. + pub padding_before_inlay_hints: Rc<[InlineAnnotation]>, + pub padding_after_inlay_hints: Rc<[InlineAnnotation]>, +} + +impl DocumentInlayHints { + /// Generate an empty list of inlay hints with the given ID. + pub fn empty_with_id(id: DocumentInlayHintsId) -> Self { + Self { + id, + type_inlay_hints: Rc::new([]), + parameter_inlay_hints: Rc::new([]), + other_inlay_hints: Rc::new([]), + padding_before_inlay_hints: Rc::new([]), + padding_after_inlay_hints: Rc::new([]), + } + } +} + +/// Associated with a [`Document`] and [`ViewId`], uniquely identifies the state of inlay hints for +/// for that document and view: if this changed since the last save, the inlay hints for the view +/// should be recomputed. +/// +/// We can't store the `ViewOffset` instead of the first and last asked-for lines because if +/// softwrapping changes, the `ViewOffset` may not change while the displayed lines will. +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct DocumentInlayHintsId { + /// First line for which the inlay hints were requested. + pub first_line: usize, + /// Last line for which the inlay hints were requested. + pub last_line: usize, +} + use std::{fmt, mem}; impl fmt::Debug for Document { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -169,6 +245,8 @@ impl fmt::Debug for Document { .field("id", &self.id) .field("text", &self.text) .field("selections", &self.selections) + .field("inlay_hints_oudated", &self.inlay_hints_oudated) + .field("text_annotations", &self.inlay_hints) .field("path", &self.path) .field("encoding", &self.encoding) .field("restore_cursor", &self.restore_cursor) @@ -187,6 +265,15 @@ impl fmt::Debug for Document { } } +impl fmt::Debug for DocumentInlayHintsId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Much more agreable to read when debugging + f.debug_struct("DocumentInlayHintsId") + .field("lines", &(self.first_line..self.last_line)) + .finish() + } +} + // The documentation and implementation of this function should be up-to-date with // its sibling function, `to_writer()`. // @@ -389,6 +476,8 @@ impl Document { encoding, text, selections: HashMap::default(), + inlay_hints: HashMap::default(), + inlay_hints_oudated: false, indent_style: DEFAULT_INDENT, line_ending: DEFAULT_LINE_ENDING, restore_cursor: false, @@ -819,13 +908,16 @@ impl Document { } } - /// Remove a view's selection from this document. + /// Remove a view's selection and inlay hints from this document. pub fn remove_view(&mut self, view_id: ViewId) { self.selections.remove(&view_id); + self.inlay_hints.remove(&view_id); } /// Apply a [`Transaction`] to the [`Document`] to change its text. fn apply_impl(&mut self, transaction: &Transaction, view_id: ViewId) -> bool { + use helix_core::Assoc; + let old_doc = self.text().clone(); let success = transaction.changes().apply(&mut self.text); @@ -881,10 +973,10 @@ impl Document { .unwrap(); } + let changes = transaction.changes(); + // map state.diagnostics over changes::map_pos too for diagnostic in &mut self.diagnostics { - use helix_core::Assoc; - let changes = transaction.changes(); diagnostic.range.start = changes.map_pos(diagnostic.range.start, Assoc::After); diagnostic.range.end = changes.map_pos(diagnostic.range.end, Assoc::After); diagnostic.line = self.text.char_to_line(diagnostic.range.start); @@ -892,13 +984,40 @@ impl Document { self.diagnostics .sort_unstable_by_key(|diagnostic| diagnostic.range); + // Update the inlay hint annotations' positions, helping ensure they are displayed in the proper place + let apply_inlay_hint_changes = |annotations: &mut Rc<[InlineAnnotation]>| { + if let Some(data) = Rc::get_mut(annotations) { + for inline in data.iter_mut() { + inline.char_idx = changes.map_pos(inline.char_idx, Assoc::After); + } + } + }; + + self.inlay_hints_oudated = true; + for text_annotation in self.inlay_hints.values_mut() { + let DocumentInlayHints { + id: _, + type_inlay_hints, + parameter_inlay_hints, + other_inlay_hints, + padding_before_inlay_hints, + padding_after_inlay_hints, + } = text_annotation; + + apply_inlay_hint_changes(padding_before_inlay_hints); + apply_inlay_hint_changes(type_inlay_hints); + apply_inlay_hint_changes(parameter_inlay_hints); + apply_inlay_hint_changes(other_inlay_hints); + apply_inlay_hint_changes(padding_after_inlay_hints); + } + // emit lsp notification if let Some(language_server) = self.language_server() { let notify = language_server.text_document_did_change( self.versioned_identifier(), &old_doc, self.text(), - transaction.changes(), + changes, ); if let Some(notify) = notify { @@ -1217,6 +1336,7 @@ impl Document { &self.selections[&view_id] } + #[inline] pub fn selections(&self) -> &HashMap { &self.selections } @@ -1355,9 +1475,27 @@ impl Document { } } + /// Get the text annotations that apply to the whole document, those that do not apply to any + /// specific view. pub fn text_annotations(&self, _theme: Option<&Theme>) -> TextAnnotations { TextAnnotations::default() } + + /// Set the inlay hints for this document and `view_id`. + pub fn set_inlay_hints(&mut self, view_id: ViewId, inlay_hints: DocumentInlayHints) { + self.inlay_hints.insert(view_id, inlay_hints); + } + + /// Get the inlay hints for this document and `view_id`. + pub fn inlay_hints(&self, view_id: ViewId) -> Option<&DocumentInlayHints> { + self.inlay_hints.get(&view_id) + } + + /// Completely removes all the inlay hints saved for the document, dropping them to free memory + /// (since it often means inlay hints have been fully deactivated). + pub fn reset_all_inlay_hints(&mut self) { + self.inlay_hints = Default::default(); + } } #[derive(Clone, Debug)] diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 41aa707f..bbed58d6 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -345,6 +345,8 @@ pub struct LspConfig { pub auto_signature_help: bool, /// Display docs under signature help popup pub display_signature_help_docs: bool, + /// Display inlay hints + pub display_inlay_hints: bool, } impl Default for LspConfig { @@ -354,6 +356,7 @@ impl Default for LspConfig { display_messages: false, auto_signature_help: true, display_signature_help_docs: true, + display_inlay_hints: false, } } } @@ -1133,6 +1136,19 @@ impl Editor { fn _refresh(&mut self) { let config = self.config(); + + // Reset the inlay hints annotations *before* updating the views, that way we ensure they + // will disappear during the `.sync_change(doc)` call below. + // + // We can't simply check this config when rendering because inlay hints are only parts of + // the possible annotations, and others could still be active, so we need to selectively + // drop the inlay hints. + if !config.lsp.display_inlay_hints { + for doc in self.documents_mut() { + doc.reset_all_inlay_hints(); + } + } + for (view, _) in self.tree.views_mut() { let doc = doc_mut!(self, &view.doc); view.sync_changes(doc); diff --git a/helix-view/src/tree.rs b/helix-view/src/tree.rs index 5ec2773d..e8afd204 100644 --- a/helix-view/src/tree.rs +++ b/helix-view/src/tree.rs @@ -278,16 +278,15 @@ impl Tree { self.try_get(index).unwrap() } - /// Try to get reference to a [View] by index. Returns `None` if node content is not a [Content::View] - /// # Panics + /// Try to get reference to a [View] by index. Returns `None` if node content is not a [`Content::View`]. /// - /// Panics if `index` is not in self.nodes. This can be checked with [Self::contains] + /// Does not panic if the view does not exists anymore. pub fn try_get(&self, index: ViewId) -> Option<&View> { - match &self.nodes[index] { - Node { + match self.nodes.get(index) { + Some(Node { content: Content::View(view), .. - } => Some(view), + }) => Some(view), _ => None, } } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index 7bfbb241..0ac7ca3b 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -1,19 +1,21 @@ use crate::{ align_view, + document::DocumentInlayHints, editor::{GutterConfig, GutterType}, graphics::Rect, Align, Document, DocumentId, Theme, ViewId, }; use helix_core::{ - char_idx_at_visual_offset, doc_formatter::TextFormat, text_annotations::TextAnnotations, - visual_offset_from_anchor, visual_offset_from_block, Position, RopeSlice, Selection, - Transaction, + char_idx_at_visual_offset, doc_formatter::TextFormat, syntax::Highlight, + text_annotations::TextAnnotations, visual_offset_from_anchor, visual_offset_from_block, + Position, RopeSlice, Selection, Transaction, }; use std::{ collections::{HashMap, VecDeque}, fmt, + rc::Rc, }; const JUMP_LIST_CAPACITY: usize = 30; @@ -402,9 +404,50 @@ impl View { Some(pos) } + /// Get the text annotations to display in the current view for the given document and theme. pub fn text_annotations(&self, doc: &Document, theme: Option<&Theme>) -> TextAnnotations { // TODO custom annotations for custom views like side by side diffs - doc.text_annotations(theme) + + let mut text_annotations = doc.text_annotations(theme); + + let DocumentInlayHints { + id: _, + type_inlay_hints, + parameter_inlay_hints, + other_inlay_hints, + padding_before_inlay_hints, + padding_after_inlay_hints, + } = match doc.inlay_hints.get(&self.id) { + Some(doc_inlay_hints) => doc_inlay_hints, + None => return text_annotations, + }; + + let type_style = theme + .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.type")) + .map(Highlight); + let parameter_style = theme + .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.parameter")) + .map(Highlight); + let other_style = theme + .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint")) + .map(Highlight); + + let mut add_annotations = |annotations: &Rc<[_]>, style| { + if !annotations.is_empty() { + text_annotations.add_inline_annotations(Rc::clone(annotations), style); + } + }; + + // Overlapping annotations are ignored apart from the first so the order here is not random: + // types -> parameters -> others should hopefully be the "correct" order for most use cases, + // with the padding coming before and after as expected. + add_annotations(padding_before_inlay_hints, None); + add_annotations(type_inlay_hints, type_style); + add_annotations(parameter_inlay_hints, parameter_style); + add_annotations(other_inlay_hints, other_style); + add_annotations(padding_after_inlay_hints, None); + + text_annotations } pub fn text_pos_at_screen_coords( diff --git a/languages.toml b/languages.toml index 86f4a64d..83a09b0b 100644 --- a/languages.toml +++ b/languages.toml @@ -19,6 +19,14 @@ indent = { tab-width = 4, unit = " " } '"' = '"' '`' = '`' +[language.config] +inlayHints.bindingModeHints.enable = false +inlayHints.closingBraceHints.minLines = 10 +inlayHints.closureReturnTypeHints.enable = "with_block" +inlayHints.discriminantHints.enable = "fieldless" +inlayHints.lifetimeElisionHints.enable = "skip_trivial" +inlayHints.typeHints.hideClosureInitialization = false + [language.debugger] name = "lldb-vscode" transport = "stdio" @@ -291,6 +299,14 @@ language-server = { command = "gopls" } # TODO: gopls needs utf-8 offsets? indent = { tab-width = 4, unit = "\t" } +[language.config.hints] +assignVariableTypes = true +compositeLiteralFields = true +constantValues = true +functionTypeParameters = true +parameterNames = true +rangeVariableTypes = true + [language.debugger] name = "go" transport = "tcp" @@ -382,6 +398,18 @@ comment-token = "//" language-server = { command = "typescript-language-server", args = ["--stdio"], language-id = "javascript" } indent = { tab-width = 2, unit = " " } +[language.config] +hostInfo = "helix" + +[language.config.javascript.inlayHints] +includeInlayEnumMemberValueHints = true +includeInlayFunctionLikeReturnTypeHints = true +includeInlayFunctionParameterTypeHints = true +includeInlayParameterNameHints = "all" +includeInlayParameterNameHintsWhenArgumentMatchesName = true +includeInlayPropertyDeclarationTypeHints = true +includeInlayVariableTypeHints = true + [language.debugger] name = "node-debug2" transport = "stdio" @@ -409,6 +437,18 @@ language-server = { command = "typescript-language-server", args = ["--stdio"], indent = { tab-width = 2, unit = " " } grammar = "javascript" +[language.config] +hostInfo = "helix" + +[language.config.javascript.inlayHints] +includeInlayEnumMemberValueHints = true +includeInlayFunctionLikeReturnTypeHints = true +includeInlayFunctionParameterTypeHints = true +includeInlayParameterNameHints = "all" +includeInlayParameterNameHintsWhenArgumentMatchesName = true +includeInlayPropertyDeclarationTypeHints = true +includeInlayVariableTypeHints = true + [[language]] name = "typescript" scope = "source.ts" @@ -420,6 +460,18 @@ roots = [] language-server = { command = "typescript-language-server", args = ["--stdio"], language-id = "typescript"} indent = { tab-width = 2, unit = " " } +[language.config] +hostInfo = "helix" + +[language.config.typescript.inlayHints] +includeInlayEnumMemberValueHints = true +includeInlayFunctionLikeReturnTypeHints = true +includeInlayFunctionParameterTypeHints = true +includeInlayParameterNameHints = "all" +includeInlayParameterNameHintsWhenArgumentMatchesName = true +includeInlayPropertyDeclarationTypeHints = true +includeInlayVariableTypeHints = true + [[grammar]] name = "typescript" source = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "6aac031ad88dd6317f02ac0bb27d099a553a7d8c", subpath = "typescript" } @@ -434,6 +486,18 @@ roots = [] language-server = { command = "typescript-language-server", args = ["--stdio"], language-id = "typescriptreact" } indent = { tab-width = 2, unit = " " } +[language.config] +hostInfo = "helix" + +[language.config.typescript.inlayHints] +includeInlayEnumMemberValueHints = true +includeInlayFunctionLikeReturnTypeHints = true +includeInlayFunctionParameterTypeHints = true +includeInlayParameterNameHints = "all" +includeInlayParameterNameHintsWhenArgumentMatchesName = true +includeInlayPropertyDeclarationTypeHints = true +includeInlayVariableTypeHints = true + [[grammar]] name = "tsx" source = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "6aac031ad88dd6317f02ac0bb27d099a553a7d8c", subpath = "tsx" } @@ -740,6 +804,14 @@ comment-token = "--" indent = { tab-width = 2, unit = " " } language-server = { command = "lua-language-server", args = [] } +[language.config.Lua.hint] +enable = true +arrayIndex = "Enable" +setType = true +paramName = "All" +paramType = true +await = true + [[grammar]] name = "lua" source = { git = "https://github.com/MunifTanjim/tree-sitter-lua", rev = "887dfd4e83c469300c279314ff1619b1d0b85b91" }