def __init__(self, rx, tx, check_parent_process=False, consumer=None): self.workspace = None self.config = None self.root_uri = None self.watching_thread = None self.workspaces = {} self.uri_workspace_mapper = {} self._check_parent_process = check_parent_process if rx is not None: self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) else: self._jsonrpc_stream_reader = None if tx is not None: self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) else: self._jsonrpc_stream_writer = None # if consumer is None, it is assumed that the default streams-based approach is being used if consumer is None: self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS) else: self._endpoint = Endpoint(self, consumer, max_workers=MAX_WORKERS) self._dispatchers = [] self._shutdown = False
def setup_bsp_server(): with setup_pipes() as pipes: context = BSPContext() rule_runner = RuleRunner(rules=bsp_rules(), extra_session_values={BSPContext: context}) conn = BSPConnection( rule_runner.scheduler, rule_runner.union_membership, context, pipes.inbound_reader, pipes.outbound_writer, ) def run_bsp_server(): conn.run() bsp_thread = Thread(target=run_bsp_server) bsp_thread.daemon = True bsp_thread.start() client_reader = JsonRpcStreamReader(pipes.outbound_reader) client_writer = JsonRpcStreamWriter(pipes.inbound_writer) endpoint = Endpoint({}, lambda msg: client_writer.write(msg)) def run_client(): client_reader.listen(lambda msg: endpoint.consume(msg)) client_thread = Thread(target=run_client) client_thread.daemon = True client_thread.start() try: yield endpoint finally: client_reader.close() client_writer.close()
def __init__(self, rx, tx, check_parent_process=False): self.workspace = None self.config = None self.root_uri = None self.watching_thread = None self.workspaces = {} self.uri_workspace_mapper = {} self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) self._check_parent_process = check_parent_process self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS) self._dispatchers = [] self._shutdown = False
def setup_bsp_server( rule_runner: RuleRunner | None = None, *, notification_names: set[str] | None = None ): rule_runner = rule_runner or RuleRunner(rules=bsp_rules()) notification_names = notification_names or set() stdio_destination = native_engine.stdio_thread_get_destination() with setup_pipes() as pipes: context = BSPContext() rule_runner.set_session_values({BSPContext: context}) conn = BSPConnection( rule_runner.scheduler, rule_runner.union_membership, context, pipes.server_reader, pipes.server_writer, ) def run_bsp_server(): native_engine.stdio_thread_set_destination(stdio_destination) conn.run() bsp_thread = Thread(target=run_bsp_server) bsp_thread.daemon = True bsp_thread.start() client_reader = JsonRpcStreamReader(pipes.client_reader) client_writer = JsonRpcStreamWriter(pipes.client_writer) notifications = Notifications([]) endpoint = Endpoint( {name: functools.partial(notifications._record, name) for name in notification_names}, lambda msg: client_writer.write(msg), ) def run_client(): client_reader.listen(lambda msg: endpoint.consume(msg)) client_thread = Thread(target=run_client) client_thread.daemon = True client_thread.start() try: yield endpoint, notifications finally: client_reader.close() client_writer.close()
def __init__( self, scheduler_session: SchedulerSession, union_membership: UnionMembership, context: BSPContext, inbound: BinaryIO, outbound: BinaryIO, max_workers: int = 5, ) -> None: self._scheduler_session = scheduler_session self._inbound = JsonRpcStreamReader(inbound) self._outbound = JsonRpcStreamWriter(outbound) self._context: BSPContext = context self._endpoint = Endpoint(self, self._send_outbound_message, max_workers=max_workers) self._handler_mappings: dict[str, type[BSPHandlerMapping]] = {} impls = union_membership.get(BSPHandlerMapping) for impl in impls: self._handler_mappings[impl.method_name] = impl
class PythonLSPServer(MethodDispatcher): """ Implementation of the Microsoft VSCode Language Server Protocol https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-1-x.md """ # pylint: disable=too-many-public-methods,redefined-builtin def __init__(self, rx, tx, check_parent_process=False): self.workspace = None self.config = None self.root_uri = None self.watching_thread = None self.workspaces = {} self.uri_workspace_mapper = {} self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) self._check_parent_process = check_parent_process self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS) self._dispatchers = [] self._shutdown = False def start(self): """Entry point for the server.""" self._jsonrpc_stream_reader.listen(self._endpoint.consume) def __getitem__(self, item): """Override getitem to fallback through multiple dispatchers.""" if self._shutdown and item != 'exit': # exit is the only allowed method during shutdown log.debug("Ignoring non-exit method during shutdown: %s", item) raise KeyError try: return super().__getitem__(item) except KeyError: # Fallback through extra dispatchers for dispatcher in self._dispatchers: try: return dispatcher[item] except KeyError: continue raise KeyError() def m_shutdown(self, **_kwargs): self._shutdown = True def m_exit(self, **_kwargs): self._endpoint.shutdown() self._jsonrpc_stream_reader.close() self._jsonrpc_stream_writer.close() def _match_uri_to_workspace(self, uri): workspace_uri = _utils.match_uri_to_workspace(uri, self.workspaces) return self.workspaces.get(workspace_uri, self.workspace) def _hook(self, hook_name, doc_uri=None, **kwargs): """Calls hook_name and returns a list of results from all registered handlers""" workspace = self._match_uri_to_workspace(doc_uri) doc = workspace.get_document(doc_uri) if doc_uri else None hook_handlers = self.config.plugin_manager.subset_hook_caller( hook_name, self.config.disabled_plugins) return hook_handlers(config=self.config, workspace=workspace, document=doc, **kwargs) def capabilities(self): server_capabilities = { 'codeActionProvider': True, 'codeLensProvider': { 'resolveProvider': False, # We may need to make this configurable }, 'completionProvider': { 'resolveProvider': False, # We know everything ahead of time 'triggerCharacters': ['.'] }, 'documentFormattingProvider': True, 'documentHighlightProvider': True, 'documentRangeFormattingProvider': True, 'documentSymbolProvider': True, 'definitionProvider': True, 'executeCommandProvider': { 'commands': flatten(self._hook('pylsp_commands')) }, 'hoverProvider': True, 'referencesProvider': True, 'renameProvider': True, 'foldingRangeProvider': True, 'signatureHelpProvider': { 'triggerCharacters': ['(', ',', '='] }, 'textDocumentSync': { 'change': lsp.TextDocumentSyncKind.INCREMENTAL, 'save': { 'includeText': True, }, 'openClose': True, }, 'workspace': { 'workspaceFolders': { 'supported': True, 'changeNotifications': True } }, 'experimental': merge(self._hook('pylsp_experimental_capabilities')) } log.info('Server capabilities: %s', server_capabilities) return server_capabilities def m_initialize(self, processId=None, rootUri=None, rootPath=None, initializationOptions=None, **_kwargs): log.debug('Language server initialized with %s %s %s %s', processId, rootUri, rootPath, initializationOptions) if rootUri is None: rootUri = uris.from_fs_path( rootPath) if rootPath is not None else '' self.workspaces.pop(self.root_uri, None) self.root_uri = rootUri self.config = config.Config(rootUri, initializationOptions or {}, processId, _kwargs.get('capabilities', {})) self.workspace = Workspace(rootUri, self._endpoint, self.config) self.workspaces[rootUri] = self.workspace self._dispatchers = self._hook('pylsp_dispatchers') self._hook('pylsp_initialize') if self._check_parent_process and processId is not None and self.watching_thread is None: def watch_parent_process(pid): # exit when the given pid is not alive if not _utils.is_process_alive(pid): log.info("parent process %s is not alive, exiting!", pid) self.m_exit() else: threading.Timer(PARENT_PROCESS_WATCH_INTERVAL, watch_parent_process, args=[pid]).start() self.watching_thread = threading.Thread( target=watch_parent_process, args=(processId, )) self.watching_thread.daemon = True self.watching_thread.start() # Get our capabilities return {'capabilities': self.capabilities()} def m_initialized(self, **_kwargs): self._hook('pylsp_initialized') def code_actions(self, doc_uri, range, context): return flatten( self._hook('pylsp_code_actions', doc_uri, range=range, context=context)) def code_lens(self, doc_uri): return flatten(self._hook('pylsp_code_lens', doc_uri)) def completions(self, doc_uri, position): completions = self._hook('pylsp_completions', doc_uri, position=position) return {'isIncomplete': False, 'items': flatten(completions)} def definitions(self, doc_uri, position): return flatten( self._hook('pylsp_definitions', doc_uri, position=position)) def document_symbols(self, doc_uri): return flatten(self._hook('pylsp_document_symbols', doc_uri)) def execute_command(self, command, arguments): return self._hook('pylsp_execute_command', command=command, arguments=arguments) def format_document(self, doc_uri): return self._hook('pylsp_format_document', doc_uri) def format_range(self, doc_uri, range): return self._hook('pylsp_format_range', doc_uri, range=range) def highlight(self, doc_uri, position): return flatten( self._hook('pylsp_document_highlight', doc_uri, position=position)) or None def hover(self, doc_uri, position): return self._hook('pylsp_hover', doc_uri, position=position) or { 'contents': '' } @_utils.debounce(LINT_DEBOUNCE_S, keyed_by='doc_uri') def lint(self, doc_uri, is_saved): # Since we're debounced, the document may no longer be open workspace = self._match_uri_to_workspace(doc_uri) if doc_uri in workspace.documents: workspace.publish_diagnostics( doc_uri, flatten(self._hook('pylsp_lint', doc_uri, is_saved=is_saved))) def references(self, doc_uri, position, exclude_declaration): return flatten( self._hook('pylsp_references', doc_uri, position=position, exclude_declaration=exclude_declaration)) def rename(self, doc_uri, position, new_name): return self._hook('pylsp_rename', doc_uri, position=position, new_name=new_name) def signature_help(self, doc_uri, position): return self._hook('pylsp_signature_help', doc_uri, position=position) def folding(self, doc_uri): return flatten(self._hook('pylsp_folding_range', doc_uri)) def m_text_document__did_close(self, textDocument=None, **_kwargs): workspace = self._match_uri_to_workspace(textDocument['uri']) workspace.rm_document(textDocument['uri']) def m_text_document__did_open(self, textDocument=None, **_kwargs): workspace = self._match_uri_to_workspace(textDocument['uri']) workspace.put_document(textDocument['uri'], textDocument['text'], version=textDocument.get('version')) self._hook('pylsp_document_did_open', textDocument['uri']) self.lint(textDocument['uri'], is_saved=True) def m_text_document__did_change(self, contentChanges=None, textDocument=None, **_kwargs): workspace = self._match_uri_to_workspace(textDocument['uri']) for change in contentChanges: workspace.update_document(textDocument['uri'], change, version=textDocument.get('version')) self.lint(textDocument['uri'], is_saved=False) def m_text_document__did_save(self, textDocument=None, **_kwargs): self.lint(textDocument['uri'], is_saved=True) def m_text_document__code_action(self, textDocument=None, range=None, context=None, **_kwargs): return self.code_actions(textDocument['uri'], range, context) def m_text_document__code_lens(self, textDocument=None, **_kwargs): return self.code_lens(textDocument['uri']) def m_text_document__completion(self, textDocument=None, position=None, **_kwargs): return self.completions(textDocument['uri'], position) def m_text_document__definition(self, textDocument=None, position=None, **_kwargs): return self.definitions(textDocument['uri'], position) def m_text_document__document_highlight(self, textDocument=None, position=None, **_kwargs): return self.highlight(textDocument['uri'], position) def m_text_document__hover(self, textDocument=None, position=None, **_kwargs): return self.hover(textDocument['uri'], position) def m_text_document__document_symbol(self, textDocument=None, **_kwargs): return self.document_symbols(textDocument['uri']) def m_text_document__formatting(self, textDocument=None, _options=None, **_kwargs): # For now we're ignoring formatting options. return self.format_document(textDocument['uri']) def m_text_document__rename(self, textDocument=None, position=None, newName=None, **_kwargs): return self.rename(textDocument['uri'], position, newName) def m_text_document__folding_range(self, textDocument=None, **_kwargs): return self.folding(textDocument['uri']) def m_text_document__range_formatting(self, textDocument=None, range=None, _options=None, **_kwargs): # Again, we'll ignore formatting options for now. return self.format_range(textDocument['uri'], range) def m_text_document__references(self, textDocument=None, position=None, context=None, **_kwargs): exclude_declaration = not context['includeDeclaration'] return self.references(textDocument['uri'], position, exclude_declaration) def m_text_document__signature_help(self, textDocument=None, position=None, **_kwargs): return self.signature_help(textDocument['uri'], position) def m_workspace__did_change_configuration(self, settings=None): self.config.update((settings or {}).get('pylsp', {})) for workspace_uri in self.workspaces: workspace = self.workspaces[workspace_uri] workspace.update_config(settings) for doc_uri in workspace.documents: self.lint(doc_uri, is_saved=False) def m_workspace__did_change_workspace_folders(self, event=None, **_kwargs): # pylint: disable=too-many-locals if event is None: return added = event.get('added', []) removed = event.get('removed', []) for removed_info in removed: if 'uri' in removed_info: removed_uri = removed_info['uri'] self.workspaces.pop(removed_uri, None) for added_info in added: if 'uri' in added_info: added_uri = added_info['uri'] workspace_config = config.Config(added_uri, self.config._init_opts, self.config._process_id, self.config._capabilities) workspace_config.update(self.config._settings) self.workspaces[added_uri] = Workspace(added_uri, self._endpoint, workspace_config) root_workspace_removed = any(removed_info['uri'] == self.root_uri for removed_info in removed) workspace_added = len(added) > 0 and 'uri' in added[0] if root_workspace_removed and workspace_added: added_uri = added[0]['uri'] self.root_uri = added_uri new_root_workspace = self.workspaces[added_uri] self.config = new_root_workspace._config self.workspace = new_root_workspace elif root_workspace_removed: # NOTE: Removing the root workspace can only happen when the server # is closed, thus the else condition of this if can never happen. if self.workspaces: log.debug('Root workspace deleted!') available_workspaces = sorted(self.workspaces) first_workspace = available_workspaces[0] new_root_workspace = self.workspaces[first_workspace] self.root_uri = first_workspace self.config = new_root_workspace._config self.workspace = new_root_workspace # Migrate documents that are on the root workspace and have a better # match now doc_uris = list(self.workspace._docs.keys()) for uri in doc_uris: doc = self.workspace._docs.pop(uri) new_workspace = self._match_uri_to_workspace(uri) new_workspace._docs[uri] = doc def m_workspace__did_change_watched_files(self, changes=None, **_kwargs): changed_py_files = set() config_changed = False for d in (changes or []): if d['uri'].endswith(PYTHON_FILE_EXTENSIONS): changed_py_files.add(d['uri']) elif d['uri'].endswith(CONFIG_FILEs): config_changed = True if config_changed: self.config.settings.cache_clear() elif not changed_py_files: # Only externally changed python files and lint configs may result in changed diagnostics. return for workspace_uri in self.workspaces: workspace = self.workspaces[workspace_uri] for doc_uri in workspace.documents: # Changes in doc_uri are already handled by m_text_document__did_save if doc_uri not in changed_py_files: self.lint(doc_uri, is_saved=False) def m_workspace__execute_command(self, command=None, arguments=None): return self.execute_command(command, arguments)
class BSPConnection: _INITIALIZE_METHOD_NAME = "build/initialize" _SHUTDOWN_METHOD_NAME = "build/shutdown" _EXIT_NOTIFCATION_NAME = "build/exit" def __init__( self, scheduler_session: SchedulerSession, union_membership: UnionMembership, context: BSPContext, inbound: BinaryIO, outbound: BinaryIO, max_workers: int = 5, ) -> None: self._scheduler_session = scheduler_session self._inbound = JsonRpcStreamReader(inbound) self._outbound = JsonRpcStreamWriter(outbound) self._context: BSPContext = context self._endpoint = Endpoint(self, self._send_outbound_message, max_workers=max_workers) self._handler_mappings: dict[str, type[BSPHandlerMapping]] = {} impls = union_membership.get(BSPHandlerMapping) for impl in impls: self._handler_mappings[impl.method_name] = impl def run(self) -> None: """Run the listener for inbound JSON-RPC messages.""" self._inbound.listen(self._received_inbound_message) def _received_inbound_message(self, msg): """Process each inbound JSON-RPC message.""" _logger.info(f"_received_inbound_message: msg={msg}") self._endpoint.consume(msg) def _send_outbound_message(self, msg): _logger.info(f"_send_outbound_message: msg={msg}") self._outbound.write(msg) # TODO: Figure out how to run this on the `Endpoint`'s thread pool by returing a callable. For now, we # need to return errors as futures given that `Endpoint` only handles exceptions returned that way versus using a try ... except block. def _handle_inbound_message(self, *, method_name: str, params: Any): # If the connection is not yet initialized and this is not the initialization request, BSP requires # returning an error for methods (and to discard all notifications). # # Concurrency: This method can be invoked from multiple threads (for each individual request). By returning # an error for all other requests, only the thread running the initialization RPC should be able to proceed. # This ensures that we can safely call `initialize_connection` on the BSPContext with the client-supplied # init parameters without worrying about multiple threads. (Not entirely true though as this does not handle # the client making multiple concurrent initialization RPCs, but which would violate the protocol in any case.) if ( not self._context.is_connection_initialized and method_name != self._INITIALIZE_METHOD_NAME ): return _make_error_future( JsonRpcException( code=-32002, message=f"Client must first call `{self._INITIALIZE_METHOD_NAME}`." ) ) # Handle the `build/shutdown` method and `build/exit` notification. if method_name == self._SHUTDOWN_METHOD_NAME: # Return no-op success for the `build/shutdown` method. This doesn't actually cause the server to # exit. That will occur once the client sends the `build/exit` notification. return None elif method_name == self._EXIT_NOTIFCATION_NAME: # The `build/exit` notification directs the BSP server to immediately exit. # The read-dispatch loop will exit once it notices that the inbound handle is closed. So close the # inbound handle (and outbound handle for completeness) and then return to the dispatch loop # to trigger the exit. self._inbound.close() self._outbound.close() return None method_mapping = self._handler_mappings.get(method_name) if not method_mapping: return _make_error_future(JsonRpcMethodNotFound.of(method_name)) try: request = method_mapping.request_type.from_json_dict(params) except Exception: return _make_error_future(JsonRpcInvalidRequest()) # TODO: This should not be necessary: see https://github.com/pantsbuild/pants/issues/15435. self._scheduler_session.new_run_id() workspace = Workspace(self._scheduler_session) params = Params(request, workspace) execution_request = self._scheduler_session.execution_request( products=[method_mapping.response_type], subjects=[params], ) returns, throws = self._scheduler_session.execute(execution_request) if len(returns) == 1 and len(throws) == 0: # Initialize the BSPContext with the client-supplied init parameters. See earlier comment on why this # call to `BSPContext.initialize_connection` is safe. if method_name == self._INITIALIZE_METHOD_NAME: self._context.initialize_connection(request, self.notify_client) return returns[0][1].value.to_json_dict() elif len(returns) == 0 and len(throws) == 1: raise throws[0][1].exc else: raise AssertionError( f"Received unexpected result from engine: returns={returns}; throws={throws}" ) # Called by `Endpoint` to dispatch requests and notifications. # TODO: Should probably vendor `Endpoint` so we can detect notifications versus method calls, which # matters when ignoring unknown notifications versus erroring for unknown methods. def __getitem__(self, method_name): def handler(params): return self._handle_inbound_message(method_name=method_name, params=params) return handler def notify_client(self, notification: BSPNotification) -> None: try: self._endpoint.notify(notification.notification_name, notification.to_json_dict()) except Exception as ex: _logger.warning(f"Received exception while notifying BSP client: {ex}")