diff --git a/book/src/configuration.md b/book/src/configuration.md index ec692cab1225..65b6a2ff8355 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -122,6 +122,7 @@ The following statusline elements can be configured: | `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` | +| `display-inline-diagnostics` | Display diagnostics under their starting line | `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. diff --git a/book/src/themes.md b/book/src/themes.md index 929f821e64cf..cfa4bda60e7d 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -295,6 +295,7 @@ These scopes are used for theming the editor interface: | `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.diagnostics` | Default style for inline diagnostics lines (notably control the background) | | `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) | diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index 58ddb0383a0a..07b5890947a7 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -1,4 +1,6 @@ //! LSP diagnostic utility types. +use std::rc::Rc; + use serde::{Deserialize, Serialize}; /// Describes the severity level of a [`Diagnostic`]. @@ -40,7 +42,8 @@ pub enum DiagnosticTag { pub struct Diagnostic { pub range: Range, pub line: usize, - pub message: String, + // Messages will also be copied in the inline diagnostics, let's avoid allocating twice + pub message: Rc, pub severity: Option, pub code: Option, pub tags: Vec, diff --git a/helix-core/src/position.rs b/helix-core/src/position.rs index 7b8dc326e82c..97a5cc5cc9f9 100644 --- a/helix-core/src/position.rs +++ b/helix-core/src/position.rs @@ -161,7 +161,7 @@ pub fn visual_offset_from_anchor( anchor_line = Some(last_pos.row); } if char_pos > pos { - last_pos.row -= anchor_line.unwrap(); + last_pos.row -= anchor_line?; return Some((last_pos, block_start)); } diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index e31df59f4e2d..7d1f6af04c87 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -110,7 +110,7 @@ pub mod util { severity, code, source: diag.source.clone(), - message: diag.message.to_owned(), + message: diag.message.as_str().into(), related_information: None, tags, data: diag.data.to_owned(), diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index c7e939959ca4..f314ade71705 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -3,11 +3,14 @@ use futures_util::Stream; use helix_core::{ diagnostic::{DiagnosticTag, NumberOrString}, path::get_relative_path, - pos_at_coords, syntax, Selection, + pos_at_coords, syntax, + text_annotations::LineAnnotation, + Selection, }; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; use helix_view::{ align_view, + document::annotations::{diagnostic_inline_messages_from_diagnostics, DiagnosticAnnotations}, document::DocumentSavedEventResult, editor::{ConfigEvent, EditorEvent}, graphics::Rect, @@ -30,8 +33,10 @@ use crate::{ use log::{debug, error, warn}; use std::{ + collections::BTreeMap, io::{stdin, stdout}, path::Path, + rc::Rc, sync::Arc, time::{Duration, Instant}, }; @@ -687,12 +692,17 @@ impl Application { return; } }; + + let enabled_inline_diagnostics = + self.editor.config().lsp.display_inline_diagnostics; let doc = self.editor.document_by_path_mut(&path); if let Some(doc) = doc { let lang_conf = doc.language_config(); let text = doc.text(); + let mut diagnostic_annotations = BTreeMap::new(); + let diagnostics = params .diagnostics .iter() @@ -776,10 +786,14 @@ impl Application { Vec::new() }; + if enabled_inline_diagnostics { + *diagnostic_annotations.entry(start).or_default() += diagnostic.message.trim().lines().count(); + } + Some(Diagnostic { range: Range { start, end }, line: diagnostic.range.start.line as usize, - message: diagnostic.message.clone(), + message: Rc::new(diagnostic.message.clone()), severity, code, tags, @@ -787,7 +801,24 @@ impl Application { data: diagnostic.data.clone(), }) }) - .collect(); + .collect::>(); + + if enabled_inline_diagnostics { + let diagnostic_annotations = diagnostic_annotations + .into_iter() + .map(|(anchor_char_idx, height)| LineAnnotation { + anchor_char_idx, + height, + }) + .collect::>(); + + doc.set_diagnostics_annotations(DiagnosticAnnotations { + annotations: diagnostic_annotations.into(), + messages: diagnostic_inline_messages_from_diagnostics( + &diagnostics, + ), + }) + } doc.set_diagnostics(diagnostics); } @@ -909,6 +940,7 @@ impl Application { == Some(server_id) { doc.set_diagnostics(Vec::new()); + doc.set_diagnostics_annotations(Default::default()); doc.url() } else { None diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 7c22df747642..c11db09c1003 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -11,6 +11,7 @@ use crate::{ }; use helix_core::{ + diagnostic::Severity, graphemes::{ ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary, }, @@ -35,6 +36,8 @@ use tui::buffer::Buffer as Surface; use super::statusline; use super::{document::LineDecoration, lsp::SignatureHelp}; +mod diagnostics_annotations; + pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option, @@ -126,6 +129,16 @@ impl EditorView { } } + if config.lsp.display_inline_diagnostics { + line_decorations.push(diagnostics_annotations::inline_diagnostics_decorator( + doc, + view, + inner, + theme, + &text_annotations, + )); + } + if is_focused && config.cursorline { line_decorations.push(Self::cursorline_decorator(doc, view, theme)) } @@ -223,7 +236,11 @@ impl EditorView { } } - Self::render_diagnostics(doc, view, inner, surface, theme); + // If inline diagnostics are already displayed, we don't need to add the diagnostics in the + // top right corner, they would be redundant + if !config.lsp.display_inline_diagnostics { + Self::render_diagnostics(doc, view, inner, surface, theme); + } let statusline_area = view .area @@ -346,7 +363,6 @@ impl EditorView { doc: &Document, theme: &Theme, ) -> [Vec<(usize, std::ops::Range)>; 5] { - use helix_core::diagnostic::Severity; let get_scope_of = |scope| { theme .find_scope_index_exact(scope) @@ -650,7 +666,6 @@ impl EditorView { surface: &mut Surface, theme: &Theme, ) { - use helix_core::diagnostic::Severity; use tui::{ layout::Alignment, text::Text, @@ -682,7 +697,7 @@ impl EditorView { Some(Severity::Info) => info, Some(Severity::Hint) => hint, }); - let text = Text::styled(&diagnostic.message, style); + let text = Text::styled(&*diagnostic.message, style); lines.extend(text.lines); } @@ -1392,7 +1407,6 @@ impl Component for EditorView { // render status msg if let Some((status_msg, severity)) = &cx.editor.status_msg { status_msg_width = status_msg.width(); - use helix_view::editor::Severity; let style = if *severity == Severity::Error { cx.editor.theme.get("error") } else { diff --git a/helix-term/src/ui/editor/diagnostics_annotations.rs b/helix-term/src/ui/editor/diagnostics_annotations.rs new file mode 100644 index 000000000000..8004594adb03 --- /dev/null +++ b/helix-term/src/ui/editor/diagnostics_annotations.rs @@ -0,0 +1,311 @@ +use std::borrow::Cow; +use std::rc::Rc; + +use helix_core::diagnostic::Severity; +use helix_core::text_annotations::TextAnnotations; +use helix_core::visual_offset_from_anchor; +use helix_core::SmallVec; +use helix_view::graphics::Rect; +use helix_view::theme::Style; +use helix_view::{Document, Theme, View}; + +use crate::ui::document::{LineDecoration, LinePos, TextRenderer}; + +pub fn inline_diagnostics_decorator( + doc: &Document, + view: &View, + viewport: Rect, + theme: &Theme, + text_annotations: &TextAnnotations, +) -> Box { + let whole_view_area = view.area; + let background = theme.get("ui.virtual.diagnostics"); + + // The maximum Y that diagnostics can be printed on. Necessary because we may want to print + // 5 lines of diagnostics while the view only has 3 left at the bottom and two more just out + // of bounds. + let max_y = viewport.height.saturating_sub(1).saturating_add(viewport.y); + + let hint = theme.get("hint"); + let info = theme.get("info"); + let warning = theme.get("warning"); + let error = theme.get("error"); + + let messages = doc.diagnostic_annotations_messages(); + + let text = doc.text().slice(..); + let text_fmt = doc.text_format(viewport.width, None); + + let mut visual_offsets = Vec::with_capacity(messages.len()); + for message in messages.iter() { + visual_offsets.push( + visual_offset_from_anchor( + text, + view.offset.anchor, + message.anchor_char_idx, + &text_fmt, + text_annotations, + viewport.height as usize, + ) + .map(|x| x.0), + ); + } + + // Compute the Style for a given severity + let sev_style = move |sev| match sev { + Some(Severity::Error) => error, + // The same is done when highlighting gutters so we do it here too to be consistent. + Some(Severity::Warning) | None => warning, + Some(Severity::Info) => info, + Some(Severity::Hint) => hint, + }; + + // Vectors used when computing the items to display. We declare them here so that they're not deallocated when the + // closure is done, only when it is dropped, that way calls are don't have to allocate as much. + let mut stack = Vec::new(); + let mut left = Vec::new(); + let mut center = SmallVec::<[_; 2]>::new(); + + let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + let mut first_message_idx = usize::MAX; + let mut found_first = false; + let mut last_message_idx = usize::MAX; + + for (idx, message) in messages.iter().enumerate() { + if message.line == pos.doc_line { + if !found_first { + first_message_idx = idx; + found_first = true; + } + last_message_idx = idx; + } + } + + // If we found no diagnostic for this position, do nothing. + if !found_first { + return; + } + + // Extract the relevant diagnostics and visual offsets. + let messages = match messages.get(first_message_idx..=last_message_idx) { + Some(m) => m, + None => return, + }; + let visual_offsets = match visual_offsets.get(first_message_idx..=last_message_idx) { + Some(v) => v, + None => return, + }; + + // Used to build a stack of diagnostics and items to use when computing `DisplayItem` + #[derive(Debug)] + enum StackItem { + // Insert `n` spaces + Space(u16), + // Two diagnostics are overlapping in their rendering, we'll need to insert a vertical bar + Overlap, + // Leave a blank space that needs a style (used when a diagnostic message is empty) + Blank(Style), + // A diagnostic and its style (computed from its severity) + Diagnostic(Rc, Style), + } + + // Additional items to display to point the messages to the diagnostic's position in the text + #[derive(Debug)] + enum DisplayItem { + Space(u16), + Static(&'static str, Style), + String(String, Style), + } + + stack.clear(); + stack.reserve( + stack + .capacity() + .saturating_sub(messages.len().saturating_mul(2)), + ); + let mut prev_col = None; + let mut line_count = 0_u16; + + // Attribution: the algorithm to compute the layout of the symbols and columns here has been + // originally written by Hugo Osvaldo Barrera, for https://git.sr.ht/~whynothugo/lsp_lines.nvim. + // At the time of this comment's writing, the commit used is ec98b45c8280e5ef8c84028d4f38aa447276c002. + // + // We diverge from the original code in that we don't iterate in reverse since we display at the end of the + // loop instead of later, which means we don't have the stack problem that `lsp_lines.nvim` has. + + // First we build the stack, inserting `StackItem`s as needed + for (message, visual_offset) in messages.iter().zip(visual_offsets.iter()) { + let visual_offset = match visual_offset { + Some(o) => *o, + None => continue, + }; + + let style = sev_style(message.severity); + + // First the item to offset the diagnostic's text + stack.push(match prev_col { + Some(prev_col) if prev_col != visual_offset.col => StackItem::Space( + visual_offset + .col + .abs_diff(prev_col) + // Account for the vertical bars that are inserted to point diagnostics to + // their position in the text + .saturating_sub(1) + .min(u16::MAX as _) as _, + ), + Some(_) => StackItem::Overlap, + None => StackItem::Space(visual_offset.col.min(u16::MAX as _) as _), + }); + + let trimmed = message.message.trim(); + + // Then the diagnostic's text + if trimmed.is_empty() { + stack.push(StackItem::Blank(style)); + } else { + stack.push(StackItem::Diagnostic(Rc::clone(&message.message), style)); + } + + prev_col = Some(visual_offset.col); + line_count = line_count.saturating_add(trimmed.lines().count().min(u16::MAX as _) as _); + } + + // When several diagnostics are present in the same virtual block, we will start by + // displaying the last one and go up one at a time + let mut code_pos_y = viewport + .y + .saturating_add(pos.visual_line) + .saturating_add(line_count); + + // Then we iterate the stack we just built to find diagnostics + for (idx, item) in stack.iter().enumerate() { + let (text, style) = match item { + StackItem::Diagnostic(text, style) => (text.trim(), *style), + _ => continue, + }; + + // Do the line count and check of pos_y now, it avoids having to build the display items + // for nothing + let lines_offset = text.lines().count() as u16; + code_pos_y -= lines_offset; + + // If the first line to be printed is out of bound, don't display anything more of the current diagnostic + if code_pos_y + 1 > max_y { + continue; + } + + left.clear(); + let mut overlap = false; + let mut multi = 0; + + // Iterate the stack for this line to find elements on the left. + let mut peekable = stack[..idx].iter().peekable(); + while let Some(item2) = peekable.next() { + match item2 { + &StackItem::Space(n) if multi == 0 => left.push(DisplayItem::Space(n)), + &StackItem::Space(n) => { + left.push(DisplayItem::String("─".repeat(n as usize), style)) + } + StackItem::Blank(_) => { + left.push(DisplayItem::Static( + if multi == 0 { "└" } else { "┴" }, + style, + )); + multi += 1; + } + StackItem::Diagnostic(_, style) => { + // If an overlap follows this, don't add an extra column. + if !(matches!(peekable.peek(), Some(StackItem::Overlap))) { + left.push(DisplayItem::Static("│", *style)); + } + overlap = false; + } + StackItem::Overlap => overlap = true, + } + } + + let center_symbol = if overlap && multi > 0 { + "┼─── " + } else if overlap { + "├─── " + } else if multi > 0 { + "┴─── " + } else { + "└─── " + }; + + center.clear(); + center.push(DisplayItem::Static(center_symbol, style)); + + // TODO: We can draw on the left side if and only if: + // a. Is the last one stacked this line. + // b. Has enough space on the left. + // c. Is just one line. + // d. Is not an overlap. + + // Use `view` since it's the whole outer view instead of just the inner area so that the background + // is also applied to the gutters and other elements that are not in the editable part of the document + let diag_area = Rect::new( + whole_view_area.x, + // We checked at the start of the loop that this is valid + code_pos_y + 1, + whole_view_area.width, + lines_offset, + ); + renderer.surface.set_style(diag_area, background); + + let area_right = diag_area.right(); + + for (offset, line) in text.lines().enumerate() { + let mut pos_x = viewport.x; + let diag_pos_y = code_pos_y + 1 + offset as u16; + // If we're out of bounds, don't display this diagnostic line, nor the following + // ones since they'll be out of bounds too. + if diag_pos_y > max_y { + break; + } + + for item in left.iter().chain(center.iter()) { + let (text, style): (Cow, _) = match *item { + // No need to allocate a string here when we simply want the default + // background filled with empty space + DisplayItem::Space(n) => { + pos_x = pos_x.saturating_add(n); + continue; + } + DisplayItem::Static(s, style) => (s.into(), style), + DisplayItem::String(ref s, style) => (s.into(), style), + }; + + let (new_x_pos, _) = renderer.surface.set_stringn( + pos_x, + diag_pos_y, + text, + area_right.saturating_sub(pos_x).into(), + style, + ); + pos_x = new_x_pos; + } + + renderer.surface.set_stringn( + pos_x, + diag_pos_y, + line.trim(), + area_right.saturating_sub(pos_x).into(), + style, + ); + + center.clear(); + // Special-case for continuation lines + if overlap { + center.push(DisplayItem::Static("│", style)); + center.push(DisplayItem::Space(4)); + } else { + center.push(DisplayItem::Space(5)); + } + } + } + }; + + Box::new(line_decoration) +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 19220f286a22..b475d217e395 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -7,7 +7,7 @@ use helix_core::auto_pairs::AutoPairs; use helix_core::doc_formatter::TextFormat; use helix_core::syntax::Highlight; use helix_core::text_annotations::{InlineAnnotation, TextAnnotations}; -use helix_core::Range; +use helix_core::{Assoc, Range}; use helix_vcs::{DiffHandle, DiffProviderRegistry}; use ::parking_lot::Mutex; @@ -37,6 +37,8 @@ use helix_core::{ use crate::editor::{Config, RedrawHandle}; use crate::{DocumentId, Editor, Theme, View, ViewId}; +pub mod annotations; + /// 8kB of buffer space for encoding and decoding `Rope`s. const BUF_SIZE: usize = 8192; @@ -165,6 +167,7 @@ pub struct Document { pub(crate) modified_since_accessed: bool, diagnostics: Vec, + diagnostic_annotations: annotations::DiagnosticAnnotations, language_server: Option>, diff_handle: Option, @@ -260,6 +263,7 @@ impl fmt::Debug for Document { .field("version", &self.version) .field("modified_since_accessed", &self.modified_since_accessed) .field("diagnostics", &self.diagnostics) + // .field("diagnostics_annotations", &self.diagnostics_annotations) // .field("language_server", &self.language_server) .finish() } @@ -486,6 +490,7 @@ impl Document { changes, old_state, diagnostics: Vec::new(), + diagnostic_annotations: Default::default(), version: 0, history: Cell::new(History::default()), savepoints: Vec::new(), @@ -916,18 +921,18 @@ impl Document { /// 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); + let changes = transaction.changes(); + + let success = changes.apply(&mut self.text); if success { for selection in self.selections.values_mut() { *selection = selection .clone() // Map through changes - .map(transaction.changes()) + .map(changes) // Ensure all selections across all views still adhere to invariants. .ensure_invariants(self.text.slice(..)); } @@ -943,7 +948,7 @@ impl Document { self.modified_since_accessed = true; } - if !transaction.changes().is_empty() { + if !changes.is_empty() { self.version += 1; // start computing the diff in parallel if let Some(diff_handle) = &self.diff_handle { @@ -968,13 +973,9 @@ impl Document { // update tree-sitter syntax tree if let Some(syntax) = &mut self.syntax { // TODO: no unwrap - syntax - .update(&old_doc, &self.text, transaction.changes()) - .unwrap(); + syntax.update(&old_doc, &self.text, changes).unwrap(); } - let changes = transaction.changes(); - // map state.diagnostics over changes::map_pos too for diagnostic in &mut self.diagnostics { diagnostic.range.start = changes.map_pos(diagnostic.range.start, Assoc::After); @@ -1011,6 +1012,8 @@ impl Document { apply_inlay_hint_changes(padding_after_inlay_hints); } + annotations::apply_changes_to_diagnostic_annotations(self, changes); + // emit lsp notification if let Some(language_server) = self.language_server() { let notify = language_server.text_document_did_change( @@ -1391,6 +1394,21 @@ impl Document { .sort_unstable_by_key(|diagnostic| diagnostic.range); } + #[inline] + pub fn diagnostic_annotations_messages( + &self, + ) -> Rc<[annotations::DiagnosticAnnotationMessage]> { + Rc::clone(&self.diagnostic_annotations.messages) + } + + #[inline] + pub fn set_diagnostics_annotations( + &mut self, + diagnostic_annotations: annotations::DiagnosticAnnotations, + ) { + self.diagnostic_annotations = diagnostic_annotations; + } + /// Get the document's auto pairs. If the document has a recognized /// language config with auto pairs configured, returns that; /// otherwise, falls back to the global auto pairs config. If the global @@ -1478,7 +1496,18 @@ 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() + let mut text_annotations = TextAnnotations::default(); + + if !self.diagnostic_annotations.annotations.is_empty() { + text_annotations + .add_line_annotation(Rc::clone(&self.diagnostic_annotations.annotations)); + } + + text_annotations + } + + pub fn reset_diagnostics_annotations(&mut self) { + self.diagnostic_annotations = Default::default(); } /// Set the inlay hints for this document and `view_id`. diff --git a/helix-view/src/document/annotations.rs b/helix-view/src/document/annotations.rs new file mode 100644 index 000000000000..12e3f95a3bef --- /dev/null +++ b/helix-view/src/document/annotations.rs @@ -0,0 +1,190 @@ +//! This module contains the various annotations that can be added to a [`super::Document`] when +//! displaying it. +//! +//! Examples: inline diagnostics, inlay hints, git blames. + +use std::rc::Rc; + +use helix_core::diagnostic::Severity; +use helix_core::text_annotations::LineAnnotation; +use helix_core::Assoc; +use helix_core::ChangeSet; + +/// Diagnostics annotations are [`LineAnnotation`]s embed below the first line of the diagnostic +/// they're about. +/// +/// Below is an example in plain text of the expect result: +/// +/// ```text +/// use std::alloc::{alloc, Layout}; +/// │ └─── unused import: `Layout` +/// │ `#[warn(unused_imports)]` on by default +/// └─── remove the unused import +/// +/// fn main() { +/// match std::cmp::Ordering::Less { +/// └─── any code following this `match` expression is unreachable, as all arms diverge +/// std::cmp::Ordering::Less => todo!(), +/// std::cmp::Ordering:Equal => todo!(), +/// │ └─── Syntax Error: expected `,` +/// ├─── maybe write a path separator here: `::` +/// ├─── expected one of `!`, `(`, `...`, `..=`, `..`, `::`, `{`, or `|`, found `:` +/// │ expected one of 8 possible tokens +/// ├─── Syntax Error: expected expression +/// └─── Syntax Error: expected FAT_ARROW +/// std::cmp::Ordering::Greater => todo!(), +/// } +/// +/// let layout: Layout = Layou::new::(); +/// │ ├─── a struct with a similar name exists: `Layout` +/// │ └─── failed to resolve: use of undeclared type `Layou` +/// │ use of undeclared type `Layou` +/// └─── unreachable statement +/// `#[warn(unreachable_code)]` on by default +/// } +/// ``` +pub struct DiagnosticAnnotations { + /// The `LineAnnotation` don't contain any text, they're simply used to reserve the space for display. + pub annotations: Rc<[LineAnnotation]>, + + /// The messages are the text linked to the `annotations`. + /// + /// To make the work of the renderer less costly, this must maintain a sort order following + /// [`DiagnosticAnnotationMessage.anchor_char_idx`]. + /// + /// The function [`diagnostic_inline_messages_from_diagnostics()`] can be used to do this. + pub messages: Rc<[DiagnosticAnnotationMessage]>, +} + +/// A `DiagnosticAnnotationMessage` is a single diagnostic to be displayed inline. +#[derive(Debug)] +pub struct DiagnosticAnnotationMessage { + /// `line` is used to quickly gather all the diagnostics for a line. + pub line: usize, + /// The anchor is where the diagnostic is positioned in the document. This is used to compute + /// the exact column for rendering after taking virtual text into account. + pub anchor_char_idx: usize, + /// The message to display. It can contain line breaks so be careful when displaying them. + pub message: Rc, + /// The diagnostic's severity, to get the relevant style at rendering time. + pub severity: Option, +} + +impl Default for DiagnosticAnnotations { + fn default() -> Self { + Self { + annotations: Vec::new().into(), + messages: Vec::new().into(), + } + } +} + +/// Compute the list of `DiagnosticAnnotationMessage`s from the diagnostics. +pub fn diagnostic_inline_messages_from_diagnostics( + diagnostics: &[helix_core::Diagnostic], +) -> Rc<[DiagnosticAnnotationMessage]> { + let mut res = Vec::with_capacity(diagnostics.len()); + + for diag in diagnostics { + res.push(DiagnosticAnnotationMessage { + line: diag.line, + anchor_char_idx: diag.range.start, + message: Rc::clone(&diag.message), + severity: diag.severity, + }); + } + + res.sort_unstable_by_key(|a| a.anchor_char_idx); + + res.into() +} + +/// Used in [`super::Document::apply_impl()`] to recompute the inline diagnostics after changes have +/// been made to the document. +/// +/// **Must be called with sorted diagnostics.** +pub(super) fn apply_changes_to_diagnostic_annotations( + doc: &mut super::Document, + changes: &ChangeSet, +) { + // Only recompute if they're not empty since being empty probably means the annotation are + // disabled, no need to build them in this case (building them would not display them since the + // line annotations list is empty too in this case). + + match Rc::get_mut(&mut doc.diagnostic_annotations.messages) { + // If for some reason we can't update the annotations, just delete them: the document is being saved and they + // will be updated soon anyway. + None | Some([]) => { + doc.diagnostic_annotations = Default::default(); + return; + } + Some(messages) => { + // The diagnostics have been sorted after being updated in `Document::apply_impl()` but nothing got deleted + // so simply use the same order for the annotation messages. + for (diag, message) in doc.diagnostics.iter().zip(messages.iter_mut()) { + let DiagnosticAnnotationMessage { + line, + anchor_char_idx, + message, + severity, + } = message; + + *line = diag.line; + *anchor_char_idx = diag.range.start; + *message = Rc::clone(&diag.message); + *severity = diag.severity; + } + } + } + + match Rc::get_mut(&mut doc.diagnostic_annotations.annotations) { + // See `None` case above + None | Some([]) => doc.diagnostic_annotations = Default::default(), + Some(line_annotations) => { + let map_pos = + |annot: &LineAnnotation| changes.map_pos(annot.anchor_char_idx, Assoc::After); + + // The algorithm here does its best to modify in place to avoid reallocations as much as possible + // + // 1) We know the line annotations are non-empty because we checked for it in the match above. + // 2) We update the first line annotation. + // 3) For each subsequent annotation + // 1) We compute its new anchor + // 2) Logically, it cannot move further back than the previous one else the previous one would + // also have moved back more + // 3) IF the new anchor is equal to the new anchor of the previous annotation, add the current one's + // height to the previous + // 4) ELSE update the write position and write the current annotation (with updated anchor) there + // 4) If the last write position was not the last member of the current lines annotations, it means we + // merged some of them together so we update the saved line annotations. + + let new_anchor_char_idx = map_pos(&line_annotations[0]); + line_annotations[0].anchor_char_idx = new_anchor_char_idx; + + let mut previous_anchor_char_idx = new_anchor_char_idx; + + let mut writing_index = 0; + + for reading_index in 1..line_annotations.len() { + let annot = &mut line_annotations[reading_index]; + let new_anchor_char_idx = map_pos(annot); + + if new_anchor_char_idx == previous_anchor_char_idx { + line_annotations[writing_index].height += annot.height; + } else { + previous_anchor_char_idx = new_anchor_char_idx; + + writing_index += 1; + line_annotations[writing_index].height = annot.height; + line_annotations[writing_index].anchor_char_idx = new_anchor_char_idx; + } + } + + // If we updated less annotations than there was previously, keep only those. + if writing_index < line_annotations.len() - 1 { + doc.diagnostic_annotations.annotations = + line_annotations[..=writing_index].to_vec().into(); + } + } + } +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index bbed58d6e7e6..c735bce86f0f 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -347,6 +347,9 @@ pub struct LspConfig { pub display_signature_help_docs: bool, /// Display inlay hints pub display_inlay_hints: bool, + /// Display diagnostic on the same line they occur automatically. + /// Also called "error lens"-style diagnostics, in reference to the popular VSCode extension. + pub display_inline_diagnostics: bool, } impl Default for LspConfig { @@ -357,6 +360,7 @@ impl Default for LspConfig { auto_signature_help: true, display_signature_help_docs: true, display_inlay_hints: false, + display_inline_diagnostics: true, } } } @@ -1149,6 +1153,12 @@ impl Editor { } } + if !config.lsp.display_inline_diagnostics { + for doc in self.documents_mut() { + doc.reset_diagnostics_annotations(); + } + } + for (view, _) in self.tree.views_mut() { let doc = doc_mut!(self, &view.doc); view.sync_changes(doc);