Repository: sublimelsp/LSP

Issue:
How to jump to next/previous diagnostic?
I saw #350 but that didn't really answer my question.

I see the `F4` and `shift+F4` bindings are disabled by default:
https://github.com/tomv564/LSP/blob/81631161d920206f64291d62ff70fdfd2ffe27ba/Keymaps/Default%20(OSX).sublime-keymap#L19-L20

But if I enable them, they work, but only until I trigger search-in-files (which I do often). Then the results from search-in-files take over the shortcuts and I can no longer use them just within given file with diagnostic shown

If diagnostics panel is open and focused, I can use F4 and Shift+F4 to jump through diagnostics but I'd rather not have to open the panel as it's less convenient.

So I think proper solution would be to add extra View commands that I could trigger manually from my key bindings.


Discussion:
Sure, we could follow the keys used by sublimelinter or see what VS Code uses?
Perhaps you want a window command instead, so you can cycle through diagnostics from different files?
Yes, I'd like to use the same keybindings as SublimeLinter - https://github.com/SublimeLinter/SublimeLinter/blob/8a96c0bf0a73c36b1fd2edc5253508c89788ca78/keymaps/Default%20(OSX).sublime-keymap#L14

> Perhaps you want a window command instead, so you can cycle through diagnostics from different files?

I wouldn't want that to be the only option as I'm only interested in diagnostics in the current file. It would be confusing to jump to some other random file on pressing the shortcut.

Resolving patch:
diff --git a/plugin/core/diagnostics.py b/plugin/core/diagnostics.py
--- a/plugin/core/diagnostics.py
+++ b/plugin/core/diagnostics.py
@@ -1,6 +1,6 @@
 from .logging import debug
 from .url import uri_to_filename
-from .protocol import Diagnostic
+from .protocol import Diagnostic, DiagnosticSeverity, Point
 assert Diagnostic
 
 try:
@@ -14,15 +14,21 @@
     Protocol = object  # type: ignore
 
 
-class DiagnosticsUpdateable(Protocol):
+class DiagnosticsUI(Protocol):
 
     def update(self, file_name: str, config_name: str, diagnostics: 'Dict[str, Dict[str, List[Diagnostic]]]') -> None:
         ...
 
+    def select(self, index: int) -> None:
+        ...
+
+    def deselect(self) -> None:
+        ...
+
 
 class DiagnosticsStorage(object):
 
-    def __init__(self, updateable: 'Optional[DiagnosticsUpdateable]') -> None:
+    def __init__(self, updateable: 'Optional[DiagnosticsUI]') -> None:
         self._diagnostics = {}  # type: Dict[str, Dict[str, List[Diagnostic]]]
         self._updatable = updateable
 
@@ -32,7 +38,7 @@ def get(self) -> 'Dict[str, Dict[str, List[Diagnostic]]]':
     def get_by_file(self, file_path: str) -> 'Dict[str, List[Diagnostic]]':
         return self._diagnostics.get(file_path, {})
 
-    def update(self, file_path: str, client_name: str, diagnostics: 'List[Diagnostic]') -> bool:
+    def _update(self, file_path: str, client_name: str, diagnostics: 'List[Diagnostic]') -> bool:
         updated = False
         if diagnostics:
             file_diagnostics = self._diagnostics.setdefault(file_path, dict())
@@ -50,8 +56,8 @@ def update(self, file_path: str, client_name: str, diagnostics: 'List[Diagnostic
     def clear(self) -> None:
         for file_path in list(self._diagnostics):
             for client_name in list(self._diagnostics[file_path]):
-                if self.update(file_path, client_name, []):
-                    self.notify(file_path, client_name)
+                if self._update(file_path, client_name, []):
+                    self._notify(file_path, client_name)
 
     def receive(self, client_name: str, update: dict) -> None:
         maybe_file_uri = update.get('uri')
@@ -61,14 +67,260 @@ def receive(self, client_name: str, update: dict) -> None:
             diagnostics = list(
                 Diagnostic.from_lsp(item) for item in update.get('diagnostics', []))
 
-            if self.update(file_path, client_name, diagnostics):
-                self.notify(file_path, client_name)
+            if self._update(file_path, client_name, diagnostics):
+                self._notify(file_path, client_name)
         else:
             debug('missing uri in diagnostics update')
 
-    def notify(self, file_path: str, client_name: str) -> None:
+    def _notify(self, file_path: str, client_name: str) -> None:
         if self._updatable:
             self._updatable.update(file_path, client_name, self._diagnostics)
 
     def remove(self, file_path: str, client_name: str) -> None:
-        self.update(file_path, client_name, [])
+        self._update(file_path, client_name, [])
+
+    def select_next(self) -> None:
+        if self._updatable:
+            self._updatable.select(1)
+
+    def select_previous(self) -> None:
+        if self._updatable:
+            self._updatable.select(-1)
+
+    def select_none(self) -> None:
+        if self._updatable:
+            self._updatable.deselect()
+
+
+class DocumentsState(Protocol):
+
+    def changed(self) -> None:
+        ...
+
+    def saved(self) -> None:
+        ...
+
+
+class DiagnosticsUpdateWalk(object):
+
+    def begin(self) -> None:
+        pass
+
+    def begin_file(self, file_path: str) -> None:
+        pass
+
+    def diagnostic(self, diagnostic: 'Diagnostic') -> None:
+        pass
+
+    def end_file(self, file_path: str) -> None:
+        pass
+
+    def end(self) -> None:
+        pass
+
+
+CURSOR_FORWARD = 1
+CURSOR_BACKWARD = -1
+
+
+class DiagnosticCursorWalk(DiagnosticsUpdateWalk):
+
+    def __init__(self, cursor: 'DiagnosticsCursor', direction: int=0) -> None:
+        self._cursor = cursor
+        self._direction = direction
+        self._current_file_path = ""
+        self._candidate = None  # type: 'Optional[Tuple[str, Diagnostic]]'
+
+    def begin_file(self, file_path: str) -> None:
+        self._current_file_path = file_path
+
+    def _take_candidate(self, diagnostic: 'Diagnostic') -> None:
+        self._candidate = self._current_file_path, diagnostic
+
+    def end(self) -> None:
+        self._cursor.set_value(self._candidate)
+
+
+class FromPositionWalk(DiagnosticCursorWalk):
+    def __init__(self, cursor: 'DiagnosticsCursor', file_path: str, point: Point, direction: int) -> None:
+        super().__init__(cursor, direction)
+        self._first = None  # type: Optional[Tuple[str, Diagnostic]]
+        self._previous = None  # type: Optional[Tuple[str, Diagnostic]]
+
+        # position-specific
+        self._file_path = file_path
+        self._point = point
+        self._first_after_file = None  # type: Optional[Tuple[str, Diagnostic]]
+        self._last_before_file = None  # type: Optional[Tuple[str, Diagnostic]]
+
+    def diagnostic(self, diagnostic: 'Diagnostic') -> None:
+        if diagnostic.severity <= DiagnosticSeverity.Warning:
+            if not self._first:
+                self._first = self._current_file_path, diagnostic
+
+            if self._current_file_path == self._file_path:
+                if self._direction == CURSOR_FORWARD:
+                    self._take_if_nearer_forward(diagnostic)
+                else:
+                    self._take_if_nearer_backward(diagnostic)
+                    self._set_last_before_file(diagnostic)
+            else:
+                if self._direction == CURSOR_FORWARD:
+                    self._set_first_after_file(diagnostic)
+
+            self._previous = self._current_file_path, diagnostic
+
+    def _take_if_nearer_forward(self, diagnostic: Diagnostic) -> None:
+        if diagnostic.range.start.row > self._point.row:
+            if not self._candidate:
+                self._take_candidate(diagnostic)
+            else:
+                candidate_start_row = self._candidate[1].range.start.row
+                if diagnostic.range.start.row < candidate_start_row:
+                    self._take_candidate(diagnostic)
+
+    def _take_if_nearer_backward(self, diagnostic: Diagnostic) -> None:
+        if diagnostic.range.start.row < self._point.row:
+            if not self._candidate:
+                self._take_candidate(diagnostic)
+            else:
+                candidate_start_row = self._candidate[1].range.start.row
+                if diagnostic.range.start.row > candidate_start_row:
+                    self._take_candidate(diagnostic)
+
+    def _set_last_before_file(self, diagnostic: Diagnostic) -> None:
+        if not self._last_before_file and self._previous:
+            if self._previous[0] != self._current_file_path:
+                self._last_before_file = self._previous
+
+    def _set_first_after_file(self, diagnostic: Diagnostic) -> None:
+        if not self._first_after_file:
+            if self._previous and self._previous[0] == self._file_path:
+                self._first_after_file = self._current_file_path, diagnostic
+
+    def end(self) -> None:
+        if self._candidate:
+            self._cursor.set_value(self._candidate)
+        else:
+            if self._direction == CURSOR_FORWARD:
+                self._cursor.set_value(self._first_after_file or self._first)
+            else:
+                self._cursor.set_value(self._last_before_file or self._previous)
+
+
+class FromDiagnosticWalk(DiagnosticCursorWalk):
+    def __init__(self, cursor: 'DiagnosticsCursor', direction: int) -> None:
+        super().__init__(cursor, direction)
+
+        self._first = None  # type: Optional[Tuple[str, Diagnostic]]
+        self._previous = None  # type: Optional[Tuple[str, Diagnostic]]
+
+        assert cursor.value
+        self._file_path, self._diagnostic = cursor.value
+        self._take_next = False
+
+    def diagnostic(self, diagnostic: 'Diagnostic') -> None:
+        if diagnostic.severity <= DiagnosticSeverity.Warning:
+
+            if self._direction == CURSOR_FORWARD:
+                if not self._first:
+                    self._first = self._current_file_path, diagnostic
+
+                if self._take_next:
+                    self._take_candidate(diagnostic)
+                    self._take_next = False
+                elif diagnostic == self._diagnostic and self._current_file_path == self._file_path:
+                    self._take_next = True
+            else:
+                if self._current_file_path == self._file_path and self._diagnostic == diagnostic:
+                    if self._previous:
+                        self._candidate = self._previous
+                self._previous = self._current_file_path, diagnostic
+
+    def end(self) -> None:
+        if self._candidate:
+            self._cursor.set_value(self._candidate)
+        else:
+            if self._direction == CURSOR_FORWARD:
+                self._cursor.set_value(self._first)
+            else:
+                self._cursor.set_value(self._previous)
+
+
+class TakeFirstDiagnosticWalk(DiagnosticCursorWalk):
+
+    def diagnostic(self, diagnostic: Diagnostic) -> None:
+        if diagnostic.severity <= DiagnosticSeverity.Warning:
+            if self._direction == CURSOR_FORWARD:
+                if self._candidate is None:
+                    self._take_candidate(diagnostic)
+            else:
+                self._take_candidate(diagnostic)
+
+
+class DiagnosticsUpdatedWalk(DiagnosticCursorWalk):
+
+    def diagnostic(self, diagnostic: 'Diagnostic') -> None:
+        if self._cursor.value:
+            if self._current_file_path == self._cursor.value[0]:
+                if diagnostic == self._cursor.value[1]:
+                    self._take_candidate(diagnostic)
+
+
+class DiagnosticsCursor(object):
+
+    def __init__(self) -> None:
+        self._file_diagnostic = None  # type: 'Optional[Tuple[str, Diagnostic]]'
+
+    @property
+    def has_value(self) -> bool:
+        return self._file_diagnostic is not None
+
+    def set_value(self, file_diagnostic: 'Optional[Tuple[str, Diagnostic]]') -> None:
+        self._file_diagnostic = file_diagnostic
+
+    @property
+    def value(self) -> 'Optional[Tuple[str, Diagnostic]]':
+        return self._file_diagnostic
+
+    def from_position(self, direction: int, file_path: 'Optional[str]' = None,
+                      point: 'Optional[Point]' = None) -> DiagnosticsUpdateWalk:
+        if file_path and point:
+            return FromPositionWalk(self, file_path, point, direction)
+        else:
+            return TakeFirstDiagnosticWalk(self, direction)
+
+    def from_diagnostic(self, direction: int) -> DiagnosticsUpdateWalk:
+        assert self._file_diagnostic
+        return FromDiagnosticWalk(self, direction)
+
+    def update(self) -> DiagnosticsUpdateWalk:
+        assert self._file_diagnostic
+        return DiagnosticsUpdatedWalk(self)
+
+
+class DiagnosticsWalker(object):
+    """ Iterate over diagnostics structure"""
+
+    def __init__(self, subs: 'List[DiagnosticsUpdateWalk]') -> None:
+        self._subscribers = subs
+
+    def walk(self, diagnostics_by_file: 'Dict[str, Dict[str, List[Diagnostic]]]') -> None:
+        self.invoke_each(lambda w: w.begin())
+
+        if diagnostics_by_file:
+            for file_path, source_diagnostics in diagnostics_by_file.items():
+
+                self.invoke_each(lambda w: w.begin_file(file_path))
+
+                for origin, diagnostics in source_diagnostics.items():
+                    for diagnostic in diagnostics:
+                        self.invoke_each(lambda w: w.diagnostic(diagnostic))
+
+                self.invoke_each(lambda w: w.end_file(file_path))
+
+        self.invoke_each(lambda w: w.end())
+
+    def invoke_each(self, func: 'Callable[[DiagnosticsUpdateWalk], None]') -> None:
+        for sub in self._subscribers:
+            func(sub)
diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py
--- a/plugin/core/protocol.py
+++ b/plugin/core/protocol.py
@@ -267,6 +267,11 @@ def __init__(self, row: int, col: int) -> None:
     def __repr__(self) -> str:
         return "{}:{}".format(self.row, self.col)
 
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Point):
+            raise NotImplementedError()
+        return self.row == other.row and self.col == other.col
+
     @classmethod
     def from_lsp(cls, point: dict) -> 'Point':
         return Point(point['line'], point['character'])
@@ -286,6 +291,12 @@ def __init__(self, start: Point, end: Point) -> None:
     def __repr__(self) -> str:
         return "({} {})".format(self.start, self.end)
 
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Range):
+            raise NotImplementedError()
+
+        return self.start == other.start and self.end == other.end
+
     @classmethod
     def from_lsp(cls, range: dict) -> 'Range':
         return Range(Point.from_lsp(range['start']), Point.from_lsp(range['end']))
@@ -388,6 +399,15 @@ def from_lsp(cls, lsp_diagnostic: dict) -> 'Diagnostic':
     def to_lsp(self) -> 'Dict[str, Any]':
         return self._lsp_diagnostic
 
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Diagnostic):
+            raise NotImplementedError()
+
+        return self.message == other.message and self.range == other.range
+
+    def __repr__(self) -> str:
+        return str(self.range) + ":" + self.message
+
 
 class WorkspaceFolder:
 
diff --git a/plugin/diagnostics.py b/plugin/diagnostics.py
--- a/plugin/diagnostics.py
+++ b/plugin/diagnostics.py
@@ -1,3 +1,4 @@
+import html
 import os
 import sublime
 import sublime_plugin
@@ -5,17 +6,18 @@
 from .core.configurations import is_supported_syntax
 from .core.logging import debug
 from .core.panels import ensure_panel
-from .core.protocol import Diagnostic, DiagnosticSeverity, Point, Range
+from .core.protocol import Diagnostic, DiagnosticSeverity, DiagnosticRelatedInformation, Point, Range
 from .core.settings import settings, PLUGIN_NAME, client_configs
 from .core.views import range_to_region, region_to_range
 from .core.registry import windows
 from .core.windows import WindowManager
+from .core.diagnostics import DiagnosticsWalker, DiagnosticsUpdateWalk, DiagnosticsCursor, DocumentsState
 
 MYPY = False
 if MYPY:
-    from typing import Any, List, Dict, Callable, Optional
+    from typing import Any, List, Dict, Callable, Optional, Tuple
     from typing_extensions import Protocol
-    assert Any and List and Dict and Callable and Optional
+    assert Any and List and Dict and Callable and Optional and Tuple
 else:
     Protocol = object  # type: ignore
 
@@ -34,7 +36,6 @@
     DiagnosticSeverity.Hint: 'markup.inserted.lsp sublimelinter.gutter-mark markup.info.suggestion.lsp'
 }
 
-
 UNDERLINE_FLAGS = (sublime.DRAW_SQUIGGLY_UNDERLINE | sublime.DRAW_NO_OUTLINE | sublime.DRAW_NO_FILL |
                    sublime.DRAW_EMPTY_AS_OVERWRITE)
 
@@ -154,31 +155,113 @@ def ensure_diagnostics_panel(window: sublime.Window) -> 'Optional[sublime.View]'
                         "Packages/" + PLUGIN_NAME + "/Syntaxes/Diagnostics.sublime-syntax")
 
 
-class DocumentsState(Protocol):
+class LspNextDiagnosticCommand(sublime_plugin.WindowCommand):
 
-    def changed(self) -> None:
-        ...
+    def run(self) -> None:
+        windows.lookup(self.window).diagnostics.select_next()
 
-    def saved(self) -> None:
-        ...
 
+class LspPreviousDiagnosticCommand(sublime_plugin.WindowCommand):
 
-class DiagnosticsUpdateWalk(object):
+    def run(self) -> None:
+        windows.lookup(self.window).diagnostics.select_previous()
 
-    def begin(self) -> None:
-        pass
 
-    def begin_file(self, file_path: str) -> None:
-        pass
+class LspHideDiagnosticCommand(sublime_plugin.WindowCommand):
 
-    def diagnostic(self, diagnostic: 'Diagnostic') -> None:
-        pass
+    def run(self) -> None:
+        windows.lookup(self.window).diagnostics.select_none()
 
-    def end_file(self, file_path: str) -> None:
-        pass
 
-    def end(self) -> None:
-        pass
+class DiagnosticsPhantoms(object):
+
+    def __init__(self, window: sublime.Window) -> None:
+        self._window = window
+        self._last_phantom_set = None  # type: 'Optional[sublime.PhantomSet]'
+
+    def set_diagnostic(self, file_diagnostic: 'Optional[Tuple[str, Diagnostic]]') -> None:
+        self.clear()
+
+        self._base_dir = windows.lookup(self._window).get_project_path()
+        if file_diagnostic:
+            file_path, diagnostic = file_diagnostic
+            view = self._window.open_file(file_path, sublime.TRANSIENT)
+            if view.is_loading():
+                sublime.set_timeout(lambda: self.apply_phantom(view, diagnostic), 500)
+            else:
+                self.apply_phantom(view, diagnostic)
+        else:
+            if self._last_phantom_set:
+                view = self._last_phantom_set.view
+                has_phantom = view.settings().get('lsp_diagnostic_phantom')
+                if not has_phantom:
+                    view.settings().set('lsp_diagnostic_phantom', False)
+
+    def apply_phantom(self, view: sublime.View, diagnostic: Diagnostic) -> None:
+        phantom_set = sublime.PhantomSet(view, "lsp_diagnostics")
+        phantom = self.create_phantom(view, diagnostic)
+        phantom_set.update([phantom])
+        view.show_at_center(phantom.region)
+        self._last_phantom_set = phantom_set
+        has_phantom = view.settings().get('lsp_diagnostic_phantom')
+        if not has_phantom:
+            view.settings().set('lsp_diagnostic_phantom', True)
+
+    def create_phantom(self, view: sublime.View, diagnostic: Diagnostic) -> sublime.Phantom:
+        region = range_to_region(diagnostic.range, view)
+        line = "[{}] {}".format(diagnostic.source, diagnostic.message) if diagnostic.source else diagnostic.message
+        message = "<p>" + "<br>".join(html.escape(line, quote=False) for line in line.splitlines()) + "</p>"
+
+        additional_infos = "<br>".join([self.format_diagnostic_related_info(info) for info in diagnostic.related_info])
+        severity = "error" if diagnostic.severity == DiagnosticSeverity.Error else "warning"
+        content = message + "<p class='additional'>" + additional_infos + "</p>" if additional_infos else message
+        markup = self.create_phantom_html(content, severity)
+        return sublime.Phantom(
+            region,
+            markup,
+            sublime.LAYOUT_BELOW,
+            self.navigate
+        )
+
+    # TODO: share with hover?
+    def format_diagnostic_related_info(self, info: DiagnosticRelatedInformation) -> str:
+        file_path = info.location.file_path
+        if self._base_dir and file_path.startswith(self._base_dir):
+            file_path = os.path.relpath(file_path, self._base_dir)
+        location = "{}:{}:{}".format(file_path, info.location.range.start.row+1, info.location.range.start.col+1)
+        return "<a href='location:{}'>{}</a>: {}".format(location, location, html.escape(info.message))
+
+    def navigate(self, href: str) -> None:
+        if href == "hide":
+            self.clear()
+        elif href == "next":
+            self._window.run_command("lsp_next_diagnostic")
+        elif href == "previous":
+            self._window.run_command("lsp_previous_diagnostic")
+        elif href.startswith("location"):
+            # todo: share with hover?
+            _, file_path, location = href.split(":", 2)
+            file_path = os.path.join(self._base_dir, file_path) if self._base_dir else file_path
+            self._window.open_file(file_path + ":" + location, sublime.ENCODED_POSITION | sublime.TRANSIENT)
+
+    def create_phantom_html(self, content: str, severity: str) -> str:
+        stylesheet = sublime.load_resource("Packages/LSP/phantoms.css")
+        return """<body id=inline-error>
+                    <style>{}</style>
+                    <div class="{}-arrow"></div>
+                    <div class="{} container">
+                        <div class="toolbar">
+                            <a href="hide">×</a>
+                            <a href="previous">↑</a>
+                            <a href="next">↓</a>
+                        </div>
+                        <div class="content">{}</div>
+                    </div>
+                </body>""".format(stylesheet, severity, severity, content)
+
+    def clear(self) -> None:
+        if self._last_phantom_set:
+            self._last_phantom_set.update([])
 
 
 class DiagnosticViewRegions(DiagnosticsUpdateWalk):
@@ -218,33 +301,6 @@ def end(self) -> None:
                 self._view.erase_regions(region_name)
 
 
-class DiagnosticsWalker(object):
-    """ Iterate over diagnostics structure"""
-
-    def __init__(self, subs: 'List[DiagnosticsUpdateWalk]') -> None:
-        self._subscribers = subs
-
-    def walk(self, diagnostics_by_file: 'Dict[str, Dict[str, List[Diagnostic]]]') -> None:
-        self.invoke_each(lambda w: w.begin())
-
-        if diagnostics_by_file:
-            for file_path, source_diagnostics in diagnostics_by_file.items():
-
-                self.invoke_each(lambda w: w.begin_file(file_path))
-
-                for origin, diagnostics in source_diagnostics.items():
-                    for diagnostic in diagnostics:
-                        self.invoke_each(lambda w: w.diagnostic(diagnostic))
-
-                self.invoke_each(lambda w: w.end_file(file_path))
-
-        self.invoke_each(lambda w: w.end())
-
-    def invoke_each(self, func: 'Callable[[DiagnosticsUpdateWalk], None]') -> None:
-        for sub in self._subscribers:
-            func(sub)
-
-
 class HasRelevantDiagnostics(DiagnosticsUpdateWalk):
     def __init__(self) -> None:
         self.result = False
@@ -335,6 +391,8 @@ def __init__(self, window: sublime.Window, documents_state: DocumentsState) -> N
         self._panel_update = DiagnosticOutputPanel(self._window)
         self._bar_summary_update = StatusBarSummary(self._window)
         self._relevance_check = HasRelevantDiagnostics()
+        self._cursor = DiagnosticsCursor()
+        self._phantoms = DiagnosticsPhantoms(self._window)
         if settings.auto_show_diagnostics_panel == 'saved':
             setattr(documents_state, 'changed', self.on_document_changed)
             setattr(documents_state, 'saved', self.on_document_saved)
@@ -357,6 +415,7 @@ def show_panel_if_relevant(self) -> None:
             self._window.run_command("hide_panel", {"panel": "output.diagnostics"})
 
     def update(self, file_path: str, config_name: str, diagnostics: 'Dict[str, Dict[str, List[Diagnostic]]]') -> None:
+        self._diagnostics = diagnostics
         self._received_diagnostics_after_change = True
 
         if not self._window.is_valid():
@@ -374,8 +433,30 @@ def update(self, file_path: str, config_name: str, diagnostics: 'Dict[str, Dict[
         else:
             debug('view not found for', file_path)
 
+        if self._cursor.has_value:
+            updatables.append(self._cursor.update())
+
         walker = DiagnosticsWalker(updatables)
         walker.walk(diagnostics)
 
         if settings.auto_show_diagnostics_panel == 'always' or self._show_panel_on_diagnostics:
             self.show_panel_if_relevant()
+
+    def select(self, direction: int) -> None:
+        file_path = None  # type: Optional[str]
+        point = None  # type: Optional[Point]
+
+        if not self._cursor.has_value:
+            active_view = self._window.active_view()
+            if active_view:
+                file_path = active_view.file_name()
+                point = Point(*active_view.rowcol(active_view.sel()[0].begin()))
+
+        walk = self._cursor.from_diagnostic(direction) if self._cursor.has_value else self._cursor.from_position(
+            direction, file_path, point)
+        walker = DiagnosticsWalker([walk])
+        walker.walk(self._diagnostics)
+        self._phantoms.set_diagnostic(self._cursor.value)
+
+    def deselect(self) -> None:
+        self._phantoms.set_diagnostic(None)


Test patch:
diff --git a/plugin/core/test_diagnostics.py b/plugin/core/test_diagnostics.py
--- a/plugin/core/test_diagnostics.py
+++ b/plugin/core/test_diagnostics.py
@@ -1,59 +1,286 @@
 import unittest
-from .diagnostics import DiagnosticsStorage
-from .protocol import Diagnostic, Range, Point
-# from .configurations import WindowConfigManager, _merge_dicts, ConfigManager, is_supported_syntax
-# from .test_session import test_config, test_language
+from collections import OrderedDict
+from unittest import mock
+from .diagnostics import DiagnosticsStorage, DiagnosticsWalker, DiagnosticsCursor, CURSOR_FORWARD, CURSOR_BACKWARD
+from .protocol import Diagnostic, Point, Range, DiagnosticSeverity
 from .test_protocol import LSP_MINIMAL_DIAGNOSTIC
 
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    from typing import List, Dict
+    assert List and Dict
+
+
+test_file_path = "/test.py"
+test_file_uri = "file:///test.py"
+second_file_path = "/test2.py"
+second_file_uri = "file:///test2.py"
+test_server_name = "test_server"
+minimal_diagnostic = Diagnostic.from_lsp(LSP_MINIMAL_DIAGNOSTIC)
+
+
+def at_row(row: int) -> Diagnostic:
+    return Diagnostic('message', Range(Point(row, 0), Point(row, 1)), DiagnosticSeverity.Error, None, dict(), [])
+
+
+def diagnostics(test_file_diags: 'List[Diagnostic]',
+                second_file_diags: 'List[Diagnostic]' = []) -> 'Dict[str, Dict[str, List[Diagnostic]]]':
+    diags = OrderedDict()  # type: Dict[str, Dict[str, List[Diagnostic]]]
+    if test_file_diags:
+        source_diags = {}
+        source_diags[test_server_name] = test_file_diags
+        diags[test_file_path] = source_diags
+    if second_file_diags:
+        source_diags = {}
+        source_diags[test_server_name] = second_file_diags
+        diags[second_file_path] = source_diags
+    return diags
+
+
+def make_update(diagnostics: 'List[dict]') -> dict:
+    return {
+        'uri': 'file:///test.py',
+        'diagnostics': diagnostics
+    }
+
 
 class DiagnosticsStorageTest(unittest.TestCase):
 
     def test_empty_diagnostics(self):
         wd = DiagnosticsStorage(None)
         self.assertEqual(wd.get_by_file(__file__), {})
+        self.assertEqual(wd.get(), {})
 
-        # todo: remove
-
-    def test_updated_diagnostics(self):
-        wd = DiagnosticsStorage(None)
+    def test_receive_diagnostics(self):
+        ui = mock.Mock()
+        wd = DiagnosticsStorage(ui)
 
-        test_file_path = "test.py"
-        diag = Diagnostic('message', Range(Point(0, 0), Point(1, 1)), 1, None, dict(), [])
-
-        wd.update(test_file_path, "test_server", [diag])
+        wd.receive("test_server", make_update([LSP_MINIMAL_DIAGNOSTIC]))
         view_diags = wd.get_by_file(test_file_path)
         self.assertEqual(len(view_diags["test_server"]), 1)
-        self.assertEqual(view_diags["test_server"][0], diag)
+        self.assertEqual(view_diags["test_server"][0].message, LSP_MINIMAL_DIAGNOSTIC['message'])
+        self.assertIn(test_file_path, wd.get())
+        ui.update.assert_called_with(test_file_path, "test_server", {'/test.py': {'test_server': [minimal_diagnostic]}})
 
-        wd.update(test_file_path, "test_server", [])
+        wd.receive("test_server", make_update([]))
         view_diags = wd.get_by_file(test_file_path)
         self.assertEqual(len(view_diags), 0)
+        self.assertEqual(wd.get(), {})
+        ui.update.assert_called_with(test_file_path, "test_server", {})
 
-    def test_handle_diagnostics_update(self):
-        wd = DiagnosticsStorage(None)
-
-        test_file_path = "/test.py"
-        update = {
-            'uri': 'file:///test.py',
-            'diagnostics': [LSP_MINIMAL_DIAGNOSTIC]
-        }
-
-        wd.receive("test_server", update)
+    def test_remove_diagnostics(self):
+        ui = mock.Mock()
+        wd = DiagnosticsStorage(ui)
 
+        wd.receive("test_server", make_update([LSP_MINIMAL_DIAGNOSTIC]))
         view_diags = wd.get_by_file(test_file_path)
         self.assertEqual(len(view_diags["test_server"]), 1)
 
-    def test_remove_diagnostics(self):
-        wd = DiagnosticsStorage(None)
-
-        test_file_path = "test.py"
-        diag = Diagnostic('message', Range(Point(0, 0), Point(1, 1)), 1, None, dict(), [])
+        wd.remove(test_file_path, "test_server")
 
-        wd.update(test_file_path, "test_server", [diag])
         view_diags = wd.get_by_file(test_file_path)
-        self.assertEqual(len(view_diags["test_server"]), 1)
+        self.assertEqual(len(view_diags), 0)
+        self.assertEqual(wd.get(), {})
+        ui.update.assert_called_with(test_file_path, "test_server", {})
 
-        wd.remove(test_file_path, "test_server")
+    def test_clear_diagnostics(self):
+        ui = mock.Mock()
+        wd = DiagnosticsStorage(ui)
+
+        wd.receive("test_server", make_update([LSP_MINIMAL_DIAGNOSTIC]))
+        wd.clear()
 
         view_diags = wd.get_by_file(test_file_path)
         self.assertEqual(len(view_diags), 0)
+        self.assertEqual(wd.get(), {})
+        ui.update.assert_called_with(test_file_path, "test_server", {})
+
+    def test_select(self):
+        ui = mock.Mock()
+        wd = DiagnosticsStorage(ui)
+
+        wd.select_next()
+        ui.select.assert_called_with(1)
+
+        wd.select_previous()
+        ui.select.assert_called_with(-1)
+
+        wd.select_none()
+        ui.deselect.assert_called()
+
+
+class DiagnosticsWalkerTests(unittest.TestCase):
+
+    def test_empty(self):
+        walk = mock.Mock()
+        walker = DiagnosticsWalker([walk])
+        walker.walk({})
+
+        walk.begin.assert_called_once()
+        walk.begin_file.assert_not_called()
+        walk.diagnostic.assert_not_called()
+        walk.end.assert_called_once()
+
+    def test_one_diagnosic(self):
+
+        walk = mock.Mock()
+        walker = DiagnosticsWalker([walk])
+        diags = {}  # type: Dict[str, Dict[str, List[Diagnostic]]]
+        diags[test_file_path] = {}
+        diags[test_file_path]["test_server"] = [minimal_diagnostic]
+        walker.walk(diags)
+
+        walk.begin.assert_called_once()
+        walk.begin_file.assert_called_with(test_file_path)
+        walk.diagnostic.assert_called_with(minimal_diagnostic)
+        walk.end.assert_called_once()
+
+
+row1 = at_row(1)
+row5 = at_row(5)
+row3 = at_row(3)
+info = at_row(4)
+info.severity = DiagnosticSeverity.Information
+test_diagnostics = diagnostics([row1, info, row5], [row3])
+
+
+class DiagnosticsCursorTest(unittest.TestCase):
+
+    def test_empty(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD, test_file_path, Point(0, 0))])
+        walker.walk({})
+        self.assertIsNone(cursor.value)
+
+    def test_from_no_position(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+    def test_from_no_position_backwards(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_BACKWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((second_file_path, row3), cursor.value)
+
+    def test_from_file_position(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD, test_file_path, Point(0, 0))])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+    def test_from_file_position_backward(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_BACKWARD, test_file_path, Point(10, 0))])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row5), cursor.value)
+
+    def test_from_other_file_position_wrap(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD, second_file_path, Point(5, 0))])
+        walker.walk(test_diagnostics)
+
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+    def test_from_file_position_backward_wrap(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_BACKWARD, test_file_path, Point(0, 0))])
+        walker.walk(test_diagnostics)
+        self.assertEqual((second_file_path, row3), cursor.value)
+
+    def test_from_other_file_position_backwards(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_BACKWARD, second_file_path, Point(1, 0))])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row5), cursor.value)
+
+    def test_updated_diagnostic_remains(self) -> None:
+
+        cursor = DiagnosticsCursor()
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD)])
+
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.update()])
+        walker.walk(test_diagnostics)
+
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+    def test_updated_diagnostic_gone(self) -> None:
+        cursor = DiagnosticsCursor()
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD)])
+
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.update()])
+        walker.walk({})
+
+        self.assertEqual(None, cursor.value)
+
+    def test_from_diagnostic_to_same(self) -> None:
+        cursor = DiagnosticsCursor()
+
+        diags = diagnostics([row1])
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD)])
+        walker.walk(diags)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_FORWARD)])
+        walker.walk(diags)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_BACKWARD)])
+        walker.walk(diags)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+    def test_from_diagnostic_forward(self) -> None:
+
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_FORWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_FORWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row5), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_FORWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((second_file_path, row3), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_FORWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+    def test_from_diagnostic_backward(self) -> None:
+
+        cursor = DiagnosticsCursor()
+
+        walker = DiagnosticsWalker([cursor.from_position(CURSOR_BACKWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((second_file_path, row3), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_BACKWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row5), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_BACKWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((test_file_path, row1), cursor.value)
+
+        walker = DiagnosticsWalker([cursor.from_diagnostic(CURSOR_BACKWARD)])
+        walker.walk(test_diagnostics)
+        self.assertEqual((second_file_path, row3), cursor.value)