diff --git a/Default.sublime-commands b/Default.sublime-commands index 9364f7b4a..bd9c22766 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -81,6 +81,15 @@ "caption": "LSP: Goto Declaration", "command": "lsp_symbol_declaration", }, + { + "caption": "LSP: Goto Diagnostic", + "command": "lsp_goto_diagnostic", + "args": {"uri": "$view_uri"} + }, + { + "caption": "LSP: Goto Diagnostic in Project", + "command": "lsp_goto_diagnostic" + }, { "caption": "LSP: Toggle Log Panel", "command": "lsp_toggle_server_panel", diff --git a/Default.sublime-keymap b/Default.sublime-keymap index ac26fd1c6..0954e65fa 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -252,6 +252,33 @@ // } // ] // }, + // Goto Diagnostic + // { + // "command": "lsp_goto_diagnostic", + // "args": { + // "uri": "$view_uri" + // }, + // "keys": [ + // "f8" + // ], + // "context": [ + // { + // "key": "setting.lsp_active" + // } + // ] + // }, + // Goto Diagnostic in Project + // { + // "command": "lsp_goto_diagnostic", + // "keys": [ + // "shift+f8" + // ], + // "context": [ + // { + // "key": "setting.lsp_active" + // } + // ] + // }, // Rename // { // "command": "lsp_symbol_rename", diff --git a/Main.sublime-menu b/Main.sublime-menu index 1e1cf498f..b87ae9da8 100644 --- a/Main.sublime-menu +++ b/Main.sublime-menu @@ -60,6 +60,15 @@ "caption": "LSP: Goto Implementation…", "command": "lsp_symbol_implementation" }, + { + "caption": "LSP: Goto Diagnostic…", + "command": "lsp_goto_diagnostic", + "args": {"uri": "$view_uri"} + }, + { + "caption": "LSP: Goto Diagnostic in Project…", + "command": "lsp_goto_diagnostic" + }, { "caption": "LSP: Find References", "command": "lsp_symbol_references" diff --git a/boot.py b/boot.py index fb9126e4e..5bf7f9813 100644 --- a/boot.py +++ b/boot.py @@ -52,6 +52,7 @@ from .plugin.goto import LspSymbolDefinitionCommand from .plugin.goto import LspSymbolImplementationCommand from .plugin.goto import LspSymbolTypeDefinitionCommand +from .plugin.goto_diagnostic import LspGotoDiagnosticCommand from .plugin.hover import LspHoverCommand from .plugin.panels import LspShowDiagnosticsPanelCommand from .plugin.panels import LspToggleServerPanelCommand diff --git a/dependencies.json b/dependencies.json index 0f21c2187..dfe18a876 100644 --- a/dependencies.json +++ b/dependencies.json @@ -1,9 +1,10 @@ { "*": { - ">=3124": [ + ">=4096": [ "backrefs", "bracex", "mdpopups", + "pathlib", "pyyaml", "wcmatch" ] diff --git a/plugin/core/diagnostics_manager.py b/plugin/core/diagnostics_manager.py index 297976994..96e7b8cf5 100644 --- a/plugin/core/diagnostics_manager.py +++ b/plugin/core/diagnostics_manager.py @@ -1,9 +1,12 @@ -from .protocol import Diagnostic, DiagnosticSeverity -from .settings import userprefs -from .typing import Callable, Iterator, List, Optional, Tuple +from .protocol import Diagnostic, DiagnosticSeverity, DocumentUri +from .typing import Callable, Iterator, List, Tuple, TypeVar from .url import parse_uri -from .views import diagnostic_severity, format_diagnostic_for_panel +from .views import diagnostic_severity from collections import OrderedDict +import functools + +ParsedUri = Tuple[str, str] +T = TypeVar('T') class DiagnosticsManager(OrderedDict): @@ -17,44 +20,83 @@ class DiagnosticsManager(OrderedDict): # # https://microsoft.github.io/language-server-protocol/specification#textDocument_publishDiagnostics - def add_diagnostics_async(self, uri: str, diagnostics: List[Diagnostic]) -> None: - _, path = parse_uri(uri) + def add_diagnostics_async(self, document_uri: DocumentUri, diagnostics: List[Diagnostic]) -> None: + """ + Add `diagnostics` for `document_uri` to the store, replacing previously received `diagnoscis` + for this `document_uri`. If `diagnostics` is the empty list, `document_uri` is removed from + the store. The item received is moved to the end of the store. + """ + uri = parse_uri(document_uri) if not diagnostics: - # received "clear diagnostics" message for this path - self.pop(path, None) + # received "clear diagnostics" message for this uri + self.pop(uri, None) return - max_severity = userprefs().diagnostics_panel_include_severity_level - self[path] = ( - list( - filter( - None, - ( - format_diagnostic_for_panel(diagnostic) - for diagnostic in diagnostics - if diagnostic_severity(diagnostic) <= max_severity - ), - ) - ), - len(list(filter(has_severity(DiagnosticSeverity.Error), diagnostics))), - len(list(filter(has_severity(DiagnosticSeverity.Warning), diagnostics))), - ) - self.move_to_end(path) # maintain incoming order + self[uri] = diagnostics + self.move_to_end(uri) # maintain incoming order + + def filter_map_diagnostics_async(self, pred: Callable[[Diagnostic], bool], + f: Callable[[ParsedUri, Diagnostic], T]) -> Iterator[Tuple[ParsedUri, List[T]]]: + """ + Yields `(uri, results)` items with `results` being a list of `f(diagnostic)` for each + diagnostic for this `uri` with `pred(diagnostic) == True`, filtered by `bool(f(diagnostic))`. + Only `uri`s with non-empty `results` are returned. Each `uri` is guaranteed to be yielded + not more than once. Items and results are ordered as they came in from the server. + """ + for uri, diagnostics in self.items(): + results = list(filter(None, map(functools.partial(f, uri), filter(pred, diagnostics)))) # type: List[T] + if results: + yield uri, results - def diagnostics_panel_contributions_async( - self, - ) -> Iterator[Tuple[str, List[Tuple[str, Optional[int], Optional[str], Optional[str]]]]]: - for path, (contribution, _, _) in self.items(): - if contribution: - yield path, contribution + def filter_map_diagnostics_flat_async(self, pred: Callable[[Diagnostic], bool], + f: Callable[[ParsedUri, Diagnostic], T]) -> Iterator[Tuple[ParsedUri, T]]: + """ + Flattened variant of `filter_map_diagnostics_async()`. Yields `(uri, result)` items for each + of the `result`s per `uri` instead. Each `uri` can be yielded more than once. Items are + grouped by `uri` and each `uri` group is guaranteed to appear not more than once. Items are + ordered as they came in from the server. + """ + for uri, results in self.filter_map_diagnostics_async(pred, f): + for result in results: + yield uri, result def sum_total_errors_and_warnings_async(self) -> Tuple[int, int]: + """ + Returns `(total_errors, total_warnings)` count of all diagnostics currently in store. + """ return ( - sum(errors for _, errors, _ in self.values()), - sum(warnings for _, _, warnings in self.values()), + sum(map(severity_count(DiagnosticSeverity.Error), self.values())), + sum(map(severity_count(DiagnosticSeverity.Warning), self.values())), ) + def diagnostics_by_document_uri(self, document_uri: DocumentUri) -> List[Diagnostic]: + """ + Returns possibly empty list of diagnostic for `document_uri`. + """ + return self.get(parse_uri(document_uri), []) + + def diagnostics_by_parsed_uri(self, uri: ParsedUri) -> List[Diagnostic]: + """ + Returns possibly empty list of diagnostic for `uri`. + """ + return self.get(uri, []) + + +def severity_count(severity: int) -> Callable[[List[Diagnostic]], int]: + def severity_count(diagnostics: List[Diagnostic]) -> int: + return len(list(filter(has_severity(severity), diagnostics))) + + return severity_count + def has_severity(severity: int) -> Callable[[Diagnostic], bool]: def has_severity(diagnostic: Diagnostic) -> bool: return diagnostic_severity(diagnostic) == severity + return has_severity + + +def is_severity_included(max_severity: int) -> Callable[[Diagnostic], bool]: + def severity_included(diagnostic: Diagnostic) -> bool: + return diagnostic_severity(diagnostic) <= max_severity + + return severity_included diff --git a/plugin/core/url.py b/plugin/core/url.py index 6a5e1a02a..c167cd344 100644 --- a/plugin/core/url.py +++ b/plugin/core/url.py @@ -61,6 +61,14 @@ def parse_uri(uri: str) -> Tuple[str, str]: return parsed.scheme, uri +def unparse_uri(parsed_uri: Tuple[str, str]) -> str: + """ + Reverse of `parse_uri()`. + """ + scheme, path = parsed_uri + return filename_to_uri(path) if scheme == "file" else path + + def _to_resource_uri(path: str, prefix: str) -> str: """ Terrible hacks from ST core leak into packages as well. diff --git a/plugin/core/views.py b/plugin/core/views.py index 0c73da992..9b1a471a8 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -647,18 +647,7 @@ def format_diagnostic_for_panel(diagnostic: Diagnostic) -> Tuple[str, Optional[i When the last three elemenst are not optional, show an inline phantom using the information given. """ - formatted = [diagnostic_source(diagnostic)] - offset = None - href = None - code = diagnostic.get("code") - if code is not None: - code = str(code) - formatted.append(":") - code_description = diagnostic.get("codeDescription") - if code_description: - href = code_description["href"] - else: - formatted.append(code) + formatted, code, href = diagnostic_source_and_code(diagnostic) lines = diagnostic["message"].splitlines() or [""] # \u200B is the zero-width space result = " {:>4}:{:<4}{:<8}{} \u200B{}".format( @@ -666,15 +655,36 @@ def format_diagnostic_for_panel(diagnostic: Diagnostic) -> Tuple[str, Optional[i diagnostic["range"]["start"]["character"] + 1, format_severity(diagnostic_severity(diagnostic)), lines[0], - "".join(formatted) + formatted ) - if href: - offset = len(result) + offset = len(result) if href else None for line in itertools.islice(lines, 1, None): result += "\n" + 18 * " " + line return result, offset, code, href +def format_diagnostic_source_and_code(diagnostic: Diagnostic) -> str: + formatted, code, href = diagnostic_source_and_code(diagnostic) + if href is None or code is None: + return formatted + return formatted + code + + +def diagnostic_source_and_code(diagnostic: Diagnostic) -> Tuple[str, Optional[str], Optional[str]]: + formatted = [diagnostic_source(diagnostic)] + href = None + code = diagnostic.get("code") + if code is not None: + code = str(code) + formatted.append(":") + code_description = diagnostic.get("codeDescription") + if code_description: + href = code_description["href"] + else: + formatted.append(code) + return "".join(formatted), code, href + + def location_to_human_readable( config: ClientConfig, base_dir: Optional[str], diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 6b7e2b7e5..8f72918ef 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -2,6 +2,7 @@ from .configurations import ConfigManager from .configurations import WindowConfigManager from .diagnostics import ensure_diagnostics_panel +from .diagnostics_manager import is_severity_included from .logging import debug from .logging import exception_log from .message_request_handler import MessageRequestHandler @@ -25,6 +26,7 @@ from .typing import Optional, Any, Dict, Deque, List, Generator, Tuple, Iterable, Sequence, Union from .url import parse_uri from .views import extract_variables +from .views import format_diagnostic_for_panel from .views import make_link from .workspace import ProjectFolders from .workspace import sorted_workspace_folders @@ -500,13 +502,15 @@ def update_diagnostics_panel_async(self) -> None: listeners = list(self._listeners) prephantoms = [] # type: List[Tuple[int, int, str, str]] row = 0 + max_severity = userprefs().diagnostics_panel_include_severity_level contributions = OrderedDict( ) # type: OrderedDict[str, List[Tuple[str, Optional[int], Optional[str], Optional[str]]]] for session in self._sessions: local_errors, local_warnings = session.diagnostics_manager.sum_total_errors_and_warnings_async() self.total_error_count += local_errors self.total_warning_count += local_warnings - for path, contribution in session.diagnostics_manager.diagnostics_panel_contributions_async(): + for (_, path), contribution in session.diagnostics_manager.filter_map_diagnostics_async( + is_severity_included(max_severity), lambda _, diagnostic: format_diagnostic_for_panel(diagnostic)): seen = path in contributions contributions.setdefault(path, []).extend(contribution) if not seen: diff --git a/plugin/goto_diagnostic.py b/plugin/goto_diagnostic.py new file mode 100644 index 000000000..8bff3c2c2 --- /dev/null +++ b/plugin/goto_diagnostic.py @@ -0,0 +1,311 @@ +from .core.diagnostics_manager import ParsedUri, is_severity_included +from .core.protocol import Diagnostic, DocumentUri, DiagnosticSeverity, Location +from .core.registry import windows +from .core.sessions import Session +from .core.settings import userprefs +from .core.types import ClientConfig +from .core.typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union +from .core.url import parse_uri, unparse_uri +from .core.views import MissingUriError, uri_from_view, get_uri_and_position_from_location, to_encoded_filename +from .core.views import format_diagnostic_for_html +from .core.views import diagnostic_severity, format_diagnostic_source_and_code, format_severity +from collections import Counter, OrderedDict +from pathlib import Path +import functools +import sublime +import sublime_plugin + +DIAGNOSTIC_KIND = { + DiagnosticSeverity.Error: (sublime.KIND_ID_COLOR_REDISH, "e", "Error"), + DiagnosticSeverity.Warning: (sublime.KIND_ID_COLOR_YELLOWISH, "w", "Warning"), + DiagnosticSeverity.Information: (sublime.KIND_ID_COLOR_BLUISH, "i", "Information"), + DiagnosticSeverity.Hint: (sublime.KIND_ID_COLOR_BLUISH, "h", "Hint"), +} +PREVIEW_PANE_CSS = """ + .diagnostics {padding: 0.5em} + .diagnostics a {color: var(--bluish)} + .diagnostics.error {background-color: color(var(--redish) alpha(0.25))} + .diagnostics.warning {background-color: color(var(--yellowish) alpha(0.25))} + .diagnostics.info {background-color: color(var(--bluish) alpha(0.25))} + .diagnostics.hint {background-color: color(var(--bluish) alpha(0.25))} + """ + + +def get_sessions(window: sublime.Window) -> Iterator[Session]: + wm = windows.lookup(window) + if wm is not None: + yield from wm._sessions + + +class LspGotoDiagnosticCommand(sublime_plugin.WindowCommand): + def run(self, uri: Optional[DocumentUri], diagnostic: Optional[dict]) -> None: # type: ignore + pass + + def is_enabled(self, uri: Optional[DocumentUri] = None, diagnostic: Optional[dict] = None) -> bool: # type: ignore + view = self.window.active_view() + if view is None: + return False + if uri == "$view_uri": + try: + uri = uri_from_view(view) + except MissingUriError: + return False + if uri: + parsed_uri = parse_uri(uri) + return any(parsed_uri in session.diagnostics_manager for session in get_sessions(self.window)) + return any(bool(session.diagnostics_manager) for session in get_sessions(self.window)) + + def input(self, args: dict) -> Optional[sublime_plugin.CommandInputHandler]: + uri, diagnostic = args.get("uri"), args.get("diagnostic") + view = self.window.active_view() + if view is None: + return None + if not uri: + return DiagnosticUriInputHandler(self.window, view) + if uri == "$view_uri": + try: + uri = uri_from_view(view) + except MissingUriError: + return None + if not diagnostic: + return DiagnosticInputHandler(self.window, view, uri) + return None + + def input_description(self) -> str: + return "Goto Diagnostic" + + +class DiagnosticUriInputHandler(sublime_plugin.ListInputHandler): + _preview = None # type: Optional[sublime.View] + uri = None # Optional[DocumentUri] + + def __init__(self, window: sublime.Window, view: sublime.View) -> None: + self.window = window + self.view = view + + def name(self) -> str: + return "uri" + + def list_items(self) -> Tuple[List[sublime.ListInputItem], int]: + max_severity = userprefs().diagnostics_panel_include_severity_level + # collect severities and location of first diagnostic per uri + severities_per_path = OrderedDict() # type: OrderedDict[ParsedUri, List[int]] + self.first_locations = dict() # type: Dict[ParsedUri, Tuple[Session, Location]] + for session in get_sessions(self.window): + for parsed_uri, severity in session.diagnostics_manager.filter_map_diagnostics_flat_async( + is_severity_included(max_severity), lambda _, diagnostic: diagnostic_severity(diagnostic)): + severities_per_path.setdefault(parsed_uri, []).append(severity) + if parsed_uri not in self.first_locations: + severities_per_path.move_to_end(parsed_uri) + diagnostics = session.diagnostics_manager.diagnostics_by_parsed_uri(parsed_uri) + if diagnostics: + self.first_locations[parsed_uri] = session, diagnostic_location(parsed_uri, diagnostics[0]) + # build items + list_items = list() + selected = 0 + for i, (parsed_uri, severities) in enumerate(severities_per_path.items()): + counts = Counter(severities) + text = "{}: {}".format(format_severity(min(counts)), self._simple_project_path(parsed_uri)) + annotation = "E: {}, W: {}".format(counts.get(DiagnosticSeverity.Error, 0), + counts.get(DiagnosticSeverity.Warning, 0)) + kind = DIAGNOSTIC_KIND[min(counts)] + uri = unparse_uri(parsed_uri) + if uri == self.uri: + selected = i # restore selection after coming back from diagnostics list + list_items.append(sublime.ListInputItem(text, uri, annotation=annotation, kind=kind)) + return list_items, selected + + def placeholder(self) -> str: + return "Select file" + + def next_input(self, args: dict) -> Optional[sublime_plugin.CommandInputHandler]: + uri, diagnostic = args.get("uri"), args.get("diagnostic") + if uri is None: + return None + if diagnostic is None: + self._preview = None + return DiagnosticInputHandler(self.window, self.view, uri) + return sublime_plugin.BackInputHandler() # type: ignore + + def confirm(self, value: Optional[DocumentUri]) -> None: + self.uri = value + + def description(self, value: DocumentUri, text: str) -> str: + return self._project_path(parse_uri(value)) + + def cancel(self) -> None: + if self._preview is not None and self._preview.sheet().is_transient(): + self._preview.close() + self.window.focus_view(self.view) + + def preview(self, value: Optional[DocumentUri]) -> str: + if not value: + return "" + parsed_uri = parse_uri(value) + session, location = self.first_locations[parsed_uri] + scheme, _ = parsed_uri + if scheme == "file": + self._preview = open_location(session, location, flags=sublime.TRANSIENT) + return "" + + def _simple_project_path(self, parsed_uri: ParsedUri) -> str: + scheme, path = parsed_uri + if scheme == "file": + path = str(simple_project_path(map(Path, self.window.folders()), Path(path))) or path + return path + + def _project_path(self, parsed_uri: ParsedUri) -> str: + scheme, path = parsed_uri + if scheme == "file": + path = str(project_path(map(Path, self.window.folders()), Path(path))) or path + return path + + +class DiagnosticInputHandler(sublime_plugin.ListInputHandler): + _preview = None # type: Optional[sublime.View] + + def __init__(self, window: sublime.Window, view: sublime.View, uri: DocumentUri) -> None: + self.window = window + self.view = view + self.sessions = list(get_sessions(window)) + self.parsed_uri = parse_uri(uri) + + def name(self) -> str: + return "diagnostic" + + def list_items(self) -> List[sublime.ListInputItem]: + list_items = [] + max_severity = userprefs().diagnostics_panel_include_severity_level + for i, session in enumerate(self.sessions): + for diagnostic in filter(is_severity_included(max_severity), + session.diagnostics_manager.diagnostics_by_parsed_uri(self.parsed_uri)): + lines = diagnostic["message"].splitlines() + first_line = lines[0] if lines else "" + if len(lines) > 1: + first_line += " …" + text = "{}: {}".format(format_severity(diagnostic_severity(diagnostic)), first_line) + annotation = format_diagnostic_source_and_code(diagnostic) + kind = DIAGNOSTIC_KIND[diagnostic_severity(diagnostic)] + list_items.append(sublime.ListInputItem(text, (i, diagnostic), annotation=annotation, kind=kind)) + return list_items + + def placeholder(self) -> str: + return "Select diagnostic" + + def next_input(self, args: dict) -> Optional[sublime_plugin.CommandInputHandler]: + return None if args.get("diagnostic") else sublime_plugin.BackInputHandler() # type: ignore + + def confirm(self, value: Optional[Tuple[int, Diagnostic]]) -> None: + if not value: + return + i, diagnostic = value + session = self.sessions[i] + location = self._get_location(diagnostic) + scheme, _ = self.parsed_uri + if scheme == "file": + open_location(session, self._get_location(diagnostic)) + else: + sublime.set_timeout_async(functools.partial(session.open_location_async, location)) + + def cancel(self) -> None: + if self._preview is not None and self._preview.sheet().is_transient(): + self._preview.close() + self.window.focus_view(self.view) + + def preview(self, value: Optional[Tuple[int, Diagnostic]]) -> Union[str, sublime.Html]: + if not value: + return "" + i, diagnostic = value + session = self.sessions[i] + base_dir = None + scheme, path = self.parsed_uri + if scheme == "file": + self._preview = open_location(session, self._get_location(diagnostic), flags=sublime.TRANSIENT) + base_dir = project_base_dir(map(Path, self.window.folders()), Path(path)) + return diagnostic_html(self.view, session.config, truncate_message(diagnostic), base_dir) + + def _get_location(self, diagnostic: Diagnostic) -> Location: + return diagnostic_location(self.parsed_uri, diagnostic) + + +def diagnostic_location(parsed_uri: ParsedUri, diagnostic: Diagnostic) -> Location: + return dict(uri=unparse_uri(parsed_uri), range=diagnostic["range"]) + + +def open_location(session: Session, location: Location, flags: int = 0, group: int = -1) -> sublime.View: + uri, position = get_uri_and_position_from_location(location) + file_name = to_encoded_filename(session.config.map_server_uri_to_client_path(uri), position) + return session.window.open_file(file_name, flags=flags | sublime.ENCODED_POSITION, group=group) + + +def diagnostic_html(view: sublime.View, config: ClientConfig, diagnostic: Diagnostic, + base_dir: Optional[Path]) -> sublime.Html: + content = format_diagnostic_for_html(view, config, truncate_message(diagnostic), + None if base_dir is None else str(base_dir)) + return sublime.Html('