From d37244fb9328f5151efc7eab195ab1e6ee92d5b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ch=C5=82odnicki?= Date: Wed, 21 Jun 2023 13:02:13 +0200 Subject: [PATCH] Add option to show diagnostics as annotations (#1702) --- LSP.sublime-settings | 10 +++++++++ annotations.css | 16 +++++++++++++++ plugin/core/css.py | 2 ++ plugin/core/sessions.py | 2 +- plugin/core/types.py | 8 +++++--- plugin/core/views.py | 19 ++++++++++++++++- plugin/diagnostics.py | 43 +++++++++++++++++++++++++++++++++++++++ plugin/documents.py | 6 +++--- plugin/goto_diagnostic.py | 2 +- plugin/session_view.py | 7 +++++++ sublime-package.json | 18 ++++++++++++++++ 11 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 annotations.css create mode 100644 plugin/diagnostics.py diff --git a/LSP.sublime-settings b/LSP.sublime-settings index ac7b08aac..45b2c8280 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -59,6 +59,16 @@ // hint: 4 "show_diagnostics_severity_level": 4, + // Show diagnostics as annotations with level equal to or less than: + // none: 0 (never show) + // error: 1 + // warning: 2 + // info: 3 + // hint: 4 + // When enabled, it's recommended not to use the "annotation" value for the + // `show_code_actions` option as it's impossible to enforce which one gets shown first. + "show_diagnostics_annotations_severity_level": 0, + // Open the diagnostics panel automatically on save when diagnostics level is // equal to or less than: // none: 0 (never open the panel automatically) diff --git a/annotations.css b/annotations.css new file mode 100644 index 000000000..e20ed8866 --- /dev/null +++ b/annotations.css @@ -0,0 +1,16 @@ +.lsp_annotation { + margin: 0; + border-width: 0; +} +.lsp_annotation .errors { + color: color(var(--redish) alpha(0.85)); +} +.lsp_annotation .warnings { + color: color(var(--yellowish) alpha(0.85)); +} +.lsp_annotation .info { + color: color(var(--bluish) alpha(0.85)); +} +.lsp_annotation .hints { + color: color(var(--bluish) alpha(0.85)); +} diff --git a/plugin/core/css.py b/plugin/core/css.py index 9a0c87214..737f05d9a 100644 --- a/plugin/core/css.py +++ b/plugin/core/css.py @@ -11,6 +11,8 @@ def __init__(self) -> None: self.sheets = sublime.load_resource("Packages/LSP/sheets.css") self.sheets_classname = "lsp_sheet" self.inlay_hints = sublime.load_resource("Packages/LSP/inlay_hints.css") + self.annotations = sublime.load_resource("Packages/LSP/annotations.css") + self.annotations_classname = "lsp_annotation" _css = None # type: Optional[CSS] diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index e2a1fed8d..fcf2b3150 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -822,7 +822,7 @@ def additional_variables(cls) -> Optional[Dict[str, str]]: def storage_path(cls) -> str: """ The storage path. Use this as your base directory to install server files. Its path is '$DATA/Package Storage'. - You should have an additional subdirectory preferrably the same name as your plugin. For instance: + You should have an additional subdirectory preferably the same name as your plugin. For instance: ```python from LSP.plugin import AbstractPlugin diff --git a/plugin/core/types.py b/plugin/core/types.py index 03299b394..b6ce5ea0b 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -110,7 +110,7 @@ def debounced(f: Callable[[], Any], timeout_ms: int = 0, condition: Callable[[], :param f: The function to possibly run. Its return type is discarded. :param timeout_ms: The time in milliseconds after which to possibly to run the function - :param condition: The condition that must evaluate to True in order to run the funtion + :param condition: The condition that must evaluate to True in order to run the function :param async_thread: If true, run the function on the async worker thread, otherwise run the function on the main thread """ @@ -142,7 +142,7 @@ class DebouncerNonThreadSafe: the callback function will only be called once, after `timeout_ms` since the last call. This implementation is not thread safe. You must ensure that `debounce()` is called from the same thread as - was choosen during initialization through the `async_thread` argument. + was chosen during initialization through the `async_thread` argument. """ def __init__(self, async_thread: bool) -> None: @@ -158,7 +158,7 @@ def debounce( :param f: The function to possibly run :param timeout_ms: The time in milliseconds after which to possibly to run the function - :param condition: The condition that must evaluate to True in order to run the funtion + :param condition: The condition that must evaluate to True in order to run the function """ def run(debounce_id: int) -> None: @@ -214,6 +214,7 @@ class Settings: show_code_lens = cast(str, None) show_inlay_hints = cast(bool, None) show_code_actions_in_hover = cast(bool, None) + show_diagnostics_annotations_severity_level = cast(int, None) show_diagnostics_count_in_view_status = cast(bool, None) show_multiline_diagnostics_highlights = cast(bool, None) show_multiline_document_highlights = cast(bool, None) @@ -254,6 +255,7 @@ def r(name: str, default: Union[bool, int, str, list, dict]) -> None: r("show_code_lens", "annotation") r("show_inlay_hints", False) r("show_code_actions_in_hover", True) + r("show_diagnostics_annotations_severity_level", 0) r("show_diagnostics_count_in_view_status", False) r("show_diagnostics_in_view_status", True) r("show_multiline_diagnostics_highlights", True) diff --git a/plugin/core/views.py b/plugin/core/views.py index c7a25dcbf..0aa449069 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -872,6 +872,23 @@ def diagnostic_severity(diagnostic: Diagnostic) -> DiagnosticSeverity: return diagnostic.get("severity", DiagnosticSeverity.Error) +def format_diagnostics_for_annotation( + diagnostics: List[Diagnostic], severity: DiagnosticSeverity, view: sublime.View +) -> Tuple[List[str], str]: + css_class = DIAGNOSTIC_SEVERITY[severity - 1][1] + scope = DIAGNOSTIC_SEVERITY[severity - 1][2] + color = view.style_for_scope(scope).get('foreground') or 'red' + annotations = [] + for diagnostic in diagnostics: + message = text2html(diagnostic.get('message') or '') + source = diagnostic.get('source') + line = "[{}] {}".format(text2html(source), message) if source else message + content = '
{3}
'.format( + lsp_css().annotations, lsp_css().annotations_classname, css_class, line) + annotations.append(content) + return (annotations, color) + + def format_diagnostic_for_panel(diagnostic: Diagnostic) -> Tuple[str, Optional[int], Optional[str], Optional[str]]: """ Turn an LSP diagnostic into a string suitable for an output panel. @@ -879,7 +896,7 @@ def format_diagnostic_for_panel(diagnostic: Diagnostic) -> Tuple[str, Optional[i :param diagnostic: The diagnostic :returns: Tuple of (content, optional offset, optional code, optional href) When the last three elements are optional, don't show an inline phantom - When the last three elemenst are not optional, show an inline phantom + When the last three elements are not optional, show an inline phantom using the information given. """ formatted, code, href = diagnostic_source_and_code(diagnostic) diff --git a/plugin/diagnostics.py b/plugin/diagnostics.py new file mode 100644 index 000000000..6c917ff40 --- /dev/null +++ b/plugin/diagnostics.py @@ -0,0 +1,43 @@ +from .core.protocol import Diagnostic +from .core.protocol import DiagnosticSeverity +from .core.settings import userprefs +from .core.typing import List, Tuple +from .core.views import DIAGNOSTIC_KINDS +from .core.views import diagnostic_severity +from .core.views import format_diagnostics_for_annotation +import sublime + + +class DiagnosticsAnnotationsView(): + + def __init__(self, view: sublime.View, config_name: str) -> None: + self._view = view + self._config_name = config_name + + def initialize_region_keys(self) -> None: + r = [sublime.Region(0, 0)] + for severity in DIAGNOSTIC_KINDS.keys(): + self._view.add_regions(self._annotation_region_key(severity), r) + + def _annotation_region_key(self, severity: DiagnosticSeverity) -> str: + return 'lsp_da-{}-{}'.format(severity, self._config_name) + + def draw(self, diagnostics: List[Tuple[Diagnostic, sublime.Region]]) -> None: + flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE + max_severity_level = userprefs().show_diagnostics_annotations_severity_level + # To achieve the correct order of annotations (most severe having priority) we have to add regions from the + # most to the least severe. + for severity in DIAGNOSTIC_KINDS.keys(): + if severity <= max_severity_level: + matching_diagnostics = ([], []) # type: Tuple[List[Diagnostic], List[sublime.Region]] + for diagnostic, region in diagnostics: + if diagnostic_severity(diagnostic) != severity: + continue + matching_diagnostics[0].append(diagnostic) + matching_diagnostics[1].append(region) + annotations, color = format_diagnostics_for_annotation(matching_diagnostics[0], severity, self._view) + self._view.add_regions( + self._annotation_region_key(severity), matching_diagnostics[1], flags=flags, + annotations=annotations, annotation_color=color) + else: + self._view.erase_regions(self._annotation_region_key(severity)) diff --git a/plugin/documents.py b/plugin/documents.py index b63fda6fd..e13130bc2 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -249,7 +249,7 @@ def diagnostics_intersecting_region_async( for diagnostic, candidate in diagnostics: # Checking against points is inclusive unlike checking whether region intersects another region # which is exclusive (at region end) and we want an inclusive behavior in this case. - if region.contains(candidate.a) or region.contains(candidate.b): + if region.intersects(candidate) or region.contains(candidate.a) or region.contains(candidate.b): covering = covering.cover(candidate) intersections.append(diagnostic) if intersections: @@ -354,7 +354,7 @@ def on_activated_async(self) -> None: sb.do_inlay_hints_async(self.view) def on_selection_modified_async(self) -> None: - first_region, any_different = self._update_stored_selection_async() + first_region, _ = self._update_stored_selection_async() if first_region is None: return if not self._is_in_higlighted_region(first_region.b): @@ -862,7 +862,7 @@ def _register_async(self) -> None: def _on_view_updated_async(self) -> None: self._code_lenses_debouncer_async.debounce( self._do_code_lenses_async, timeout_ms=self.code_lenses_debounce_time) - first_region, any_different = self._update_stored_selection_async() + first_region, _ = self._update_stored_selection_async() if first_region is None: return self._clear_highlight_regions() diff --git a/plugin/goto_diagnostic.py b/plugin/goto_diagnostic.py index 60c4849be..f9fec0ce2 100644 --- a/plugin/goto_diagnostic.py +++ b/plugin/goto_diagnostic.py @@ -234,7 +234,7 @@ def name(self) -> str: return "diagnostic" def list_items(self) -> List[sublime.ListInputItem]: - list_items = [] + list_items = [] # type: List[sublime.ListInputItem] max_severity = userprefs().diagnostics_panel_include_severity_level for i, session in enumerate(self.sessions): for diagnostic in filter(is_severity_included(max_severity), diff --git a/plugin/session_view.py b/plugin/session_view.py index 46d57aa21..6a205b3aa 100644 --- a/plugin/session_view.py +++ b/plugin/session_view.py @@ -14,6 +14,7 @@ from .core.views import DIAGNOSTIC_SEVERITY from .core.views import DiagnosticSeverityData from .core.views import text_document_identifier +from .diagnostics import DiagnosticsAnnotationsView from .session_buffer import SessionBuffer from weakref import ref from weakref import WeakValueDictionary @@ -51,6 +52,7 @@ class SessionView: def __init__(self, listener: AbstractViewListener, session: Session, uri: DocumentUri) -> None: self._view = listener.view self._session = session + self._diagnostic_annotations = DiagnosticsAnnotationsView(self._view, session.config.name) self._initialize_region_keys() self._active_requests = {} # type: Dict[int, ActiveRequest] self._listener = ref(listener) @@ -96,6 +98,9 @@ def on_before_remove(self) -> None: self.view.erase_regions("{}_underline".format(self.diagnostics_key(severity, True))) self.view.erase_regions("lsp_document_link") self.session_buffer.remove_session_view(self) + listener = self.listener() + if listener: + listener.on_diagnostics_updated_async(False) @property def session(self) -> Session: @@ -156,6 +161,7 @@ def _initialize_region_keys(self) -> None: self.view.add_regions("lsp_highlight_{}{}".format(kind, mode), r) if hover_highlight_style in ("underline", "stippled"): self.view.add_regions(HOVER_HIGHLIGHT_KEY, r) + self._diagnostic_annotations.initialize_region_keys() def _clear_auto_complete_triggers(self, settings: sublime.Settings) -> None: '''Remove all of our modifications to the view's "auto_complete_triggers"''' @@ -293,6 +299,7 @@ def present_diagnostics_async( data_per_severity, sev, level, flags[sev - 1] or DIAGNOSTIC_SEVERITY[sev - 1][4], multiline=False) self._draw_diagnostics( data_per_severity, sev, level, multiline_flags or DIAGNOSTIC_SEVERITY[sev - 1][5], multiline=True) + self._diagnostic_annotations.draw(self.session_buffer.diagnostics) listener = self.listener() if listener: listener.on_diagnostics_updated_async(is_view_visible) diff --git a/sublime-package.json b/sublime-package.json index cb4be4cbc..00cd93edb 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -462,6 +462,24 @@ "maximum": 4, "markdownDescription": "Show highlights and gutter markers in the file views for diagnostics with level equal to or less than:\n\n- _none_: `0`,\n- _error_: `1`,\n- _warning_: `2`,\n- _info_: `3`,\n- _hint_: `4`" }, + "show_diagnostics_annotations_severity_level": { + "default": 0, + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "markdownDescription": "Show diagnostics as annotations with level equal to or less than given value.\n\nWhen enabled, it's recommended not to use the `\"annotation\"` value for the `show_code_actions` option as it's impossible to enforce which one gets shown first.", + "markdownEnumDescriptions": [ + "never show", + "error", + "warning", + "info", + "hint" + ] + }, "diagnostics_panel_include_severity_level": { "type": "integer", "minimum": 1,