import html import os import re import sublime import sublime_plugin from .core.diagnostics import DiagnosticsWalker, DiagnosticsUpdateWalk, DiagnosticsCursor, DocumentsState from .core.logging import debug from .core.panels import ensure_panel from .core.protocol import Diagnostic, DiagnosticSeverity, DiagnosticRelatedInformation, Point, Range from .core.registry import windows, LSPViewEventListener from .core.settings import settings, PLUGIN_NAME from .core.typing import List, Dict, Optional, Tuple from .core.views import range_to_region, region_to_range diagnostic_severity_names = { DiagnosticSeverity.Error: "error", DiagnosticSeverity.Warning: "warning", DiagnosticSeverity.Information: "info", DiagnosticSeverity.Hint: "hint" } diagnostic_severity_scopes = { DiagnosticSeverity.Error: 'markup.deleted.lsp sublimelinter.mark.error markup.error.lsp', DiagnosticSeverity.Warning: 'markup.changed.lsp sublimelinter.mark.warning markup.warning.lsp', DiagnosticSeverity.Information: 'markup.inserted.lsp sublimelinter.gutter-mark markup.info.lsp', 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) BOX_FLAGS = sublime.DRAW_NO_FILL | sublime.DRAW_EMPTY_AS_OVERWRITE def is_same_file(file_path_a: str, file_path_b: str) -> bool: try: return os.path.samefile(file_path_a, file_path_b) except FileNotFoundError: return False def format_severity(severity: int) -> str: return diagnostic_severity_names.get(severity, "???") def view_diagnostics(view: sublime.View) -> Dict[str, List[Diagnostic]]: if view.window(): file_name = view.file_name() if file_name: window = view.window() if window: window_diagnostics = windows.lookup(window).diagnostics.get() for file in window_diagnostics: if is_same_file(file, file_name): return window_diagnostics[file] return {} def filter_by_point(file_diagnostics: Dict[str, List[Diagnostic]], point: Point) -> Dict[str, List[Diagnostic]]: diagnostics_by_config = {} for config_name, diagnostics in file_diagnostics.items(): point_diagnostics = [ diagnostic for diagnostic in diagnostics if diagnostic.range.contains(point) ] if point_diagnostics: diagnostics_by_config[config_name] = point_diagnostics return diagnostics_by_config def filter_by_range(file_diagnostics: Dict[str, List[Diagnostic]], rge: Range) -> Dict[str, List[Diagnostic]]: diagnostics_by_config = {} for config_name, diagnostics in file_diagnostics.items(): point_diagnostics = [ diagnostic for diagnostic in diagnostics if diagnostic.range.intersects(rge) ] if point_diagnostics: diagnostics_by_config[config_name] = point_diagnostics return diagnostics_by_config class DiagnosticsCursorListener(LSPViewEventListener): def __init__(self, view: sublime.View) -> None: super().__init__(view) self.has_status = False @classmethod def is_applicable(cls, view_settings: dict) -> bool: return settings.show_diagnostics_in_view_status and cls.has_supported_syntax(view_settings) def on_selection_modified_async(self) -> None: selections = self.view.sel() if len(selections) > 0: file_path = self.view.file_name() if file_path: pos = selections[0].begin() region = self.view.line(pos) line_range = region_to_range(self.view, region) diagnostics = filter_by_range(self.manager.diagnostics.get_by_file(file_path), line_range) if diagnostics: flattened = (d for sublist in diagnostics.values() for d in sublist) first_diagnostic = next(flattened, None) if first_diagnostic: self.show_diagnostics_status(first_diagnostic) return if self.has_status: self.clear_diagnostics_status() def show_diagnostics_status(self, diagnostic: Diagnostic) -> None: self.has_status = True # Because set_status eats newlines, newlines that aren't surrounded by any space # need to have some added, to stop words from becoming joined. spaced_message = re.sub(r'(\S)\n(\S)', r'\1 \2', diagnostic.message) self.view.set_status('lsp_diagnostics', spaced_message) def clear_diagnostics_status(self) -> None: self.view.erase_status('lsp_diagnostics') self.has_status = False class LspClearDiagnosticsCommand(sublime_plugin.WindowCommand): def run(self) -> None: windows.lookup(self.window).diagnostics.clear() def ensure_diagnostics_panel(window: sublime.Window) -> Optional[sublime.View]: return ensure_panel(window, "diagnostics", r"^\s*\S\s+(\S.*):$", r"^\s+([0-9]+):?([0-9]+).*$", "Packages/" + PLUGIN_NAME + "/Syntaxes/Diagnostics.sublime-syntax") class LspNextDiagnosticCommand(sublime_plugin.WindowCommand): def run(self) -> None: windows.lookup(self.window).diagnostics.select_next() class LspPreviousDiagnosticCommand(sublime_plugin.WindowCommand): def run(self) -> None: windows.lookup(self.window).diagnostics.select_previous() class LspHideDiagnosticCommand(sublime_plugin.WindowCommand): def run(self) -> None: windows.lookup(self.window).diagnostics.select_none() 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() 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 base_dir = windows.lookup(self._window).get_project_path(file_path) if base_dir: file_path = os.path.relpath(file_path, base_dir) location = "{}:{}:{}".format(info.location.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) 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.view.settings().set('lsp_diagnostic_phantom', False) self._last_phantom_set.update([]) class DiagnosticViewRegions(DiagnosticsUpdateWalk): def __init__(self, view: sublime.View) -> None: self._view = view self._regions = {} # type: Dict[int, List[sublime.Region]] self._relevant_file = False def begin(self) -> None: for severity in self._regions: self._regions[severity] = [] def begin_file(self, file_name: str) -> None: # TODO: would be nice if walk could skip this updater file = self._view.file_name() if file and is_same_file(file_name, file): self._relevant_file = True def diagnostic(self, diagnostic: Diagnostic) -> None: if self._relevant_file: self._regions.setdefault(diagnostic.severity, []).append(range_to_region(diagnostic.range, self._view)) def end_file(self, file_name: str) -> None: self._relevant_file = False def end(self) -> None: for severity in reversed(range(settings.show_diagnostics_severity_level + 1)): region_name = "lsp_" + format_severity(severity) if severity in self._regions: regions = self._regions[severity] scope_name = diagnostic_severity_scopes[severity] if settings.diagnostics_gutter_marker == "sign": diagnostic_severity_icons = { DiagnosticSeverity.Error: "Packages/LSP/icons/error.png", DiagnosticSeverity.Warning: "Packages/LSP/icons/warning.png", DiagnosticSeverity.Information: "Packages/LSP/icons/info.png", DiagnosticSeverity.Hint: "Packages/LSP/icons/info.png" } icon = diagnostic_severity_icons[severity] else: icon = settings.diagnostics_gutter_marker self._view.add_regions( region_name, regions, scope_name, icon, UNDERLINE_FLAGS if settings.diagnostics_highlight_style == "underline" else BOX_FLAGS) else: self._view.erase_regions(region_name) class HasRelevantDiagnostics(DiagnosticsUpdateWalk): def __init__(self) -> None: self.result = False def begin(self) -> None: self.result = False def diagnostic(self, diagnostic: Diagnostic) -> None: if diagnostic.severity <= settings.auto_show_diagnostics_panel_level: self.result = True class StatusBarSummary(DiagnosticsUpdateWalk): def __init__(self, window: sublime.Window) -> None: self._window = window def begin(self) -> None: self._errors = 0 self._warnings = 0 def diagnostic(self, diagnostic: Diagnostic) -> None: if diagnostic.severity == DiagnosticSeverity.Error: self._errors += 1 elif diagnostic.severity == DiagnosticSeverity.Warning: self._warnings += 1 def end(self) -> None: if self._errors > 0 or self._warnings > 0: count = 'E: {} W: {}'.format(self._errors, self._warnings) else: count = "" # todo: make a sticky status on active view. active_view = self._window.active_view() if active_view: active_view.set_status('lsp_errors_warning_count', count) class DiagnosticOutputPanel(DiagnosticsUpdateWalk): def __init__(self, window: sublime.Window) -> None: self._window = window self._to_render = [] # type: List[str] self._panel = ensure_diagnostics_panel(self._window) def begin(self) -> None: self._to_render = [] self._file_content = "" def begin_file(self, file_path: str) -> None: self._base_dir = windows.lookup(self._window).get_project_path(file_path) self._file_content = "" def diagnostic(self, diagnostic: Diagnostic) -> None: if diagnostic.severity <= settings.show_diagnostics_severity_level: item = self.format_diagnostic(diagnostic) self._file_content += item + "\n" def end_file(self, file_path: str) -> None: if self._file_content: panel_file_path = os.path.relpath(file_path, self._base_dir) if self._base_dir else file_path self._to_render.append(" ◌ {}:\n{}".format(panel_file_path, self._file_content)) def end(self) -> None: assert self._panel, "must have a panel now!" self._panel.settings().set("result_base_dir", self._base_dir) self._panel.run_command("lsp_update_panel", {"characters": "\n".join(self._to_render)}) def format_diagnostic(self, diagnostic: Diagnostic) -> str: location = "{:>8}:{:<4}".format( diagnostic.range.start.row + 1, diagnostic.range.start.col + 1) lines = diagnostic.message.splitlines() or [""] formatted = " {}\t{:<12}\t{:<10}\t{}".format( location, diagnostic.source, format_severity(diagnostic.severity), lines[0]) for line in lines[1:]: formatted = formatted + "\n {:<12}\t{:<12}\t{:<10}\t{}".format("", "", "", line) return formatted class DiagnosticsPresenter(object): def __init__(self, window: sublime.Window, documents_state: DocumentsState) -> None: self._window = window self._dirty = False self._received_diagnostics_after_change = False self._show_panel_on_diagnostics = False if settings.auto_show_diagnostics_panel == 'never' else True self._panel_update = DiagnosticOutputPanel(self._window) self._bar_summary_update = StatusBarSummary(self._window) self._relevance_check = HasRelevantDiagnostics() self._cursor = DiagnosticsCursor(settings.show_diagnostics_severity_level) self._phantoms = DiagnosticsPhantoms(self._window) self._diagnostics = {} # type: Dict[str, Dict[str, List[Diagnostic]]] if settings.auto_show_diagnostics_panel == 'saved': setattr(documents_state, 'changed', self.on_document_changed) setattr(documents_state, 'saved', self.on_document_saved) def on_document_changed(self) -> None: self._received_diagnostics_after_change = False def on_document_saved(self) -> None: if self._received_diagnostics_after_change: self.show_panel_if_relevant() else: self._show_panel_on_diagnostics = True def show_panel_if_relevant(self) -> None: self._show_panel_on_diagnostics = False if self._relevance_check.result: self._window.run_command("show_panel", {"panel": "output.diagnostics"}) else: 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(): debug('ignoring update to closed window') return updatables = [self._panel_update, self._relevance_check] if settings.show_diagnostics_count_in_view_status: updatables.append(self._bar_summary_update) view = self._window.find_open_file(file_path) if view and view.is_valid(): view_region_updater = DiagnosticViewRegions(view) updatables.append(view_region_updater) 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)