Skip to content

Commit

Permalink
Add support and mode for workspace pull diagnostics (#2225)
Browse files Browse the repository at this point in the history
  • Loading branch information
jwortmann authored May 3, 2023
1 parent b4fc857 commit 0d14926
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 42 deletions.
11 changes: 11 additions & 0 deletions LSP.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,17 @@
//
// // Extra variables to override/add to language server's environment.
// "env": { },
//
// // Sets the diagnostics mode:
// // "open_files" - All diagnostics reported from the server are shown.
// // If the server supports `diagnosticProvider`, diagnostics are
// // requested only for files which are opened in the editor and they
// // are cleared when the file gets closed.
// // "workspace" - If there are project folders (folders in the side bar),
// // diagnostics for files not within those folders are ignored.
// // If the server supports `diagnosticProvider.workspaceDiagnostics`,
// // diagnostics are requested for all files in the project folders.
// "diagnostics_mode": "open_files",
// }
// }
"clients": {},
Expand Down
2 changes: 1 addition & 1 deletion docs/src/client_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Below is an example of the `LSP.sublime-settings` file with configurations for t
| initializationOptions | options to send to the server at startup (rarely used) |
| selector | This is _the_ connection between your files and language servers. It's a selector that is matched against the current view's base scope. If the selector matches with the base scope of the the file, the associated language server is started. For more information, see https://www.sublimetext.com/docs/3/selectors.html |
| priority_selector | Used to prioritize a certain language server when choosing which one to query on views with multiple servers active. Certain LSP actions have to pick which server to query and this setting can be used to decide which one to pick based on the current scopes at the cursor location. For example when having both HTML and PHP servers running on a PHP file, this can be used to give priority to the HTML one in HTML blocks and to PHP one otherwise. That would be done by setting "feature_selector" to `text.html` for HTML server and `source.php` to PHP server. Note: when the "feature_selector" is missing, it will be the same as the "document_selector".
| hide_non_project_diagnostics | Enable to ignore diagnostics for files that are not within the project (window) folders. If project has no folders then this option has no effect and diagnostics are shown for all files. |
| diagnostics_mode | Set to `"workspace"` (default is `"open_files"`) to ignore diagnostics for files that are not within the project (window) folders. If project has no folders then this option has no effect and diagnostics are shown for all files. If the server supports _pull diagnostics_ (`diagnosticProvider`), this setting also controls whether diagnostics are requested only for open files (`"open_files"`), or for all files in the project folders (`"workspace"`). |
| tcp_port | see instructions below |
| experimental_capabilities | Turn on experimental capabilities of a language server. This is a dictionary and differs per language server |
| disabled_capabilities | Disables specific capabilities of a language server. This is a dictionary with key being a capability key and being `true`. Refer to the `ServerCapabilities` structure in [LSP capabilities](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#initialize) to find capabilities that you might want to disable. Note that the value should be `true` rather than `false` for capabilites that you want to disable. For example: `"signatureHelpProvider": true` |
Expand Down
4 changes: 2 additions & 2 deletions messages/1.24.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ Sublime Text once it finishes updating all packages. ⚠️

# Breaking changes

- Diagnostics for files that are not withing the project folders are no longer ignored.
You can set `hide_non_project_diagnostics` in server-specific configuration to enable old behavior.
- Diagnostics for files that are not within the project folders are no longer ignored.
You can set `"diagnostics_mode": "workspace"` in server-specific configuration to enable old behavior.
15 changes: 12 additions & 3 deletions plugin/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -5845,19 +5845,21 @@ class TokenFormat(Enum):

class Request(Generic[R]):

__slots__ = ('method', 'params', 'view', 'progress')
__slots__ = ('method', 'params', 'view', 'progress', 'partial_results')

def __init__(
self,
method: str,
params: Any = None,
view: Optional[sublime.View] = None,
progress: bool = False
progress: bool = False,
partial_results: bool = False
) -> None:
self.method = method
self.params = params
self.view = view
self.progress = progress # type: Union[bool, str]
self.partial_results = partial_results

@classmethod
def initialize(cls, params: InitializeParams) -> 'Request':
Expand Down Expand Up @@ -5975,7 +5977,7 @@ def documentDiagnostic(cls, params: DocumentDiagnosticParams, view: sublime.View

@classmethod
def workspaceDiagnostic(cls, params: WorkspaceDiagnosticParams) -> 'Request':
return Request('workspace/diagnostic', params)
return Request('workspace/diagnostic', params, partial_results=True)

@classmethod
def shutdown(cls) -> 'Request':
Expand Down Expand Up @@ -6124,6 +6126,13 @@ def to_lsp(self) -> 'Position':
}


ResponseError = TypedDict('ResponseError', {
'code': int,
'message': str,
'data': NotRequired['LSPAny']
})


CodeLensExtended = TypedDict('CodeLensExtended', {
# The range in which this code lens is valid. Should only span a single line.
'range': 'Range',
Expand Down
119 changes: 116 additions & 3 deletions plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@
from .protocol import CompletionItemKind
from .protocol import CompletionItemTag
from .protocol import Diagnostic
from .protocol import DiagnosticServerCancellationData
from .protocol import DiagnosticSeverity
from .protocol import DiagnosticTag
from .protocol import DidChangeWatchedFilesRegistrationOptions
from .protocol import DidChangeWorkspaceFoldersParams
from .protocol import DocumentDiagnosticReportKind
from .protocol import DocumentLink
from .protocol import DocumentUri
from .protocol import Error
Expand All @@ -43,15 +45,18 @@
from .protocol import Location
from .protocol import LocationLink
from .protocol import LSPAny
from .protocol import LSPErrorCodes
from .protocol import LSPObject
from .protocol import MarkupKind
from .protocol import Notification
from .protocol import PrepareSupportDefaultBehavior
from .protocol import PreviousResultId
from .protocol import PublishDiagnosticsParams
from .protocol import RegistrationParams
from .protocol import Range
from .protocol import Request
from .protocol import Response
from .protocol import ResponseError
from .protocol import SemanticTokenModifiers
from .protocol import SemanticTokenTypes
from .protocol import SymbolKind
Expand All @@ -62,6 +67,10 @@
from .protocol import UnregistrationParams
from .protocol import WindowClientCapabilities
from .protocol import WorkspaceClientCapabilities
from .protocol import WorkspaceDiagnosticParams
from .protocol import WorkspaceDiagnosticReport
from .protocol import WorkspaceDocumentDiagnosticReport
from .protocol import WorkspaceFullDocumentDiagnosticReport
from .protocol import WorkspaceEdit
from .settings import client_configs
from .settings import globalprefs
Expand All @@ -76,9 +85,11 @@
from .types import method_to_capability
from .types import SettingsRegistration
from .types import sublime_pattern_to_glob
from .typing import Callable, cast, Dict, Any, Optional, List, Tuple, Generator, Type, Protocol, Mapping, Set, TypeVar, Union # noqa: E501
from .types import WORKSPACE_DIAGNOSTICS_TIMEOUT
from .typing import Callable, cast, Dict, Any, Optional, List, Tuple, Generator, Type, TypeGuard, Protocol, Mapping, Set, TypeVar, Union # noqa: E501
from .url import filename_to_uri
from .url import parse_uri
from .url import unparse_uri
from .version import __version__
from .views import extract_variables
from .views import get_storage_path
Expand All @@ -101,6 +112,16 @@
T = TypeVar('T')


def is_workspace_full_document_diagnostic_report(
report: WorkspaceDocumentDiagnosticReport
) -> TypeGuard[WorkspaceFullDocumentDiagnosticReport]:
return report['kind'] == DocumentDiagnosticReportKind.Full


def is_diagnostic_server_cancellation_data(data: Any) -> TypeGuard[DiagnosticServerCancellationData]:
return isinstance(data, dict) and 'retriggerRequest' in data


def get_semantic_tokens_map(custom_tokens_map: Optional[Dict[str, str]]) -> Tuple[Tuple[str, str], ...]:
tokens_scope_map = SEMANTIC_TOKENS_MAP.copy()
if custom_tokens_map is not None:
Expand Down Expand Up @@ -550,6 +571,10 @@ def session(self) -> 'Session':
def session_views(self) -> 'WeakSet[SessionViewProtocol]':
...

@property
def version(self) -> Optional[int]:
...

def get_uri(self) -> Optional[str]:
...

Expand Down Expand Up @@ -1162,8 +1187,9 @@ def check_applicable(self, sb: SessionBufferProtocol) -> None:
return


# This prefix should disambiguate common string generation techniques like UUID4.
_WORK_DONE_PROGRESS_PREFIX = "$ublime-"
# These prefixes should disambiguate common string generation techniques like UUID4.
_WORK_DONE_PROGRESS_PREFIX = "$ublime-work-done-progress-"
_PARTIAL_RESULT_PROGRESS_PREFIX = "$ublime-partial-result-progress-"


class Session(TransportCallbacks):
Expand All @@ -1182,6 +1208,8 @@ def __init__(self, manager: Manager, logger: Logger, workspace_folders: List[Wor
self.state = ClientStates.STARTING
self.capabilities = Capabilities()
self.diagnostics = DiagnosticsStorage()
self.diagnostics_result_ids = {} # type: Dict[DocumentUri, Optional[str]]
self.workspace_diagnostics_pending_response = None # type: Optional[int]
self.exiting = False
self._registrations = {} # type: Dict[str, _RegistrationData]
self._init_callback = None # type: Optional[InitCallback]
Expand Down Expand Up @@ -1467,6 +1495,9 @@ def _handle_initialize_success(self, result: InitializeResult) -> None:
if self._init_callback:
self._init_callback(self, False)
self._init_callback = None
if self.config.diagnostics_mode == "workspace" and \
self.has_capability('diagnosticProvider.workspaceDiagnostics'):
self.do_workspace_diagnostics_async()

def _handle_initialize_error(self, result: InitializeError) -> None:
self._initialize_error = (result.get('code', -1), Exception(result.get('message', 'Error initializing server')))
Expand Down Expand Up @@ -1695,6 +1726,76 @@ def session_views_by_visibility(self) -> Tuple[Set[SessionViewProtocol], Set[Ses
not_visible_session_views.add(sv)
return visible_session_views, not_visible_session_views

# --- Workspace Pull Diagnostics -----------------------------------------------------------------------------------

def do_workspace_diagnostics_async(self) -> None:
if self.workspace_diagnostics_pending_response:
# The server is probably leaving the request open intentionally, in order to continuously stream updates via
# $/progress notifications.
return
previous_result_ids = [
{'uri': uri, 'value': result_id} for uri, result_id in self.diagnostics_result_ids.items()
if result_id is not None
] # type: List[PreviousResultId]
params = {'previousResultIds': previous_result_ids} # type: WorkspaceDiagnosticParams
identifier = self.get_capability("diagnosticProvider.identifier")
if identifier:
params['identifier'] = identifier
self.workspace_diagnostics_pending_response = self.send_request_async(
Request.workspaceDiagnostic(params),
self._on_workspace_diagnostics_async,
self._on_workspace_diagnostics_error_async)

def _on_workspace_diagnostics_async(
self, response: WorkspaceDiagnosticReport, reset_pending_response: bool = True
) -> None:
if reset_pending_response:
self.workspace_diagnostics_pending_response = None
if not response['items']:
return
window = sublime.active_window()
active_view = window.active_view() if window else None
active_view_path = active_view.file_name() if active_view else None
for diagnostic_report in response['items']:
uri = diagnostic_report['uri']
# Normalize URI
scheme, path = parse_uri(uri)
if scheme == 'file':
# Skip for active view
if path == active_view_path:
continue
uri = unparse_uri((scheme, path))
# Note: 'version' is a mandatory field, but some language servers have serialization bugs with null values.
version = diagnostic_report.get('version')
# Skip if outdated
# Note: this is just a necessary, but not a sufficient condition to decide whether the diagnostics for this
# file are likely not accurate anymore, because changes in another file in the meanwhile could have affected
# the diagnostics in this file. If this is the case, a new request is already queued, or updated partial
# results are expected to be streamed by the server.
if isinstance(version, int):
sb = self.get_session_buffer_for_uri_async(uri)
if sb and sb.version != version:
continue
self.diagnostics_result_ids[uri] = diagnostic_report.get('resultId')
if is_workspace_full_document_diagnostic_report(diagnostic_report):
self.m_textDocument_publishDiagnostics({'uri': uri, 'diagnostics': diagnostic_report['items']})

def _on_workspace_diagnostics_error_async(self, error: ResponseError) -> None:
if error['code'] == LSPErrorCodes.ServerCancelled:
data = error.get('data')
if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']:
# Retrigger the request after a short delay, but don't reset the pending response variable for this
# moment, to prevent new requests of this type in the meanwhile. The delay is used in order to prevent
# infinite cycles of cancel -> retrigger, in case the server is busy.

def _retrigger_request() -> None:
self.workspace_diagnostics_pending_response = None
self.do_workspace_diagnostics_async()

sublime.set_timeout_async(_retrigger_request, WORKSPACE_DIAGNOSTICS_TIMEOUT)
return
self.workspace_diagnostics_pending_response = None

# --- server request handlers --------------------------------------------------------------------------------------

def m_window_showMessageRequest(self, params: Any, request_id: Any) -> None:
Expand Down Expand Up @@ -1895,6 +1996,16 @@ def m___progress(self, params: Any) -> None:
"""handles the $/progress notification"""
token = params['token']
value = params['value']
# Partial Result Progress
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#partialResults
if token.startswith(_PARTIAL_RESULT_PROGRESS_PREFIX):
request_id = int(token[len(_PARTIAL_RESULT_PROGRESS_PREFIX):])
request = self._response_handlers[request_id][0]
if request.method == "workspace/diagnostic":
self._on_workspace_diagnostics_async(value, reset_pending_response=False)
return
# Work Done Progress
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workDoneProgress
kind = value['kind']
if token not in self._progress:
# If the token is not in the _progress map then that could mean two things:
Expand Down Expand Up @@ -1996,6 +2107,8 @@ def send_request_async(
request_id = self.request_id
if request.progress and isinstance(request.params, dict):
request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id)
if request.partial_results and isinstance(request.params, dict):
request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id)
self._response_handlers[request_id] = (request, on_result, on_error)
self._invoke_views(request, "on_request_started_async", request_id, request)
if self._plugin:
Expand Down
12 changes: 6 additions & 6 deletions plugin/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

TCP_CONNECT_TIMEOUT = 5 # seconds
FEATURES_TIMEOUT = 300 # milliseconds
WORKSPACE_DIAGNOSTICS_TIMEOUT = 3000 # milliseconds

PANEL_FILE_REGEX = r"^(\S.*):$"
PANEL_LINE_REGEX = r"^\s+(\d+):(\d+)"
Expand Down Expand Up @@ -653,7 +654,7 @@ def __init__(self,
disabled_capabilities: DottedDict = DottedDict(),
file_watcher: FileWatcherConfig = {},
semantic_tokens: Optional[Dict[str, str]] = None,
hide_non_project_diagnostics: bool = False,
diagnostics_mode: str = "open_files",
path_maps: Optional[List[PathMap]] = None) -> None:
self.name = name
self.selector = selector
Expand All @@ -679,7 +680,7 @@ def __init__(self,
self.path_maps = path_maps
self.status_key = "lsp_{}".format(self.name)
self.semantic_tokens = semantic_tokens
self.hide_non_project_diagnostics = hide_non_project_diagnostics
self.diagnostics_mode = diagnostics_mode

@classmethod
def from_sublime_settings(cls, name: str, s: sublime.Settings, file: str) -> "ClientConfig":
Expand Down Expand Up @@ -712,7 +713,7 @@ def from_sublime_settings(cls, name: str, s: sublime.Settings, file: str) -> "Cl
disabled_capabilities=disabled_capabilities,
file_watcher=file_watcher,
semantic_tokens=semantic_tokens,
hide_non_project_diagnostics=bool(s.get("hide_non_project_diagnostics", False)),
diagnostics_mode=str(s.get("diagnostics_mode", "open_files")),
path_maps=PathMap.parse(s.get("path_maps"))
)

Expand Down Expand Up @@ -742,7 +743,7 @@ def from_dict(cls, name: str, d: Dict[str, Any]) -> "ClientConfig":
disabled_capabilities=disabled_capabilities,
file_watcher=d.get("file_watcher", dict()),
semantic_tokens=d.get("semantic_tokens", dict()),
hide_non_project_diagnostics=d.get("hide_non_project_diagnostics", False),
diagnostics_mode=d.get("diagnostics_mode", "open_files"),
path_maps=PathMap.parse(d.get("path_maps"))
)

Expand Down Expand Up @@ -772,8 +773,7 @@ def from_config(cls, src_config: "ClientConfig", override: Dict[str, Any]) -> "C
disabled_capabilities=disabled_capabilities,
file_watcher=override.get("file_watcher", src_config.file_watcher),
semantic_tokens=override.get("semantic_tokens", src_config.semantic_tokens),
hide_non_project_diagnostics=override.get(
"hide_non_project_diagnostics", src_config.hide_non_project_diagnostics),
diagnostics_mode=override.get("diagnostics_mode", src_config.diagnostics_mode),
path_maps=path_map_override if path_map_override else src_config.path_maps
)

Expand Down
2 changes: 1 addition & 1 deletion plugin/core/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi
scheme, path = parse_uri(uri)
if scheme != "file":
return None
if configuration.hide_non_project_diagnostics and not self._workspace.contains(path):
if configuration.diagnostics_mode == "workspace" and not self._workspace.contains(path):
return "not inside window folders"
view = self._window.active_view()
if not view:
Expand Down
Loading

0 comments on commit 0d14926

Please sign in to comment.