Repository: sublimelsp/LSP

Issue:
Support for relatedInformation diagnostic property
Some language servers support a `relatedInformation` field that supplements diagnostic messages with additional context. Here's the [spec](https://microsoft.github.io/language-server-protocol/specification#diagnostic) and the [LSP proposal](https://github.com/Microsoft/language-server-protocol/issues/260) from a year ago.

For example, in Hack (Facebook's PHP replacement), the language server delivers diagnostic messages when an argument type does not match the type of the formal parameter. Here's a [sample payload](https://gist.github.com/jeffomatic/ee031e11bbe42a7228f64e07d1165c4f).

There are a couple of things the ST LSP plugin could do in order to make this data truly useful:

- Within the diagnostic pane and the error hover popup, the LSP plugin surfaces the top-level diagnostic message, but what you really want to see is an expansion containing the two underlying messages, one at the callsite (the nested message reports the deduced type of the argument) and one at the declaration (the nested message reports the declared type of the parameter).
- The plugin successfully adds highlighting effects to the function callsite, but it ought to highlight the function definitions as well.

Probably, the first is easier to accomplish than the latter, but both would be amazing.


Discussion:
I originally published this issue referring to "relatedLocations", but that was an old name from the original proposal. That property is now part of the LSP spec as "relatedInformation". I've updated the parent accordingly.

Resolving patch:
diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py
--- a/plugin/core/protocol.py
+++ b/plugin/core/protocol.py
@@ -1,3 +1,4 @@
+from .url import uri_to_filename
 try:
     from typing import Any, List, Dict, Tuple, Callable, Optional, Union, Mapping
     assert Any and List and Dict and Tuple and Callable and Optional and Union and Mapping
@@ -332,14 +333,41 @@ def __repr__(self) -> str:
         return "{} {} '{}'".format(self.range, self.range_length, self.text)
 
 
+class Location(object):
+    def __init__(self, file_path: str, range: Range) -> None:
+        self.file_path = file_path
+        self.range = range
+
+    @classmethod
+    def from_lsp(cls, lsp_location: dict) -> 'Location':
+        return Location(
+            uri_to_filename(lsp_location["uri"]),
+            Range.from_lsp(lsp_location["range"])
+        )
+
+
+class DiagnosticRelatedInformation(object):
+
+    def __init__(self, location: Location, message: str) -> None:
+        self.location = location
+        self.message = message
+
+    @classmethod
+    def from_lsp(cls, lsp_related_information: dict) -> 'DiagnosticRelatedInformation':
+        return DiagnosticRelatedInformation(
+            Location.from_lsp(lsp_related_information["location"]),
+            lsp_related_information["message"])
+
+
 class Diagnostic(object):
-    def __init__(self, message: str, range: Range, severity: int,
-                 source: 'Optional[str]', lsp_diagnostic: dict) -> None:
+    def __init__(self, message: str, range: Range, severity: int, source: 'Optional[str]', lsp_diagnostic: dict,
+                 related_info: 'List[DiagnosticRelatedInformation]') -> None:
         self.message = message
         self.range = range
         self.severity = severity
         self.source = source
         self._lsp_diagnostic = lsp_diagnostic
+        self.related_info = related_info
 
     @classmethod
     def from_lsp(cls, lsp_diagnostic: dict) -> 'Diagnostic':
@@ -350,7 +378,8 @@ def from_lsp(cls, lsp_diagnostic: dict) -> 'Diagnostic':
             # optional keys
             lsp_diagnostic.get('severity', DiagnosticSeverity.Error),
             lsp_diagnostic.get('source'),
-            lsp_diagnostic
+            lsp_diagnostic,
+            [DiagnosticRelatedInformation.from_lsp(info) for info in lsp_diagnostic.get('relatedInformation', [])]
         )
 
     def to_lsp(self) -> 'Dict[str, Any]':
diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py
--- a/plugin/core/sessions.py
+++ b/plugin/core/sessions.py
@@ -122,7 +122,10 @@ def get_initialize_params(project_path: str, config: ClientConfig) -> dict:
                     }
                 },
                 "rename": {},
-                "colorProvider": {}
+                "colorProvider": {},
+                "publishDiagnostics": {
+                    "relatedInformation": True
+                }
             },
             "workspace": {
                 "applyEdit": True,
diff --git a/plugin/hover.py b/plugin/hover.py
--- a/plugin/hover.py
+++ b/plugin/hover.py
@@ -2,11 +2,12 @@
 import sublime
 import sublime_plugin
 import webbrowser
+import os
 from html import escape
 from .core.configurations import is_supported_syntax
 from .diagnostics import filter_by_point, view_diagnostics
-from .core.registry import session_for_view, LspTextCommand
-from .core.protocol import Request, DiagnosticSeverity, Diagnostic, Point
+from .core.registry import session_for_view, LspTextCommand, windows
+from .core.protocol import Request, DiagnosticSeverity, Diagnostic, DiagnosticRelatedInformation, Point
 from .core.documents import get_document_position
 from .core.popups import popups
 from .code_actions import actions_manager, run_code_action_or_command
@@ -75,6 +76,7 @@ def __init__(self, lsp_name: str, label: str, subl_cmd_name: str) -> None:
 class LspHoverCommand(LspTextCommand):
     def __init__(self, view: sublime.View) -> None:
         super().__init__(view)
+        self._base_dir = None   # type: Optional[str]
 
     def is_likely_at_symbol(self, point: int) -> bool:
         word_at_sel = self.view.classify(point)
@@ -82,6 +84,8 @@ def is_likely_at_symbol(self, point: int) -> bool:
 
     def run(self, edit: sublime.Edit, point: 'Optional[int]' = None) -> None:
         hover_point = point or self.view.sel()[0].begin()
+        self._base_dir = windows.lookup(self.view.window()).get_project_path()
+
         self._hover = None  # type: Optional[Any]
         self._actions_by_config = {}  # type: Dict[str, List[CodeActionOrCommand]]
         self._diagnostics_by_config = {}  # type: Dict[str, List[Diagnostic]]
@@ -130,13 +134,24 @@ def symbol_actions_content(self) -> str:
             actions.append("<a href='{}'>{}</a>".format('rename', 'Rename'))
         return "<p>" + " | ".join(actions) + "</p>"
 
+    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, info.location.range.start.col)
+        return "<a href='location:{}'>{}</a>: {}".format(location, location, escape(info.message))
+
     def format_diagnostic(self, diagnostic: 'Diagnostic') -> str:
         diagnostic_message = escape(diagnostic.message, False).replace('\n', '<br>')
+        related_infos = [self.format_diagnostic_related_info(info) for info in diagnostic.related_info]
+        related_content = "<pre class='related_info'>" + "<br>".join(related_infos) + "</pre>" if related_infos else ""
+
         if diagnostic.source:
-            return "<pre class=\"{}\">[{}] {}</pre>".format(class_for_severity[diagnostic.severity], diagnostic.source,
-                                                            diagnostic_message)
+            return "<pre class=\"{}\">[{}] {}</pre>{}".format(class_for_severity[diagnostic.severity],
+                                                              diagnostic.source, diagnostic_message, related_content)
         else:
-            return "<pre class=\"{}\">{}</pre>".format(class_for_severity[diagnostic.severity], diagnostic_message)
+            return "<pre class=\"{}\">{}</pre>{}".format(class_for_severity[diagnostic.severity], diagnostic_message,
+                                                         related_content)
 
     def diagnostics_content(self) -> str:
         formatted = []
@@ -230,6 +245,12 @@ def on_hover_navigate(self, href: str, point: int) -> None:
             sel.add(sublime.Region(point, point))
 
             self.view.show_popup_menu(titles, lambda i: self.handle_code_action_select(config_name, i))
+        elif href.startswith('location'):
+            _, file_path, location = href.split(":", 2)
+            file_path = os.path.join(self._base_dir, file_path) if self._base_dir else file_path
+            window = self.view.window()
+            if window:
+                window.open_file(file_path + ":" + location, sublime.ENCODED_POSITION | sublime.TRANSIENT)
         else:
             webbrowser.open_new_tab(href)
 


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
@@ -18,7 +18,7 @@ def test_updated_diagnostics(self):
         wd = DiagnosticsStorage(None)
 
         test_file_path = "test.py"
-        diag = Diagnostic('message', Range(Point(0, 0), Point(1, 1)), 1, None, dict())
+        diag = Diagnostic('message', Range(Point(0, 0), Point(1, 1)), 1, None, dict(), [])
 
         wd.update(test_file_path, "test_server", [diag])
         view_diags = wd.get_by_file(test_file_path)
@@ -47,7 +47,7 @@ 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())
+        diag = Diagnostic('message', Range(Point(0, 0), Point(1, 1)), 1, None, dict(), [])
 
         wd.update(test_file_path, "test_server", [diag])
         view_diags = wd.get_by_file(test_file_path)